agentenkit 0.3.0 → 0.5.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 (295) hide show
  1. package/README.md +82 -15
  2. package/dist/adapters/brave.d.ts +21 -0
  3. package/dist/adapters/brave.d.ts.map +1 -0
  4. package/dist/adapters/brave.js +52 -0
  5. package/dist/adapters/brave.js.map +1 -0
  6. package/dist/adapters/computesdk.d.ts +71 -0
  7. package/dist/adapters/computesdk.d.ts.map +1 -0
  8. package/dist/adapters/computesdk.js +163 -0
  9. package/dist/adapters/computesdk.js.map +1 -0
  10. package/dist/adapters/docker.d.ts +35 -0
  11. package/dist/adapters/docker.d.ts.map +1 -0
  12. package/dist/adapters/docker.js +246 -0
  13. package/dist/adapters/docker.js.map +1 -0
  14. package/dist/adapters/e2b.d.ts +44 -0
  15. package/dist/adapters/e2b.d.ts.map +1 -0
  16. package/dist/adapters/e2b.js +349 -0
  17. package/dist/adapters/e2b.js.map +1 -0
  18. package/dist/adapters/inline.d.ts +19 -5
  19. package/dist/adapters/inline.d.ts.map +1 -1
  20. package/dist/adapters/inline.js +87 -14
  21. package/dist/adapters/inline.js.map +1 -1
  22. package/dist/adapters/jina.d.ts +34 -0
  23. package/dist/adapters/jina.d.ts.map +1 -0
  24. package/dist/adapters/jina.js +99 -0
  25. package/dist/adapters/jina.js.map +1 -0
  26. package/dist/adapters/local-sandbox.d.ts +32 -0
  27. package/dist/adapters/local-sandbox.d.ts.map +1 -0
  28. package/dist/adapters/local-sandbox.js +257 -0
  29. package/dist/adapters/local-sandbox.js.map +1 -0
  30. package/dist/adapters/memory.d.ts +146 -3
  31. package/dist/adapters/memory.d.ts.map +1 -1
  32. package/dist/adapters/memory.js +474 -3
  33. package/dist/adapters/memory.js.map +1 -1
  34. package/dist/adapters/page-reader.d.ts +39 -0
  35. package/dist/adapters/page-reader.d.ts.map +1 -0
  36. package/dist/adapters/page-reader.js +165 -0
  37. package/dist/adapters/page-reader.js.map +1 -0
  38. package/dist/adapters/prisma.d.ts +125 -18
  39. package/dist/adapters/prisma.d.ts.map +1 -1
  40. package/dist/adapters/prisma.js +308 -67
  41. package/dist/adapters/prisma.js.map +1 -1
  42. package/dist/adapters/qstash.d.ts +15 -2
  43. package/dist/adapters/qstash.d.ts.map +1 -1
  44. package/dist/adapters/qstash.js +31 -14
  45. package/dist/adapters/qstash.js.map +1 -1
  46. package/dist/adapters/redis.d.ts +40 -4
  47. package/dist/adapters/redis.d.ts.map +1 -1
  48. package/dist/adapters/redis.js +265 -20
  49. package/dist/adapters/redis.js.map +1 -1
  50. package/dist/adapters/sandbox-shell.d.ts +60 -0
  51. package/dist/adapters/sandbox-shell.d.ts.map +1 -0
  52. package/dist/adapters/sandbox-shell.js +129 -0
  53. package/dist/adapters/sandbox-shell.js.map +1 -0
  54. package/dist/adapters/sqlite.d.ts +88 -2
  55. package/dist/adapters/sqlite.d.ts.map +1 -1
  56. package/dist/adapters/sqlite.js +443 -37
  57. package/dist/adapters/sqlite.js.map +1 -1
  58. package/dist/adapters/stream-scripts.d.ts +49 -0
  59. package/dist/adapters/stream-scripts.d.ts.map +1 -0
  60. package/dist/adapters/stream-scripts.js +150 -0
  61. package/dist/adapters/stream-scripts.js.map +1 -0
  62. package/dist/adapters/upstash.d.ts +20 -0
  63. package/dist/adapters/upstash.d.ts.map +1 -1
  64. package/dist/adapters/upstash.js +29 -0
  65. package/dist/adapters/upstash.js.map +1 -1
  66. package/dist/adapters/web-search-shared.d.ts +11 -0
  67. package/dist/adapters/web-search-shared.d.ts.map +1 -0
  68. package/dist/adapters/web-search-shared.js +40 -0
  69. package/dist/adapters/web-search-shared.js.map +1 -0
  70. package/dist/admin/memory.d.ts +11 -1
  71. package/dist/admin/memory.d.ts.map +1 -1
  72. package/dist/admin/memory.js +83 -14
  73. package/dist/admin/memory.js.map +1 -1
  74. package/dist/admin/migrations/postgres/0002-run-settled-at.d.ts +5 -0
  75. package/dist/admin/migrations/postgres/0002-run-settled-at.d.ts.map +1 -0
  76. package/dist/admin/migrations/postgres/0002-run-settled-at.js +11 -0
  77. package/dist/admin/migrations/postgres/0002-run-settled-at.js.map +1 -0
  78. package/dist/admin/migrations/postgres/0003-run-enqueued-at.d.ts +5 -0
  79. package/dist/admin/migrations/postgres/0003-run-enqueued-at.d.ts.map +1 -0
  80. package/dist/admin/migrations/postgres/0003-run-enqueued-at.js +15 -0
  81. package/dist/admin/migrations/postgres/0003-run-enqueued-at.js.map +1 -0
  82. package/dist/admin/migrations/postgres/0004-run-settle-claim.d.ts +5 -0
  83. package/dist/admin/migrations/postgres/0004-run-settle-claim.d.ts.map +1 -0
  84. package/dist/admin/migrations/postgres/0004-run-settle-claim.js +15 -0
  85. package/dist/admin/migrations/postgres/0004-run-settle-claim.js.map +1 -0
  86. package/dist/admin/migrations/postgres/0005-bigint-counters.d.ts +5 -0
  87. package/dist/admin/migrations/postgres/0005-bigint-counters.d.ts.map +1 -0
  88. package/dist/admin/migrations/postgres/0005-bigint-counters.js +12 -0
  89. package/dist/admin/migrations/postgres/0005-bigint-counters.js.map +1 -0
  90. package/dist/admin/migrations/postgres/index.d.ts.map +1 -1
  91. package/dist/admin/migrations/postgres/index.js +16 -1
  92. package/dist/admin/migrations/postgres/index.js.map +1 -1
  93. package/dist/admin/migrations/runner.d.ts +21 -1
  94. package/dist/admin/migrations/runner.d.ts.map +1 -1
  95. package/dist/admin/migrations/runner.js +41 -4
  96. package/dist/admin/migrations/runner.js.map +1 -1
  97. package/dist/admin/migrations/sqlite/0002-run-settled-at.d.ts +5 -0
  98. package/dist/admin/migrations/sqlite/0002-run-settled-at.d.ts.map +1 -0
  99. package/dist/admin/migrations/sqlite/0002-run-settled-at.js +11 -0
  100. package/dist/admin/migrations/sqlite/0002-run-settled-at.js.map +1 -0
  101. package/dist/admin/migrations/sqlite/0003-run-enqueued-at.d.ts +5 -0
  102. package/dist/admin/migrations/sqlite/0003-run-enqueued-at.d.ts.map +1 -0
  103. package/dist/admin/migrations/sqlite/0003-run-enqueued-at.js +15 -0
  104. package/dist/admin/migrations/sqlite/0003-run-enqueued-at.js.map +1 -0
  105. package/dist/admin/migrations/sqlite/0004-run-settle-claim.d.ts +5 -0
  106. package/dist/admin/migrations/sqlite/0004-run-settle-claim.d.ts.map +1 -0
  107. package/dist/admin/migrations/sqlite/0004-run-settle-claim.js +15 -0
  108. package/dist/admin/migrations/sqlite/0004-run-settle-claim.js.map +1 -0
  109. package/dist/admin/migrations/sqlite/index.d.ts +2 -2
  110. package/dist/admin/migrations/sqlite/index.d.ts.map +1 -1
  111. package/dist/admin/migrations/sqlite/index.js +17 -3
  112. package/dist/admin/migrations/sqlite/index.js.map +1 -1
  113. package/dist/admin/postgres.d.ts +14 -4
  114. package/dist/admin/postgres.d.ts.map +1 -1
  115. package/dist/admin/postgres.js +131 -36
  116. package/dist/admin/postgres.js.map +1 -1
  117. package/dist/admin/shared.d.ts +19 -0
  118. package/dist/admin/shared.d.ts.map +1 -0
  119. package/dist/admin/shared.js +28 -0
  120. package/dist/admin/shared.js.map +1 -0
  121. package/dist/admin/sqlite.d.ts +9 -1
  122. package/dist/admin/sqlite.d.ts.map +1 -1
  123. package/dist/admin/sqlite.js +112 -29
  124. package/dist/admin/sqlite.js.map +1 -1
  125. package/dist/core/admin.d.ts +28 -6
  126. package/dist/core/admin.d.ts.map +1 -1
  127. package/dist/core/admin.js +92 -23
  128. package/dist/core/admin.js.map +1 -1
  129. package/dist/core/agent.d.ts +7 -4
  130. package/dist/core/agent.d.ts.map +1 -1
  131. package/dist/core/agent.js +15 -11
  132. package/dist/core/agent.js.map +1 -1
  133. package/dist/core/agui.d.ts +17 -0
  134. package/dist/core/agui.d.ts.map +1 -0
  135. package/dist/core/agui.js +69 -0
  136. package/dist/core/agui.js.map +1 -0
  137. package/dist/core/builtin/definitions.d.ts +12 -0
  138. package/dist/core/builtin/definitions.d.ts.map +1 -0
  139. package/dist/core/builtin/definitions.js +149 -0
  140. package/dist/core/builtin/definitions.js.map +1 -0
  141. package/dist/core/builtin/html.d.ts +13 -0
  142. package/dist/core/builtin/html.d.ts.map +1 -0
  143. package/dist/core/builtin/html.js +96 -0
  144. package/dist/core/builtin/html.js.map +1 -0
  145. package/dist/core/builtin/index.d.ts +21 -0
  146. package/dist/core/builtin/index.d.ts.map +1 -0
  147. package/dist/core/builtin/index.js +55 -0
  148. package/dist/core/builtin/index.js.map +1 -0
  149. package/dist/core/builtin/run.d.ts +37 -0
  150. package/dist/core/builtin/run.d.ts.map +1 -0
  151. package/dist/core/builtin/run.js +55 -0
  152. package/dist/core/builtin/run.js.map +1 -0
  153. package/dist/core/builtin/sandbox-tools.d.ts +69 -0
  154. package/dist/core/builtin/sandbox-tools.d.ts.map +1 -0
  155. package/dist/core/builtin/sandbox-tools.js +389 -0
  156. package/dist/core/builtin/sandbox-tools.js.map +1 -0
  157. package/dist/core/builtin/sandbox.d.ts +52 -0
  158. package/dist/core/builtin/sandbox.d.ts.map +1 -0
  159. package/dist/core/builtin/sandbox.js +174 -0
  160. package/dist/core/builtin/sandbox.js.map +1 -0
  161. package/dist/core/builtin/web.d.ts +54 -0
  162. package/dist/core/builtin/web.d.ts.map +1 -0
  163. package/dist/core/builtin/web.js +167 -0
  164. package/dist/core/builtin/web.js.map +1 -0
  165. package/dist/core/context.d.ts +13 -1
  166. package/dist/core/context.d.ts.map +1 -1
  167. package/dist/core/context.js +70 -19
  168. package/dist/core/context.js.map +1 -1
  169. package/dist/core/deleteThread.d.ts.map +1 -1
  170. package/dist/core/deleteThread.js +36 -4
  171. package/dist/core/deleteThread.js.map +1 -1
  172. package/dist/core/engine.d.ts +41 -11
  173. package/dist/core/engine.d.ts.map +1 -1
  174. package/dist/core/engine.js +880 -353
  175. package/dist/core/engine.js.map +1 -1
  176. package/dist/core/follow.d.ts +63 -1
  177. package/dist/core/follow.d.ts.map +1 -1
  178. package/dist/core/follow.js +293 -16
  179. package/dist/core/follow.js.map +1 -1
  180. package/dist/core/hitl.d.ts +43 -0
  181. package/dist/core/hitl.d.ts.map +1 -1
  182. package/dist/core/hitl.js +163 -35
  183. package/dist/core/hitl.js.map +1 -1
  184. package/dist/core/keys.d.ts +22 -4
  185. package/dist/core/keys.d.ts.map +1 -1
  186. package/dist/core/keys.js +23 -5
  187. package/dist/core/keys.js.map +1 -1
  188. package/dist/core/lease.d.ts +46 -0
  189. package/dist/core/lease.d.ts.map +1 -0
  190. package/dist/core/lease.js +142 -0
  191. package/dist/core/lease.js.map +1 -0
  192. package/dist/core/loop.d.ts +27 -7
  193. package/dist/core/loop.d.ts.map +1 -1
  194. package/dist/core/loop.js +140 -55
  195. package/dist/core/loop.js.map +1 -1
  196. package/dist/core/messages.d.ts +21 -2
  197. package/dist/core/messages.d.ts.map +1 -1
  198. package/dist/core/messages.js +61 -2
  199. package/dist/core/messages.js.map +1 -1
  200. package/dist/core/permanent.d.ts +11 -0
  201. package/dist/core/permanent.d.ts.map +1 -0
  202. package/dist/core/permanent.js +37 -0
  203. package/dist/core/permanent.js.map +1 -0
  204. package/dist/core/prune.d.ts +24 -0
  205. package/dist/core/prune.d.ts.map +1 -0
  206. package/dist/core/prune.js +43 -0
  207. package/dist/core/prune.js.map +1 -0
  208. package/dist/core/publish.d.ts +38 -10
  209. package/dist/core/publish.d.ts.map +1 -1
  210. package/dist/core/publish.js +126 -12
  211. package/dist/core/publish.js.map +1 -1
  212. package/dist/core/reclaim.d.ts +8 -1
  213. package/dist/core/reclaim.d.ts.map +1 -1
  214. package/dist/core/reclaim.js +149 -18
  215. package/dist/core/reclaim.js.map +1 -1
  216. package/dist/core/run.d.ts +7 -3
  217. package/dist/core/run.d.ts.map +1 -1
  218. package/dist/core/run.js +95 -35
  219. package/dist/core/run.js.map +1 -1
  220. package/dist/core/segment.d.ts +62 -0
  221. package/dist/core/segment.d.ts.map +1 -0
  222. package/dist/core/segment.js +318 -0
  223. package/dist/core/segment.js.map +1 -0
  224. package/dist/core/settle.d.ts +57 -0
  225. package/dist/core/settle.d.ts.map +1 -0
  226. package/dist/core/settle.js +181 -0
  227. package/dist/core/settle.js.map +1 -0
  228. package/dist/core/snapshot.d.ts +36 -0
  229. package/dist/core/snapshot.d.ts.map +1 -0
  230. package/dist/core/snapshot.js +87 -0
  231. package/dist/core/snapshot.js.map +1 -0
  232. package/dist/core/stop.d.ts +16 -1
  233. package/dist/core/stop.d.ts.map +1 -1
  234. package/dist/core/stop.js +69 -15
  235. package/dist/core/stop.js.map +1 -1
  236. package/dist/core/stream-events.d.ts +184 -0
  237. package/dist/core/stream-events.d.ts.map +1 -0
  238. package/dist/core/stream-events.js +26 -0
  239. package/dist/core/stream-events.js.map +1 -0
  240. package/dist/core/stream.d.ts +29 -0
  241. package/dist/core/stream.d.ts.map +1 -1
  242. package/dist/core/stream.js +79 -0
  243. package/dist/core/stream.js.map +1 -1
  244. package/dist/core/subagent.d.ts +36 -5
  245. package/dist/core/subagent.d.ts.map +1 -1
  246. package/dist/core/subagent.js +130 -25
  247. package/dist/core/subagent.js.map +1 -1
  248. package/dist/core/tools.d.ts +9 -3
  249. package/dist/core/tools.d.ts.map +1 -1
  250. package/dist/core/tools.js.map +1 -1
  251. package/dist/core/types.d.ts +212 -13
  252. package/dist/core/types.d.ts.map +1 -1
  253. package/dist/core/types.js +29 -1
  254. package/dist/core/types.js.map +1 -1
  255. package/dist/core/usage.d.ts +53 -5
  256. package/dist/core/usage.d.ts.map +1 -1
  257. package/dist/core/usage.js +170 -47
  258. package/dist/core/usage.js.map +1 -1
  259. package/dist/index.d.ts +29 -12
  260. package/dist/index.d.ts.map +1 -1
  261. package/dist/index.js +23 -7
  262. package/dist/index.js.map +1 -1
  263. package/dist/ports/admin.d.ts +59 -0
  264. package/dist/ports/admin.d.ts.map +1 -1
  265. package/dist/ports/kv.d.ts +17 -0
  266. package/dist/ports/kv.d.ts.map +1 -1
  267. package/dist/ports/queue.d.ts +87 -7
  268. package/dist/ports/queue.d.ts.map +1 -1
  269. package/dist/ports/queue.js +36 -1
  270. package/dist/ports/queue.js.map +1 -1
  271. package/dist/ports/runtime.d.ts +188 -20
  272. package/dist/ports/runtime.d.ts.map +1 -1
  273. package/dist/ports/sandbox.d.ts +159 -0
  274. package/dist/ports/sandbox.d.ts.map +1 -0
  275. package/dist/ports/sandbox.js +44 -0
  276. package/dist/ports/sandbox.js.map +1 -0
  277. package/dist/ports/storage.d.ts +26 -5
  278. package/dist/ports/storage.d.ts.map +1 -1
  279. package/dist/ports/streams.d.ts +60 -0
  280. package/dist/ports/streams.d.ts.map +1 -0
  281. package/dist/ports/streams.js +20 -0
  282. package/dist/ports/streams.js.map +1 -0
  283. package/dist/ports/tools.d.ts +60 -0
  284. package/dist/ports/tools.d.ts.map +1 -0
  285. package/dist/ports/tools.js +5 -0
  286. package/dist/ports/tools.js.map +1 -0
  287. package/dist/pricing.d.ts +29 -0
  288. package/dist/pricing.d.ts.map +1 -1
  289. package/dist/pricing.js +64 -10
  290. package/dist/pricing.js.map +1 -1
  291. package/dist/runtime.d.ts +6 -0
  292. package/dist/runtime.d.ts.map +1 -1
  293. package/dist/runtime.js +147 -46
  294. package/dist/runtime.js.map +1 -1
  295. package/package.json +1 -1
