@ultimat3/cli 29.3.2 → 30.0.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.
Files changed (448) hide show
  1. package/CLAUDE.md +64 -40
  2. package/README.md +61 -11
  3. package/package.json +42 -28
  4. package/src/affected-tests.ts +97 -0
  5. package/src/affected-unowned.ts +43 -0
  6. package/src/affected.ts +25 -6
  7. package/src/app-boundaries.ts +29 -1
  8. package/src/app-evals.ts +12 -4
  9. package/src/app-load-unresolved.ts +62 -0
  10. package/src/app-load.ts +30 -37
  11. package/src/app-mcp.ts +14 -2
  12. package/src/app-module-graph.ts +232 -0
  13. package/src/app-reload-graph.ts +2 -2
  14. package/src/app-root.ts +37 -1
  15. package/src/bin.ts +1 -0
  16. package/src/boundary-findings.ts +6 -0
  17. package/src/budgets-freshness.ts +32 -0
  18. package/src/budgets.ts +20 -1
  19. package/src/build-capture-env.ts +9 -0
  20. package/src/cdp-shot-a11y.ts +2 -39
  21. package/src/ci-tested.ts +243 -0
  22. package/src/client-emit-customer-runtime.ts +139 -0
  23. package/src/client-emit-customer.ts +108 -0
  24. package/src/client-emit-drift.ts +72 -0
  25. package/src/client-emit-header.ts +67 -0
  26. package/src/client-emit-schemas.ts +175 -0
  27. package/src/client-emit-types.ts +121 -0
  28. package/src/client-emit-vendor.ts +146 -0
  29. package/src/client-errors-routes.ts +229 -0
  30. package/src/client-errors-script.ts +74 -0
  31. package/src/client-vitals-route.ts +197 -0
  32. package/src/client-vitals-script.ts +56 -0
  33. package/src/cmd-affected.ts +23 -0
  34. package/src/cmd-build-spec.ts +7 -1
  35. package/src/cmd-build.ts +35 -9
  36. package/src/cmd-ci-spec.ts +42 -6
  37. package/src/cmd-ci.ts +99 -2
  38. package/src/cmd-client-customer.ts +134 -0
  39. package/src/cmd-client-source.ts +81 -0
  40. package/src/cmd-client-spec.ts +48 -0
  41. package/src/cmd-client-write.ts +41 -0
  42. package/src/cmd-client.ts +111 -0
  43. package/src/cmd-db-recount.ts +120 -0
  44. package/src/cmd-db-spec.ts +36 -6
  45. package/src/cmd-db.ts +13 -3
  46. package/src/cmd-deploy-compose.ts +61 -3
  47. package/src/cmd-deploy-drain.ts +10 -2
  48. package/src/cmd-deploy-helm.ts +11 -0
  49. package/src/cmd-deploy-preflight.ts +118 -0
  50. package/src/cmd-deploy-spec.ts +58 -6
  51. package/src/cmd-deploy-verify.ts +380 -0
  52. package/src/cmd-deploy.ts +42 -9
  53. package/src/cmd-docs.ts +34 -4
  54. package/src/cmd-doctor.ts +66 -9
  55. package/src/cmd-drill-spec.ts +25 -0
  56. package/src/cmd-drill.ts +176 -0
  57. package/src/cmd-env-init.ts +102 -0
  58. package/src/cmd-env-spec.ts +10 -3
  59. package/src/cmd-env.ts +15 -10
  60. package/src/cmd-errors-recent.ts +60 -0
  61. package/src/cmd-errors-spec.ts +9 -1
  62. package/src/cmd-errors.ts +4 -0
  63. package/src/cmd-exercise-spec.ts +19 -0
  64. package/src/cmd-exercise.ts +129 -0
  65. package/src/cmd-generate-spec.ts +9 -1
  66. package/src/cmd-generate.ts +32 -27
  67. package/src/cmd-graph-load.ts +97 -0
  68. package/src/cmd-graph-spec.ts +16 -0
  69. package/src/cmd-graph.ts +73 -0
  70. package/src/cmd-i18n.ts +4 -1
  71. package/src/cmd-impact-spec.ts +13 -0
  72. package/src/cmd-impact.ts +64 -0
  73. package/src/cmd-jobs-resolve.ts +76 -0
  74. package/src/cmd-jobs-spec.ts +21 -2
  75. package/src/cmd-jobs.ts +10 -6
  76. package/src/cmd-mail-spec.ts +28 -0
  77. package/src/cmd-mail.ts +70 -0
  78. package/src/cmd-mcp-lock.ts +69 -0
  79. package/src/cmd-mcp-spec.ts +23 -4
  80. package/src/cmd-mcp.ts +17 -3
  81. package/src/cmd-new-spec.ts +8 -1
  82. package/src/cmd-new.ts +61 -9
  83. package/src/cmd-planned.ts +0 -6
  84. package/src/cmd-repro-spec.ts +31 -0
  85. package/src/cmd-repro.ts +174 -0
  86. package/src/cmd-stop-spec.ts +21 -0
  87. package/src/cmd-stop.ts +166 -0
  88. package/src/cmd-test-spec.ts +17 -4
  89. package/src/cmd-test.ts +70 -14
  90. package/src/cmd-upgrade-spec.ts +26 -0
  91. package/src/cmd-upgrade.ts +274 -0
  92. package/src/cmd-verify-spec.ts +3 -3
  93. package/src/db-backfill.ts +6 -0
  94. package/src/db-diagnostics.ts +106 -0
  95. package/src/db-generate.ts +28 -8
  96. package/src/db-reset-template.ts +1 -1
  97. package/src/db-rolling.ts +45 -0
  98. package/src/db-seed-generate.ts +188 -0
  99. package/src/deploy-verify-findings.ts +98 -0
  100. package/src/deploy-verify-flags.ts +109 -0
  101. package/src/deploy-verify-grade.ts +185 -0
  102. package/src/deploy-verify-queues.ts +53 -0
  103. package/src/dev-boot.ts +35 -15
  104. package/src/dev-child-watch.ts +16 -4
  105. package/src/dev-dashboard-guard.ts +8 -1
  106. package/src/dev-dashboard.ts +7 -0
  107. package/src/dev-lock-identity.ts +61 -0
  108. package/src/dev-lock-port-holder.ts +80 -0
  109. package/src/dev-lock-steal.ts +124 -0
  110. package/src/dev-lock.ts +92 -120
  111. package/src/dev-mail-templates.ts +51 -0
  112. package/src/dev-rebuild.ts +26 -2
  113. package/src/dev-route-table.ts +17 -0
  114. package/src/dev-supervisor.ts +5 -2
  115. package/src/dev-watch-tree.ts +11 -10
  116. package/src/dev-watch.ts +1 -1
  117. package/src/dispatch.ts +29 -6
  118. package/src/docs-section.ts +69 -0
  119. package/src/docs-sources.ts +100 -0
  120. package/src/doctor-auth.ts +11 -2
  121. package/src/doctor-chart-alerts.ts +55 -0
  122. package/src/doctor-pooler.ts +107 -0
  123. package/src/doctor-rolling.ts +35 -0
  124. package/src/doctor-row-security.ts +40 -0
  125. package/src/document-options.ts +13 -0
  126. package/src/drill-sync-lane.ts +25 -0
  127. package/src/drill-targets.ts +39 -0
  128. package/src/duplicate-packages.ts +1 -1
  129. package/src/env-production.ts +93 -0
  130. package/src/error-codes-shot.ts +59 -0
  131. package/src/error-codes-tooling.ts +29 -0
  132. package/src/error-codes.ts +100 -54
  133. package/src/error-contract.ts +2 -2
  134. package/src/errors.ts +22 -3
  135. package/src/exercise-database.ts +99 -0
  136. package/src/exercise-findings.ts +159 -0
  137. package/src/exercise-overrides.ts +42 -0
  138. package/src/exercise-registration.ts +58 -0
  139. package/src/exercise-static.ts +88 -0
  140. package/src/exercise-subject.ts +69 -0
  141. package/src/failure-capture-boot.ts +30 -0
  142. package/src/framework-env.ts +7 -1
  143. package/src/framework-schema.ts +72 -5
  144. package/src/generate-catalog-locales.ts +19 -0
  145. package/src/generate-chart-alerts.ts +156 -0
  146. package/src/generate-feature-required.ts +65 -0
  147. package/src/generate-feature.ts +83 -6
  148. package/src/generate-files.ts +24 -0
  149. package/src/generate-format-written.ts +32 -0
  150. package/src/generate-kinds.ts +6 -0
  151. package/src/generate-sign-in-config.ts +100 -0
  152. package/src/generate-sign-in-manifest.ts +28 -0
  153. package/src/generate-sign-in-mode.ts +101 -0
  154. package/src/generate-sign-in.ts +250 -0
  155. package/src/generate-write.ts +44 -7
  156. package/src/hand-rolled-socket.ts +72 -0
  157. package/src/i18n-app-key-shapes.ts +29 -0
  158. package/src/i18n-audit.ts +10 -3
  159. package/src/i18n-registration.ts +18 -3
  160. package/src/index.ts +2 -0
  161. package/src/island-bundle.ts +4 -3
  162. package/src/island-disposer.ts +116 -0
  163. package/src/island-realtime-state.ts +34 -6
  164. package/src/island-realtime.ts +6 -2
  165. package/src/island-routes.ts +3 -2
  166. package/src/isr-query-advice.ts +6 -11
  167. package/src/isr-query-guard.ts +4 -3
  168. package/src/job-budget.ts +95 -0
  169. package/src/jobs-json.ts +11 -0
  170. package/src/lint-findings.ts +104 -0
  171. package/src/local-cli.ts +34 -4
  172. package/src/loopback-host.ts +2 -2
  173. package/src/mail-preview-findings.ts +24 -0
  174. package/src/mcp-errors.ts +82 -2
  175. package/src/mcp-host.ts +11 -1
  176. package/src/mcp-lock-findings.ts +57 -0
  177. package/src/measure-paths.ts +12 -4
  178. package/src/measure-scope.ts +27 -13
  179. package/src/messages-client.ts +23 -0
  180. package/src/messages-db.ts +56 -0
  181. package/src/messages-gate.ts +107 -0
  182. package/src/messages-repro.ts +11 -0
  183. package/src/messages-shot.ts +51 -0
  184. package/src/messages-upgrade.ts +45 -0
  185. package/src/messages.ts +53 -178
  186. package/src/metrics-endpoint.ts +23 -5
  187. package/src/new-shapes-generate.ts +71 -0
  188. package/src/new-shapes.ts +63 -0
  189. package/src/openapi-compile-ir.ts +68 -0
  190. package/src/openapi-compile-schema.ts +284 -0
  191. package/src/openapi-compile.ts +273 -0
  192. package/src/openapi-refuse.ts +45 -0
  193. package/src/otlp-export.ts +2 -1
  194. package/src/output-findings.ts +123 -0
  195. package/src/output.ts +32 -8
  196. package/src/package-boundaries.ts +110 -0
  197. package/src/page-pins.ts +23 -0
  198. package/src/plain-routes.ts +13 -0
  199. package/src/prerender-entries.ts +59 -0
  200. package/src/prerender-heaviest.ts +27 -0
  201. package/src/prerender-preload.ts +6 -0
  202. package/src/prerender-report.ts +12 -0
  203. package/src/prerender-seo-reads.ts +38 -0
  204. package/src/prerender-served.ts +22 -7
  205. package/src/prerender-vitals.ts +42 -0
  206. package/src/prerender.ts +61 -32
  207. package/src/pwa-artifacts.ts +4 -3
  208. package/src/pwa-manifest-locales.ts +1 -1
  209. package/src/registry.ts +18 -0
  210. package/src/repro-bundle.ts +65 -0
  211. package/src/repro-errors.ts +47 -0
  212. package/src/repro-job.ts +72 -0
  213. package/src/repro-literal.ts +41 -0
  214. package/src/repro-request.ts +48 -0
  215. package/src/repro-write.ts +100 -0
  216. package/src/retain-sweep.ts +27 -0
  217. package/src/retained-assets-boot.ts +113 -0
  218. package/src/retained-assets-layout.ts +110 -0
  219. package/src/retained-assets-read.ts +142 -0
  220. package/src/retained-assets-sweep.ts +70 -0
  221. package/src/retained-assets.ts +171 -0
  222. package/src/revalidated-response.ts +8 -5
  223. package/src/role-replicator.ts +7 -1
  224. package/src/role-start-devices.ts +119 -0
  225. package/src/role-start-types.ts +6 -0
  226. package/src/role-start.ts +22 -5
  227. package/src/role-sync.ts +7 -1
  228. package/src/route-islands.ts +42 -0
  229. package/src/route-table.ts +2 -1
  230. package/src/row-security-boot.ts +58 -0
  231. package/src/row-security-wanted.ts +32 -0
  232. package/src/runtime-admin.ts +0 -1
  233. package/src/runtime-assets-site-cache.ts +55 -0
  234. package/src/runtime-assets-site.ts +136 -0
  235. package/src/runtime-assets.ts +99 -55
  236. package/src/runtime-bindings.ts +19 -3
  237. package/src/runtime-cache.ts +23 -14
  238. package/src/runtime-isr-outcome.ts +114 -60
  239. package/src/runtime-purge.ts +14 -2
  240. package/src/runtime-queue.ts +31 -4
  241. package/src/runtime-render-document.ts +204 -0
  242. package/src/runtime-render.ts +48 -198
  243. package/src/runtime-replica-position.ts +62 -0
  244. package/src/runtime-replica.ts +18 -3
  245. package/src/runtime-s3-path-style.ts +17 -0
  246. package/src/runtime-services.ts +19 -6
  247. package/src/runtime-statement-bound.ts +22 -0
  248. package/src/runtime-storage-upload.ts +9 -0
  249. package/src/runtime-storage.ts +42 -138
  250. package/src/schema-diff-views.ts +54 -0
  251. package/src/schema-diff.ts +21 -3
  252. package/src/schema-drift.ts +28 -9
  253. package/src/schema-dump-drift.ts +91 -7
  254. package/src/script-csp.ts +1 -1
  255. package/src/seo-cards.ts +26 -0
  256. package/src/seo-routes.ts +22 -8
  257. package/src/serve-boot.ts +54 -21
  258. package/src/serve-drain.ts +1 -0
  259. package/src/serve-env.ts +26 -8
  260. package/src/serve-retire.ts +12 -6
  261. package/src/serve-web.ts +42 -4
  262. package/src/serve.ts +9 -7
  263. package/src/site-asset-routes.ts +17 -27
  264. package/src/site-config.ts +4 -0
  265. package/src/site-llms-pages.ts +106 -0
  266. package/src/site-llms.ts +64 -0
  267. package/src/site-seo-write.ts +4 -2
  268. package/src/site-seo.ts +55 -6
  269. package/src/source-mtime.ts +50 -0
  270. package/src/static-capture-errors.ts +41 -0
  271. package/src/static-capture.ts +56 -0
  272. package/src/static-cards-serve.ts +39 -0
  273. package/src/static-cards.ts +93 -0
  274. package/src/static-document.ts +14 -13
  275. package/src/static-images.ts +115 -0
  276. package/src/stream-hole-scope.ts +55 -0
  277. package/src/style-bundle.ts +1 -1
  278. package/src/style-routes.ts +3 -2
  279. package/src/sw-precache-plan.ts +2 -2
  280. package/src/sw-routes.ts +2 -2
  281. package/src/templates/action-tests.ts +306 -0
  282. package/src/templates/action.ts +81 -269
  283. package/src/templates/backfill.ts +1 -1
  284. package/src/templates/entity.ts +21 -5
  285. package/src/templates/github/ci.yml.ts +8 -9
  286. package/src/templates/guard-bare-error.ts +2 -2
  287. package/src/templates/guard-error-declared.ts +263 -0
  288. package/src/templates/guard-native-confirm.ts +132 -0
  289. package/src/templates/guard-raw-fetch.ts +151 -0
  290. package/src/templates/guard-raw-interval.ts +121 -0
  291. package/src/templates/guard-repo-raw-sql.ts +71 -2
  292. package/src/templates/job.ts +1 -1
  293. package/src/templates/naming.ts +5 -0
  294. package/src/templates/pinned/docker-compose.yml +6 -0
  295. package/src/templates/pinned-images.ts +8 -0
  296. package/src/templates/policy.ts +85 -2
  297. package/src/templates/query.ts +70 -13
  298. package/src/templates/resource-create.ts +3 -1
  299. package/src/templates/resource-form-island.ts +15 -4
  300. package/src/templates/resource-page-db.ts +83 -0
  301. package/src/templates/resource-page.ts +161 -52
  302. package/src/templates/route.ts +4 -6
  303. package/src/templates/scaffold-app.ts +3 -0
  304. package/src/templates/scaffold-auth.ts +2 -1
  305. package/src/templates/scaffold-bin.ts +5 -52
  306. package/src/templates/scaffold-claude-agents.ts +19 -19
  307. package/src/templates/scaffold-claude-commands.ts +28 -31
  308. package/src/templates/scaffold-claude.ts +26 -23
  309. package/src/templates/scaffold-container-compose.ts +6 -1
  310. package/src/templates/scaffold-container.ts +24 -4
  311. package/src/templates/scaffold-dashboard-bare.ts +3 -3
  312. package/src/templates/scaffold-dashboard-example-tests.ts +49 -9
  313. package/src/templates/scaffold-dashboard-example-view.ts +156 -0
  314. package/src/templates/scaffold-dashboard-example.ts +43 -131
  315. package/src/templates/scaffold-dashboard-shared.ts +3 -7
  316. package/src/templates/scaffold-db-client.ts +20 -6
  317. package/src/templates/scaffold-db-package.ts +5 -14
  318. package/src/templates/scaffold-docs.ts +73 -57
  319. package/src/templates/scaffold-domain-package.ts +18 -14
  320. package/src/templates/scaffold-entries.ts +7 -2
  321. package/src/templates/scaffold-exercise.ts +37 -0
  322. package/src/templates/scaffold-guards.ts +13 -1
  323. package/src/templates/scaffold-helm-alerts.ts +234 -0
  324. package/src/templates/scaffold-helm-templates.ts +7 -5
  325. package/src/templates/scaffold-helm.ts +46 -12
  326. package/src/templates/scaffold-i18n.ts +6 -2
  327. package/src/templates/scaffold-mcp-endpoint.ts +182 -0
  328. package/src/templates/scaffold-mcp-json.ts +31 -0
  329. package/src/templates/scaffold-mcp-package.ts +59 -56
  330. package/src/templates/scaffold-repo.ts +7 -2
  331. package/src/templates/scaffold-shell.ts +45 -79
  332. package/src/templates/scaffold-test-services.ts +13 -5
  333. package/src/templates/shapes/api-keys.ts +170 -0
  334. package/src/templates/shapes/api.ts +101 -0
  335. package/src/templates/shapes/blog-site.ts +340 -0
  336. package/src/templates/shapes/blog-slice.ts +265 -0
  337. package/src/templates/shapes/blog.ts +114 -0
  338. package/src/templates/shapes/dashboard-detail.ts +242 -0
  339. package/src/templates/shapes/dashboard.ts +53 -0
  340. package/src/templates/shapes/index.ts +17 -0
  341. package/src/templates/shapes/overlay.ts +99 -0
  342. package/src/templates/shapes/shared-edits.ts +46 -0
  343. package/src/templates/sign-in-action-tests.ts +237 -0
  344. package/src/templates/sign-in-authenticator.ts +112 -0
  345. package/src/templates/sign-in-oidc.ts +189 -0
  346. package/src/templates/sign-in-pages.ts +148 -0
  347. package/src/templates/sign-in-password.ts +216 -0
  348. package/src/templates/sign-in-shared.ts +268 -0
  349. package/src/templates/slice-foundation.ts +70 -15
  350. package/src/test-batches.ts +1 -1
  351. package/src/test-database.ts +1 -1
  352. package/src/test-shards.ts +7 -1
  353. package/src/test-workers.ts +18 -12
  354. package/src/tool-gate-names.ts +34 -0
  355. package/src/upgrade-agents-link.ts +49 -0
  356. package/src/upgrade-bun-floor.ts +225 -0
  357. package/src/upgrade-gate.ts +228 -0
  358. package/src/upgrade-pins.ts +63 -0
  359. package/src/upgrade-plan.ts +214 -0
  360. package/src/upgrade-registry.ts +53 -0
  361. package/src/upgrade-report.ts +139 -0
  362. package/src/upgrade-root.ts +74 -0
  363. package/src/verify-budgets-build.ts +118 -0
  364. package/src/verify-checks.ts +67 -24
  365. package/src/verify-coverage-run.ts +1 -1
  366. package/src/verify-e2e-lane.ts +41 -0
  367. package/src/verify-exercise.ts +64 -0
  368. package/src/verify-floor.ts +41 -0
  369. package/src/verify-merge.ts +19 -4
  370. package/src/verify-role-load.ts +2 -0
  371. package/src/verify-run.ts +28 -8
  372. package/src/verify-shard.ts +12 -2
  373. package/src/verify-skip-ceiling.ts +87 -0
  374. package/src/verify-step.ts +5 -0
  375. package/src/verify-tests.ts +25 -5
  376. package/src/view-drift.ts +55 -0
  377. package/src/worker-bundle.ts +3 -2
  378. package/src/workspace-graph.ts +38 -19
  379. package/src/write-generated.ts +43 -0
  380. package/src/zone-boot.ts +20 -0
  381. package/wiki/Actions.md +342 -0
  382. package/wiki/Admin-Dashboard.md +432 -0
  383. package/wiki/Agents.md +958 -0
  384. package/wiki/Auth.md +195 -0
  385. package/wiki/Bare-VM.md +111 -0
  386. package/wiki/Batching-And-Preloading.md +136 -0
  387. package/wiki/Building-Your-Own-Base.md +203 -0
  388. package/wiki/CI-Parallel-Gate.md +220 -0
  389. package/wiki/CLI-Reference.md +1894 -0
  390. package/wiki/Caching-And-Invalidation.md +255 -0
  391. package/wiki/Client-Data.md +194 -0
  392. package/wiki/Client-Navigation.md +513 -0
  393. package/wiki/Compared.md +93 -0
  394. package/wiki/Configuration.md +868 -0
  395. package/wiki/Contributing.md +12 -0
  396. package/wiki/Deploy-Recipes.md +122 -0
  397. package/wiki/Deployment.md +508 -0
  398. package/wiki/Entities-And-Migrations.md +1116 -0
  399. package/wiki/FAQ.md +175 -0
  400. package/wiki/Feature-Flags.md +65 -0
  401. package/wiki/Getting-Started.md +221 -0
  402. package/wiki/Home.md +160 -0
  403. package/wiki/I18n.md +239 -0
  404. package/wiki/Installation.md +206 -0
  405. package/wiki/Interface-Rules.md +339 -0
  406. package/wiki/Jobs-And-Workflows.md +783 -0
  407. package/wiki/Known-Gaps.md +185 -0
  408. package/wiki/MCP-And-AI.md +753 -0
  409. package/wiki/Mail.md +138 -0
  410. package/wiki/Migrating-An-Existing-App.md +594 -0
  411. package/wiki/Migrating-From-Django.md +54 -0
  412. package/wiki/Migrating-From-Laravel.md +53 -0
  413. package/wiki/Migrating-From-Node-Drizzle.md +53 -0
  414. package/wiki/Migrating-From-Rails.md +53 -0
  415. package/wiki/Migrations-And-Backfills.md +552 -0
  416. package/wiki/Money.md +338 -0
  417. package/wiki/N-Plus-One-Detection.md +197 -0
  418. package/wiki/Notify.md +208 -0
  419. package/wiki/Observability.md +245 -0
  420. package/wiki/Outbound-HTTP.md +109 -0
  421. package/wiki/PWA-And-Offline.md +343 -0
  422. package/wiki/Policies-And-Authz.md +251 -0
  423. package/wiki/Project-Layout.md +150 -0
  424. package/wiki/Queries-And-Live-Queries.md +429 -0
  425. package/wiki/Realtime.md +734 -0
  426. package/wiki/Resource-Management.md +121 -0
  427. package/wiki/Routes-And-Render-Modes.md +521 -0
  428. package/wiki/SEO.md +288 -0
  429. package/wiki/Scheduled-Tasks.md +175 -0
  430. package/wiki/Scraping.md +493 -0
  431. package/wiki/Shapes.md +63 -0
  432. package/wiki/Stability.md +97 -0
  433. package/wiki/Static-Assets.md +166 -0
  434. package/wiki/Storage-And-Uploads.md +404 -0
  435. package/wiki/Testing.md +1313 -0
  436. package/wiki/The-Eight-Primitives.md +247 -0
  437. package/wiki/Theming.md +362 -0
  438. package/wiki/Timezones-And-Dates.md +194 -0
  439. package/wiki/Troubleshooting.md +163 -0
  440. package/wiki/Tutorial-01-First-App.md +289 -0
  441. package/wiki/Tutorial-02-First-Feature.md +339 -0
  442. package/wiki/Tutorial-03-Auth-And-Admin.md +379 -0
  443. package/wiki/Tutorial-04-Jobs-And-Realtime.md +263 -0
  444. package/wiki/Tutorial-05-Deploy-Free.md +231 -0
  445. package/wiki/Tutorial-06-Growing-Up.md +158 -0
  446. package/wiki/UI-Components.md +156 -0
  447. package/wiki/Upgrading.md +3667 -0
  448. package/src/stored-object-headers.ts +0 -43
