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.
- package/README.md +82 -15
- package/dist/adapters/brave.d.ts +21 -0
- package/dist/adapters/brave.d.ts.map +1 -0
- package/dist/adapters/brave.js +52 -0
- package/dist/adapters/brave.js.map +1 -0
- package/dist/adapters/computesdk.d.ts +71 -0
- package/dist/adapters/computesdk.d.ts.map +1 -0
- package/dist/adapters/computesdk.js +163 -0
- package/dist/adapters/computesdk.js.map +1 -0
- package/dist/adapters/docker.d.ts +35 -0
- package/dist/adapters/docker.d.ts.map +1 -0
- package/dist/adapters/docker.js +246 -0
- package/dist/adapters/docker.js.map +1 -0
- package/dist/adapters/e2b.d.ts +44 -0
- package/dist/adapters/e2b.d.ts.map +1 -0
- package/dist/adapters/e2b.js +349 -0
- package/dist/adapters/e2b.js.map +1 -0
- package/dist/adapters/inline.d.ts +19 -5
- package/dist/adapters/inline.d.ts.map +1 -1
- package/dist/adapters/inline.js +87 -14
- package/dist/adapters/inline.js.map +1 -1
- package/dist/adapters/jina.d.ts +34 -0
- package/dist/adapters/jina.d.ts.map +1 -0
- package/dist/adapters/jina.js +99 -0
- package/dist/adapters/jina.js.map +1 -0
- package/dist/adapters/local-sandbox.d.ts +32 -0
- package/dist/adapters/local-sandbox.d.ts.map +1 -0
- package/dist/adapters/local-sandbox.js +257 -0
- package/dist/adapters/local-sandbox.js.map +1 -0
- package/dist/adapters/memory.d.ts +146 -3
- package/dist/adapters/memory.d.ts.map +1 -1
- package/dist/adapters/memory.js +474 -3
- package/dist/adapters/memory.js.map +1 -1
- package/dist/adapters/page-reader.d.ts +39 -0
- package/dist/adapters/page-reader.d.ts.map +1 -0
- package/dist/adapters/page-reader.js +165 -0
- package/dist/adapters/page-reader.js.map +1 -0
- package/dist/adapters/prisma.d.ts +125 -18
- package/dist/adapters/prisma.d.ts.map +1 -1
- package/dist/adapters/prisma.js +308 -67
- package/dist/adapters/prisma.js.map +1 -1
- package/dist/adapters/qstash.d.ts +15 -2
- package/dist/adapters/qstash.d.ts.map +1 -1
- package/dist/adapters/qstash.js +31 -14
- package/dist/adapters/qstash.js.map +1 -1
- package/dist/adapters/redis.d.ts +40 -4
- package/dist/adapters/redis.d.ts.map +1 -1
- package/dist/adapters/redis.js +265 -20
- package/dist/adapters/redis.js.map +1 -1
- package/dist/adapters/sandbox-shell.d.ts +60 -0
- package/dist/adapters/sandbox-shell.d.ts.map +1 -0
- package/dist/adapters/sandbox-shell.js +129 -0
- package/dist/adapters/sandbox-shell.js.map +1 -0
- package/dist/adapters/sqlite.d.ts +88 -2
- package/dist/adapters/sqlite.d.ts.map +1 -1
- package/dist/adapters/sqlite.js +443 -37
- package/dist/adapters/sqlite.js.map +1 -1
- package/dist/adapters/stream-scripts.d.ts +49 -0
- package/dist/adapters/stream-scripts.d.ts.map +1 -0
- package/dist/adapters/stream-scripts.js +150 -0
- package/dist/adapters/stream-scripts.js.map +1 -0
- package/dist/adapters/upstash.d.ts +20 -0
- package/dist/adapters/upstash.d.ts.map +1 -1
- package/dist/adapters/upstash.js +29 -0
- package/dist/adapters/upstash.js.map +1 -1
- package/dist/adapters/web-search-shared.d.ts +11 -0
- package/dist/adapters/web-search-shared.d.ts.map +1 -0
- package/dist/adapters/web-search-shared.js +40 -0
- package/dist/adapters/web-search-shared.js.map +1 -0
- package/dist/admin/memory.d.ts +11 -1
- package/dist/admin/memory.d.ts.map +1 -1
- package/dist/admin/memory.js +83 -14
- package/dist/admin/memory.js.map +1 -1
- package/dist/admin/migrations/postgres/0002-run-settled-at.d.ts +5 -0
- package/dist/admin/migrations/postgres/0002-run-settled-at.d.ts.map +1 -0
- package/dist/admin/migrations/postgres/0002-run-settled-at.js +11 -0
- package/dist/admin/migrations/postgres/0002-run-settled-at.js.map +1 -0
- package/dist/admin/migrations/postgres/0003-run-enqueued-at.d.ts +5 -0
- package/dist/admin/migrations/postgres/0003-run-enqueued-at.d.ts.map +1 -0
- package/dist/admin/migrations/postgres/0003-run-enqueued-at.js +15 -0
- package/dist/admin/migrations/postgres/0003-run-enqueued-at.js.map +1 -0
- package/dist/admin/migrations/postgres/0004-run-settle-claim.d.ts +5 -0
- package/dist/admin/migrations/postgres/0004-run-settle-claim.d.ts.map +1 -0
- package/dist/admin/migrations/postgres/0004-run-settle-claim.js +15 -0
- package/dist/admin/migrations/postgres/0004-run-settle-claim.js.map +1 -0
- package/dist/admin/migrations/postgres/0005-bigint-counters.d.ts +5 -0
- package/dist/admin/migrations/postgres/0005-bigint-counters.d.ts.map +1 -0
- package/dist/admin/migrations/postgres/0005-bigint-counters.js +12 -0
- package/dist/admin/migrations/postgres/0005-bigint-counters.js.map +1 -0
- package/dist/admin/migrations/postgres/index.d.ts.map +1 -1
- package/dist/admin/migrations/postgres/index.js +16 -1
- package/dist/admin/migrations/postgres/index.js.map +1 -1
- package/dist/admin/migrations/runner.d.ts +21 -1
- package/dist/admin/migrations/runner.d.ts.map +1 -1
- package/dist/admin/migrations/runner.js +41 -4
- package/dist/admin/migrations/runner.js.map +1 -1
- package/dist/admin/migrations/sqlite/0002-run-settled-at.d.ts +5 -0
- package/dist/admin/migrations/sqlite/0002-run-settled-at.d.ts.map +1 -0
- package/dist/admin/migrations/sqlite/0002-run-settled-at.js +11 -0
- package/dist/admin/migrations/sqlite/0002-run-settled-at.js.map +1 -0
- package/dist/admin/migrations/sqlite/0003-run-enqueued-at.d.ts +5 -0
- package/dist/admin/migrations/sqlite/0003-run-enqueued-at.d.ts.map +1 -0
- package/dist/admin/migrations/sqlite/0003-run-enqueued-at.js +15 -0
- package/dist/admin/migrations/sqlite/0003-run-enqueued-at.js.map +1 -0
- package/dist/admin/migrations/sqlite/0004-run-settle-claim.d.ts +5 -0
- package/dist/admin/migrations/sqlite/0004-run-settle-claim.d.ts.map +1 -0
- package/dist/admin/migrations/sqlite/0004-run-settle-claim.js +15 -0
- package/dist/admin/migrations/sqlite/0004-run-settle-claim.js.map +1 -0
- package/dist/admin/migrations/sqlite/index.d.ts +2 -2
- package/dist/admin/migrations/sqlite/index.d.ts.map +1 -1
- package/dist/admin/migrations/sqlite/index.js +17 -3
- package/dist/admin/migrations/sqlite/index.js.map +1 -1
- package/dist/admin/postgres.d.ts +14 -4
- package/dist/admin/postgres.d.ts.map +1 -1
- package/dist/admin/postgres.js +131 -36
- package/dist/admin/postgres.js.map +1 -1
- package/dist/admin/shared.d.ts +19 -0
- package/dist/admin/shared.d.ts.map +1 -0
- package/dist/admin/shared.js +28 -0
- package/dist/admin/shared.js.map +1 -0
- package/dist/admin/sqlite.d.ts +9 -1
- package/dist/admin/sqlite.d.ts.map +1 -1
- package/dist/admin/sqlite.js +112 -29
- package/dist/admin/sqlite.js.map +1 -1
- package/dist/core/admin.d.ts +28 -6
- package/dist/core/admin.d.ts.map +1 -1
- package/dist/core/admin.js +92 -23
- package/dist/core/admin.js.map +1 -1
- package/dist/core/agent.d.ts +7 -4
- package/dist/core/agent.d.ts.map +1 -1
- package/dist/core/agent.js +15 -11
- package/dist/core/agent.js.map +1 -1
- package/dist/core/agui.d.ts +17 -0
- package/dist/core/agui.d.ts.map +1 -0
- package/dist/core/agui.js +69 -0
- package/dist/core/agui.js.map +1 -0
- package/dist/core/builtin/definitions.d.ts +12 -0
- package/dist/core/builtin/definitions.d.ts.map +1 -0
- package/dist/core/builtin/definitions.js +149 -0
- package/dist/core/builtin/definitions.js.map +1 -0
- package/dist/core/builtin/html.d.ts +13 -0
- package/dist/core/builtin/html.d.ts.map +1 -0
- package/dist/core/builtin/html.js +96 -0
- package/dist/core/builtin/html.js.map +1 -0
- package/dist/core/builtin/index.d.ts +21 -0
- package/dist/core/builtin/index.d.ts.map +1 -0
- package/dist/core/builtin/index.js +55 -0
- package/dist/core/builtin/index.js.map +1 -0
- package/dist/core/builtin/run.d.ts +37 -0
- package/dist/core/builtin/run.d.ts.map +1 -0
- package/dist/core/builtin/run.js +55 -0
- package/dist/core/builtin/run.js.map +1 -0
- package/dist/core/builtin/sandbox-tools.d.ts +69 -0
- package/dist/core/builtin/sandbox-tools.d.ts.map +1 -0
- package/dist/core/builtin/sandbox-tools.js +389 -0
- package/dist/core/builtin/sandbox-tools.js.map +1 -0
- package/dist/core/builtin/sandbox.d.ts +52 -0
- package/dist/core/builtin/sandbox.d.ts.map +1 -0
- package/dist/core/builtin/sandbox.js +174 -0
- package/dist/core/builtin/sandbox.js.map +1 -0
- package/dist/core/builtin/web.d.ts +54 -0
- package/dist/core/builtin/web.d.ts.map +1 -0
- package/dist/core/builtin/web.js +167 -0
- package/dist/core/builtin/web.js.map +1 -0
- package/dist/core/context.d.ts +13 -1
- package/dist/core/context.d.ts.map +1 -1
- package/dist/core/context.js +70 -19
- package/dist/core/context.js.map +1 -1
- package/dist/core/deleteThread.d.ts.map +1 -1
- package/dist/core/deleteThread.js +36 -4
- package/dist/core/deleteThread.js.map +1 -1
- package/dist/core/engine.d.ts +41 -11
- package/dist/core/engine.d.ts.map +1 -1
- package/dist/core/engine.js +880 -353
- package/dist/core/engine.js.map +1 -1
- package/dist/core/follow.d.ts +63 -1
- package/dist/core/follow.d.ts.map +1 -1
- package/dist/core/follow.js +293 -16
- package/dist/core/follow.js.map +1 -1
- package/dist/core/hitl.d.ts +43 -0
- package/dist/core/hitl.d.ts.map +1 -1
- package/dist/core/hitl.js +163 -35
- package/dist/core/hitl.js.map +1 -1
- package/dist/core/keys.d.ts +22 -4
- package/dist/core/keys.d.ts.map +1 -1
- package/dist/core/keys.js +23 -5
- package/dist/core/keys.js.map +1 -1
- package/dist/core/lease.d.ts +46 -0
- package/dist/core/lease.d.ts.map +1 -0
- package/dist/core/lease.js +142 -0
- package/dist/core/lease.js.map +1 -0
- package/dist/core/loop.d.ts +27 -7
- package/dist/core/loop.d.ts.map +1 -1
- package/dist/core/loop.js +140 -55
- package/dist/core/loop.js.map +1 -1
- package/dist/core/messages.d.ts +21 -2
- package/dist/core/messages.d.ts.map +1 -1
- package/dist/core/messages.js +61 -2
- package/dist/core/messages.js.map +1 -1
- package/dist/core/permanent.d.ts +11 -0
- package/dist/core/permanent.d.ts.map +1 -0
- package/dist/core/permanent.js +37 -0
- package/dist/core/permanent.js.map +1 -0
- package/dist/core/prune.d.ts +24 -0
- package/dist/core/prune.d.ts.map +1 -0
- package/dist/core/prune.js +43 -0
- package/dist/core/prune.js.map +1 -0
- package/dist/core/publish.d.ts +38 -10
- package/dist/core/publish.d.ts.map +1 -1
- package/dist/core/publish.js +126 -12
- package/dist/core/publish.js.map +1 -1
- package/dist/core/reclaim.d.ts +8 -1
- package/dist/core/reclaim.d.ts.map +1 -1
- package/dist/core/reclaim.js +149 -18
- package/dist/core/reclaim.js.map +1 -1
- package/dist/core/run.d.ts +7 -3
- package/dist/core/run.d.ts.map +1 -1
- package/dist/core/run.js +95 -35
- package/dist/core/run.js.map +1 -1
- package/dist/core/segment.d.ts +62 -0
- package/dist/core/segment.d.ts.map +1 -0
- package/dist/core/segment.js +318 -0
- package/dist/core/segment.js.map +1 -0
- package/dist/core/settle.d.ts +57 -0
- package/dist/core/settle.d.ts.map +1 -0
- package/dist/core/settle.js +181 -0
- package/dist/core/settle.js.map +1 -0
- package/dist/core/snapshot.d.ts +36 -0
- package/dist/core/snapshot.d.ts.map +1 -0
- package/dist/core/snapshot.js +87 -0
- package/dist/core/snapshot.js.map +1 -0
- package/dist/core/stop.d.ts +16 -1
- package/dist/core/stop.d.ts.map +1 -1
- package/dist/core/stop.js +69 -15
- package/dist/core/stop.js.map +1 -1
- package/dist/core/stream-events.d.ts +184 -0
- package/dist/core/stream-events.d.ts.map +1 -0
- package/dist/core/stream-events.js +26 -0
- package/dist/core/stream-events.js.map +1 -0
- package/dist/core/stream.d.ts +29 -0
- package/dist/core/stream.d.ts.map +1 -1
- package/dist/core/stream.js +79 -0
- package/dist/core/stream.js.map +1 -1
- package/dist/core/subagent.d.ts +36 -5
- package/dist/core/subagent.d.ts.map +1 -1
- package/dist/core/subagent.js +130 -25
- package/dist/core/subagent.js.map +1 -1
- package/dist/core/tools.d.ts +9 -3
- package/dist/core/tools.d.ts.map +1 -1
- package/dist/core/tools.js.map +1 -1
- package/dist/core/types.d.ts +212 -13
- package/dist/core/types.d.ts.map +1 -1
- package/dist/core/types.js +29 -1
- package/dist/core/types.js.map +1 -1
- package/dist/core/usage.d.ts +53 -5
- package/dist/core/usage.d.ts.map +1 -1
- package/dist/core/usage.js +170 -47
- package/dist/core/usage.js.map +1 -1
- package/dist/index.d.ts +29 -12
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +23 -7
- package/dist/index.js.map +1 -1
- package/dist/ports/admin.d.ts +59 -0
- package/dist/ports/admin.d.ts.map +1 -1
- package/dist/ports/kv.d.ts +17 -0
- package/dist/ports/kv.d.ts.map +1 -1
- package/dist/ports/queue.d.ts +87 -7
- package/dist/ports/queue.d.ts.map +1 -1
- package/dist/ports/queue.js +36 -1
- package/dist/ports/queue.js.map +1 -1
- package/dist/ports/runtime.d.ts +188 -20
- package/dist/ports/runtime.d.ts.map +1 -1
- package/dist/ports/sandbox.d.ts +159 -0
- package/dist/ports/sandbox.d.ts.map +1 -0
- package/dist/ports/sandbox.js +44 -0
- package/dist/ports/sandbox.js.map +1 -0
- package/dist/ports/storage.d.ts +26 -5
- package/dist/ports/storage.d.ts.map +1 -1
- package/dist/ports/streams.d.ts +60 -0
- package/dist/ports/streams.d.ts.map +1 -0
- package/dist/ports/streams.js +20 -0
- package/dist/ports/streams.js.map +1 -0
- package/dist/ports/tools.d.ts +60 -0
- package/dist/ports/tools.d.ts.map +1 -0
- package/dist/ports/tools.js +5 -0
- package/dist/ports/tools.js.map +1 -0
- package/dist/pricing.d.ts +29 -0
- package/dist/pricing.d.ts.map +1 -1
- package/dist/pricing.js +64 -10
- package/dist/pricing.js.map +1 -1
- package/dist/runtime.d.ts +6 -0
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +147 -46
- package/dist/runtime.js.map +1 -1
- package/package.json +1 -1
package/dist/core/engine.js
CHANGED
|
@@ -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,
|
|
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 {
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
*
|
|
20
|
-
*
|
|
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
|
|
23
|
-
throw new Error(`${label} must be a positive
|
|
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
|
-
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
:
|
|
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
|
-
|
|
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
|
-
*
|
|
83
|
-
*
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
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
|
-
|
|
162
|
-
|
|
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: '
|
|
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', {
|
|
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
|
|
192
|
-
|
|
193
|
-
|
|
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,
|
|
364
|
+
if (!(await unwindVerdict(deps, threadId, pending, settled, subCtx, signal)))
|
|
196
365
|
return false;
|
|
197
366
|
}
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
await
|
|
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
|
|
211
|
-
//
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
|
222
|
-
// The money cap (§4) resolves the same way, widest last.
|
|
223
|
-
|
|
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
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
//
|
|
375
|
-
//
|
|
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
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
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
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
?
|
|
398
|
-
:
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
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
|
-
|
|
422
|
-
|
|
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
|
-
|
|
430
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
453
|
-
//
|
|
454
|
-
//
|
|
455
|
-
//
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
//
|
|
459
|
-
//
|
|
460
|
-
//
|
|
461
|
-
//
|
|
462
|
-
|
|
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
|
-
|
|
472
|
-
|
|
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
|
-
/**
|
|
481
|
-
* is
|
|
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
|
|
484
|
-
*
|
|
485
|
-
* -
|
|
486
|
-
*
|
|
487
|
-
*
|
|
488
|
-
*
|
|
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
|
-
|
|
493
|
-
|
|
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
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
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
|
-
|
|
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(
|
|
528
|
-
await deps.kv.del(redriveKey(
|
|
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
|
-
|
|
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
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
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
|
-
|
|
562
|
-
await deps
|
|
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
|
-
/**
|
|
566
|
-
*
|
|
567
|
-
*
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
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
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
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
|