@@ -1,28 +1,39 @@
1
- import { randomUUID } from 'node:crypto';
2
1
  import { wireId } from './types.js';
3
2
  import { compactContext } from './context.js';
4
3
  import { markPromptCaching } from './cache.js';
5
4
  import { promptMessages, repairDanglingToolCalls } from './messages.js';
6
5
  import { mergeProviderOptions } from './types.js';
7
- import { hitlKey, loadOpenHitls, withHitl, hitlDeadline, } from './hitl.js';
8
- import { publish, publishEvent, setThreadState, withPublishEvent } from './publish.js';
9
- import { runNestedAgent, spawnSubagentTool } from './subagent.js';
10
- import { redriveKey, runIdKey } from './keys.js';
6
+ import { hitlDoneKey, hitlKey, loadOpenHitls, withHitl, hitlDeadline, commitParks, ParkBox, } from './hitl.js';
7
+ import { ACTIVE_STATES, publish, publishEvent, runStatePayload, transition, withPublishEvent } from './publish.js';
8
+ import { closeNested, nestedRawTools, RunSlots, runNestedAgent, spawnSubagentTool } from './subagent.js';
9
+ import { attemptsKey, COUNTER_TTL_SECONDS, counterScope, redriveKey, runIdKey } from './keys.js';
11
10
  import { withRunState } from './state.js';
12
- import { runLoop } from './loop.js';
11
+ import { TOOL_RUN, withToolRun } from './builtin/run.js';
12
+ import { isPermanentError } from './permanent.js';
13
+ import { runLoop, seedRunLedger } from './loop.js';
14
+ import { enqueueJob, Lease, parseLockValue, runLockKey, RunLockLostError } from './lease.js';
15
+ import { activeSegment, closeLostSegment, openSegment } from './segment.js';
16
+ import { closeOpenParks } from './stop.js';
17
+ import { callOnFinish, isTerminal, runBill, settleEndedRun, settleRun } from './settle.js';
18
+ import { DuplicateJobError, PRIORITY_LOW } from '../ports/queue.js';
13
19
  export { countTokens } from './usage.js';
14
20
  // executeStep and the loop live in ./loop.js so a nested run can share them
15
21
  // without engine ↔ subagent becoming a cycle (§2.7).
16
22
  export { executeStep, isParked, runLoop } from './loop.js';