package/wiki/Agents.md ADDED
@@ -0,0 +1,958 @@
1
+ # Agents
2
+
3
+ `agent()` is an **action factory**, not a ninth primitive. A tool-using run is one server-authoritative operation with an input schema, an output schema and a policy — the definition of an `action` — so `agent()` returns one, and inherits `.openapi()`, `.client()`, `.job()`, `.contract()`, its MCP tool (`toolFrom`) and its manifest row without a line of agent-specific code.
4
+
5
+ `As of 2026-08`. Source: [`packages/ai/src/agent.ts`](https://github.com/developerz-ai/ultimate/blob/main/packages/ai/src/agent.ts). The gateway, prompts, evals and the MCP surface are [MCP and AI](MCP-And-AI); the primitive vocabulary is [The eight primitives](The-Eight-Primitives).
6
+
7
+ ## Register your models, pick your provider
8
+
9
+ **The app names every model an agent runs on, and the provider that serves it.** `As of 2026-10`. The framework ships the mechanism — the `Provider` contract, two wire-format adapters as peers, the gateway, budgets, `registerModel` — and no vendor choice. Full reference: [`packages/ai/README.md`](https://github.com/developerz-ai/ultimate/blob/main/packages/ai/README.md).
10
+
11
+ ```ts
12
+ // app.config.ts, or any module the boot imports
13
+ import { configureAi, providerGateway, openAiProvider, registerModel } from '@ultimat3/ai';
14
+
15
+ registerModel({
16
+ id: 'house-large',
17
+ contextWindow: 200_000,
18
+ maxOutput: 32_000,
19
+ inputPerMillion: { minor: 300, currency: 'USD' },
20
+ outputPerMillion: { minor: 1_500, currency: 'USD' },
21
+ cacheMinimumTokens: 1_024,
22
+ reasoning: { effort: true, adaptive: false, disableThinkingUpTo: undefined },
23
+ });
24
+
25
+ configureAi({
26
+ gateway: providerGateway({
27
+ providers: [
28
+ openAiProvider({
29
+ apiKey: Bun.env['LLM_TOKEN'] ?? '',
30
+ baseUrl: Bun.env['LLM_URL'] ?? 'http://127.0.0.1:8000/v1',
31
+ models: ['house-large'],
32
+ }),
33
+ ],
34
+ defaultModel: 'house-large',
35
+ }),
36
+ });
37
+ ```
38
+
39
+ | Provider | Speaks |
40
+ |---|---|
41
+ | `anthropicProvider({ models, apiKey?, baseUrl? })` | the Anthropic Messages format |
42
+ | `openAiProvider({ baseUrl, models, apiKey, auth? })` | the OpenAI chat-completions format, on any compatible server |
43
+ | your own `Provider` | anything — `{ name, models, generate, stream }` |
44
+
45
+ An agent's model resolves from, first match wins: its `model`, its prompt's `model`, then `providerGateway({ defaultModel })` (`resolveModel`, `packages/ai/src/model-resolve.ts`). The gateway routes it to the first provider whose `models` lists it.
46
+
47
+ The framework ships no default model and no catalogue row (25.0.0): every id is one the app `registerModel`-ed.
48
+
49
+ | Missing | Refusal | Fix |
50
+ |---|---|---|
51
+ | none of the three names a model | `X_AI_MODEL_UNRESOLVED`, naming all three | `model:` on the declaration or in `definePrompt`, or `providerGateway({ defaultModel })` |
52
+ | the id was never registered | `X_AI_MODEL_UNKNOWN`, before any provider call or budget reservation | `registerModel({ id, … })` at boot, in the app's `models.ts` |
53
+
54
+ ## Two per-model rows worth knowing
55
+
56
+ | Row | When |
57
+ |---|---|
58
+ | `sampling: { temperature: true, seed: true }` | the model accepts `temperature` / `seed`. Absent, a declaration's `temperature` and `seed` are not sent to it |
59
+ | `forcedToolChoice: false` | the model rejects a **forced** tool choice. `llm()` offers exactly one tool (`respond`) and the chat-completions wire names it in `tool_choice`; a model whose thinking is always on answers that with a 400 — measured 2026-10-11 through OpenRouter on `anthropic/claude-sonnet-5.5`: `tool_choice: type "tool" and "any" are not supported for this model`. With the row, the tool is offered under the endpoint's default and a prose answer takes the repair turn. `agent()` never forces a choice and is unaffected |
60
+
61
+ ## Quick answers
62
+
63
+ | Question | Answer |
64
+ |---|---|
65
+ | How do I make one agent call another agent? | List it in the caller's `tools`. An agent **is** an action, so it needs nothing else → [An agent is a tool of another agent](#an-agent-is-a-tool-of-another-agent) |
66
+ | How do I cancel an agent run when the user closes the tab? | `ctx.signal`. Over HTTP it fires on the **request deadline** or a **closed tab**, the two joined by `AbortSignal.any` `As of 2026-11` → [Cancellation](#cancellation) |
67
+ | How do I run an agent over 2 million rows without blowing my budget? | `backfill()` for the sweep, `hive()` or `agentJob()` per page, `budget.tokensPerRun` on both, and a shared `BudgetStore` for the fleet-wide ceiling → [Millions of rows](#millions-of-rows) |
68
+ | Do agent tool calls run in parallel? | Yes — every tool of **one turn**, concurrently and unbounded. Turns themselves are serial → [Tools of one turn run concurrently](#tools-of-one-turn-run-concurrently) |
69
+ | How do I stop paying full price for the same long prompt every turn? | Nothing — `agent()` asks the provider to cache each turn's prefix by default. Put text every run shares in `system().stable` → [Provider prompt caching](#provider-prompt-caching) |
70
+ | What stops an agent that loops, or hangs, with nobody watching? | `maxRepeatedToolCalls` (default 3) and `deadlineMs` → [Unattended runs](#unattended-runs) |
71
+ | How do I show progress while an agent is thinking? | Ask its route for `accept: text/event-stream`: each turn's `text`, `tool_call`, `tool_result` and `turn` arrive as chunks, then `done`. Server-side, `onTurn` → [Progress](#progress-onturn) |
72
+
73
+ ## A worked example
74
+
75
+ ```ts
76
+ import { action, t } from '@ultimat3/action';
77
+ import { agent, definePrompt } from '@ultimat3/ai';
78
+ import { can } from '@ultimat3/policy';
79
+
80
+ const lookupOrder = action({
81
+ input: t.object({ orderId: t.string }),
82
+ output: t.object({ status: t.string, totalMinor: t.number.int() }),
83
+ policy: can('order:read'),
84
+ mcp: { expose: true, description: 'Read one order by id.' },
85
+ handle: ({ input }) => ({ status: `shipped ${input.orderId}`, totalMinor: 1999 }),
86
+ });
87
+
88
+ const issueRefund = action({
89
+ input: t.object({ orderId: t.string, amountMinor: t.number.int() }),
90
+ output: t.object({ refundId: t.string }),
91
+ policy: can('order:refund'),
92
+ mcp: { expose: true, description: 'Refund an order. Idempotent on (orderId, amountMinor).' },
93
+ handle: ({ input }) => ({ refundId: `rf_${input.orderId}` }),
94
+ });
95
+
96
+ const supportPrompt = definePrompt<{ orderId: string }>({
97
+ id: 'support',
98
+ version: '1.0.0',
99
+ system: 'You resolve support tickets. Answer only through the respond tool.',
100
+ template: 'Resolve the ticket for order {{orderId}}.',
101
+ });
102
+
103
+ export const supportAgent = agent({
104
+ model: 'house-large', // registered above
105
+ input: t.object({ orderId: t.string }),
106
+ output: t.object({ answer: t.string, refunded: t.boolean }),
107
+ prompt: supportPrompt,
108
+ vars: ({ input }) => ({ orderId: input.orderId }),
109
+ tools: [lookupOrder, issueRefund],
110
+ maxTurns: 6,
111
+ maxToolResultChars: 4_000,
112
+ budget: { tokensPerRun: 200_000, costPerCall: { minor: 50, currency: 'USD' } },
113
+ policy: can('order:support'),
114
+ mcp: { expose: true, description: 'Resolve one support ticket end to end.' },
115
+ onTurn: ({ turn, maxTurns, toolCalls, cost }) => {
116
+ console.log(`turn ${turn}/${maxTurns}: ${toolCalls.join(', ')} — ${cost.minor} ${cost.currency}`);
117
+ },
118
+ });
119
+ ```
120
+
121
+ `supportAgent` is an `Action<TInput, TOutput>`. Call it, project it, queue it, expose it — the six artifacts an action projects are all there, and none of them knows an agent produced them.
122
+
123
+ ## Every field
124
+
125
+ | Field | Required | Default | Behaviour |
126
+ |---|---|---|---|
127
+ | `input` | yes | — | parsed before the loop starts; the action's own `input:` |
128
+ | `output` | yes | — | projected into the forced `respond` tool, and validated on the answer |
129
+ | `prompt` | yes | — | a `definePrompt()` artifact — versioned, content-hashed, evaluated |
130
+ | `vars` | yes | — | the one declared place a run loads data, and the one place a redactor sees it. A `Secret` here is `X_AI_PROMPT_SECRET` |
131
+ | `tools` | yes | — | real `action()`s. Each must be `mcp: { expose: true }` |
132
+ | `offerTools` | no | every tool | the tool names one run is offered, a subset of `tools` → [Per-run picks](#per-run-picks-offertools-model-limits) |
133
+ | `limits` | no | the declaration | ceilings one run may **lower** → [Per-run picks](#per-run-picks-offertools-model-limits) |
134
+ | `policy` | yes | — | the action's own policy, evaluated on every surface identically |
135
+ | `model` | no | the prompt's `model`, else the gateway's `defaultModel`; none is `X_AI_MODEL_UNRESOLVED` | a model id the app registered — [Register your models](#register-your-models-pick-your-provider) — or a function of `{ input, ctx }` picking one per run → [Per-run picks](#per-run-picks-offertools-model-limits) |
136
+ | `maxTurns` | no | **8** | reaching it is `X_AGENT_MAX_TURNS`, never a partial answer |
137
+ | `maxTokens` | no | **4,096** | completion ceiling **per turn**. The model never sees it |
138
+ | `maxToolResultChars` | no | **4,000** | one tool result's ceiling; truncation says so in the transcript |
139
+ | `maxRepeatedToolCalls` | no | **3** | consecutive turns that may repeat the same calls **and** results; reaching it is `X_AGENT_TOOL_LOOP` → [Unattended runs](#unattended-runs) |
140
+ | `deadlineMs` | no | none | wall-clock ceiling for the whole run; passing it is `X_AGENT_DEADLINE` |
141
+ | `temperature`, `seed` | no | not sent | determinism knobs for a verifier or a classifier (`temperature` 0 to 2, `seed` a whole number). **Sent only to a model whose row takes them** — `registerModel({ …, sampling: { temperature: true, seed: true } })`; to any other they are left out, because a reasoning model answers a sampling parameter with a 400, and the `llm.sampling.ignored` span attribute names what was left out. `seed` exists on the chat-completions wire only (the Messages API has none) and is best effort where it exists; `temperature: 0` narrows variation, it does not promise identical answers. Same two keys on `llm()` |
142
+ | `system` | no | — | system text loaded at run time, as `{ stable?, run? }` → [Provider prompt caching](#provider-prompt-caching) |
143
+ | `promptCache` | no | **`'auto'`** | `'auto'` \| `'off'` \| `{ session }` — asks the provider to cache each turn's prefix |
144
+ | `compact` | no | **`'auto'`** | `'auto'` \| `'off'` \| `{ at, keepTurns, model, maxSummaryTokens }` — summarise older turns before a request that would outgrow the context window → [Auto-compaction](#auto-compaction-compact) |
145
+ | `budget` | no | unmetered | `tokensIn`, `costPerCall`, `tokensPerRun`, `costPerRun` |
146
+ | `mcp` | no | not exposed | exposes the agent itself as a tool |
147
+ | `onTurn` | no | — | one call per completed turn, awaited |
148
+ | `onCompact` | no | — | one call per compaction attempt, awaited → [Auto-compaction](#auto-compaction-compact) |
149
+ | `onCheckpoint` | no | — | `(event, { input, ctx })` between turns, with a signed handle to resume from, awaited → [Checkpoints](#checkpoints-oncheckpoint) |
150
+
151
+ ## Tools are real actions
152
+
153
+ `tools: [lookupOrder, issueRefund]` — the imports themselves, not adapters. `agent()` accepts `AnyAction | ProjectableAction`; the real `action()` comes first because it is what an app has.
154
+
155
+ | Rule | Why |
156
+ |---|---|
157
+ | An **action** used as a tool must declare `mcp: { expose: true }` | `isMcpExposed` is the one predicate, so an in-app agent and an external MCP client are offered exactly the same catalogue of actions. A tool no MCP client may ever see is an [`inProcessTool()`](#in-process-tools) instead |
158
+ | A tool that is neither is `X_AGENT_TOOL_UNEXPOSED` **at declaration**, never filtered at the call | a silently dropped tool reads as offered and is not — the worst of both |
159
+ | The tool's name is its **export name, verbatim** | the same name `tools/call`, `scopes:` and `openapi.json` use. No second spelling |
160
+ | The tool's own `policy` decides every call | there is no "LLM permissions" concept, because there is no second authz system |
161
+ | A tool in `mcpConfirmations({ tools })` waits for a person here too | one list, both surfaces, so there is no `agent({ confirmations })` to forget. A prompt-injected in-app agent is the one with the less trusted input → [Confirmation](#a-tool-that-waits-for-a-person) |
162
+ | The tool's own `input:` parses what the model sent | which is what drops an `{ actor: 'admin' }` the model invented, before any handler sees it |
163
+ | A tool failure is a `tool_result` flagged `is_error`, not a crash | a policy denial is an outcome the model should read and react to |
164
+ | A failure's fix is the **caller's** when the error declares one (`callerFix`) | a model is a caller, as an app's MCP client is: `x policy explain` is not a command it can run |
165
+ | A 5xx failure's cause reaches the model only when its code declares it public | the provider keeps every request it is sent; a `X_DB_STATEMENT_FAILED` reads as the code and a fixed sentence — the verdict a production problem document and an MCP client get |
166
+ | An **in-process** tool's own typed refusal reaches the model whole | a tool marked `inProcess: true` is the app's code called by its own agent. An `UltimateError` it throws under a code nobody gave an HTTP status is rendered as `{"error":{"code","cause","fix"}}` (the `callerFix` when it has one) — the model needs exactly that to correct its call. Still withheld: an uncoded throw, a framework 5xx, a code the app registered ≥ 500, a look-alike object; never a stack. `discloseErrors: false` opts out |
167
+ | The actor is **`ctx.actor`**, read once, and nothing the model emits can reach it | this is the mistake a hand-rolled loop ships, and the reason the loop belongs in the framework |
168
+
169
+ An unknown tool name answers `unknown tool: <name>` as an error result rather than throwing — the model asked for something it was not offered, which is a turn to correct, not a run to abort.
170
+
171
+ ### A tool that waits for a person
172
+
173
+ `mcpConfirmations({ tools: ['refundOrder'], … })` gates that name wherever it is called from: over MCP, and from `agent({ tools: [refundOrder] })`. The order is the tool's policy (actor half), then the gate, then `invoke`, so a caller the tool refuses outright opens no row.
174
+
175
+ No new run outcome and no deferred tool. The call is a `tool_result` flagged `is_error`, `X_MCP_CONFIRMATION_PENDING`, carrying the id and what a caller does next:
176
+
177
+ 1. The model reports the pending confirmation through `output`. Give `output` a field for it.
178
+ 2. A person approves through the decision action (`view`, then `approve` with exactly the `arguments` it showed).
179
+ 3. A later user turn repeats the call with the same arguments. It consumes the approval and runs once, through the tool's own policy; the next identical call asks again.
180
+
181
+ | Rule | Why |
182
+ |---|---|
183
+ | An approval binds actor, tool and the digest of the validated, defaulted arguments, not the surface | a repeat spelling a default differently consumes it; other arguments or another actor ride none. An in-app agent's actor is the user and an MCP agent's its own, so the two rarely meet; the same actor on both shares one row |
184
+ | Arguments the published schema refuses open no row: `X_INPUT_INVALID` | refused at the gate, never passed on. The action's own parse drops a key it does not declare, so `{ orderId, invented: 1 }` would otherwise run with nobody asked |
185
+ | The gate binds a **tool's name**, never what the tool reaches | `inProcessTool({ name: 'refundNow', execute: () => invoke(refundOrder, …) })` is not gated by `refundOrder`'s row: list the tool's own name in `tools` |
186
+ | A server handed no `confirmations` still waits: it asks the registered gate per call | forgetting the option is no way around the list. A server's own `confirmations` wins for the names it gates |
187
+ | A gated name no tool answers to is `X_MCP_CONFIRMATION_TOOL_UNKNOWN` at the `manifest` step | `tools: ['refundOrdr']` holds nothing, and the real `refundOrder` would run with nobody asked |
188
+ | Two `mcpConfirmations` on one name: each MCP server keeps its own, and **in-app the first registered answers** | any gated tool still waits for a person in-app. A later one for that name is a no-op, so a person decides through the first registered |
189
+ | The decision action is not a tool: it declares no `mcp.expose`, so `agent({ tools: [decide] })` is `X_AGENT_TOOL_UNEXPOSED` | its `kind === 'agent'` refusal does not stop an in-app agent, whose actor is the user. **Exposing the decision action hands an in-app agent its own approval** |
190
+ | A gate declared only in a module a worker does not load is `X_ROLE_LOAD_INCOMPLETE` at the `manifest` step | an `agentJob()` reads the gate by name, and absent there the call would run ungated. The finding's fix names the import |
191
+
192
+ ### In-process tools
193
+
194
+ `As of 2026-10`. A tool that exists only for an agent in this process — a knowledge-base read for an unattended writer, a scratch calculation — is not an action and is never an MCP tool:
195
+
196
+ ```ts
197
+ import { inProcessTool } from '@ultimat3/ai';
198
+ import { defineError } from '@ultimat3/core';
199
+ import { t } from '@ultimat3/schema';
200
+
201
+ declare function loadDoc(id: string): Promise<{ id: string; body: string } | undefined>;
202
+
203
+ const KbDocNotFound = defineError<string>({
204
+ code: 'X_KB_DOC_NOT_FOUND',
205
+ title: 'no such knowledge-base document',
206
+ status: 404,
207
+ cause: (id) => `no knowledge-base document has the id "${id}"`,
208
+ fix: 'kbList() # call it first and pass one of the ids it returns',
209
+ });
210
+
211
+ export const kbRead = inProcessTool({
212
+ name: 'kbRead',
213
+ description: 'Read one knowledge-base document by id.',
214
+ input: t.object({ id: t.string }),
215
+ execute: async ({ input }) => {
216
+ const doc = await loadDoc(input.id);
217
+ if (doc === undefined) throw new KbDocNotFound(input.id);
218
+ return doc;
219
+ },
220
+ });
221
+ ```
222
+
223
+ | | |
224
+ |---|---|
225
+ | What it needs | `name` (letters, digits, `_`, `-`; ≤ 64; not `respond`), `description`, `input`, `execute`. No `mcp`, no `policy`, no registration |
226
+ | What sees it | the agents that list it. It is in no MCP catalogue, no HTTP route, no `openapi.json`, no manifest row |
227
+ | Authorization | the **agent** is the operation with a policy. `execute` runs as `ctx.actor` inside it, exactly as code in the agent's own handler would; anything it reaches that needs its own authorization is an `action()` it calls, whose policy decides |
228
+ | Its input | parsed by `input` before `execute` runs; arguments that fail read back to the model as `X_INPUT_INVALID` naming the fields |
229
+ | Its refusals | an `UltimateError` it throws reaches the model as `{"error":{"code","cause","fix"}}` (row above). `cause` is cut to 1,000 characters and `fix` to 500 **before** it is serialised, so `maxToolResultChars` never cuts the JSON mid-string |
230
+ | What a refusal must not say | the `cause` and `fix` you write are sent to the model's provider, which keeps every request. **Never put another tenant's data, a row's contents or SQL in them** — name the id the model passed and what to do instead. The framework cannot check this: it is the app's own text |
231
+ | Withholding them | one tool: `inProcessTool({ discloseErrors: false })`. Every in-process tool of one agent: `agent({ discloseToolErrors: false })`. Either way the model reads the code and a fixed sentence, as it does for a projected tool |
232
+
233
+ ## An agent is a tool of another agent
234
+
235
+ An `agent()` returns an action, and a tool is an action. So a supervisor lists a sub-agent in its own `tools` and nothing else is needed — no supervisor primitive, no delegation API.
236
+
237
+ ```ts
238
+ import { t } from '@ultimat3/action';
239
+ import { agent, definePrompt } from '@ultimat3/ai';
240
+ import { can } from '@ultimat3/policy';
241
+ import { supportAgent } from './support';
242
+
243
+ const supervisorPrompt = definePrompt<{ ticketIds: string }>({
244
+ id: 'supervisor',
245
+ version: '1.0.0',
246
+ template: 'Triage these tickets, delegating each to supportAgent: {{ticketIds}}.',
247
+ });
248
+
249
+ export const supervisor = agent({
250
+ input: t.object({ ticketIds: t.array(t.string) }),
251
+ output: t.object({ handled: t.number.int() }),
252
+ prompt: supervisorPrompt,
253
+ vars: ({ input }) => ({ ticketIds: input.ticketIds.join(', ') }),
254
+ tools: [supportAgent],
255
+ maxTurns: 12,
256
+ policy: can('order:support'),
257
+ });
258
+ ```
259
+
260
+ The sub-agent runs under the **same actor** and through its **own policy**, exactly as any other tool does. It needs `mcp: { expose: true }` like any other tool. Its `budget` derives from the supervisor's, tightening and never widening — see [Budgets](#budgets-and-the-ceiling-that-holds-under-concurrency).
261
+
262
+ For a fan-out that is a *fixed* list rather than a model's choice, use [`hive()`](#hive--many-members-one-action) instead: the split is declared, not emitted by a model.
263
+
264
+ ## Structured output is the `respond` tool
265
+
266
+ There is no JSON mode and no `response_format`. `output` is projected into one tool named `respond`, offered alongside the app's tools, and the answer is read out of that tool call.
267
+
268
+ | Step | Behaviour |
269
+ |---|---|
270
+ | The model calls app tools | their results go back as `tool_result` blocks and the loop takes another turn |
271
+ | The model calls `respond` | its arguments are validated against `output` |
272
+ | Validation fails | the failure is fed back as a message naming the issues, and the loop takes **another turn** — unlike `llm()`, which gets exactly one repair |
273
+ | Every turn used, and the last failure was a shape | `X_LLM_OUTPUT_INVALID`, naming the attempts and the issues |
274
+ | Every turn used, and the loop kept calling tools | `X_AGENT_MAX_TURNS`, naming the turns and the tool-call count |
275
+ | Any turn stops at its `maxTokens` (or the context window) | `X_LLM_TRUNCATED`, before a tool runs and before the answer is validated: a cut-off `respond` call reads as `{}`, which an `output` of optional keys would accept |
276
+ | The provider refused | `X_LLM_REFUSED`, carrying `stopDetails.category` and naming a more capable model |
277
+
278
+ Two exhaustions, two codes, on purpose: a loop that kept calling tools and never answered is not the same event as one that answered the wrong shape every time.
279
+
280
+ `tool_choice` is never forced on an agent. Forcing it would decide the model's next step for it, which is the one decision a tool loop exists to leave open.
281
+
282
+ ## Tools of one turn run concurrently
283
+
284
+ Every tool a single turn asked for runs **at once**, through one `Promise.all`, and deliberately unbounded.
285
+
286
+ | Property | Detail |
287
+ |---|---|
288
+ | Concurrency | the whole batch, in parallel. A turn asking for five tools costs one tool's wall clock, not five |
289
+ | No second ceiling here | the batch is bounded by what one turn asked for, and each entry is an action carrying its own `policy` and its own `rateLimit`; a ceiling here would be a throttle competing with those |
290
+ | Ordering | **positional**, never by completion. `Promise.all` resolves by index and each result carries the `tool_use` id it was handed, so a fast tool answering first cannot be paired with a slow tool's call |
291
+ | Turns | serial, always. Every turn re-sends the whole transcript, which is why cost grows quadratically in turns and `maxTurns` defaults low |
292
+ | Budget under parallelism | still holds — every reservation debits before it checks, so concurrent calls cannot all read the same `spent` |
293
+
294
+ ## Budgets, and the ceiling that holds under concurrency
295
+
296
+ ```ts
297
+ budget: {
298
+ tokensIn: 8_000, // prompt tokens, per call
299
+ tokensPerRun: 200_000, // every turn of this run, counted
300
+ costPerCall: { minor: 50, currency: 'USD' }, // worst-case price of one call
301
+ costPerRun: { minor: 500, currency: 'USD' }, // the whole run, checked before each turn
302
+ }
303
+ ```
304
+
305
+ | Scope | Counts | Declared on |
306
+ |---|---|---|
307
+ | `tokensIn` | prompt tokens of one call | `agent()`, `llm()`, `hive()` |
308
+ | `tokensPerRun` | prompt + completion across **every turn and every nested call** of one run | `agent()`, `hive()` |
309
+ | `costPerCall` | worst-case price of one call, integer minor units | `agent()`, `llm()`, `hive()` |
310
+ | `costPerRun` | what the run cost so far (every turn, continuation and nested call) plus the next turn's worst case, checked before each turn → [Per-run picks](#per-run-picks-offertools-model-limits) | `agent()` |
311
+ | `actor` / `org` | tokens across a whole window, per identity — the CALLER's: `llm()`, `agent()` and `hive()` key it `budgetKeysFor(ctx.actor)` | `providerGateway({ budget })` |
312
+
313
+ A budget **refuses**; it never truncates. A shortened prompt yields a confidently wrong answer with no signal, and `X_AI_BUDGET_EXCEEDED` names the scope and what remains.
314
+
315
+ **Why the ceiling holds when members run in parallel.** Two mechanisms, both in [`packages/ai/src/budget.ts`](https://github.com/developerz-ai/ultimate/blob/main/packages/ai/src/budget.ts):
316
+
317
+ | Mechanism | What it fixes |
318
+ |---|---|
319
+ | `reserve()` **debits, then checks** — never a read-then-write across an `await` | the in-memory `request` counters are checked and debited with nothing awaited between; the store's `actor` / `org` counters go through `BudgetStore.take(key, tokens, limit)`, one atomic step. A read-then-write let concurrent calls — one request per scope racing one org key — all read `spent() === 0` and all pass. A refusal at a later scope gives back what the earlier ones took; `record()` replaces the estimate with the provider's real counts, `release()` gives it back when the call never happened |
320
+ | `derive()` takes the **tighter** of each limit and never the looser | a per-call budget on an inner `agent()` must not be able to widen the actor or org ceiling it runs inside |
321
+ | `reserveNow()` walks the **whole** parent chain | each ledger keeps its own counter, and the tightest limit is not always the one with the most spent against it |
322
+
323
+ `take` is required of every `BudgetStore`, and a store shared across **processes** must make it atomic on its own side — a Redis `EVAL`, a SQL `update … where spent + $n <= $limit`. The default `memoryBudgetStore()` is per process and resets on every deploy: `org: 20_000_000` at six replicas is six ledgers of twenty million.
324
+
325
+ ## Cancellation
326
+
327
+ `ctx.signal` is the one cancellation seam, and `agent()` reads it in three places: at the top of every turn, before every tool batch, and on the `GenerateRequest` itself — so a provider call already in flight is cut rather than paid for. An aborted run throws `X_ABORTED`.
328
+
329
+ That matters because the transcript **is** the request: a loop that keeps going after the caller is gone re-sends the whole transcript once per remaining turn, runs every remaining tool's side effects, and discards the answer.
330
+
331
+ **Where the signal comes from, `As of 2026-08`:**
332
+
333
+ | Surface | `ctx.signal` fires when |
334
+ |---|---|
335
+ | HTTP | the **request deadline** passes, **or the caller goes away** — the two are joined with `AbortSignal.any` ([`packages/http/src/deadline.ts`](https://github.com/developerz-ai/ultimate/blob/main/packages/http/src/deadline.ts)). The deadline is `requestTimeoutMs` (30s), or the shorter budget the caller sent as `x-request-timeout-ms` |
336
+ | Job / `agentJob()` | the attempt times out, or the job is cancelled — `x jobs cancel <id> --reason "…"`. The worker's own `ctx` is what `agentJob` forwards, so the abort reaches the turn loop |
337
+ | Your own code | a signal you pass: `withChildContext({ signal }, () => supportAgent(input, { ctx }))` |
338
+
339
+ **A closed browser tab DOES abort an in-flight agent, `As of 2026-08`.** `pipeline.ts` hands the inbound `Request.signal` to `startDeadline`, which joins it with the timer's controller through `AbortSignal.any` — so a disconnect unwinds the turn loop instead of paying for every remaining turn. This page said the opposite until 12.0.0, and it was true when it was written: nothing in `@ultimat3/http` read the inbound signal, so a closed tab held its handler, its pool slot and its vendor connection for the whole budget.
340
+
341
+ | Want | Do |
342
+ |---|---|
343
+ | every agent request bounded | call `configureHttp({ requestTimeoutMs: 120_000 })` at module scope in a file under `apps/*/` — `ctx.signal` fires there and `X_TIMEOUT` answers the socket either way |
344
+ | this call bounded shorter | send `x-request-timeout-ms` with the milliseconds you will wait; a caller may only **shorten** the deadline, never lengthen it, and anything under 1ms falls back to the server's own budget |
345
+ | the next hop bounded by what is LEFT | nothing — both typed clients spread `traceHeaders()` before your own headers, and it sends the **remaining** budget as `x-request-timeout-ms`. A spent budget sends no header rather than `0`, because `0` reads one hop later as "the caller asked for nothing" |
346
+ | a run the user can actually stop | run it as a job — `agentJob()` — and cancel it by id: `x jobs cancel <id> --reason "user cancelled"` |
347
+
348
+ ## Provider prompt caching
349
+
350
+ Every turn re-sends the whole transcript, so a run with a long stable prefix — rules, a knowledge-base index — pays for that prefix once per turn. `agent()` asks the provider to cache it: **on by default**.
351
+
352
+ ```ts
353
+ import { action, t } from '@ultimat3/action';
354
+ import { agent, definePrompt } from '@ultimat3/ai';
355
+ import { can } from '@ultimat3/policy';
356
+
357
+ const readDocument = action({
358
+ input: t.object({ id: t.string }),
359
+ output: t.object({ body: t.string }),
360
+ policy: can('kb:read'),
361
+ mcp: { expose: true, description: 'Read one knowledge-base document by id.' },
362
+ handle: ({ input }) => ({ body: `body of ${input.id}` }),
363
+ });
364
+
365
+ // Your own loader. What it returns must be the same BYTES run to run: sorted, undated.
366
+ const knowledgeIndex = async (orgId: string): Promise<string> => `INDEX for ${orgId}\n…`;
367
+
368
+ const kbPrompt = definePrompt<{ question: string }>({
369
+ id: 'kb',
370
+ version: '1.0.0',
371
+ system: 'Answer from the knowledge base only. Answer through the respond tool.',
372
+ template: 'Question: {{question}}',
373
+ });
374
+
375
+ export const kbAgent = agent({
376
+ model: 'house-large',
377
+ input: t.object({ question: t.string, orgId: t.string }),
378
+ output: t.object({ answer: t.string }),
379
+ prompt: kbPrompt, // its static `system` starts the stable block
380
+ vars: ({ input }) => ({ question: input.question }),
381
+ // Text loaded at run time. `stable` joins the prompt's system in ONE block every run shares;
382
+ // `run` is a second block after it, for whatever differs.
383
+ system: async ({ input }) => ({
384
+ stable: await knowledgeIndex(input.orgId),
385
+ run: `Asked on ${new Date().toISOString().slice(0, 10)}.`,
386
+ }),
387
+ // 'auto' (the default) | 'off' | { session } — a key for hosts that route by one
388
+ promptCache: { session: ({ input }) => `kb:${input.orgId}` },
389
+ tools: [readDocument],
390
+ maxTurns: 10,
391
+ deadlineMs: 120_000,
392
+ policy: can('kb:ask'),
393
+ onTurn: ({ turn, usage, run }) => {
394
+ console.log(`turn ${turn}: read ${usage.cacheReadTokens}, wrote ${usage.cacheWriteTokens}`);
395
+ console.log(`run so far: ${run.cost.minor} ${run.cost.currency}`);
396
+ },
397
+ });
398
+ ```
399
+
400
+ | Turn | What the provider is asked | What it bills |
401
+ |---|---|---|
402
+ | 1 | cache the stable system block, and the transcript up to its tail | the prefix as a cache **write** |
403
+ | N+1 | the same, with the mark moved to the new tail | turn N's prefix as a cache **read**, only the new tail as a write |
404
+ | 1 of the next run | the same stable block, byte for byte | the stable block as a read, while the cache entry lives (5 minutes, refreshed by each read) |
405
+
406
+ **What is sent depends on the wire and the host** — `PROMPT_CACHE_RULES` in [`prompt-cache.ts`](https://github.com/developerz-ai/ultimate/blob/main/packages/ai/src/prompt-cache.ts), first match wins. `As of 2026-10`:
407
+
408
+ | Provider | Endpoint | Model id | Mark | `session` goes |
409
+ |---|---|---|---|---|
410
+ | `anthropicProvider()` | any | any | `cache_control: { type: 'ephemeral' }` on the stable system block and the last block of the last two messages — 3 of the 4 breakpoints | nowhere |
411
+ | `openAiProvider()` | `openrouter.ai` | `anthropic/…` | the same mark on the stable system **part** and the tail message's last part — 2 breakpoints | `x-session-id` header |
412
+ | `openAiProvider()` | `openrouter.ai` | anything else | **none** — Gemini and OpenAI cache implicitly, and an explicit mark on Gemini turns a free cache into a paid write | `x-session-id` header |
413
+ | `openAiProvider()` | `api.openai.com` (the default) | any | none — caching is automatic | `prompt_cache_key` body field |
414
+ | `openAiProvider()` | any other `baseUrl` | any | none | nowhere |
415
+
416
+ **Your own endpoint is a row on its provider**, read before the table, first match by model prefix
417
+ (`As of 2026-11`): `openAiProvider({ …, promptCache: { rules: [{ model: 'anthropic/', marks: 'parts', session: 'x-session-id' }] } })`
418
+ for a company gateway fronting a marking family; `anthropicProvider({ …, promptCache: { rules: [{ model: '', marks: 'none', session: 'none' }] } })`
419
+ for a proxy that rejects `cache_control`. The budget's estimate follows the row. A mark or session
420
+ carrier the wire cannot speak — `parts` or a session on the Messages wire, `blocks` on
421
+ chat-completions — is `X_AI_REQUEST_INVALID` when the provider is built.
422
+
423
+ | Rule | Detail |
424
+ |---|---|
425
+ | The stable block must be the same **bytes** | a cache is a prefix match. A timestamp, a request id or an unsorted list in `stable` is a full-price write on every call — put it in `run` |
426
+ | Below the model's minimum nothing is cached | 512 to 4,096 tokens depending on the model (`ModelSpec.cacheMinimumTokens`); a shorter prefix is processed uncached, with no error |
427
+ | `session` is a routing key, not a secret | on OpenRouter it leaves the process in a header: 1 to 256 printable ASCII characters, else `X_AI_REQUEST_INVALID`. On OpenAI a key over 64 characters is sent as a hash of itself (`h:` + 62 hex) — the same key always groups the same requests. OpenAI's guide states no maximum; 64 is a conservative ceiling `As of 2026-10` |
428
+ | The Anthropic row matches any host | `anthropicProvider({ baseUrl })` pointed at a proxy still sends `cache_control`: a server speaking that wire is assumed to speak its marks, unless the provider declares a `marks: 'none'` row |
429
+ | Never an empty block, never a fifth mark | a mark moves back past an empty or whitespace-only text block, or is not sent; marks a raw request already carries count against the vendor's four, and the framework's are dropped first — the message before the tail, then the system block, then the tail |
430
+ | More than about 9 tool calls in **one** turn, on OpenRouter | that turn's transcript is not read back: only the tail is marked there, the provider looks back 20 blocks from it, and each call adds two. The next turn pays a write for the transcript; the stable system block still reads |
431
+ | No routing preference is ever sent | OpenRouter skips sticky routing when a request orders its providers, and a cache lives on one upstream |
432
+ | `llm()` is **off** by default | `promptCache: 'auto'` on an `llm()` that is called many times with one long `system`. A one-shot call that marks its prefix pays the write premium for a cache nothing reads |
433
+ | A direct `gateway.generate()` call | `system: { stable, run }` and `promptCache: {}` on the request — the same two fields the loop sets |
434
+
435
+ **Reading it back.** `onTurn`'s `usage` carries `cacheReadTokens` and `cacheWriteTokens` per turn, `run` carries the running total (`run.usage`, `run.cost`), and the `agent.turn` span event carries `llm.cache.read_tokens` / `llm.cache.write_tokens`. A run whose later turns read nothing has an unstable prefix.
436
+
437
+ **What it costs.** `costOf` prices a read at the model row's `cacheReadPerMillion` (absent: 0.1x input) and a write at `cacheWritePerMillion` (absent: 1.25x input). A token budget (`tokensPerRun`, `tokensIn`, the gateway's actor and org ceilings) counts **every** token the provider processed — input, output, cache read and cache write, each as one — so caching lowers what a run costs, not how many tokens it is allowed. The pre-flight estimate prices the prompt as a cache write **only when a provider serving the model would actually mark the request** (an Anthropic model; not Gemini, OpenAI or an unknown host, whose estimate is what it was), and at the dearer of the write and input rates — so `costPerCall` holds on turn one.
438
+
439
+ **One total.** Cost is computed exactly and rounded up once. `run.cost` is the run's usage summed and priced once. The ledger — `gateway.spent()`, and what `costPerCall` and every token ceiling are enforced against — sums the exact cost of every recorded call and rounds once when it is read, so for a run on the built-in providers the two agree. A `Provider` an app wrote that states no `costExact` is summed from its rounded `cost`, and can then read higher by under one minor unit per call.
440
+
441
+ Measured, 2026-10-11, one three-turn run against OpenRouter with a 19,914-token stable prefix: turn 1 wrote 19,914 tokens and read 0; turn 2 read 19,914 and wrote 72; turn 3 read 19,986 and wrote 68.
442
+
443
+ ## Unattended runs
444
+
445
+ Three bounds stop a run nobody is watching, beside `maxTurns` and `budget.tokensPerRun`:
446
+
447
+ | Bound | Default | Reaching it |
448
+ |---|---|---|
449
+ | `maxRepeatedToolCalls` | **3** | `X_AGENT_TOOL_LOOP` — the most recent tool turns are one cycle gone round that many times: the same turn over and over, or a cycle of 2 or 3 turns (A,B,A,B…). "The same" is the calls, their arguments, the results and whether each failed, so a polled tool whose answer changes is progress and is left alone. A repair turn (a wrong-shaped answer) is not a tool turn: it neither counts nor resets. A longer cycle is `maxTurns`' to stop |
450
+ | `deadlineMs` | none | `X_AGENT_DEADLINE` — the run was still going; the model call in flight is aborted on the socket, every tool's own `ctx.signal` fires so a tool that reads it stops, and no further turn starts. A tool that ignores its signal is no longer waited for but **does run to its own end** — its effects can land after the run was refused |
451
+ | `maxToolResultChars` | **4,000** | not an error — the result is truncated, and says so in the transcript |
452
+
453
+ **Under a job.** `X_AGENT_TOOL_LOOP`, `X_AGENT_DEADLINE`, `X_LLM_TRUNCATED`, `X_LLM_OUTPUT_INVALID`, `X_LLM_TOOL_ARGUMENTS_INVALID` and `X_AGENT_TRANSCRIPT_INVALID` are registered `terminal` — the same input fails the same way, so the caller changes `maxTokens` or the prompt: an `agentJob()` dead-letters on the first attempt instead of buying the same run once per `retry.attempts`.
454
+
455
+ A caller that goes away mid-call is `X_ABORTED`, not the transport failure (`X_AI_PROVIDER_UNAVAILABLE`, retryable) the aborted socket used to be reported as. `X_ABORTED` is core's and unclassified, so over HTTP it reads `terminal`; in a job the attempt count stays in charge, and a drain or an attempt timeout is settled by jobs off its own signal as before.
456
+
457
+ **What a failed run cost.** Every failure thrown by `llm()` or `agent()` after a provider call was started carries the run's bill on `meta.billed`; read it with `billedOf(error)`:
458
+
459
+ ```ts
460
+ import type { Billed } from '@ultimat3/ai';
461
+ import { billedOf } from '@ultimat3/ai';
462
+
463
+ declare function recordSpend(runId: string, billed: Billed): Promise<void>;
464
+
465
+ export async function metered<T>(runId: string, run: () => Promise<T>): Promise<T> {
466
+ try {
467
+ return await run();
468
+ } catch (error) {
469
+ const billed = billedOf(error); // undefined: no provider call was ever started
470
+ if (billed !== undefined) await recordSpend(runId, billed);
471
+ throw error;
472
+ }
473
+ }
474
+ ```
475
+
476
+ | Field | Meaning |
477
+ |---|---|
478
+ | `usage` | the four token counters, summed over every provider call of the run that **answered** |
479
+ | `cost` | the price of `usage`, `Money`, rounded up once |
480
+ | `calls` | how many calls answered |
481
+ | `unsettled` | calls that were started and never reported usage — aborted by the caller or the deadline, cut mid-stream, failed in transport. **Their bill cannot be known**: a provider may charge for what it generated before the socket closed, and usage is the last thing a response carries. They are counted, never estimated, and their budget reservation is given back |
482
+
483
+ A tool that itself calls `llm()` or another `agent()` spends under the run that called it: that spend is in `usage`, `calls` and `cost` here, in `onTurn`'s `run`, and in `AgentRun.usage` — including a nested call that failed and reached the model only as a tool error.
484
+
485
+ **What a piece of work cost.** One total across several calls — a job that runs two `llm()` calls and an `agent()` — is `meteredRun(fn)`: every provider call `fn` makes, however deeply nested, summed once. A throw is rethrown with that total on `meta.billed`, so the `billedOf(error)` above reads the whole job's bill, not the last call's. It meters; it refuses nothing — a ceiling is `budget`'s.
486
+
487
+ ```ts
488
+ import type { Billed } from '@ultimat3/ai';
489
+ import { meteredRun } from '@ultimat3/ai';
490
+
491
+ declare function recordSpend(runId: string, billed: Billed): Promise<void>;
492
+ declare function draftAndReview(postId: string): Promise<string>;
493
+
494
+ export async function reviewJob(runId: string, postId: string): Promise<string> {
495
+ const { value, billed } = await meteredRun(() => draftAndReview(postId));
496
+ if (billed !== undefined) await recordSpend(runId, billed); // undefined: no model call started
497
+ return value;
498
+ }
499
+ ```
500
+
501
+ It is the same on `X_LLM_TRUNCATED`, `X_LLM_REFUSED`, `X_LLM_OUTPUT_INVALID`, `X_LLM_TOOL_ARGUMENTS_INVALID`, `X_AGENT_MAX_TURNS`, `X_AGENT_TOOL_LOOP`, `X_AGENT_DEADLINE`, `X_ABORTED`, `X_AI_BUDGET_EXCEEDED` and a transport failure on a later turn. The ledger the run ran under holds the same settled figures. `meta` is operator-only: it is never in a problem document. A throw that is not an `UltimateError` (an `onTurn` that threw a `TypeError`) has no `meta` to carry it; the ledger still does.
502
+
503
+ ## Continuing a run (`.run()` / `.continue()`)
504
+
505
+ `As of 2026-10`. A plain call returns the answer and keeps nothing. To correct a finished answer without starting over, keep the transcript and continue from it:
506
+
507
+ ```ts
508
+ import type { AgentAction, AgentTranscript } from '@ultimat3/ai';
509
+ import type { Ctx } from '@ultimat3/core';
510
+ import type { t } from '@ultimat3/schema';
511
+
512
+ type Article = ReturnType<typeof t.object<{ title: typeof t.string }>>;
513
+ declare const writeArticle: AgentAction<ReturnType<typeof t.object<{ topic: typeof t.string }>>, Article>;
514
+ declare function store(transcript: AgentTranscript): Promise<void>;
515
+
516
+ export async function writeThenFix(ctx: Ctx, problem: string): Promise<{ title: string }> {
517
+ const input = { topic: 'tides' };
518
+ const first = await writeArticle.run(input, { ctx });
519
+ await store(first.transcript); // JSON; pass back exactly this value
520
+ const second = await writeArticle.continue(
521
+ input,
522
+ { transcript: first.transcript, user: `Fix this and answer again: ${problem}` },
523
+ { ctx },
524
+ );
525
+ return second.output;
526
+ }
527
+ ```
528
+
529
+ | | |
530
+ |---|---|
531
+ | `.run(input, opts)` | the same action — policy, audit, rate limit, budget — resolving to `{ output, transcript, turns, usage }` |
532
+ | `.continue(input, { transcript, user }, opts)` | appends ONE user turn and resumes the loop. `input` is the run's own input again (the policy and `system()` read it); `vars()` is not called and the prompt is not re-rendered — the transcript holds it. `user` is redacted like the prompt |
533
+ | what is re-sent | the whole transcript, byte for byte, then the new turn. A provider has no session, so the tokens are sent again — but the prefix is the one the previous request cached, so it is read at the cache rate, and **no tool runs twice** |
534
+ | `maxTurns` | counts the run **and every continuation**: a run that used 5 of 8 has 3 left; one that used them all is `X_AGENT_MAX_TURNS` before a provider call |
535
+ | `budget.tokensPerRun`, `budget.costPerRun` | cumulative too: the ceiling for a continuation is what the earlier calls left; none left is `X_AI_BUDGET_EXCEEDED` before a provider call. The actor and org scopes are charged once per call, as always |
536
+ | `deadlineMs`, `maxRepeatedToolCalls` | per call |
537
+ | `usage`, `onTurn`'s `run` and `turn` | cumulative across continuations; `meta.billed` on a failure is the failed **call's** own bill |
538
+
539
+ **The handle is signed data.** `AgentTranscript` is plain JSON that the app stores; treat it as opaque. It holds what the model was sent (already redacted) and what the tools answered, so store it where tool results may live. It also carries the run's own ceilings — `turns` and `usage` — so it is **signed**: an HMAC-SHA256 over its canonical bytes with the framework's signing secret (`ULTIMATE_CURSOR_SECRET`, under a label of its own), and **bound** to the agent (`id@version` of its prompt: a bumped prompt starts a new run), the model the run is on, the set of tool names the run was offered (every declared one unless [`offerTools()`](#per-run-picks-offertools-model-limits) narrowed it), and the tenant (`orgId`) of the actor it was written for. On the way back, before anything is loaded or spent, `.continue()` checks: at most `MAX_TRANSCRIPT_CHARS` (2,000,000) of JSON; the signature; each binding (a handle written for org A is refused for an actor of org B); then its shape — roles alternating, only the three block kinds a loop writes, every tool call answered by the next message, every tool call naming a tool the agent declares. Anything else is `X_AGENT_TRANSCRIPT_INVALID` (422, `terminal` for a job). An edited handle is therefore refused, not trusted: zeroing `usage`, lowering `turns`, rewriting a message or fabricating a tool result all break the signature. Rotating the secret invalidates stored handles. In production a process still on the shipped development key refuses `.continue()` and names `ULTIMATE_CURSOR_SECRET`; in development and test the development key signs. What the signature does not do: it does not make the transcript an authority — tools still run as `ctx.actor` under their own policies — and it does not stop a holder of a **valid** handle from continuing it, so store it per tenant like any other row.
540
+
541
+ **A failed call leaves a handle too.** When `.run()` or `.continue()` throws after a provider answered, the error carries a signed handle — `transcriptOf(error)` — holding the transcript up to the request the failed turn was answering, with `turns` and `usage` counting everything the failed call was billed. **A job stores that handle, not the one it started with.** Continuing from the old handle forgets the failed call's spend: `maxTurns` and `budget.tokensPerRun` then count from the old figures, and only the actor and org ceilings bound the retries. A call aborted before it reported usage is not in the handle (its bill is unknown — `billedOf(error).unsettled`). A plain call keeps nothing and leaves no handle.
542
+
543
+ ## Auto-compaction (`compact`)
544
+
545
+ `As of 2026-10`. Every turn re-sends the whole transcript, so a long tool loop grows until it no longer fits the model's context window. Before each turn, `agent()` estimates that turn's request — the gateway's prompt estimate plus the tool definitions, scaled by what the provider reported for the last turn — against the model row's `contextWindow` less `maxTokens` (the room). Over `at` of the room, ONE model call summarises every turn older than the last `keepTurns`, and the turn is sent compacted:
546
+
547
+ | Before | After |
548
+ |---|---|
549
+ | tools, system prompt (stable block, per-run block) | **the same bytes** — the provider's cached prefix survives |
550
+ | first user message: the rendered prompt | the prompt verbatim, then one `[compacted: N earlier turns …]` text block holding the summary |
551
+ | turns 1 … N−k | gone — folded into the summary |
552
+ | the last `keepTurns` turns (each an assistant message and the user message answering its tool calls) | **the same bytes** — no tool call is ever separated from its result |
553
+
554
+ | Option | Default | |
555
+ |---|---|---|
556
+ | `compact` | `'auto'` | `'off'` never compacts and never checks: the loop exactly as before |
557
+ | `at` | `0.8` | fraction of the room (above 0, at most 1) that triggers a compaction |
558
+ | `keepTurns` | `4` | newest turns kept verbatim, at least 1 |
559
+ | `model` | the run's | the model that writes the summary. The run's own reads the cached prefix; another is priced at its own rates |
560
+ | `maxSummaryTokens` | `min(2,048, maxTokens)` | the summary call's completion ceiling |
561
+
562
+ | Rule | Detail |
563
+ |---|---|
564
+ | decided before the call, on the measurement | `As of 2026-11` the estimate is scaled by `max(1, reported / estimated)` of the last turn (input + cache read + cache write): four characters a token is wrong by 3x on dense JSON and non-Latin text. A turn reporting zero keeps the last ratio; per run, never signed |
565
+ | a provider refusal anyway | `X_AI_CONTEXT_OVERFLOW` (a 400/413 saying the prompt outgrew the window) gets **one forced compaction** and the same turn re-sent. A second refusal, a compaction that fails, or `compact: 'off'` is `X_AGENT_CONTEXT_EXHAUSTED` with the provider's text on `meta.refused`. The refusal billed nothing and is not a turn |
566
+ | the summary call is the run's own request up to the cut, plus one instruction | same gateway, tools, system and `promptCache` hint: it reads the prefix the turn that ended at the cut already cached, and on OpenRouter carries the same `x-session-id` — for a DeepSeek or Gemini model too, which the host caches implicitly |
567
+ | billed to the run | same ledger (`budget.tokensPerRun`), `costPerRun` checked before it, counted in `usage`, `onTurn`'s `run` and `meta.billed`. A budget refusal, a deadline or an abort is thrown as itself |
568
+ | not a turn | `maxTurns` and `onTurn` count the loop alone |
569
+ | turns too long for the summary call itself | `As of 2026-11`. When the run's own request up to the cut does not fit the summary model's room (`contextWindow` less `maxSummaryTokens`), the same tools, system and first message are sent with the turns to summarise as **text**, shortened oldest first until it fits: each tool result clipped to its first and last 1,000 characters (`SUMMARY_CLIP_CHARS`), then — once every turn is clipped — left out, oldest first. No turn is ever dropped, and only the summary call's input is shortened: the run's transcript and kept turns stay the bytes they were. After opencode's overflow fallback |
570
+ | a failed summary — no text, cut off, refused, unreachable, no shorter, or too long even with every tool result left out | the run goes on **whole** while the request still fits; over the room it is `X_AGENT_CONTEXT_EXHAUSTED` (500, `terminal` for a job), before anything is sent. Never a truncated transcript |
571
+ | after a failed attempt | the next one waits until the request no longer fits — each attempt is a billed call |
572
+ | the handle | `.run()`'s `transcript` and `transcriptOf(error)` hold the compacted messages and a signed `compacted` count (absent at zero, so a run that never compacted writes the handle it always did); `.continue()` resumes from it and compacts on its own turns |
573
+ | observed | `onCompact({ turn, outcome, reason, before, after, limit, summarised, kept, compactions, model, summaryInput, clipped, omitted, usage, cost })` — `summaryInput` is `'messages'` (the cached prefix) or `'text'`, with `clipped`/`omitted` turns — awaited, a throw fails the run — and an `agent.compact` span event with the same facts |
574
+
575
+ Limits: the estimate is about four characters a token until the first turn reports, which is why `at` defaults below 1; a summary written from clipped tool results knows only their ends, which the transcript block tells the model; on the chat-completions wire an agent with `tools: []` offers only `respond`, which is then forced, so its summary call answers no text and the run falls back to whole.
576
+
577
+ ## Checkpoints (`onCheckpoint`)
578
+
579
+ `As of 2026-11`. A worker that dies mid-run throws nothing, so `transcriptOf(error)` has nothing to carry and the next attempt re-buys every turn. `onCheckpoint` hands the app a signed handle **between** turns — after a turn's tool results (or its correction) land, before the next turn is bought; never after the answer, which `.run()` returns.
580
+
581
+ ```ts
582
+ import type { AgentAction, AgentTranscript } from '@ultimat3/ai';
583
+ import type { Ctx } from '@ultimat3/core';
584
+ import { stringField } from '@ultimat3/core';
585
+ import type { t } from '@ultimat3/schema';
586
+
587
+ type Review = ReturnType<typeof t.object<{ verdict: typeof t.string }>>;
588
+ type In = ReturnType<typeof t.object<{ postId: typeof t.string }>>;
589
+ declare const reviewer: AgentAction<In, Review>; // declared with onCheckpoint → saveProgress
590
+ declare function progress(postId: string): Promise<AgentTranscript | undefined>;
591
+
592
+ // The action an agentJob() runs: resume from the stored handle, else start.
593
+ export async function review(ctx: Ctx, input: { postId: string }) {
594
+ const stored = await progress(input.postId);
595
+ try {
596
+ if (stored === undefined) return await reviewer.run(input, { ctx });
597
+ return await reviewer.continue(input, { transcript: stored, user: 'Carry on.' }, { ctx });
598
+ } catch (error) {
599
+ // A prompt bump, a model change or a secret rotation invalidates a stored handle by design.
600
+ if (stringField(error, 'code') !== 'X_AGENT_TRANSCRIPT_INVALID') throw error;
601
+ return reviewer.run(input, { ctx });
602
+ }
603
+ }
604
+ ```
605
+
606
+ | Rule | Detail |
607
+ |---|---|
608
+ | the event | `{ turn, transcript, run }` — the same signed, bound handle `.run()` returns, so an edited or cross-tenant copy is refused by the same screen (`X_AGENT_TRANSCRIPT_INVALID`) |
609
+ | the second argument | the run's own `{ input, ctx }`, as `vars()` gets them: where the handle lives is the app's row, stored as tool results may be |
610
+ | resuming | the existing `.continue(input, { transcript, user })`. `maxTurns`, `tokensPerRun` and `costPerRun` count from the handle; `deadlineMs` is per call |
611
+ | awaited; a throw fails the run | as `onTurn`'s: a checkpoint that silently stopped reads exactly like one that works |
612
+ | cost | sealed only when declared; a run without the hook is byte-identical |
613
+ | after a compaction | the next checkpoint holds the compacted messages and the signed `compacted` count |
614
+ | plain calls too | the hook reads no session; a plain call checkpoints like `.run()` |
615
+ | the limit | a worker dying **during** a tool batch re-asks the model for that turn, so those tools may run again. [The at-least-once rule](#the-at-least-once-trap) still binds; the replay shrinks from the whole run to one turn. No store of open tool calls: the screen refuses an unanswered `tool_use` by design |
616
+
617
+ ## Per-run picks (`offerTools`, `model`, `limits`)
618
+
619
+ `As of 2026-10`. A declaration is the agent's **ceiling**; one run may pick **inside** it — from its own input and context (a stored permission rule, a tenant's model tier, a cheaper plan) — and never outside it.
620
+
621
+ ```ts
622
+ import { action, t } from '@ultimat3/action';
623
+ import { agent, definePrompt } from '@ultimat3/ai';
624
+ import { can } from '@ultimat3/policy';
625
+
626
+ const readDoc = action({
627
+ input: t.object({ id: t.string }),
628
+ output: t.object({ body: t.string }),
629
+ policy: can('doc:read'),
630
+ mcp: { expose: true, description: 'Read one document.' },
631
+ handle: ({ input }) => ({ body: `body of ${input.id}` }),
632
+ });
633
+
634
+ const publishPost = action({
635
+ input: t.object({ body: t.string }),
636
+ output: t.object({ postId: t.string }),
637
+ policy: can('post:publish'),
638
+ mcp: { expose: true, description: 'Publish one post. Idempotent on body.' },
639
+ handle: () => ({ postId: 'p_1' }),
640
+ });
641
+
642
+ export const writer = agent({
643
+ input: t.object({ topic: t.string, tier: t.string }),
644
+ output: t.object({ title: t.string }),
645
+ prompt: definePrompt<{ topic: string }>({ id: 'writer', version: '1.0.0', template: 'Write about {{topic}}.' }),
646
+ vars: ({ input }) => ({ topic: input.topic }),
647
+ tools: [readDoc, publishPost],
648
+ maxTurns: 8,
649
+ budget: { tokensPerRun: 200_000, costPerRun: { minor: 200, currency: 'USD' } },
650
+ policy: can('post:write'),
651
+ // A subset of `tools`, by name: the model is offered only these.
652
+ offerTools: ({ input }) => (input.tier === 'pro' ? ['readDoc', 'publishPost'] : ['readDoc']),
653
+ // An id the app registered — or undefined for the prompt's model, then the gateway's default.
654
+ model: ({ input }) => (input.tier === 'pro' ? 'house-large' : undefined),
655
+ // Lower only: a value over the declaration is X_AI_REQUEST_INVALID.
656
+ limits: ({ input }) =>
657
+ input.tier === 'pro' ? {} : { maxTurns: 4, budget: { costPerRun: { minor: 50, currency: 'USD' } } },
658
+ });
659
+ ```
660
+
661
+ | Pick | Contract | Refused as |
662
+ |---|---|---|
663
+ | `offerTools({ input, ctx })` | tool **names**, a subset of `tools`; unset offers them all. Sent in the catalogue's own order whatever order the rule lists them in, so the same subset is the same request bytes. A call to a tool left out answers `unknown tool: <name>` and runs nothing. An empty list offers `respond` alone | a name `tools` does not list: `X_AGENT_TOOL_UNDECLARED` (500, `terminal` for a job); not an array of strings: `X_AI_REQUEST_INVALID` |
664
+ | `model: ({ input, ctx }) => id \| undefined` | the first of the three places, asked per run; `undefined` falls through to the prompt's `model`, then `defaultModel`. `describeAgents()` reports `model: null, modelFrom: 'run'` | an unregistered id: `X_AI_MODEL_UNKNOWN`; not a non-empty string: `X_AI_REQUEST_INVALID` |
665
+ | `limits({ input, ctx })` | `{ maxTurns, maxTokens, maxToolResultChars, maxRepeatedToolCalls, deadlineMs, budget: { tokensIn, tokensPerRun, costPerCall, costPerRun } }`, each optional. Each is screened like the declared bound and held **at or under** it (after its default); one the declaration leaves unset (`deadlineMs`, a `budget` key) may be set — any value is tighter than none | a higher value: `X_AI_REQUEST_INVALID` naming the key and the declared ceiling; not a count: refused under `limits().<key>` |
666
+
667
+ All three are called once per call (plain, `.run()` or `.continue()`), in the order `limits`, `offerTools`, `model`, **before** `vars()`, before the deadline is armed and before anything is reserved or sent — a refusal loads nothing. They run as the action's handler does, after its policy; keep them a read, not a model call.
668
+
669
+ **The handle binds the picks.** A transcript is bound to the run's model and to the tool names **this run was offered** (plus `respond`), not the declared catalogue: a `.continue()` whose `model` or `offerTools()` answers differently for the same input is `X_AGENT_TRANSCRIPT_INVALID`. `limits()` is not bound — it is re-asked on every call, and `maxTurns` / `tokensPerRun` / `costPerRun` count the run and every continuation against whatever it answers then.
670
+
671
+ **`budget.costPerRun`** is the run's money ceiling (declared, or set by `limits()`): before **each** turn, what the run has cost so far — its turns, every continuation's (priced from the handle's usage on the run's model), and every nested `llm()` / `agent()` a tool ran — plus that turn's worst-case estimate (full `maxTokens` completion, priced as the gateway's own reservation prices it) must fit. Otherwise `X_AI_BUDGET_EXCEEDED` with scope `costPerRun`, before the provider is called; the error carries `meta.billed` like any other. A nested call is not pre-checked against it — it counts once it has been billed; bound it with its own `budget`.
672
+
673
+ ## Progress (`onTurn`)
674
+
675
+ A 90-second agent run emits nothing until it returns. `onTurn` is what a progress indicator, a per-turn spend line and a transcript log read.
676
+
677
+ ```ts
678
+ onTurn: ({ turn, maxTurns, model, toolCalls, stopReason, usage, cost }) => {
679
+ // turn is 1-based, so `turn === maxTurns` is the last one this run will take
680
+ // toolCalls are names, in the order the model emitted them — `respond` is not one
681
+ // usage and cost are THIS turn's alone: inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens
682
+ // the event also carries `run` — the running total, this turn included: run.usage, run.cost
683
+ },
684
+ ```
685
+
686
+ | Field | |
687
+ |---|---|
688
+ | `cost` | this turn alone, in the currency's minor units, rounded up **once** over the whole turn |
689
+ | `costExact` | the same figure exactly — `Money` at a finer `scale` (`{ minor: 450_000, currency: 'USD', scale: 8 }` is 0.45 cents). Absent when an app-written `Provider` states none |
690
+ | `run.cost` / `run.costExact` | the run so far, priced once from the summed usage: 14 turns of 0.45 cents are 7 cents, not 14 |
691
+ | `repairOf` | on a **repair turn** only: the schema issues of the previous turn's answer — what the model was told to fix before it produced this one |
692
+
693
+ `llm()` has the same hook per provider call — `onCall({ attempt, model, provider, stopReason, usage, cost, costExact, repairOf, run })` — `attempt` 2 being its one repair turn. It is not called on a semantic-cache hit: nothing was sent.
694
+
695
+ **Cost is exact, and rounded once.** `exactCostOf(model, usage)` prices a call in micro-units of the currency's minor unit, in integers; `costOf` is that figure rounded up to the minor unit. A ledger (`gateway.spent()`, `BudgetLedger.report()`) sums the exact figures and rounds once when it is read (`cost`, with `costExact` beside it). A price below one minor unit per million tokens is a row with a `scale`: `inputPerMillion: { minor: 75, currency: 'USD', scale: 3 }` is $0.075 per million.
696
+
697
+ | Rule | Detail |
698
+ |---|---|
699
+ | Observation only | it cannot steer the loop, cannot see the transcript, and cannot reach the actor |
700
+ | Awaited, not guarded | a throw from `onTurn` **fails the run**. It is the app's code on the run's own path, and an observer that silently stopped working reads exactly like one that is fine |
701
+ | Always on the span too | the same facts land as an `agent.turn` event, so a run that declares no hook is still readable in a trace |
702
+ | Per turn, not per token | tokens on a screen is a different contract |
703
+
704
+ **An agent streams per turn, on its own route** (As of 2026-11). `agent()` declares `stream: AgentChunk`: a request with `accept: text/event-stream` gets `event: chunk` frames — `{ type: 'text' | 'tool_call' | 'tool_result' | 'turn' }`, ids pairing a call with its result, no arguments or tool output — then one `done` (the answer) or `error` (the problem). Every other surface (JSON, MCP, `.job()`, `.run()`) drains it and gets the answer alone. A tool call arrives whole, so per-turn is the finest granularity a tool loop has; budget and `maxTurns` are unchanged.
705
+
706
+ ## `hive()` — many members, one action
707
+
708
+ Fan one action out over many inputs. The fourth factory over a primitive, after `llm()`, `backfill()` and `agent()`.
709
+
710
+ ```ts
711
+ import { t } from '@ultimat3/action';
712
+ import { hive } from '@ultimat3/ai';
713
+ import { allow } from '@ultimat3/policy';
714
+ import { supportAgent } from './support';
715
+
716
+ export const triageBacklog = hive({
717
+ input: t.object({ orderIds: t.array(t.string) }),
718
+ member: supportAgent,
719
+ split: ({ input }) => input.orderIds.map((orderId) => ({ orderId })),
720
+ concurrency: 8,
721
+ minMembers: 2,
722
+ onMemberError: 'collect',
723
+ budget: { tokensPerRun: 2_000_000 },
724
+ policy: allow(),
725
+ });
726
+ ```
727
+
728
+ | Field | Required | Default | Behaviour |
729
+ |---|---|---|---|
730
+ | `member` | yes | — | any action — most usefully an `agent()`, which makes a hive a supervisor over sub-agents |
731
+ | `split` | yes | — | the one declared place a run decides what the members are, derived from `input` and `ctx` and from **nothing a model emitted** |
732
+ | `onMemberError` | yes | — | `'abort'` or `'collect'`. No default: both are right for somebody |
733
+ | `concurrency` | no | **4** | members in flight at once. Deliberately arbitrary — the framework cannot know a provider's allowance |
734
+ | `minMembers` | no | **2** | below this the split runs serially. It **drops nothing** — every input still runs |
735
+ | `budget` | no | unmetered | `tokensPerRun` covers every member's every turn |
736
+
737
+ ### Three member outcomes, never two
738
+
739
+ ```ts
740
+ const result = await triageBacklog({ orderIds }, { ctx });
741
+
742
+ for (const member of result.members) {
743
+ if (member.status === 'ok') use(member.index, member.value);
744
+ if (member.status === 'failed') log(member.index, member.code, member.reason);
745
+ if (member.status === 'skipped') retryLater(member.index, member.reason);
746
+ }
747
+
748
+ result.ok; result.failed; result.skipped; result.tokens; result.cost;
749
+ ```
750
+
751
+ | Arm | Means |
752
+ |---|---|
753
+ | `ok` | the member ran and returned. `value` is the member's own `output`, embedded verbatim — so a hive over `supportAgent` publishes an answer in its OpenAPI response and its MCP `outputSchema`, not an opaque object |
754
+ | `failed` | the member ran and threw. `code` is the `UltimateError` code, or `'unknown'` for a foreign throw; `reason` is its cause |
755
+ | `skipped` | the member **never ran** — a sibling failed under `onMemberError: 'abort'` |
756
+
757
+ *Ran and threw* and *never ran* are different facts, and collapsing them makes "the hive stopped early" read as "every remaining item is bad data" — which is the difference between retrying the tail and fixing the source. `index` is on every arm, so a result joins back to the row it came from without depending on array position surviving a filter.
758
+
759
+ | Rule | Why |
760
+ |---|---|
761
+ | `members` comes back in **split order**, failures included | a hand-rolled `Promise.all` reports in completion order, and joining a result back to its row then silently depends on nothing having failed |
762
+ | The hive **never names an actor** | each member runs through the member action's own callable, so `invoke` applies its policy with `ctx.actor` untouched |
763
+ | An empty split is `X_HIVE_EMPTY` | `0 ok, 0 failed` cannot be told apart from a query that returned no rows and nobody noticed |
764
+ | An aborted `ctx` unwinds the whole hive | distinct from `onMemberError: 'abort'`, which is a completed run with a partial harvest worth returning — here there is nobody left to hand it to |
765
+ | `skipped` is published beside `ok` and `failed` | three arms and two counters means `members.length - ok - failed`, and a caller who writes that once writes it wrong once |
766
+
767
+ ## `agentJob()` — an agent as durable background work
768
+
769
+ ```ts
770
+ import { agentJob } from '@ultimat3/ai';
771
+ import { supportAgent } from './support';
772
+
773
+ export const triageJob = agentJob(supportAgent, {
774
+ name: 'support-triage',
775
+ tenant: 'none',
776
+ retry: { attempts: 3, backoff: 'exponential' },
777
+ });
778
+ ```
779
+
780
+ `As of 2026-08` this is the **only** way an agent reaches a queue. `.job()` hands back `kind: 'action-job'`, and `isJobHandle` needs `kind === 'job'` plus membership of a `WeakMap` only `job()` writes, so nothing externally shaped has ever reached the registry, the worker or the dead-letter path.
781
+
782
+ | Field | Required | Behaviour |
783
+ |---|---|---|
784
+ | `name` | yes | the durable queue key, never derived from an export name — queued, retrying and dead-lettered rows already carry it, so renaming an export must not move where they are delivered |
785
+ | `tenant` | yes | the org this run's body acts under. `'none'` is the explicit statement that it touches no tenant-scoped table, and then every scoped read inside it fails closed |
786
+ | `retry` | yes | a model call fails transiently; how many times is nobody else's guess |
787
+ | `queue` | no | |
788
+ | `idempotencyKey` | no | defaults to the action projection's `action:<name>:<fingerprint of input>` |
789
+ | `actor` | no | `({ input, ctx }) => Actor` — who the run acts FOR, re-resolved on every attempt from the id its input carries. Without it the agent runs as the worker, whose actor is **anonymous** in a served app, so a member policy refuses it `X_UNAUTHENTICATED`. Swapped in through core's `impersonate` (the worker stays on the record as `onBehalfOf`); an actor outside the declared `tenant` is `X_JOB_TENANT_MISMATCH` before the agent starts — terminal, dead-lettered on attempt 1, since the same payload resolves the same actor every time |
790
+
791
+ It composes `job()` rather than imitating a handle, so `.enqueue()`, the outbox, the worker's cancellation, the dead-letter path, `x jobs show` and its manifest row all arrive for free. One execution path, and it is the action's: `invoke(agent, input, { surface: 'job', ctx })`, so the agent's policy, input parse, budget scope and span all apply — and `ctx` is the **worker's**, so an attempt timing out aborts the turn loop.
792
+
793
+ **A queued run's return value is not stored** — `x_jobs` keeps no result column. So `agentJob` wraps an ACTION that runs the agent and keeps the answer itself, and the agent stays read-only.
794
+
795
+ > **Rule: never hand the model a write tool whose target it chooses.** The model reads text someone else wrote — a draft, a ticket, a scraped page — and "now record this for record X" is an instruction a model may follow. A write tool is a write that text can aim. The wrapping action binds the target from its OWN input.
796
+
797
+ Worked example: the reference app's `keepDraftReview` → `reviewDraftLater` (`examples/dummy/apps/web/app/posts/actions/keep-draft-review.ts`, `jobs/review-draft-later.ts`). `reviewDraft` is an agent with one read tool; `keepDraftReview` runs it and upserts the verdict on the post id it was given — `(orgId, postId)` is the key, so a replayed run is the same row — under `postReviewKeep`, the publishing right, never `post:read`; `reviewDraftLater = agentJob(keepDraftReview, { actor })`; the `postReview` query reads it back. `review.job.test.ts` drives it on the production worker, with a model that tries to write onto another post and has nothing to write with.
798
+
799
+ ### The at-least-once trap
800
+
801
+ > **Rule you must satisfy: every write an `agentJob` run makes — by the wrapping action or by any tool — is idempotent. The framework does not and cannot check this.**
802
+
803
+ **`idempotencyKey` dedupes the ENQUEUE, never the ATTEMPT.**
804
+
805
+ Two `enqueue` calls with the same payload are one row. One row that a worker claims, half-runs and loses the lease on is claimed again — and **the agent runs a second time from the top**. Every turn re-executes. Every tool call re-executes.
806
+
807
+ So **every write a run makes has to be idempotent** — the wrapping action's, and any tool's: an `upsertAll`, an `updateWhere`, a statement whose second run changes nothing. Otherwise a replayed attempt issues a second refund against the same order. Idempotent is not aimed: a write the run needs belongs in the wrapping action, bound to its input, not in a tool the model points.
808
+
809
+ The same holds one level up: `backfill()`'s `handle` is at-least-once by construction — it runs before its checkpoint lands — so an attempt cancelled between the two replays that page, and re-runs every agent on it.
810
+
811
+ **The framework does not check this, and cannot.** `mutates` is not a fact an `action()` declares — it exists only in `@ultimat3/mcp`, which sets it to `true` for *every* action it projects — so a read-only `lookupOrder` and a destructive `issueRefund` are indistinguishable at this seam. A rule refusing every tool that had not declared `idempotent: true` would refuse the reads too, and a wrong refusal is worse than a stated obligation. This is a contract you keep, not one the compiler keeps for you.
812
+
813
+ | Make it idempotent by | Example |
814
+ |---|---|
815
+ | A natural key the second run collides with | `issueRefund` keyed on `(orderId, amountMinor)`, written with `upsertAll(..., { onMatch: 'nothing' })` |
816
+ | A statement whose second run changes nothing | `updateWhere({ status: 'pending' }, { status: 'refunded' })` |
817
+ | Never | `count + 1`, an append-only log line, a `POST` to a vendor with no idempotency key |
818
+
819
+ ## Millions of rows
820
+
821
+ Three primitives, each doing the job the others cannot:
822
+
823
+ | Layer | Primitive | What it owns |
824
+ |---|---|---|
825
+ | the sweep | `backfill()` | resumable pages, a cursor persisted per `step.run`, mandatory pacing, an environment gate, the `x_backfills` ledger |
826
+ | the page | `hive()` | bounded concurrency inside one page, split-order results, three-way member outcomes, one derived ledger |
827
+ | the run | `agent()` | the turn loop, the tools, `tokensPerRun` |
828
+
829
+ ```ts
830
+ import { database, entity, text, timestamp, uuid } from '@ultimat3/entity';
831
+ import { backfill } from '@ultimat3/jobs';
832
+ import { triageBacklog } from './triage';
833
+
834
+ const orders = entity('orders', {
835
+ columns: { id: uuid().primaryKey(), status: text(), createdAt: timestamp().defaultNow() },
836
+ });
837
+
838
+ const db = database({ orders });
839
+
840
+ export const triageEveryOrder = backfill({
841
+ name: 'triage-every-order',
842
+ tenant: 'none',
843
+ environments: ['staging', 'production'],
844
+ batch: 200,
845
+ rate: 1, // batches per second — there is no unthrottled mode
846
+ source: () => db.orders.where({ status: 'needs-triage' }),
847
+ count: () => db.orders.where({ status: 'needs-triage' }).count(),
848
+ handle: async ({ rows, ctx }) => {
849
+ await triageBacklog({ orderIds: rows.map((row) => row.id) }, { ctx });
850
+ },
851
+ });
852
+ ```
853
+
854
+ | Ceiling | Where it goes |
855
+ |---|---|
856
+ | per model call | `budget.costPerCall` on the `agent()` |
857
+ | per agent run | `budget.tokensPerRun` on the `agent()` |
858
+ | per page | `budget.tokensPerRun` on the `hive()` — every member's every turn |
859
+ | per actor, per org, **fleet-wide** | `providerGateway({ budget: { actor, org }, budgetStore })`. The default `memoryBudgetStore()` is per process; a shared store is what makes these mean anything above one replica |
860
+ | rows per second | `backfill`'s `rate` and `batch`. This pass shares its pool with the requests the app is still serving |
861
+ | which deploys sweep at all | `backfill`'s `environments`. A mismatch is `X_BACKFILL_ENVIRONMENT`, refused inside the pass as well as by the CLI |
862
+
863
+ `x db backfill --pending` lists declared sweeps the ledger has never completed; `x db backfill <name> --write` enqueues one. Full detail: [Migrations and backfills](Migrations-And-Backfills).
864
+
865
+ ## `describeAgents()`
866
+
867
+ ```ts
868
+ import { describeAgents } from '@ultimat3/ai';
869
+
870
+ describeAgents();
871
+ // [{ name: 'supportAgent', prompt: 'support@1.0.0', promptId: 'support', promptHash: '…',
872
+ // model: 'house-large', modelFrom: 'declaration', maxTurns: 6, maxToolResultChars: 4000,
873
+ // tools: ['issueRefund', 'lookupOrder'],
874
+ // budget: { tokensIn: null, tokensPerRun: 200000, costPerCall: { minor: 50, currency: 'USD' }, costPerRun: null },
875
+ // mcp: true }]
876
+ ```
877
+
878
+ An agent projects to an `ActionDescriptor` like every other action, and that descriptor knows nothing about turns or tools — so "how far can this loop, and what may it call" had no answer outside the source. `tools` is the agent's **blast radius**, sorted. `promptHash` is there because an agent's behaviour is its prompt: a row without one records which agent ran and not which agent it *was*. `modelFrom` says which place answered `model` — `declaration`, `prompt` or `gateway`, or `run` (with `model: null`) when `model` is a function each run asks; both are `null` when none names one yet (a call in that state is refused). `gateway` and `null` are as of the gateway installed when the row is read. Reading a row never throws.
879
+
880
+ Names are read when you ask, not when the agent was declared: `registerAction` stamps them at boot, long after `agent()` ran at module scope. An agent nothing registered has **no row**, and that is not a silent drop — an action with no name reaches no route, no tool catalogue and no queue, so there is no capability for a row to describe. Register it: `registerAction('supportAgent', support)`.
881
+
882
+ ## No semantic cache
883
+
884
+ `llm()` has one. `agent()` deliberately does not.
885
+
886
+ Similar prompts do not have similar answers once the answer depends on what `lookupOrder` returned this second, and a cache over that would serve one run's world state to another. A prompt version bump plus the tools' own caching is the story. The gateway's exact-match response cache still applies per model call, and a **refusal is never cached** — a cached one keeps serving a classifier decision after the prompt was fixed.
887
+
888
+ ## Errors
889
+
890
+ | Code | Cause | Fix |
891
+ |---|---|---|
892
+ | `X_AGENT_TOOL_UNEXPOSED` | a `tools` entry is an action with no `mcp` block (or `mcp: { expose: false }`) and is not an `inProcessTool()`. Raised at **declaration** | `inProcessTool({ name, description, input, execute })` for a tool only this agent may call; or `mcp: { expose: true, description }` on the action to offer it to MCP clients too; or drop it from `tools` |
893
+ | `X_AGENT_TOOL_UNDECLARED` | a run's `offerTools()` named a tool `tools` does not list. Raised before anything is loaded | return only names from the agent's `tools`, or add the tool to `tools` first |
894
+ | `X_AGENT_MAX_TURNS` | the loop used every turn calling tools and never answered | tell the template when to stop and answer through the respond tool, then bump its version |
895
+ | `X_AGENT_TOOL_LOOP` | consecutive turns made the same tool calls and read the same results | `maxRepeatedToolCalls: 5` on the agent, only for a tool that really is polled; otherwise tell the template what to do when the result does not change, then bump its version |
896
+ | `X_AGENT_DEADLINE` | the run outlived its declared `deadlineMs` | `deadlineMs: 300_000` on the agent, once a trace shows the run converging; otherwise lower `maxTurns` |
897
+ | `X_AGENT_CONTEXT_EXHAUSTED` | the next request would not fit the model's context window and [compaction](#auto-compaction-compact) could not bring it under. Refused before it is sent — or after the provider refused it twice (`meta.refused`) | `compact: { at: 0.6, keepTurns: 2 }` on the agent; or lower `maxToolResultChars`, or a model with a larger `contextWindow` |
898
+ | `X_AI_CONTEXT_OVERFLOW` | a provider refused a request as over the model's window (400/413 and its own wording). Never retried, `terminal` for a job; `agent()` compacts once and re-sends before it stops as `X_AGENT_CONTEXT_EXHAUSTED` | `compact: { at: 0.6, keepTurns: 2 }` on the agent; or a shorter prompt, or a model with a larger `contextWindow` |
899
+ | `X_LLM_OUTPUT_INVALID` | every turn's answer failed the `output` schema | describe the shape in the template and bump its version, or widen `output` |
900
+ | `X_LLM_REFUSED` | the provider's classifiers declined; `stopDetails.category` names why | set `model:` to the more capable one the thrown `fix:` names, or edit the template and bump its version |
901
+ | `X_LLM_TRUNCATED` | a turn hit `maxTokens` before finishing — read before the answer, so nothing truncated is ever validated or run. A tool call cut off mid-arguments is this code too, streamed or not | raise `maxTokens:` on the declaration, or drop fields from `output`. A model that reasons spends the same ceiling on reasoning: a measured 28,000-character answer used 12,954 output tokens |
902
+ | `X_AGENT_TRANSCRIPT_INVALID` | `.continue()` was handed a transcript that did not pass its screen | start a new `.run()` and store its transcript; pass back exactly the value returned, to the same agent and prompt version |
903
+ | `X_LLM_TOOL_ARGUMENTS_INVALID` | the turn finished and a tool call's arguments are not JSON. Billed (`meta.billed`), recorded on the ledger, not retried by the gateway, `terminal` for a job | raise `maxTokens:` if the arguments were long, and shorten or split the string fields of that tool's input schema; to try once more, catch the code and re-enqueue deliberately |
904
+ | `X_HIVE_EMPTY` | `split()` produced zero members | return at least one member input, or guard the call site |
905
+ | `X_AI_BUDGET_EXCEEDED` | refused pre-flight, naming the scope and what remains | raise that scope's ceiling, or shorten the prompt |
906
+ | `X_AI_PROMPT_SECRET` | `vars()` returned a `Secret` | drop the key from `vars()` and the template, or `revealSecret(value)` if the model genuinely has to read it |
907
+ | `X_AI_GATEWAY_MISSING` | the agent ran before `configureAi`; the cause lists the peer providers | `configureAi({ gateway: providerGateway({ providers: [provider] }) })` at boot, with `provider` your choice — [above](#register-your-models-pick-your-provider) |
908
+ | `X_ABORTED` | `ctx.signal` fired mid-run | pass a longer deadline, or run it as a job |
909
+
910
+ Full list: [Error codes](Error-Codes).
911
+
912
+ ## Rules
913
+
914
+ - `agent()` is a factory over `action()`. A new capability arrives as a factory over an existing primitive, never as a new kind of thing.
915
+ - Tools are real `action()`s, each `mcp: { expose: true }`, each deciding its own calls with its own policy.
916
+ - A tool in `mcpConfirmations({ tools })` waits for a person from an agent exactly as over MCP (`X_MCP_CONFIRMATION_PENDING`).
917
+ - The actor is `ctx.actor`, read once. A model can never name the identity it acts as.
918
+ - Structured output is the forced `respond` tool. There is no JSON mode and no second structured-output path.
919
+ - Tools of one turn run concurrently and are paired positionally; turns are serial.
920
+ - A budget refuses, never truncates, and the ceiling holds under parallelism because the reservation debits before the call.
921
+ - `ctx.signal` unwinds the run at every turn boundary, before every tool batch, and on the socket.
922
+ - `onTurn` observes and cannot steer. The streamed route carries the same facts per turn.
923
+ - Every turn asks the provider to cache its prefix unless `promptCache: 'off'`; what is sent is a row in `PROMPT_CACHE_RULES`, never an `if`.
924
+ - A run that repeats itself is stopped (`X_AGENT_TOOL_LOOP`); a run with a `deadlineMs` is stopped at it (`X_AGENT_DEADLINE`).
925
+ - A run nearing its context window is compacted before the turn that would not fit — tools, system and the last turns byte for byte — or refused (`X_AGENT_CONTEXT_EXHAUSTED`); never truncated.
926
+ - A hive reports in split order with three member arms; an empty split is an error.
927
+ - `idempotencyKey` dedupes the enqueue, never the attempt — every tool an agent may call must be idempotent, and nothing checks it for you.
928
+
929
+ Source: [`packages/ai/src`](https://github.com/developerz-ai/ultimate/blob/main/packages/ai/src)
930
+
931
+ ## The coding agent building your app: a cold-start check
932
+
933
+ The rest of this page is `agent()`, an agent inside your app. This section is the other one — the
934
+ coding agent that builds the app — and the check a maintainer runs once per major, with any vendor's
935
+ agent, on a fresh scaffold. Nothing here claims "works with every agent"; the claim is what CI
936
+ tests: `AGENTS.md`'s commands resolve (`scaffold-agents-md.test.ts`), `.mcp.json`'s command answers
937
+ `initialize` and `tools/list`, and `POST /mcp` refuses a request with no token
938
+ (`scripts/scaffold-agent.ts`, in `scaffold-smoke`).
939
+
940
+ 1. `bunx create-ultimate notes-cold && cd notes-cold && bun run setup`, in a shell with no global `x`.
941
+ 2. Start the agent in that directory with no other context, and give it exactly this prompt:
942
+
943
+ ```text
944
+ Add a notes feature to this app: a signed-in member can create a note with a title and a body,
945
+ list their own notes, and delete one. Make the gate green. Read AGENTS.md first.
946
+ ```
947
+
948
+ 3. Record, per agent and per major:
949
+
950
+ | Measure | How |
951
+ |---|---|
952
+ | commands before the first green gate | count every shell command the agent ran until `bunx x verify` was green |
953
+ | wrong guesses | a command that does not exist, a bare `x`, a hand-written primitive `bunx x g` would have written, a file path that is not there |
954
+ | files read before the first edit | the agent's own transcript |
955
+ | did it find the tools | whether it read `x.manifest.json`, used `.mcp.json`, or followed `.claude/commands/feature.md` |
956
+
957
+ Each wrong guess is a defect in what the scaffold says, not in the agent: fix the file that misled
958
+ it. Results stay out of this repository unless they form a vendor-neutral table.