17
- const runLockKey = (threadId) => `agent:lock:${threadId}`;
18
- /** The safety cap (§2.1) must be either absent (unbounded apart from
19
- * maxSteps) or a positive number — `0`/negative/NaN would silently disable
20
- * the cap and let a run spend without bound. */
23
+ /** A cap (§2.1, §4) is absent, 0 (no cap from this level: the next level's
24
+ * applies), or a positive number. A negative or non-finite one is a bug in
25
+ * the caller and is refused, never read as "no cap". The same rule as the Go
26
+ * runtime, whose zero value can only mean "not set". */
21
27
  export function validateTokenBudget(value, label) {
22
- if (value !== undefined && (!Number.isFinite(value) || value <= 0)) {
23
- throw new Error(`${label} must be a positive finite number`);
28
+ if (value !== undefined && (!Number.isFinite(value) || value < 0)) {
29
+ throw new Error(`${label} must be zero or a positive number`);
24
30
  }
25
31
  }
32
+ /** The first cap set, widest last: run input → agent spec → config. 0 and
33
+ * absent both fall through to the next level. */
34
+ function firstCap(...levels) {
35
+ return levels.find((v) => v !== undefined && v > 0);
36
+ }
26
37
  /** Tools the engine treats as destructive: parked behind parkForApproval
27
38
  * (§2.5) instead of executing directly. Purely a marker — see withHitl. */
28
39
  export function markRequiresConfirmation(t) {
@@ -33,150 +44,300 @@ export function markRequiresConfirmation(t) {
33
44
  * redelivery would run half of them and then leave the thread parked with
34
45
  * those verdicts already consumed. */
35
46
  async function verdictReady(deps, pending) {
47
+ if (pending.landed)
48
+ return 'answered'; // its result is in the history already
36
49
  if (await deps.kv.get(hitlKey(pending.toolCallId)))
37
50
  return 'answered';
38
51
  return Date.now() >= hitlDeadline(pending, deps.config) ? 'expired' : 'open';
39
52
  }
40
53
  /** Turn a settled approval into the tool result the conversation will carry
41
54
  * (§2.5): run the approved tool, record the denial, or convert an expired
42
- * request into the timeout denial ("user had no response").
55
+ * request into the timeout denial ("user had no response"). Reports whether
56
+ * the request expired.
57
+ *
58
+ * Nothing here is consumed: the answer stays until its result is in the
59
+ * history (see landVerdict). An approved tool's output is kept under
60
+ * `hitlDoneKey` the moment it returns, so a worker that dies before the
61
+ * result is saved does not run the tool a second time on the retry: the
62
+ * retry reuses the output.
43
63
  *
44
64
  * The tool failure is surfaced TO THE MODEL as the tool result — the verdict
45
65
  * arrives from a different process than the one that ran the model, so the
46
66
  * conversation always stays executable. */
47
- async function settleVerdict(deps, threadId, pending, target, signal, state) {
67
+ async function settleVerdict(deps, threadId, pending, target, signal, state, toolRun) {
48
68
  const raw = await deps.kv.get(hitlKey(pending.toolCallId));
49
- await deps.kv.del(hitlKey(pending.toolCallId));
50
69
  if (!raw) {
51
- await publish(deps, threadId, 'INPUT_EXPIRED', { toolCallId: pending.toolCallId });
52
- return { responded: false, cancelled: true, reason: 'timeout' };
70
+ return { result: { responded: false, cancelled: true, reason: 'timeout' }, expired: true };
53
71
  }
54
72
  const answer = JSON.parse(raw);
55
73
  if (!answer.approved)
56
- return { denied: true };
74
+ return { result: { denied: true }, expired: false };
75
+ if (!target?.execute) {
76
+ return { result: { error: `Unknown tool: ${pending.toolName}` }, expired: false };
77
+ }
78
+ const done = await deps.kv.get(hitlDoneKey(pending.toolCallId));
79
+ if (done !== null)
80
+ return { result: JSON.parse(done), expired: false }; // it already ran
81
+ let result;
57
82
  try {
58
- return target?.execute
59
- ? await target.execute(pending.arguments, {
60
- toolCallId: pending.toolCallId,
61
- abortSignal: signal,
62
- // The resumed tool gets the same context a live one does (§2.10).
63
- state,
64
- publishEvent: (type, payload, options) => publishEvent(deps, threadId, type, payload, options),
65
- // What the human sent back with the approval (§2.5): answers to
66
- // the questions the tool asked, a corrected value, a reason.
67
- approval: { payload: answer.payload },
68
- })
69
- : { error: `Unknown tool: ${pending.toolName}` };
83
+ result = await target.execute(pending.arguments, {
84
+ toolCallId: pending.toolCallId,
85
+ abortSignal: signal,
86
+ // The resumed tool gets the same context a live one does (§2.10).
87
+ state,
88
+ publishEvent: (type, payload, options) => publishEvent(deps, threadId, type, payload, options),
89
+ // What the human sent back with the approval (§2.5): answers to
90
+ // the questions the tool asked, a corrected value, a reason.
91
+ approval: { payload: answer.payload },
92
+ threadId,
93
+ runId: toolRun.runId,
94
+ [TOOL_RUN]: toolRun,
95
+ });
70
96
  }
71
97
  catch (err) {
72
- return { error: err instanceof Error ? err.message : String(err) };
98
+ result = { error: err instanceof Error ? err.message : String(err) };
73
99
  }
100
+ await deps.kv.set(hitlDoneKey(pending.toolCallId), JSON.stringify(result ?? null), {
101
+ exSeconds: HITL_DONE_TTL_SECONDS,
102
+ });
103
+ return { result, expired: false };
104
+ }
105
+ /** How long an approved tool's output is kept for a retry that has to land
106
+ * it: far past any retry backoff. */
107
+ const HITL_DONE_TTL_SECONDS = 24 * 60 * 60;
108
+ /** Clear a verdict once its result is in the history (§2.5): the answer and
109
+ * the kept output go, and an expiry is published. Before that, a retry must
110
+ * still find both. */
111
+ async function landVerdict(deps, threadId, pending, expired) {
112
+ if (expired)
113
+ await publish(deps, threadId, 'INPUT_EXPIRED', { toolCallId: pending.toolCallId });
114
+ await deps.kv.del(hitlKey(pending.toolCallId));
115
+ await deps.kv.del(hitlDoneKey(pending.toolCallId));
116
+ }
117
+ /** A result the resume saved, on the resume's run stream: a live tool's
118
+ * result goes out as its chunk, but a verdict's is only ever saved. */
119
+ async function streamToolResult(deps, threadId, agentId, toolCallId, toolName, result) {
120
+ const seg = activeSegment(deps, threadId);
121
+ if (!seg)
122
+ return;
123
+ const chunk = { type: 'tool-result', toolCallId, toolName, result: result ?? null };
124
+ await (agentId
125
+ ? seg.forward('SUBAGENT_CHUNK', { agentId, chunk }, true)
126
+ : seg.forward('CHUNK', chunk, true));
74
127
  }
75
128
  /** Land a settled verdict and unwind whatever was waiting on it (§2.7).
76
129
  *
77
130
  * The verdict belongs to the stream that asked — the main agent's, or a
78
131
  * nested run's. When a nested run asked, its own loop is re-entered from its
79
132
  * persisted turns and its result is handed to the call waiting one level up,
80
- * repeating until the main agent's `spawnSubagent` call is answered.
133
+ * repeating until the main agent's `spawnSubagent` call is answered. A park
134
+ * that already landed (see PendingHitl.landed) carries on from the first
135
+ * level still waiting.
81
136
  *
82
- * Returns false when the unwind parked again: the thread stays
83
- * WAITING_FOR_INPUT and a later dispatch picks up from the new request. */
84
- async function unwindVerdict(deps, threadId, pending, result, subCtx, signal) {
85
- await deps.storage.messages.append(threadId, {
86
- role: 'tool',
87
- agentId: pending.agentId,
88
- content: [
89
- { type: 'tool-result', toolCallId: pending.toolCallId, toolName: pending.toolName, result },
90
- ],
91
- });
137
+ * A child that fails on the way up is reported to the level above as the
138
+ * delegation's result, exactly as a live spawnSubagent reports it, so one
139
+ * failed child never leaves the whole thread stuck waiting.
140
+ *
141
+ * Returns false when the unwind parked again, or a user stopped it: the
142
+ * thread stays as it is and a later dispatch picks up from there. */
143
+ async function unwindVerdict(deps, threadId, pending, settled, subCtx, signal) {
144
+ let start = 0;
92
145
  // `producer` is whoever must now run to produce the next result. Undefined
93
146
  // means the main agent, whose loop the caller re-enters itself.
94
147
  let producer = pending.nested;
95
- for (let i = 0; i < pending.frames.length; i += 1) {
148
+ if (pending.landed) {
149
+ start = pending.resumeFrame ?? 0;
150
+ if (start > 0)
151
+ producer = pending.frames[start - 1].nested;
152
+ }
153
+ else {
154
+ await deps.storage.messages.append(threadId, {
155
+ role: 'tool',
156
+ agentId: pending.agentId,
157
+ content: [
158
+ {
159
+ type: 'tool-result',
160
+ toolCallId: pending.toolCallId,
161
+ toolName: pending.toolName,
162
+ result: settled?.result,
163
+ },
164
+ ],
165
+ });
166
+ await streamToolResult(deps, threadId, pending.agentId, pending.toolCallId, pending.toolName, settled?.result);
167
+ await landVerdict(deps, threadId, pending, settled?.expired ?? false);
168
+ }
169
+ for (let i = start; i < pending.frames.length; i += 1) {
96
170
  const frame = pending.frames[i];
97
171
  if (!producer || !subCtx)
98
172
  break;
99
- const outcome = await runNestedAgent(subCtx, producer, null, signal, pending.frames.slice(i));
100
- if (outcome.parked)
101
- return false; // parked again, one level down
102
- if (outcome.aborted)
103
- return false; // user stop mid-unwind (§2.1)
104
- await deps.admin.runs.patch(producer.agentId, {
105
- state: 'COMPLETED',
106
- result: { text: outcome.text },
107
- endedAt: new Date(),
108
- });
109
- await publish(deps, threadId, 'SUBAGENT_COMPLETED', { agentId: producer.agentId });
110
- // Hand the capped result to the call one level up (§2.6)
173
+ let handed;
174
+ try {
175
+ const outcome = await runNestedAgent(subCtx, producer, null, signal, pending.frames.slice(i));
176
+ if (outcome.parked)
177
+ return false; // parked again, one level down
178
+ if (outcome.aborted)
179
+ return false; // user stop mid-unwind (§2.1)
180
+ // A child whose stream ended with no finish did not finish.
181
+ if (outcome.interrupted)
182
+ throw new Error(`step ${outcome.steps + 1} ended without a finish`);
183
+ const run = await deps.admin.runs.get(producer.agentId).catch(() => null);
184
+ if (run)
185
+ await closeNested(subCtx, run, outcome, { state: 'COMPLETED', result: { text: outcome.text } });
186
+ await publish(deps, threadId, 'SUBAGENT_COMPLETED', { agentId: producer.agentId });
187
+ // Hand the capped result to the call one level up (§2.6)
188
+ handed = {
189
+ agentId: producer.agentId,
190
+ result: outcome.text.slice(0, deps.config.subagentResultCapChars),
191
+ };
192
+ }
193
+ catch (err) {
194
+ if (signal.aborted || (await deps.kv.get(`agent:state:${threadId}`)) === 'CANCELLED') {
195
+ return false; // user stop mid-unwind (§2.1)
196
+ }
197
+ const message = err instanceof Error ? err.message : String(err);
198
+ const run = await deps.admin.runs.get(producer.agentId).catch(() => null);
199
+ if (run)
200
+ await closeNested(subCtx, run, null, { state: 'FAILED', error: message });
201
+ await publish(deps, threadId, 'SUBAGENT_FAILED', {
202
+ agentId: producer.agentId,
203
+ state: 'FAILED',
204
+ error: message,
205
+ });
206
+ handed = { agentId: producer.agentId, error: message };
207
+ }
111
208
  await deps.storage.messages.append(threadId, {
112
209
  role: 'tool',
113
210
  agentId: frame.agentId,
114
211
  content: [
115
- {
116
- type: 'tool-result',
117
- toolCallId: frame.toolCallId,
118
- toolName: 'spawnSubagent',
119
- result: {
120
- agentId: producer.agentId,
121
- result: outcome.text.slice(0, deps.config.subagentResultCapChars),
122
- },
123
- },
212
+ { type: 'tool-result', toolCallId: frame.toolCallId, toolName: 'spawnSubagent', result: handed },
124
213
  ],
125
214
  });
215
+ await streamToolResult(deps, threadId, frame.agentId, frame.toolCallId, 'spawnSubagent', handed);
126
216
  producer = frame.nested;
127
217
  }
128
218
  return true;
129
219
  }
130
220
  /** Sum this segment onto the run's record and stamp how it ended (§2.9). */
131
221
  async function closeRunRecord(deps, runId, f) {
222
+ let endedAt = new Date();
132
223
  try {
133
224
  const prior = await deps.admin.runs.get(runId);
134
225
  if (!prior)
135
- return; // a run started before §2.9, or a foreign dispatch
226
+ return endedAt; // a run started before §2.9, or a foreign dispatch
136
227
  // stop() already records when the user ended this run. Worker teardown
137
228
  // may add usage, but must not move that timestamp or undo cancellation.
138
229
  const cancelled = prior.state === 'CANCELLED';
139
- const endedAt = cancelled && prior.endedAt ? new Date(prior.endedAt) : new Date();
230
+ if (cancelled && prior.endedAt)
231
+ endedAt = new Date(prior.endedAt);
140
232
  await deps.admin.runs.patch(runId, {
141
233
  state: cancelled ? 'CANCELLED' : f.state,
142
234
  stopReason: cancelled ? 'cancelled' : f.stopReason,
143
235
  ...(f.error ? { error: f.error } : {}),
144
236
  endedAt,
145
237
  durationMs: endedAt.getTime() - new Date(prior.startedAt).getTime(),
146
- steps: prior.steps + (f.steps ?? 0),
147
- inputTokens: prior.inputTokens + f.attribution.inputTokens,
148
- cachedInputTokens: prior.cachedInputTokens + f.attribution.cachedInputTokens,
149
- outputTokens: prior.outputTokens + f.attribution.outputTokens,
150
- totalTokens: prior.totalTokens + f.attribution.totalTokens,
151
238
  });
239
+ // The counters are added in the store, not read and written back here: a
240
+ // nested run and its parent can close at the same moment.
241
+ await deps.admin.runs.increment(runId, deltasOf(f.steps ?? 0, f.attribution));
152
242
  }
153
243
  catch {
154
244
  // Observability must never be able to fail a run that otherwise succeeded.
155
245
  }
246
+ return endedAt;
247
+ }
248
+ /** A segment's steps and tokens as counters to add to its run. */
249
+ function deltasOf(steps, a) {
250
+ return {
251
+ steps, inputTokens: a.inputTokens, cachedInputTokens: a.cachedInputTokens,
252
+ outputTokens: a.outputTokens, totalTokens: a.totalTokens,
253
+ };
254
+ }
255
+ /** Close a run record that can never be worked on again (§2.9): the thread is
256
+ * gone, or a newer run replaced this one. Without this the record stays
257
+ * open for ever and every "in flight" count carries it. */
258
+ export async function closeIfOpen(deps, runId, stopReason) {
259
+ if (!runId)
260
+ return;
261
+ const rec = await deps.admin.runs.get(runId).catch(() => null);
262
+ if (!rec || rec.endedAt)
263
+ return;
264
+ await closeRunRecord(deps, runId, {
265
+ state: 'CANCELLED',
266
+ stopReason,
267
+ tokensUsed: 0,
268
+ attribution: { inputTokens: 0, cachedInputTokens: 0, outputTokens: 0, totalTokens: 0 },
269
+ runId,
270
+ });
156
271
  }
157
272
  /** Finalise a run as FAILED on both homes AND keep why (§2.9). The reason used
158
273
  * to be dropped entirely, so an operator could see that something failed but
159
274
  * never what. */
160
- async function failRun(deps, threadId, runId, error) {
161
- await deps.kv.set(`agent:state:${threadId}`, 'FAILED');
162
- await setThreadState(deps, threadId, 'FAILED');
275
+ async function failRun(deps, agent, threadId, runId, error) {
276
+ // A failed run still spent money on the steps it did make (§4), so it
277
+ // settles like any other end. Settled even with no hook, so the late sweep
278
+ // does not keep coming back to it.
279
+ const bill = await runBill(deps, threadId, runId);
280
+ const info = {
281
+ threadId, ...(runId ? { runId } : {}), state: 'FAILED', stopReason: 'failed', cancelled: false, error,
282
+ tokensUsed: bill.usage.totalTokens,
283
+ attribution: { inputTokens: 0, cachedInputTokens: 0, outputTokens: 0, totalTokens: 0 },
284
+ steps: 0,
285
+ usage: bill.usage,
286
+ ...(bill.error !== undefined ? { usageError: bill.error } : {}),
287
+ };
288
+ const { reached } = await settleRun(deps, agent, info);
289
+ // Only while the thread is still this run's and still going: a stop or a
290
+ // newer run that got there first keeps its own ending (§3.4).
291
+ if (!(await transition(deps, threadId, { from: ACTIVE_STATES, to: 'FAILED', runId }))) {
292
+ (deps.log ?? console).error('run not failed: the thread has moved on', { threadId, runId, reason: error });
293
+ return;
294
+ }
295
+ let endedAt = new Date();
163
296
  if (runId) {
164
- await closeRunRecord(deps, runId, {
297
+ endedAt = await closeRunRecord(deps, runId, {
165
298
  state: 'FAILED',
166
- stopReason: 'completed',
299
+ stopReason: 'failed',
167
300
  tokensUsed: 0,
168
301
  attribution: { inputTokens: 0, cachedInputTokens: 0, outputTokens: 0, totalTokens: 0 },
169
302
  error,
170
303
  runId,
171
304
  });
172
305
  }
173
- await publish(deps, threadId, 'STATE_CHANGE', { state: 'FAILED', error });
306
+ await publish(deps, threadId, 'STATE_CHANGE', {
307
+ state: 'FAILED', stopReason: 'failed', error, endedAt, ...(runId ? { runId } : {}),
308
+ });
309
+ // onFinish fires on every end, a failure included, once.
310
+ if (reached)
311
+ await callOnFinish(deps, agent, info);
312
+ }
313
+ /** Whether this run already made its last step in an earlier delivery
314
+ * (§2.8): a worker saved the step that answered, then died before the run
315
+ * ended. Every run appends its own user turn when it is dispatched, so a
316
+ * main-agent history that ends in an assistant message with no tool calls can
317
+ * only be this run's answer. A run already settled is finished too. The text
318
+ * is that answer, for a generate-text run's result. */
319
+ async function finishedEarlier(deps, threadId, runId) {
320
+ const no = { done: false, text: '' };
321
+ if (!runId)
322
+ return no;
323
+ const rec = await deps.admin.runs.get(runId).catch(() => null);
324
+ const settled = !!rec?.settledAt;
325
+ const messages = await deps.storage.messages.list(threadId, { agentId: null }).catch(() => null);
326
+ const last = messages?.at(-1);
327
+ if (!last || last.role !== 'assistant')
328
+ return { done: settled, text: '' };
329
+ const parts = typeof last.content === 'string'
330
+ ? [{ type: 'text', text: last.content }]
331
+ : Array.isArray(last.content) ? last.content : [];
332
+ if (parts.some((p) => p?.type === 'tool-call'))
333
+ return { done: settled, text: '' }; // the loop was not done
334
+ return { done: true, text: parts.filter((p) => p?.type === 'text').map((p) => p.text ?? '').join('') };
174
335
  }
175
336
  /** Resolve every parked request at segment start (§2.5, §2.7) and flip the
176
337
  * thread back to RUNNING. Returns false when at least one approval is still
177
338
  * open within its TTL: the dispatch is an at-least-once redelivery and the
178
339
  * thread stays parked. `rawTools` must be the UNWRAPPED main toolset. */
179
- async function resumePendingHitl(deps, threadId, open, rawTools, subCtx, signal, state) {
340
+ async function resumePendingHitl(deps, threadId, open, rawTools, subCtx, signal, state, runId, toolRun) {
180
341
  // Readiness first, side effects second: the thread resumes only when EVERY
181
342
  // open approval has been answered or has expired (§2.7).
182
343
  const states = await Promise.all(open.map((p) => verdictReady(deps, p)));
@@ -188,16 +349,27 @@ async function resumePendingHitl(deps, threadId, open, rawTools, subCtx, signal,
188
349
  // agent's set — the approved tool has to be resolved where it lives.
189
350
  const target = pending.agentId === null
190
351
  ? rawTools[pending.toolName]
191
- : subCtx?.sub.tools?.[pending.toolName];
192
- const result = await settleVerdict(deps, threadId, pending, target, signal, state);
193
- if (result?.reason === 'timeout')
352
+ : subCtx
353
+ ? nestedRawTools(subCtx, pending.nested)[pending.toolName]
354
+ : undefined;
355
+ const settled = pending.landed
356
+ ? null
357
+ : await settleVerdict(deps, threadId, pending, target, signal, state, {
358
+ ...toolRun,
359
+ agentId: pending.agentId,
360
+ ...(pending.agentId !== null && subCtx ? { agentName: pending.nested?.name } : {}),
361
+ });
362
+ if (settled?.expired)
194
363
  expiredAny = true;
195
- if (!(await unwindVerdict(deps, threadId, pending, result, subCtx, signal)))
364
+ if (!(await unwindVerdict(deps, threadId, pending, settled, subCtx, signal)))
196
365
  return false;
197
366
  }
198
- await deps.kv.set(`agent:state:${threadId}`, 'RUNNING');
199
- await setThreadState(deps, threadId, 'RUNNING');
200
- await publish(deps, threadId, 'STATE_CHANGE', { state: 'RUNNING' });
367
+ // A stop that landed while the verdicts were applied wins: the thread stays
368
+ // CANCELLED and this segment goes no further (§3.4).
369
+ if (!(await transition(deps, threadId, { from: ['WAITING_FOR_INPUT'], to: 'RUNNING', runId }))) {
370
+ return false;
371
+ }
372
+ await publish(deps, threadId, 'STATE_CHANGE', await runStatePayload(deps, 'RUNNING', runId));
201
373
  void expiredAny;
202
374
  return true;
203
375
  }
@@ -205,22 +377,43 @@ export async function execute(deps, agent, input) {
205
377
  const { threadId, runId } = input;
206
378
  const abort = new AbortController();
207
379
  validateTokenBudget(input.tokenBudget, 'tokenBudget');
380
+ validateTokenBudget(input.costBudgetMicros, 'costBudgetMicros');
208
381
  /** True once the thread has started a NEWER run than this one (§2.1). */
209
382
  const stale = async () => runId !== undefined && (await deps.kv.get(runIdKey(threadId))) !== runId;
210
- // The lock carries the run id, so a later conflict can tell a duplicate
211
- // delivery of THIS run apart from an older run that is still finishing.
212
- const locked = await deps.kv.set(runLockKey(threadId), runId ?? randomUUID(), {
213
- onlyIfNotExists: true,
214
- exSeconds: deps.config.runLockLeaseSeconds,
215
- });
216
- if (!locked)
383
+ // The lock names this run and this delivery of it (§3.4), so a later
384
+ // conflict can tell a duplicate of THIS job apart from another delivery of
385
+ // the same run and from an older run that is still finishing.
386
+ const lease = await Lease.acquire(deps, threadId, runId, input.dispatchId);
387
+ if (!lease)
217
388
  return 'lock-conflict'; // another worker owns this thread (§2.8)
389
+ // Renewed until released, not only while the model runs: finalize is a
390
+ // write too. A lease that cannot be kept ends the segment at once — and is
391
+ // told apart from a user stop, which ends it the same way.
392
+ lease.keep(() => abort.abort());
218
393
  // Token budget (§2.1 safety cap) — precedence: execute input → spec →
219
394
  // config. Checked BETWEEN steps: the finished step is always kept in full,
220
395
  // nothing is aborted mid-generation. This is NOT a user stop.
221
- const tokenBudget = input.tokenBudget ?? agent.spec.tokenBudget ?? deps.config.tokenBudget;
222
- // The money cap (§4) resolves the same way, widest last.
223
- const costBudget = input.costBudgetMicros ?? agent.spec.costBudgetMicros ?? deps.config.costBudgetMicros;
396
+ const tokenBudget = firstCap(input.tokenBudget, agent.spec.tokenBudget, deps.config.tokenBudget);
397
+ // The money cap (§4) resolves the same way, widest last. A billing check at
398
+ // pickup may still lower it.
399
+ let costBudget = firstCap(input.costBudgetMicros, agent.spec.costBudgetMicros, deps.config.costBudgetMicros);
400
+ // The run's own step cap, lowered by a billing check the same way.
401
+ let runMaxSteps = input.maxSteps ?? 0;
402
+ // A segmentTimeoutMs bounds the segment in wall time: past it everything
403
+ // tears down as for a stop, and the run ends FAILED ('timeout') rather than
404
+ // holding the worker.
405
+ // A shutdown ends the segment as a stop does, but it is not a stop: the
406
+ // policy hands the job back (see executeWithPolicy).
407
+ let shutdown = false;
408
+ const onShutdown = () => { shutdown = true; abort.abort(); };
409
+ if (input.signal?.aborted)
410
+ onShutdown();
411
+ else
412
+ input.signal?.addEventListener('abort', onShutdown, { once: true });
413
+ let segmentTimedOut = false;
414
+ const segmentTimer = deps.config.segmentTimeoutMs > 0
415
+ ? setTimeout(() => { segmentTimedOut = true; abort.abort(); }, deps.config.segmentTimeoutMs)
416
+ : undefined;
224
417
  // Provider-specific options (§3.1): spec default <- execute input,
225
418
  // shallow per-provider namespace; the execute input wins.
226
419
  // Three levels, widest first: runtime config → agent spec → this run.
@@ -231,6 +424,12 @@ export async function execute(deps, agent, input) {
231
424
  // 2. the run id has moved on: the user pressed stop and then sent another
232
425
  // message, which put RUNNING back over CANCELLED before this poll could
233
426
  // read it. The state key lies in that window; the run id never does.
427
+ // The segment's run stream, open from pickup (see SegmentStream), and how
428
+ // it ends: set where the segment's outcome is known, or worked out in the
429
+ // finally below from how it stopped.
430
+ let seg = null;
431
+ let segEnd;
432
+ let failure;
234
433
  const controlPoll = setInterval(async () => {
235
434
  try {
236
435
  if ((await deps.kv.get(`agent:state:${threadId}`)) === 'CANCELLED' || (await stale())) {
@@ -242,199 +441,392 @@ export async function execute(deps, agent, input) {
242
441
  }
243
442
  }, deps.config.stopPollMs);
244
443
  try {
245
- // At-least-once idempotency (§2.8): a job whose run already ended — or
246
- // was stopped — must be a no-op on redelivery. A MISSING thread is the
247
- // same no-op: it was deleted (§3.2) and must never be resurrected.
248
- // A newer run already owns this thread: this job has nothing to do, and
249
- // must not touch state on the live run's behalf (§2.1).
250
- if (await stale())
251
- return 'stale';
252
- const durable = await deps.storage.threads.get(threadId);
253
- if (!durable ||
254
- durable.state === 'CANCELLED' ||
255
- durable.state === 'COMPLETED' ||
256
- durable.state === 'FAILED') {
257
- return 'executed';
258
- }
259
- const resume = {
260
- agent: agent.name,
261
- model: input.model,
262
- ...(runId ? { runId } : {}),
263
- ...(input.tokenBudget !== undefined ? { tokenBudget: input.tokenBudget } : {}),
264
- ...(input.costBudgetMicros !== undefined
265
- ? { costBudgetMicros: input.costBudgetMicros }
266
- : {}),
267
- ...(providerOptions ? { providerOptions } : {}),
268
- // Carried so the resumed segment scopes its storage the same way this
269
- // one does (§2.10).
270
- ...(input.state ? { state: input.state } : {}),
271
- };
272
- // One ledger for the whole run: a nested run's spend counts against the
273
- // same safety cap the main agent is checked against (§2.7).
274
- const ledger = { tokensUsed: 0 };
275
- // How long the dispatch sat in the queue before a worker took it (§2.9).
276
- if (runId && input.enqueuedAt) {
277
- await deps.admin.runs
278
- .patch(runId, { queuedMs: Date.now() - input.enqueuedAt })
279
- .catch(() => undefined);
280
- }
281
- // Platform-owned toolset: HITL (§2.5) over the user's set; spawnSubagent
282
- // added ONLY when the spec opts in (§2.7). rawTools keeps the real
283
- // implementations — the resolved park executes the approved tool.
284
- const sub = agent.spec.subagents
285
- ? agent.spec.subagents === true
286
- ? {}
287
- : agent.spec.subagents
288
- : null;
289
- const subCtx = sub
290
- ? {
291
- threadId,
292
- depth: 0,
293
- sem: agent.sem,
294
- ports: deps,
295
- sub,
296
- agent,
297
- ledger,
298
- resume,
299
- agentId: null, // spawned by the main agent
300
- frames: [],
301
- tokenBudget,
302
- costBudgetMicros: costBudget,
303
- billingRunId: runId,
304
- providerOptions,
305
- abortSignal: abort.signal,
306
- state: input.state,
444
+ try {
445
+ // At-least-once idempotency (§2.8): a job whose run already ended — or
446
+ // was stopped — must be a no-op on redelivery. A MISSING thread is the
447
+ // same no-op: it was deleted (§3.2) and must never be resurrected.
448
+ // A newer run already owns this thread: this job has nothing to do, and
449
+ // must not touch state on the live run's behalf (§2.1).
450
+ // Its own record is closed, so it does not count as in flight for ever.
451
+ if (await stale()) {
452
+ await closeIfOpen(deps, runId, 'replaced');
453
+ return 'stale';
307
454
  }
308
- : null;
309
- const rawTools = {
310
- ...(agent.args.tools ?? {}),
311
- ...(subCtx ? { spawnSubagent: spawnSubagentTool(subCtx) } : {}),
312
- };
313
- // The main agent's own toolset: nothing is waiting on its parks (§2.7).
314
- // Every tool also sees the run's state (§2.10).
315
- // Every tool also sees the run's state (§2.10) and can publish its own
316
- // events on the thread.
317
- const tools = withRunState(withPublishEvent(deps, threadId, withHitl(deps, threadId, rawTools, { resume, agentId: null, frames: [] })), input.state ?? {});
318
- // §2.5 resume: a WAITING thread at segment start is either the /respond
319
- // continuation or a redelivery of the original job while still parked.
320
- if (durable?.state === 'WAITING_FOR_INPUT') {
321
- // Every approval still open, not just the latest: one parent step can
322
- // park several nested runs at once (§2.7).
323
- const open = await loadOpenHitls(deps, threadId);
324
- if (open.length === 0) {
325
- // WAITING without a pending request cannot be continued — fail into
326
- // the §2.8 policy rather than corrupting the conversation.
327
- throw new Error(`Thread ${threadId} is WAITING_FOR_INPUT without a pending INPUT_REQUIRED`);
455
+ const durable = await deps.storage.threads.get(threadId);
456
+ if (!durable) {
457
+ await closeIfOpen(deps, runId, 'orphaned');
458
+ return 'executed';
328
459
  }
329
- const resumed = await resumePendingHitl(deps, threadId, open, rawTools, subCtx, abort.signal, input.state ?? {});
330
- if (!resumed)
331
- return 'executed'; // still parked — nothing to do yet
332
- }
333
- // Durable compaction pass — history always fits the model budget (§2.6);
334
- // the budget uses the resolved model's contextWindow (§3.3)
335
- const history = await compactContext(deps, threadId, input.model);
336
- const model = deps.resolveModel(input.model);
337
- // Prompt caching (§2.6): stamp the stable prefix once — appended step
338
- // messages extend the prompt without invalidating the breakpoints.
339
- let messages = repairDanglingToolCalls(promptMessages(history));
340
- if (deps.config.promptCaching) {
341
- messages = markPromptCaching(messages);
342
- }
343
- const userArgs = agent.args;
344
- const loop = await runLoop(deps, agent, threadId, {
345
- agentId: null, // the main agent's stream (§2.7)
346
- runId,
347
- kind: agent.kind,
348
- model: model.instance(),
349
- messages,
350
- tools,
351
- maxSteps: deps.config.maxSteps,
352
- abortSignal: abort.signal,
353
- providerOptions,
354
- tokenBudget,
355
- costBudgetMicros: costBudget,
356
- billingRunId: runId,
357
- modelKey: input.model,
358
- modelId: wireId(model, input.model),
359
- agentName: agent.name,
360
- cacheSystemPrompt: deps.config.promptCaching,
361
- onChunk: async (chunk) => {
362
- // One canonical path for every client: durable log + live Pub/Sub (§2.1, §2.2)
363
- await publish(deps, threadId, 'CHUNK', chunk);
364
- userArgs.onChunk?.({ chunk }); // user callback still fires
365
- },
366
- }, ledger);
367
- const { attribution, parked } = loop;
368
- const tokensUsed = ledger.tokensUsed;
369
- const lastText = loop.text;
370
- const lastFinishReason = loop.finishReason;
371
- if (parked) {
372
- // The segment ends holding the park. Every call it made was already
373
- // recorded and priced as it happened (§4), so there is nothing left to
374
- // bill here. NO state flip: WAITING_FOR_INPUT (or CANCELLED if the user
375
- // stopped meanwhile) stands.
460
+ if (isTerminal(durable.state)) {
461
+ // A stop ended this run before any worker got to it, or while a worker
462
+ // was between segments; or the run ended and its settle never landed.
463
+ // Whoever holds the lock settles it, once.
464
+ await settleEndedRun(deps, agent, threadId, runId);
465
+ return 'executed';
466
+ }
467
+ // A wait has a ceiling (§2.8): a job picked up long past it fails with
468
+ // the reason, instead of doing work nobody is waiting for any more.
469
+ if (deps.config.maxQueueWaitMs > 0 && input.enqueuedAt) {
470
+ const waited = Date.now() - input.enqueuedAt;
471
+ if (waited > deps.config.maxQueueWaitMs) {
472
+ const reason = `the run waited ${Math.round(waited / 1000)}s in the queue, past the ${Math.round(deps.config.maxQueueWaitMs / 1000)}s limit`;
473
+ (deps.log ?? console).warn?.('run queued too long; failing it', { threadId, runId, waited });
474
+ if (durable.state === 'WAITING_FOR_INPUT')
475
+ await closeOpenParks(deps, threadId).catch(() => undefined);
476
+ await failRun(deps, agent, threadId, runId, reason);
477
+ return 'executed';
478
+ }
479
+ }
480
+ // Billing at pickup (§4): the dispatch check ran before the wait; the
481
+ // balance may have moved since. A refusal here fails the run with the
482
+ // reason; a lowered cap applies to this segment.
483
+ if (deps.config.billingPreCheck && runId) {
484
+ const budget = { costBudgetMicros: costBudget ?? 0, maxSteps: runMaxSteps };
485
+ const check = await deps.config.billingPreCheck({
486
+ threadId, runId, state: input.state ?? {}, stage: 'pickup', budget,
487
+ publishEvent: (type, payload, options) => publishEvent(deps, threadId, type, payload, options),
488
+ });
489
+ if (!check.ok) {
490
+ const error = check.error ?? 'Billing check failed';
491
+ (deps.log ?? console).error('run refused at pickup', { threadId, runId, error });
492
+ await publish(deps, threadId, 'RUN_REFUSED', { reason: 'billing', error, runId });
493
+ if (durable.state === 'WAITING_FOR_INPUT')
494
+ await closeOpenParks(deps, threadId).catch(() => undefined);
495
+ await failRun(deps, agent, threadId, runId, error);
496
+ return 'executed';
497
+ }
498
+ if (budget.costBudgetMicros > 0 && (!costBudget || budget.costBudgetMicros < costBudget)) {
499
+ costBudget = budget.costBudgetMicros;
500
+ }
501
+ if (budget.maxSteps > 0 && (!runMaxSteps || budget.maxSteps < runMaxSteps)) {
502
+ runMaxSteps = budget.maxSteps;
503
+ }
504
+ }
505
+ // Step ceiling (§2.1): the run's own cap when it set one, the config's
506
+ // otherwise, and never above the config's.
507
+ const maxSteps = runMaxSteps > 0 && runMaxSteps < deps.config.maxSteps ? runMaxSteps : deps.config.maxSteps;
508
+ const resume = {
509
+ agent: agent.name,
510
+ model: input.model,
511
+ ...(input.maxSteps ? { maxSteps: input.maxSteps } : {}),
512
+ ...(input.dispatchedAt ? { dispatchedAt: input.dispatchedAt } : {}),
513
+ ...(runId ? { runId } : {}),
514
+ ...(input.tokenBudget !== undefined ? { tokenBudget: input.tokenBudget } : {}),
515
+ ...(input.costBudgetMicros !== undefined
516
+ ? { costBudgetMicros: input.costBudgetMicros }
517
+ : {}),
518
+ ...(providerOptions ? { providerOptions } : {}),
519
+ // Carried so the resumed segment scopes its storage the same way this
520
+ // one does (§2.10).
521
+ ...(input.state ? { state: input.state } : {}),
522
+ };
523
+ // One ledger for the whole run: a nested run's spend counts against the
524
+ // same caps the main agent is checked against (§2.7), and it starts from
525
+ // what the run spent before a park or a retry, so no segment gets a
526
+ // fresh budget.
527
+ const ledger = await seedRunLedger(deps, threadId, runId);
528
+ // Pickup (§2.8): the run has a worker now. A QUEUED thread becomes
529
+ // RUNNING on every home, its record takes the moment work started, and
530
+ // the wire says so, so a client's clock measures work rather than
531
+ // waiting. The wait itself is kept on the record: the latest dispatch's,
532
+ // so a resume's or a retry's wait shows as its own.
533
+ const pickedUp = new Date();
376
534
  if (runId) {
377
- try {
378
- const prior = await deps.admin.runs.get(runId);
379
- if (prior)
380
- await deps.admin.runs.patch(runId, {
381
- steps: prior.steps + loop.steps,
382
- inputTokens: prior.inputTokens + attribution.inputTokens,
383
- cachedInputTokens: prior.cachedInputTokens + attribution.cachedInputTokens,
384
- outputTokens: prior.outputTokens + attribution.outputTokens,
385
- totalTokens: prior.totalTokens + attribution.totalTokens,
386
- });
535
+ const patch = {};
536
+ if (input.enqueuedAt)
537
+ patch.queuedMs = pickedUp.getTime() - input.enqueuedAt;
538
+ if (durable.state === 'QUEUED') {
539
+ // A stop (or a newer run) that landed since the read above wins:
540
+ // this job has nothing to pick up (§3.4).
541
+ if (!(await transition(deps, threadId, {
542
+ from: ['QUEUED'], to: 'RUNNING', runId, model: input.model,
543
+ }))) {
544
+ return 'executed';
545
+ }
546
+ let startedAt = pickedUp;
547
+ const rec = await deps.admin.runs.get(runId).catch(() => null);
548
+ if (rec && rec.queuedMs != null) {
549
+ startedAt = new Date(rec.startedAt); // a retry: the run started when it first ran
550
+ }
551
+ else {
552
+ patch.startedAt = pickedUp;
553
+ }
554
+ patch.state = 'RUNNING';
555
+ await deps.admin.runs.patch(runId, patch).catch(() => undefined);
556
+ await publish(deps, threadId, 'STATE_CHANGE', { state: 'RUNNING', runId, startedAt });
557
+ durable.state = 'RUNNING';
558
+ }
559
+ else if (patch.queuedMs !== undefined) {
560
+ await deps.admin.runs.patch(runId, patch).catch(() => undefined);
387
561
  }
388
- catch { /* Operational history must not fail a parked run. */ }
389
562
  }
390
- return 'executed';
391
- }
392
- const stopReason = abort.signal.aborted
393
- ? 'cancelled'
394
- : loop.costExhausted
395
- ? 'cost_budget' // the money cap (§4)
396
- : tokenBudget && tokensUsed >= tokenBudget
397
- ? 'token_budget'
398
- : lastFinishReason === 'tool-calls'
399
- ? 'max_steps' // step ceiling hit (§2.1)
400
- : 'completed';
401
- const state = abort.signal.aborted ? 'CANCELLED' : 'COMPLETED';
402
- await finalize(deps, agent, threadId, {
403
- state,
404
- stopReason,
405
- tokensUsed,
406
- attribution,
407
- oneShotText: agent.kind === 'generate-text' ? lastText : undefined,
408
- runId,
409
- steps: loop.steps,
410
- });
411
- if (typeof userArgs.onFinish === 'function') {
412
- // The whole run's bill, read back from the rows the loop wrote (§4):
413
- // every segment and every nested run, priced and grouped into lines, so
414
- // a settle hook charges in one pass without keeping its own tally.
415
- // The run is already finalized: a callback that throws is the
416
- // caller's bug to see in the log, not a reason to fail a finished run.
417
- try {
418
- await userArgs.onFinish({
563
+ // From here on the segment does work, and says so on its own stream.
564
+ seg = await openSegment(deps, threadId, runId);
565
+ // Platform-owned toolset: HITL (§2.5) over the user's set; spawnSubagent
566
+ // added ONLY when the spec opts in (§2.7). rawTools keeps the real
567
+ // implementations — the resolved park executes the approved tool.
568
+ const sub = agent.spec.subagents
569
+ ? agent.spec.subagents === true
570
+ ? {}
571
+ : agent.spec.subagents
572
+ : null;
573
+ // Parks raised during this segment wait here until the step that raised
574
+ // them is saved (see ParkBox).
575
+ const parks = new ParkBox();
576
+ // Calls whose tool failed, for their live chunk (see chunkPayload).
577
+ const toolErrors = new Map();
578
+ const subCtx = sub
579
+ ? {
419
580
  threadId,
581
+ depth: 0,
582
+ // Made per run: the cap is this run's, never shared with others.
583
+ slots: new RunSlots(deps.config.subagentMaxConcurrent),
584
+ ports: deps,
585
+ sub,
586
+ agent,
587
+ ledger,
588
+ resume,
589
+ agentId: null, // spawned by the main agent
590
+ frames: [],
591
+ tokenBudget,
592
+ costBudgetMicros: costBudget,
593
+ billingRunId: runId,
594
+ providerOptions,
595
+ abortSignal: abort.signal,
596
+ fenced: () => lease.lost,
597
+ parks,
598
+ toolErrors,
599
+ state: input.state,
600
+ }
601
+ : null;
602
+ const rawTools = {
603
+ ...(agent.args.tools ?? {}),
604
+ ...(subCtx ? { spawnSubagent: spawnSubagentTool(subCtx) } : {}),
605
+ };
606
+ // The main agent's own toolset: nothing is waiting on its parks (§2.7).
607
+ // Every tool also sees the run's state (§2.10).
608
+ // Every tool also sees the run's state (§2.10) and can publish its own
609
+ // events on the thread.
610
+ // ...and the run it is part of: its ports, its ledger, its ids.
611
+ const toolRun = {
612
+ deps, threadId, runId, agentId: null, agentName: agent.name, ledger,
613
+ ...(input.state ? { state: input.state } : {}),
614
+ };
615
+ const tools = withToolRun(withRunState(withPublishEvent(deps, threadId, withHitl(deps, threadId, rawTools, { resume, agentId: null, frames: [], parks, toolErrors })), input.state ?? {}), toolRun);
616
+ // §2.5 resume: a WAITING thread at segment start is either the /respond
617
+ // continuation or a redelivery of the original job while still parked.
618
+ if (durable?.state === 'WAITING_FOR_INPUT') {
619
+ // Every approval still open, not just the latest: one parent step can
620
+ // park several nested runs at once (§2.7).
621
+ const open = await loadOpenHitls(deps, threadId);
622
+ if (open.length === 0) {
623
+ // WAITING without a pending request cannot be continued — fail into
624
+ // the §2.8 policy rather than corrupting the conversation.
625
+ throw new Error(`Thread ${threadId} is WAITING_FOR_INPUT without a pending INPUT_REQUIRED`);
626
+ }
627
+ const resumed = await resumePendingHitl(deps, threadId, open, rawTools, subCtx, abort.signal, input.state ?? {}, runId, toolRun);
628
+ if (!resumed) {
629
+ // Still parked, or parked again one level down while unwinding: the
630
+ // new park's step is saved by now, so it is written here.
631
+ await commitParks(deps, parks);
632
+ segEnd = { type: 'RUN_FINISHED', status: 'parked' };
633
+ return 'executed';
634
+ }
635
+ }
636
+ const userArgs = agent.args;
637
+ // A retry after the run's last step was already saved (§2.8): the worker
638
+ // died between that step and the end of the run. The answer is in the
639
+ // history, so the run is finalized from it rather than asking the model
640
+ // again, which would answer twice. The same when the run already settled.
641
+ let loop;
642
+ const earlier = await finishedEarlier(deps, threadId, runId);
643
+ if (earlier.done) {
644
+ // The Logger port only promises `error`; this is not one.
645
+ (deps.log ?? console).info?.('run already made its last step; finalized without calling the model again', { threadId, runId });
646
+ loop = {
647
+ text: earlier.text, finishReason: 'stop',
648
+ attribution: { inputTokens: 0, cachedInputTokens: 0, outputTokens: 0, totalTokens: 0 },
649
+ tokensUsed: 0, parked: false, aborted: false, interrupted: false, steps: 0, costExhausted: false,
650
+ };
651
+ }
652
+ else {
653
+ // Durable compaction pass — history always fits the model budget (§2.6);
654
+ // the budget uses the resolved model's contextWindow (§3.3)
655
+ const history = await compactContext(deps, threadId, input.model, {
420
656
  runId,
421
- state,
422
- stopReason,
423
- tokensUsed,
424
- attribution,
425
- steps: loop.steps,
426
- usage: await runBill(deps, threadId, runId),
657
+ abortSignal: abort.signal,
658
+ ledger,
427
659
  });
660
+ const model = deps.resolveModel(input.model);
661
+ // Prompt caching (§2.6): stamp the stable prefix once — appended step
662
+ // messages extend the prompt without invalidating the breakpoints.
663
+ let messages = repairDanglingToolCalls(promptMessages(history));
664
+ if (deps.config.promptCaching) {
665
+ messages = markPromptCaching(messages);
666
+ }
667
+ loop = await runLoop(deps, agent, threadId, {
668
+ agentId: null, // the main agent's stream (§2.7)
669
+ runId,
670
+ kind: agent.kind,
671
+ model: model.instance(),
672
+ messages,
673
+ tools,
674
+ maxSteps,
675
+ abortSignal: abort.signal,
676
+ providerOptions,
677
+ tokenBudget,
678
+ systemFn: userArgs.systemFn,
679
+ prepareStep: userArgs.prepareStep,
680
+ state: input.state ?? {},
681
+ costBudgetMicros: costBudget,
682
+ billingRunId: runId,
683
+ modelKey: input.model,
684
+ modelId: wireId(model, input.model),
685
+ agentName: agent.name,
686
+ cacheSystemPrompt: deps.config.promptCaching,
687
+ fenced: () => lease.lost,
688
+ commitParks: () => commitParks(deps, parks),
689
+ // One canonical path for every client: durable log + live Pub/Sub
690
+ // (§2.1, §2.2), with token deltas merged (see ChunkBatcher).
691
+ publishChunk: async (chunk) => {
692
+ await publish(deps, threadId, 'CHUNK', chunk);
693
+ },
694
+ toolErrors,
695
+ onChunk: async (chunk) => {
696
+ userArgs.onChunk?.({ chunk }); // the user callback sees every raw chunk
697
+ },
698
+ }, ledger);
699
+ // A lost lock aborts the run the way a stop does, but it is not a stop:
700
+ // another worker may own the thread now, so nothing below may write.
701
+ if (lease.lost)
702
+ return 'lock-lost';
703
+ // Nor is a shutdown: every finished step is saved, and the job comes
704
+ // back to carry on from the last one.
705
+ if (shutdown)
706
+ throw new Error('the worker shut down mid-run');
707
+ // The stream ended with no finish and no error: the step was not
708
+ // completed, so it goes to the retry policy rather than finalizing.
709
+ if (loop.interrupted)
710
+ throw new Error(`step ${loop.steps + 1} ended without a finish`);
428
711
  }
429
- catch (err) {
430
- (deps.log ?? console).error('onFinish threw', { runId, err: String(err) });
712
+ const { attribution, parked } = loop;
713
+ const tokensUsed = ledger.tokensUsed;
714
+ const lastText = loop.text;
715
+ const lastFinishReason = loop.finishReason;
716
+ if (parked) {
717
+ // The segment ends holding the park. Every call it made was already
718
+ // recorded and priced as it happened (§4), so there is nothing left to
719
+ // bill here. NO state flip: WAITING_FOR_INPUT (or CANCELLED if the user
720
+ // stopped meanwhile) stands.
721
+ if (runId) {
722
+ await deps.admin.runs
723
+ .increment(runId, deltasOf(loop.steps, attribution))
724
+ .catch(() => undefined); // operational history must not fail a parked run
725
+ }
726
+ segEnd = { type: 'RUN_FINISHED', status: 'parked' };
727
+ return 'executed';
728
+ }
729
+ // The segment's own deadline ended it: not a stop, a failure that says
730
+ // so.
731
+ const stopReason = segmentTimedOut
732
+ ? 'timeout'
733
+ : abort.signal.aborted
734
+ ? 'cancelled'
735
+ : loop.costExhausted
736
+ ? 'cost_budget' // the money cap (§4)
737
+ : tokenBudget && tokensUsed >= tokenBudget
738
+ ? 'token_budget'
739
+ : lastFinishReason === 'tool-calls'
740
+ ? 'max_steps' // step ceiling hit (§2.1)
741
+ : 'completed';
742
+ let state = segmentTimedOut ? 'FAILED' : abort.signal.aborted ? 'CANCELLED' : 'COMPLETED';
743
+ let error = segmentTimedOut
744
+ ? `the run ran longer than ${deps.config.segmentTimeoutMs}ms and was stopped`
745
+ : undefined;
746
+ if (segmentTimedOut) {
747
+ (deps.log ?? console).error('segment timed out', { threadId, runId, steps: loop.steps });
431
748
  }
749
+ // The caller settles BEFORE the terminal state lands (§5.6): what the run
750
+ // produced is committed by the time any client sees it end. A settle
751
+ // failure is a run failure. A stop reaches the hook with `cancelled`
752
+ // set. The whole run's bill, read back from the rows the loop wrote
753
+ // (§4): every segment and every nested run, priced and grouped into
754
+ // lines, so a settle hook charges in one pass without keeping its own
755
+ // tally. Read once, handed to both hooks; a failed read is reported,
756
+ // not hidden.
757
+ const bill = await runBill(deps, threadId, runId);
758
+ const info = () => ({
759
+ threadId, ...(runId ? { runId } : {}), state, stopReason,
760
+ cancelled: state === 'CANCELLED', ...(error ? { error } : {}),
761
+ tokensUsed, attribution, steps: loop.steps,
762
+ usage: bill.usage, ...(bill.error !== undefined ? { usageError: bill.error } : {}),
763
+ });
764
+ const settled = await settleRun(deps, agent, info());
765
+ if (settled.error !== undefined && state !== 'CANCELLED') {
766
+ state = 'FAILED';
767
+ error = settled.error instanceof Error ? settled.error.message : String(settled.error);
768
+ }
769
+ await finalize(deps, agent, threadId, {
770
+ state,
771
+ stopReason,
772
+ tokensUsed,
773
+ attribution,
774
+ oneShotText: agent.kind === 'generate-text' && !segmentTimedOut ? lastText : undefined,
775
+ runId,
776
+ steps: loop.steps,
777
+ ...(error ? { error } : {}),
778
+ });
779
+ segEnd = state === 'FAILED'
780
+ ? { type: 'RUN_ERROR', status: 'error', error: error ?? 'the run failed' }
781
+ : {
782
+ type: 'RUN_FINISHED',
783
+ status: state === 'CANCELLED' ? 'stopped' : 'finished',
784
+ usage: {
785
+ inputTokens: bill.usage.inputTokens,
786
+ cachedInputTokens: bill.usage.cachedInputTokens,
787
+ outputTokens: bill.usage.outputTokens,
788
+ totalTokens: bill.usage.totalTokens,
789
+ },
790
+ ...(bill.usage.costs?.length
791
+ ? { costs: bill.usage.costs.map((c) => ({ currency: c.currency, micros: c.costMicros })) }
792
+ : {}),
793
+ ...(lastFinishReason ? { finishReason: lastFinishReason } : {}),
794
+ ...(seg?.oneShotText !== undefined ? { text: seg.oneShotText } : {}),
795
+ };
796
+ await callOnFinish(deps, agent, info());
797
+ return 'executed';
798
+ }
799
+ catch (err) {
800
+ failure = err;
801
+ // Whatever failed after the lease was lost is that loss showing through
802
+ // (an aborted call, a fenced write): the job comes back, it is not a
803
+ // failed attempt.
804
+ if (lease.lost || err instanceof RunLockLostError)
805
+ return 'lock-lost';
806
+ throw err;
432
807
  }
433
- return 'executed';
434
808
  }
435
809
  finally {
436
810
  clearInterval(controlPoll);
437
- await deps.kv.del(runLockKey(threadId)); // release — success, failure, or stop
811
+ clearTimeout(segmentTimer);
812
+ input.signal?.removeEventListener('abort', onShutdown);
813
+ // The stream closes before the lock goes, so the next segment's stream
814
+ // never starts while this one is still open.
815
+ if (seg && !seg.closed) {
816
+ await seg.close(segEnd ??
817
+ (lease.lost || failure instanceof RunLockLostError
818
+ ? { type: 'RUN_ERROR', status: 'lost', error: 'the worker lost the run lock' }
819
+ : shutdown
820
+ ? { type: 'RUN_ERROR', status: 'lost', error: 'the worker shut down mid-run' }
821
+ : {
822
+ type: 'RUN_ERROR',
823
+ status: 'error',
824
+ error: failure instanceof Error ? failure.message : failure !== undefined ? String(failure) : 'the segment ended',
825
+ }));
826
+ }
827
+ // Release — success, failure, or stop — only while the lock is still this
828
+ // worker's. One another worker took after this one's lapsed is theirs.
829
+ await lease.release();
438
830
  }
439
831
  }
440
832
  /** Finalize a finished run (§5.6): attribute the segment's total tokens
@@ -449,68 +841,144 @@ export async function finalize(deps, agent, threadId, f) {
449
841
  // it happened, inside the loop (§4). Even a run that was replaced part-way
450
842
  // through has already had its calls written.
451
843
  //
452
- // Close the run's durable record (§2.9). Additive: a run that parked and
453
- // resumed finalises once, but its steps and tokens accrued over several
454
- // segments, so they are summed onto what is already there. The run lock
455
- // (§3.4) makes this read-modify-write single-writer.
456
- if (f.runId)
457
- await closeRunRecord(deps, f.runId, f);
458
- // Past that, a replaced run stays silent. Its CANCELLED would otherwise land
459
- // on top of the next run's RUNNING and wedge the thread: the new worker
460
- // would read a terminal state and no-op, and nobody would ever answer the
461
- // message the user just sent (§2.1).
462
- if (f.runId !== undefined && (await deps.kv.get(runIdKey(threadId))) !== f.runId)
844
+ // The thread moves only while it is still this run's and still RUNNING
845
+ // (§3.4). A stop that got there first has already written CANCELLED and
846
+ // said so; a newer run owns the thread. Either way this finish loses and
847
+ // stays silent: publishing it would land on top of the stop, or wedge the
848
+ // newer run (§2.1).
849
+ const won = await transition(deps, threadId, { from: ['RUNNING'], to: f.state, runId: f.runId });
850
+ // The run's record closes either way (§2.9): the segment's steps and tokens
851
+ // are real. Additive: a run that parked and resumed finalises once, but its
852
+ // steps and tokens accrued over several segments. A stop already recorded
853
+ // on it is kept (closeRunRecord never undoes one).
854
+ const endedAt = f.runId ? await closeRunRecord(deps, f.runId, f) : new Date();
855
+ if (!won)
463
856
  return;
464
- if ((await deps.kv.get(`agent:state:${threadId}`)) === 'CANCELLED') {
465
- f = { ...f, state: 'CANCELLED', stopReason: 'cancelled', oneShotText: undefined };
466
- }
467
857
  if (f.oneShotText !== undefined) {
468
858
  // One-shot flavor: no CHUNK stream — publish the final text as one event
469
859
  await publish(deps, threadId, 'TEXT_RESULT', { text: f.oneShotText });
470
860
  }
471
- await deps.kv.set(`agent:state:${threadId}`, f.state);
472
- await setThreadState(deps, threadId, f.state);
861
+ // The run's identity and end time ride the terminal event, so a client
862
+ // closes the right timer without reading the run record back.
473
863
  await publish(deps, threadId, 'STATE_CHANGE', {
474
864
  state: f.state,
475
865
  stopReason: f.stopReason,
476
866
  tokensUsed: f.tokensUsed,
477
867
  usage: f.attribution,
868
+ endedAt,
869
+ ...(f.runId ? { runId: f.runId } : {}),
870
+ ...(f.error ? { error: f.error } : {}),
478
871
  });
479
872
  }
480
- /** A lock conflict has two very different causes (§2.8), and only one of them
481
- * is a no-op:
873
+ /** Fail a run the queue gave up on (§2.8), when it is still the thread's
874
+ * current run and the thread is still waiting on it. False when the run had
875
+ * already moved on. Runs under the run lock like every other terminal write. */
876
+ export async function failLostRun(deps, agent, threadId, runId, reason) {
877
+ if (!runId)
878
+ return false;
879
+ if ((await deps.kv.get(runIdKey(threadId))) !== runId)
880
+ return false;
881
+ const thread = await deps.storage.threads.get(threadId);
882
+ if (!thread || !ACTIVE_STATES.includes(thread.state))
883
+ return false;
884
+ const lease = await Lease.acquire(deps, threadId, runId, undefined);
885
+ if (!lease)
886
+ return false; // a worker still holds it; the run is not lost
887
+ lease.keep(); // the settle hook in failRun can be slow
888
+ try {
889
+ if (thread.state === 'WAITING_FOR_INPUT')
890
+ await closeOpenParks(deps, threadId).catch(() => undefined);
891
+ await failRun(deps, agent, threadId, runId, reason);
892
+ // The dead worker never closed its segment's stream: a reader waiting
893
+ // on it stops here.
894
+ await closeLostSegment(deps, threadId, runId, reason);
895
+ return true;
896
+ }
897
+ finally {
898
+ await lease.release();
899
+ }
900
+ }
901
+ /** A lock conflict (§2.8). The lock names the run and the delivery that hold
902
+ * it (§3.4), so a conflict can say which of these it is:
482
903
  *
483
- * - the lock carries THIS run's id → an at-least-once duplicate of a job that
484
- * is already executing. Drop it.
485
- * - the lock belongs to an OLDER run that has not finished tearing down →
486
- * this job never ran. Dropping it strands the message the user just sent,
487
- * so come back once the lock clears. Bounded by maxAttempts, then FAILED,
488
- * so a wedged lock reports itself instead of spinning forever. */
904
+ * - the same job, delivered twice by the queue (same run, same dispatch): a
905
+ * duplicate of work already running. Drop it.
906
+ * - another delivery of the same run — a retry, an approval's answer or its
907
+ * expiry (§2.5) — arriving while the current holder winds down. It has
908
+ * work to do once the lock clears, so it comes back. Dropping it would
909
+ * leave the thread waiting (or RUNNING) with nobody working on it.
910
+ * - an OLDER run that has not finished tearing down: this job never ran.
911
+ * Dropping it strands the message the user just sent, so it comes back.
912
+ *
913
+ * Because the lock is renewed while its holder runs, a held lock means a
914
+ * live worker, and waiting for it is right. The job comes back with a
915
+ * growing delay until it has waited at least one lease and used its
916
+ * attempts; only then is the lock taken to be wedged and the run FAILED. */
489
917
  async function redriveOnLockConflict(deps, agent, input, maxAttempts) {
490
918
  if (!input.runId)
491
919
  return; // legacy dispatch, no identity — old drop behavior
492
- if ((await deps.kv.get(runLockKey(input.threadId))) === input.runId)
493
- return; // own duplicate
920
+ const log = deps.log ?? console;
921
+ const holderValue = await deps.kv.get(runLockKey(input.threadId));
922
+ const held = holderValue !== null && holderValue !== undefined;
923
+ const holder = parseLockValue(holderValue);
924
+ const durable = await deps.storage.threads.get(input.threadId);
925
+ if (held && holder.runId !== input.runId && durable && isTerminal(durable.state)) {
926
+ // The thread has ended: this job has nothing left to do, whoever holds
927
+ // the lock. Redriving it would only fail the ended run again.
928
+ return;
929
+ }
930
+ if (held && holder.runId === input.runId) {
931
+ if (holder.dispatchId !== null && holder.dispatchId === input.dispatchId)
932
+ return; // the same job, twice
933
+ if (!durable || durable.state === 'WAITING_FOR_INPUT') {
934
+ // fall through to the redrive below
935
+ }
936
+ else if (isTerminal(durable.state)) {
937
+ // The run ended under a held lock. When its settle has not run, this
938
+ // job is the last chance to settle it, so it comes back once the lock
939
+ // has surely cleared.
940
+ const rec = await deps.admin.runs.get(input.runId).catch(() => null);
941
+ if (rec && !rec.settledAt) {
942
+ (log.info)?.('run ended under a held lock and is not settled; settle retried once the lock clears', { threadId: input.threadId, runId: input.runId });
943
+ await enqueueJob(deps, jobOf(agent, input, 'redrive'), {
944
+ delaySeconds: deps.config.runLockLeaseSeconds, key: `settle:${input.runId}`, priority: PRIORITY_LOW,
945
+ }).catch((err) => {
946
+ if (!(err instanceof DuplicateJobError))
947
+ throw err; // once per run
948
+ });
949
+ }
950
+ return;
951
+ }
952
+ else if (holder.dispatchId === null || !input.dispatchId) {
953
+ // A lock or a job from before dispatch ids: the two deliveries cannot be
954
+ // told apart, so the old rule stands — a duplicate of a running segment.
955
+ return;
956
+ }
957
+ // Otherwise: another delivery of this run, waiting on the current holder.
958
+ }
494
959
  if ((await deps.kv.get(runIdKey(input.threadId))) !== input.runId)
495
960
  return; // already replaced
496
- const tries = await deps.kv.incr(redriveKey(input.threadId));
497
- if (tries <= maxAttempts) {
498
- return deps.queue.enqueue({
499
- threadId: input.threadId,
500
- runId: input.runId,
501
- enqueuedAt: Date.now(),
502
- model: input.model,
503
- agent: agent.name,
504
- tokenBudget: input.tokenBudget,
505
- // A redrive is the SAME run trying again, so it keeps the caps it was
506
- // dispatched with — a retry that lost its money cap would be unbounded.
507
- costBudgetMicros: input.costBudgetMicros,
508
- providerOptions: input.providerOptions,
509
- state: input.state,
510
- }, { delaySeconds: deps.config.runRedriveDelaySeconds });
961
+ const scope = counterScope(input.threadId, input.runId);
962
+ const tries = await deps.kv.incrWithExpiry(redriveKey(scope), COUNTER_TTL_SECONDS);
963
+ const { delaySeconds, waitedSeconds } = redriveDelay(deps, tries);
964
+ if (tries <= maxAttempts || waitedSeconds < deps.config.runLockLeaseSeconds) {
965
+ return enqueueJob(deps, jobOf(agent, input, 'redrive'), { delaySeconds });
966
+ }
967
+ await deps.kv.del(redriveKey(scope));
968
+ await failRun(deps, agent, input.threadId, input.runId, 'the run lock never cleared');
969
+ }
970
+ /** How long the given try waits (`runRedriveDelaySeconds`, doubled on each
971
+ * try, capped at the lease), and how long the tries before it waited in all. */
972
+ function redriveDelay(deps, tries) {
973
+ const lease = deps.config.runLockLeaseSeconds;
974
+ // A zero base would never grow, and never give up.
975
+ let delaySeconds = deps.config.runRedriveDelaySeconds || 1;
976
+ let waitedSeconds = 0;
977
+ for (let i = 1; i < tries; i++) {
978
+ waitedSeconds += delaySeconds;
979
+ delaySeconds = Math.min(delaySeconds * 2, lease);
511
980
  }
512
- await deps.kv.del(redriveKey(input.threadId));
513
- await failRun(deps, input.threadId, input.runId, 'the run lock never cleared');
981
+ return { delaySeconds, waitedSeconds };
514
982
  }
515
983
  /** §2.8 failure policy: transient errors redrive through the queue; exhausted
516
984
  * attempts finalize FAILED (hot cache + durable). A user stop is never
@@ -519,13 +987,14 @@ async function redriveOnLockConflict(deps, agent, input, maxAttempts) {
519
987
  * `exec` is an injection seam for tests (default: the real execute). */
520
988
  export async function executeWithPolicy(deps, agent, input, policy, exec = execute) {
521
989
  const maxAttempts = policy?.maxAttempts ?? deps.config.runMaxAttempts;
990
+ const scope = counterScope(input.threadId, input.runId);
522
991
  try {
523
992
  const outcome = await exec(deps, agent, input);
524
993
  // Only a run THIS worker executed may reset the retry budget — a
525
994
  // lock-conflict no-op must never clear it while the owning worker runs (§2.8)
526
995
  if (outcome === 'executed') {
527
- await deps.kv.del(`agent:attempts:${input.threadId}`);
528
- await deps.kv.del(redriveKey(input.threadId));
996
+ await deps.kv.del(attemptsKey(scope));
997
+ await deps.kv.del(redriveKey(scope));
529
998
  return;
530
999
  }
531
1000
  // A newer run owns the thread: this job is a genuine no-op.
@@ -534,51 +1003,109 @@ export async function executeWithPolicy(deps, agent, input, policy, exec = execu
534
1003
  await redriveOnLockConflict(deps, agent, input, maxAttempts);
535
1004
  }
536
1005
  catch (err) {
1006
+ const log = deps.log ?? console;
1007
+ // The worker was cut off: a shutdown. That is not the run's fault, so the
1008
+ // job goes back at once and the attempt is not counted.
1009
+ if (input.signal?.aborted) {
1010
+ (log.info)?.('run interrupted; requeued', {
1011
+ threadId: input.threadId, runId: input.runId, err: String(err),
1012
+ });
1013
+ await requeue(deps, agent, { ...input, signal: undefined }, 0);
1014
+ return;
1015
+ }
537
1016
  // A user stop already finalized the thread — never retry a stop
538
1017
  if ((await deps.kv.get(`agent:state:${input.threadId}`)) === 'CANCELLED')
539
1018
  return;
540
- const attempts = await deps.kv.incr(`agent:attempts:${input.threadId}`);
1019
+ // A run that a newer one replaced failed after it stopped mattering: its
1020
+ // error is not the thread's, so it spends no attempt and fails nothing.
1021
+ if (input.runId && (await deps.kv.get(runIdKey(input.threadId))) !== input.runId)
1022
+ return;
1023
+ // An error that retrying cannot fix fails the run at once (§2.8): a bad
1024
+ // key, an unknown model, no credits. Every retry would fail the same way.
1025
+ if (isPermanentError(err)) {
1026
+ log.error('run failed; not retried, the error cannot pass', {
1027
+ threadId: input.threadId, runId: input.runId, err: String(err),
1028
+ });
1029
+ await failRun(deps, agent, input.threadId, input.runId, err instanceof Error ? err.message : String(err));
1030
+ await deps.kv.del(attemptsKey(scope));
1031
+ return;
1032
+ }
1033
+ const attempts = await deps.kv.incrWithExpiry(attemptsKey(scope), COUNTER_TTL_SECONDS);
541
1034
  if (attempts < maxAttempts) {
542
- return deps.queue.enqueue({
543
- threadId: input.threadId,
544
- // A retry is the SAME run trying again (§2.1). Dropping the id here
545
- // left the retried job unable to notice it had been replaced, and
546
- // unable to redrive if it found the lock held.
547
- runId: input.runId,
548
- enqueuedAt: Date.now(),
549
- model: input.model,
550
- agent: agent.name,
551
- tokenBudget: input.tokenBudget,
552
- // A redrive is the SAME run trying again, so it keeps the caps it was
553
- // dispatched with — a retry that lost its money cap would be unbounded.
554
- costBudgetMicros: input.costBudgetMicros,
555
- providerOptions: input.providerOptions,
556
- state: input.state,
1035
+ // A retry is the SAME run trying again (§2.1): it keeps the id, so it
1036
+ // can notice it was replaced and redrive if it finds the lock held.
1037
+ const delayMs = retryBackoffMs(deps, attempts);
1038
+ log.warn?.('run failed; retry scheduled', {
1039
+ threadId: input.threadId, runId: input.runId, err: String(err), attempt: attempts, maxAttempts, delayMs,
557
1040
  });
1041
+ await requeue(deps, agent, input, delayMs);
1042
+ return;
558
1043
  }
559
1044
  // Attempts exhausted: finalize FAILED on BOTH the hot cache and durable
560
1045
  // truth, or subsequent runs would still treat the thread as active (§2.1)
561
- await failRun(deps, input.threadId, input.runId, err instanceof Error ? err.message : String(err));
562
- await deps.kv.del(`agent:attempts:${input.threadId}`);
1046
+ log.error('run failed; attempts spent', { threadId: input.threadId, runId: input.runId, err: String(err), attempts });
1047
+ await failRun(deps, agent, input.threadId, input.runId, err instanceof Error ? err.message : String(err));
1048
+ await deps.kv.del(attemptsKey(scope));
563
1049
  }
564
1050
  }
565
- /** Sum every model call a run made, nested runs included (§4). Best effort
566
- * like every other read on the finish path: a storage hiccup must not turn a
567
- * finished run into a failed one, so a failure comes back as zero totals and
568
- * the error is logged. */
569
- async function runBill(deps, threadId, runId) {
570
- const empty = {
571
- inputTokens: 0, cachedInputTokens: 0, outputTokens: 0, totalTokens: 0,
572
- costMicros: 0, unpriced: 0, lines: [],
1051
+ /** Put the same run back on the queue as a retry (§2.8). The thread reads
1052
+ * QUEUED while it waits, so a client sees a run waiting to retry rather than
1053
+ * one that looks like it is working. */
1054
+ async function requeue(deps, agent, input, delayMs) {
1055
+ if (input.runId)
1056
+ await markQueued(deps, input.threadId, input.runId, input.model);
1057
+ await enqueueJob(deps, jobOf(agent, input, 'retry'), delayMs > 0 ? { delaySeconds: Math.ceil(delayMs / 1000) } : undefined);
1058
+ }
1059
+ /** The job that runs the same run again: a retry or a redrive. It keeps the
1060
+ * caps the run was dispatched with — a retry that lost its money cap would be
1061
+ * unbounded. */
1062
+ function jobOf(agent, input, kind) {
1063
+ return {
1064
+ kind,
1065
+ threadId: input.threadId,
1066
+ runId: input.runId,
1067
+ enqueuedAt: Date.now(),
1068
+ model: input.model,
1069
+ agent: agent.name,
1070
+ tokenBudget: input.tokenBudget,
1071
+ costBudgetMicros: input.costBudgetMicros,
1072
+ providerOptions: input.providerOptions,
1073
+ state: input.state,
1074
+ ...(input.maxSteps ? { maxSteps: input.maxSteps } : {}),
1075
+ ...(input.dispatchedAt ? { dispatchedAt: input.dispatchedAt } : {}),
1076
+ ...(input.partitionKey ? { partitionKey: input.partitionKey } : {}),
573
1077
  };
574
- if (!runId)
575
- return empty;
576
- try {
577
- return await deps.storage.usage.total(threadId, { runId });
578
- }
579
- catch (err) {
580
- (deps.log ?? console).error('run bill not read', { run: runId, err });
581
- return empty;
1078
+ }
1079
+ /** Move a RUNNING thread back to QUEUED for a retry, on every home, and say so
1080
+ * on the wire. A parked thread keeps WAITING_FOR_INPUT: the park machinery
1081
+ * reads that state. A replaced run leaves the live run's state alone. */
1082
+ async function markQueued(deps, threadId, runId, model) {
1083
+ if ((await deps.kv.get(runIdKey(threadId))) !== runId)
1084
+ return;
1085
+ // Only a RUNNING thread that is still this run's (§3.4).
1086
+ if (!(await transition(deps, threadId, { from: ['RUNNING'], to: 'QUEUED', runId, model })))
1087
+ return;
1088
+ await deps.admin.runs.patch(runId, { state: 'QUEUED' }).catch(() => undefined);
1089
+ await publish(deps, threadId, 'STATE_CHANGE', { state: 'QUEUED', runId, enqueuedAt: new Date() });
1090
+ }
1091
+ /** How long the nth retry waits (§2.8): the base doubled per attempt, capped,
1092
+ * with up to a quarter of jitter so a fleet that failed together does not
1093
+ * retry together. */
1094
+ function retryBackoffMs(deps, attempt) {
1095
+ const base = deps.config.runRetryBackoffMs;
1096
+ if (base <= 0)
1097
+ return 0;
1098
+ const max = deps.config.runRetryBackoffMaxMs;
1099
+ let d = base;
1100
+ for (let i = 1; i < attempt; i++) {
1101
+ d *= 2;
1102
+ if (max > 0 && d >= max) {
1103
+ d = max;
1104
+ break;
1105
+ }
582
1106
  }
1107
+ if (max > 0 && d > max)
1108
+ d = max;
1109
+ return d + Math.floor(Math.random() * (d / 4 + 1));
583
1110
  }
584
1111
  //# sourceMappingURL=engine.js.map