@loopingai/core 0.8.1 → 0.8.3

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.
@@ -29,6 +29,40 @@ export interface DeliverTerminalOptions {
29
29
  * An agent with no managed children omits it and no `sweep` step appears.
30
30
  */
31
31
  sweep?: () => Promise<void>;
32
+ /**
33
+ * Prefix for this delivery's three step names. Empty by default, which is the
34
+ * only value an ordinary turn may use.
35
+ *
36
+ * **Step names are durable cache keys**, so this is not cosmetic. A workflow
37
+ * that delivers twice — an ordinary delivery, then
38
+ * {@link deliverAbandonedTask} from a `catch` above it — would otherwise call
39
+ * `step.do("complete")` a second time and be handed the *first* call's cached
40
+ * result, so the failed Task it built would never be written and the outcome
41
+ * would depend on Workflows' caching semantics for a step whose failure was
42
+ * caught. A distinct prefix makes the second delivery its own set of steps,
43
+ * where the guarded write decides the outcome as it is supposed to.
44
+ */
45
+ stepPrefix?: string;
46
+ }
47
+ /**
48
+ * Thrown when a delivery failed *after* its terminal Task was durably saved.
49
+ *
50
+ * The distinction it carries is the difference between "this turn produced
51
+ * nothing" and "this turn produced a result the gateway has not heard about
52
+ * yet", and only the first of those is an abandoned task.
53
+ *
54
+ * Without it, an ordinary delivery whose `notify` step exhausted its retries
55
+ * would unwind into an abandoned-task recovery, which would write a generic
56
+ * failure over a real answer. The guarded write refuses that now
57
+ * ({@link file://../db/models/tasks.ts}), but refusing it is not enough on its
58
+ * own: the recovery would then succeed at doing nothing, swallow a genuine
59
+ * callback failure, and log the word "abandoned" about a turn that completed.
60
+ * So the two work together — this stops the recovery being attempted, and the
61
+ * guard is the backstop for the narrow window where a `complete` step throws
62
+ * without saying whether its write applied.
63
+ */
64
+ export declare class TaskAlreadyTerminalError extends Error {
65
+ constructor(cause: unknown);
32
66
  }
33
67
  /**
34
68
  * Persist a turn's terminal Task, then notify the gateway.
@@ -47,3 +81,79 @@ export interface DeliverTerminalOptions {
47
81
  * it. Everything it calls already lives here.
48
82
  */
49
83
  export declare function deliverTerminalTask(step: WorkflowStep, options: DeliverTerminalOptions): Promise<void>;
84
+ /** What {@link deliverAbandonedTask} needs to end a turn nobody else will. */
85
+ export interface AbandonedTaskOptions {
86
+ /** Which Task this is about, and where its callback goes. */
87
+ push: TurnPushContext;
88
+ /** The agent's card-signing key, for the callback JWT. */
89
+ signingKey: string;
90
+ /** The guarded write — same contract as {@link DeliverTerminalOptions.saveTask}. */
91
+ saveTask: (task: PlainTask) => Promise<boolean>;
92
+ /**
93
+ * The user-facing words. The host's, always: core ships no prompt copy and no
94
+ * user-facing copy, and a paraphrase invented at the failure site is exactly
95
+ * what this parameter exists to prevent.
96
+ */
97
+ text: string;
98
+ /** Best-effort child cleanup. Omitted by an agent that delegates to nothing. */
99
+ sweep?: () => Promise<void>;
100
+ /** Log prefix — conventionally the agent's tenant id. */
101
+ label?: string;
102
+ }
103
+ /**
104
+ * Turn an unrecoverable orchestration fault into a delivered failed Task.
105
+ *
106
+ * ## The failure this exists for
107
+ *
108
+ * A turn ends badly in two different ways. A **typed** failure — the models were
109
+ * tried and nothing usable came back — is a value, and the ordinary delivery
110
+ * path carries it. A **transient** fault instead throws, so the step retries and
111
+ * recovers without paying for a second inference. That is the right default.
112
+ *
113
+ * What neither covers is a transient fault that never stops being one. `step.do`
114
+ * retries a bounded number of times and then rethrows, and with nothing above it
115
+ * to catch that, the orchestration unwinds, the delivery path is never reached,
116
+ * and the Workflow instance errors with the Task still sitting in `working`. The
117
+ * user is told nothing at all — and because the instance dies mid-`run`, the
118
+ * runtime records it as *"your Worker's code had hung and would never generate a
119
+ * response"*, which reads like a bug in the workflow rather than a provider that
120
+ * was refusing every request.
121
+ *
122
+ * Observed exactly that way in a deployed agent on 2026-08-19: a turn step
123
+ * exhausted its four attempts against a rate-limited provider, and the agent
124
+ * simply went quiet.
125
+ *
126
+ * ## Three behaviours here are load-bearing
127
+ *
128
+ * **It resolves after a successful delivery — it does not rethrow.** Rethrowing
129
+ * would reproduce the very "hung Worker" record this exists to remove, and the
130
+ * instance genuinely has finished its job: the Task is terminal and the gateway
131
+ * has been told.
132
+ *
133
+ * **It rethrows the *original* `cause` when the delivery itself fails.**
134
+ * Swallowing there would mark the instance successful while the user got
135
+ * nothing — strictly worse than the erroring instance being replaced, because it
136
+ * would also be silent in the Workflows console. `cause` is what an operator
137
+ * needs to see, not the delivery's own secondary fault.
138
+ *
139
+ * **It refuses to run at all once a terminal Task exists.** A
140
+ * {@link TaskAlreadyTerminalError} means the ordinary delivery persisted its
141
+ * result and only the callback failed, so the original fault is rethrown and
142
+ * nothing is written. An earlier draft of this claimed the guarded write alone
143
+ * made that safe; it did not. The write refused only cancellation conflicts, so
144
+ * `completed → failed` applied cleanly and a turn that succeeded would have been
145
+ * rewritten as a generic failure because a webhook was flaky. The guard now
146
+ * refuses any terminal-over-different-terminal write as a backstop, and this
147
+ * check is what stops the attempt being made in the first place — without it the
148
+ * recovery would quietly succeed at doing nothing, swallowing a real callback
149
+ * failure and logging "abandoned" about a completed turn.
150
+ *
151
+ * ## Why it is exported rather than private to `/round`
152
+ *
153
+ * `runHandleTask` wraps itself in it, so every round agent gets this without
154
+ * asking. But an agent whose turn is a single inference writes its own workflow
155
+ * body and has the identical exposure — and the alternative to exporting this is
156
+ * that each such host reimplements the delivery, which is precisely the mistake
157
+ * this function was extracted from.
158
+ */
159
+ export declare function deliverAbandonedTask(step: WorkflowStep, cause: unknown, options: AbandonedTaskOptions): Promise<void>;
@@ -1,4 +1,30 @@
1
+ import { buildFailedTask } from "./notify.js";
1
2
  import { createPushChannel } from "./push.js";
3
+ /**
4
+ * Thrown when a delivery failed *after* its terminal Task was durably saved.
5
+ *
6
+ * The distinction it carries is the difference between "this turn produced
7
+ * nothing" and "this turn produced a result the gateway has not heard about
8
+ * yet", and only the first of those is an abandoned task.
9
+ *
10
+ * Without it, an ordinary delivery whose `notify` step exhausted its retries
11
+ * would unwind into an abandoned-task recovery, which would write a generic
12
+ * failure over a real answer. The guarded write refuses that now
13
+ * ({@link file://../db/models/tasks.ts}), but refusing it is not enough on its
14
+ * own: the recovery would then succeed at doing nothing, swallow a genuine
15
+ * callback failure, and log the word "abandoned" about a turn that completed.
16
+ * So the two work together — this stops the recovery being attempted, and the
17
+ * guard is the backstop for the narrow window where a `complete` step throws
18
+ * without saying whether its write applied.
19
+ */
20
+ export class TaskAlreadyTerminalError extends Error {
21
+ constructor(cause) {
22
+ super("the terminal Task was saved; the callback after it failed", {
23
+ cause
24
+ });
25
+ this.name = "TaskAlreadyTerminalError";
26
+ }
27
+ }
2
28
  /**
3
29
  * Persist a turn's terminal Task, then notify the gateway.
4
30
  *
@@ -16,7 +42,8 @@ import { createPushChannel } from "./push.js";
16
42
  * it. Everything it calls already lives here.
17
43
  */
18
44
  export async function deliverTerminalTask(step, options) {
19
- const task = await step.do("complete", async () => {
45
+ const at = options.stepPrefix ?? "";
46
+ const task = await step.do(`${at}complete`, async () => {
20
47
  const terminal = options.terminal();
21
48
  return (await options.saveTask(terminal)) ? terminal : null;
22
49
  });
@@ -28,7 +55,7 @@ export async function deliverTerminalTask(step, options) {
28
55
  if (options.sweep) {
29
56
  const sweep = options.sweep;
30
57
  try {
31
- await step.do("sweep", async () => {
58
+ await step.do(`${at}sweep`, async () => {
32
59
  await sweep();
33
60
  });
34
61
  }
@@ -43,7 +70,116 @@ export async function deliverTerminalTask(step, options) {
43
70
  // terminal messageId is deterministic and the gateway is idempotent and
44
71
  // single-use, so retries are safe. If it ultimately fails, the gateway's own
45
72
  // reaction backstop clears the pending marker.
46
- await step.do("notify", async () => {
47
- await createPushChannel(options.signingKey, options.push).deliver(task);
73
+ //
74
+ // Wrapped, because by this line the Task is durably terminal: anything that
75
+ // fails from here is a callback that did not land, never a turn that produced
76
+ // nothing. See {@link TaskAlreadyTerminalError}.
77
+ try {
78
+ await step.do(`${at}notify`, async () => {
79
+ await createPushChannel(options.signingKey, options.push).deliver(task);
80
+ });
81
+ }
82
+ catch (err) {
83
+ throw new TaskAlreadyTerminalError(err);
84
+ }
85
+ }
86
+ /**
87
+ * The step-name prefix an abandoned delivery runs under.
88
+ *
89
+ * Its own namespace so it can never collide with the ordinary delivery's — see
90
+ * {@link DeliverTerminalOptions.stepPrefix}.
91
+ */
92
+ const ABANDONED_PREFIX = "abandoned:";
93
+ /**
94
+ * Turn an unrecoverable orchestration fault into a delivered failed Task.
95
+ *
96
+ * ## The failure this exists for
97
+ *
98
+ * A turn ends badly in two different ways. A **typed** failure — the models were
99
+ * tried and nothing usable came back — is a value, and the ordinary delivery
100
+ * path carries it. A **transient** fault instead throws, so the step retries and
101
+ * recovers without paying for a second inference. That is the right default.
102
+ *
103
+ * What neither covers is a transient fault that never stops being one. `step.do`
104
+ * retries a bounded number of times and then rethrows, and with nothing above it
105
+ * to catch that, the orchestration unwinds, the delivery path is never reached,
106
+ * and the Workflow instance errors with the Task still sitting in `working`. The
107
+ * user is told nothing at all — and because the instance dies mid-`run`, the
108
+ * runtime records it as *"your Worker's code had hung and would never generate a
109
+ * response"*, which reads like a bug in the workflow rather than a provider that
110
+ * was refusing every request.
111
+ *
112
+ * Observed exactly that way in a deployed agent on 2026-08-19: a turn step
113
+ * exhausted its four attempts against a rate-limited provider, and the agent
114
+ * simply went quiet.
115
+ *
116
+ * ## Three behaviours here are load-bearing
117
+ *
118
+ * **It resolves after a successful delivery — it does not rethrow.** Rethrowing
119
+ * would reproduce the very "hung Worker" record this exists to remove, and the
120
+ * instance genuinely has finished its job: the Task is terminal and the gateway
121
+ * has been told.
122
+ *
123
+ * **It rethrows the *original* `cause` when the delivery itself fails.**
124
+ * Swallowing there would mark the instance successful while the user got
125
+ * nothing — strictly worse than the erroring instance being replaced, because it
126
+ * would also be silent in the Workflows console. `cause` is what an operator
127
+ * needs to see, not the delivery's own secondary fault.
128
+ *
129
+ * **It refuses to run at all once a terminal Task exists.** A
130
+ * {@link TaskAlreadyTerminalError} means the ordinary delivery persisted its
131
+ * result and only the callback failed, so the original fault is rethrown and
132
+ * nothing is written. An earlier draft of this claimed the guarded write alone
133
+ * made that safe; it did not. The write refused only cancellation conflicts, so
134
+ * `completed → failed` applied cleanly and a turn that succeeded would have been
135
+ * rewritten as a generic failure because a webhook was flaky. The guard now
136
+ * refuses any terminal-over-different-terminal write as a backstop, and this
137
+ * check is what stops the attempt being made in the first place — without it the
138
+ * recovery would quietly succeed at doing nothing, swallowing a real callback
139
+ * failure and logging "abandoned" about a completed turn.
140
+ *
141
+ * ## Why it is exported rather than private to `/round`
142
+ *
143
+ * `runHandleTask` wraps itself in it, so every round agent gets this without
144
+ * asking. But an agent whose turn is a single inference writes its own workflow
145
+ * body and has the identical exposure — and the alternative to exporting this is
146
+ * that each such host reimplements the delivery, which is precisely the mistake
147
+ * this function was extracted from.
148
+ */
149
+ export async function deliverAbandonedTask(step, cause, options) {
150
+ // Nothing was abandoned: the ordinary delivery already persisted a terminal
151
+ // Task and only its callback failed. Rethrow the fault that actually
152
+ // happened, so the instance still errors exactly as it did before any
153
+ // recovery existed and the gateway's own backstop clears the pending marker.
154
+ //
155
+ // Checked **here** rather than in each caller's `catch` on purpose. This whole
156
+ // change exists because a recovery every host had to remember to wire was one
157
+ // three hosts forgot; a caveat every host had to remember to check would be
158
+ // the same mistake a second time.
159
+ if (cause instanceof TaskAlreadyTerminalError)
160
+ throw cause.cause;
161
+ const label = options.label ?? "agent";
162
+ // Logged before anything is attempted: if the delivery below also fails, this
163
+ // line is the only record of what actually went wrong.
164
+ console.error(`[${label}] task abandoned after retries were exhausted`, {
165
+ taskId: options.push.taskId,
166
+ error: String(cause)
48
167
  });
168
+ try {
169
+ await deliverTerminalTask(step, {
170
+ push: options.push,
171
+ signingKey: options.signingKey,
172
+ saveTask: options.saveTask,
173
+ terminal: () => buildFailedTask(options.push.taskId, options.push.contextId, options.text),
174
+ sweep: options.sweep,
175
+ stepPrefix: ABANDONED_PREFIX
176
+ });
177
+ }
178
+ catch (deliveryFailed) {
179
+ console.error(`[${label}] could not deliver the failed Task`, {
180
+ taskId: options.push.taskId,
181
+ error: String(deliveryFailed)
182
+ });
183
+ throw cause;
184
+ }
49
185
  }
@@ -22,7 +22,7 @@ export { A2A_JWS_ALG, audienceFor, endpointUrl, jwksUrl } from "@loopingai/a2a-p
22
22
  export { IDENTITY_CLAIM, TENANT_CLAIM, GatewayAuthError, bearerToken, normalizeGatewayOrigins, verifyGatewayToken, type GatewayIdentity, type VerifyOptions } from "./verify.js";
23
23
  export { A2A_RPC_PATH, buildBaseCard, signCard, wireCard, parsePrivateJwk, publicCardJwks, type AgentManifest, type BuildCardOptions, type CardSigningConfig, type WireAgentCard } from "./card.js";
24
24
  export { NOTIFICATION_TOKEN_HEADER, buildSubmittedTask, buildWorkingTask, buildCompletedTask, buildFailedTask, buildNoReplyCompletedTask, signCallbackJwt, postNotification } from "./notify.js";
25
- export { deliverTerminalTask, type DeliverTerminalOptions } from "./deliver.js";
25
+ export { deliverTerminalTask, deliverAbandonedTask, TaskAlreadyTerminalError, type DeliverTerminalOptions, type AbandonedTaskOptions } from "./deliver.js";
26
26
  export { signCallerToken, type CallerTokenOptions } from "./caller-token.js";
27
27
  export { SelfOrigin } from "./self-origin.js";
28
28
  export { callerContext } from "./caller.js";
package/dist/a2a/index.js CHANGED
@@ -22,7 +22,7 @@ export { A2A_JWS_ALG, audienceFor, endpointUrl, jwksUrl } from "@loopingai/a2a-p
22
22
  export { IDENTITY_CLAIM, TENANT_CLAIM, GatewayAuthError, bearerToken, normalizeGatewayOrigins, verifyGatewayToken } from "./verify.js";
23
23
  export { A2A_RPC_PATH, buildBaseCard, signCard, wireCard, parsePrivateJwk, publicCardJwks } from "./card.js";
24
24
  export { NOTIFICATION_TOKEN_HEADER, buildSubmittedTask, buildWorkingTask, buildCompletedTask, buildFailedTask, buildNoReplyCompletedTask, signCallbackJwt, postNotification } from "./notify.js";
25
- export { deliverTerminalTask } from "./deliver.js";
25
+ export { deliverTerminalTask, deliverAbandonedTask, TaskAlreadyTerminalError } from "./deliver.js";
26
26
  export { signCallerToken } from "./caller-token.js";
27
27
  export { SelfOrigin } from "./self-origin.js";
28
28
  export { callerContext } from "./caller.js";
@@ -78,6 +78,19 @@ export declare function makeTasks(db: DB): {
78
78
  * `canceled` onto a `submitted`/`working` row, or re-writing it onto an
79
79
  * already-`canceled` one, stays allowed: that is how the a2a-js handler's own
80
80
  * cancel branch records the cancellation.
81
+ *
82
+ * **And no terminal row may be replaced by a *different* terminal state.**
83
+ * The two rules above were written about cancellation and between them left
84
+ * `completed → failed` wide open, which is not hypothetical: a workflow whose
85
+ * `notify` step exhausts its retries throws *after* `complete` durably saved
86
+ * a completed Task, and an abandoned-task recovery above it would then write
87
+ * a generic failure over a real answer and post a callback contradicting it.
88
+ * A turn that succeeded would be recorded as having failed because a webhook
89
+ * was flaky.
90
+ *
91
+ * Same terminal state re-written is still allowed, and must be: a Workflow
92
+ * replay legitimately re-runs `complete` and saves what it already saved, and
93
+ * refusing that would suppress the callback that replay exists to send.
81
94
  */
82
95
  save(task: Task): boolean;
83
96
  /**
@@ -15,6 +15,21 @@ function nowIso() {
15
15
  export function stateOf(task) {
16
16
  return task.status?.state ?? TaskState.TASK_STATE_UNSPECIFIED;
17
17
  }
18
+ /**
19
+ * The states a task never leaves.
20
+ *
21
+ * `INPUT_REQUIRED` and `AUTH_REQUIRED` are deliberately absent: a turn parked on
22
+ * either is waiting, not finished, and will move again.
23
+ */
24
+ const TERMINAL_STATES = new Set([
25
+ TaskState.TASK_STATE_COMPLETED,
26
+ TaskState.TASK_STATE_FAILED,
27
+ TaskState.TASK_STATE_CANCELED,
28
+ TaskState.TASK_STATE_REJECTED
29
+ ]);
30
+ function isTerminal(state) {
31
+ return TERMINAL_STATES.has(state);
32
+ }
18
33
  /**
19
34
  * Query methods for the `notify_tasks` table (async A2A task state).
20
35
  *
@@ -143,6 +158,19 @@ export function makeTasks(db) {
143
158
  * `canceled` onto a `submitted`/`working` row, or re-writing it onto an
144
159
  * already-`canceled` one, stays allowed: that is how the a2a-js handler's own
145
160
  * cancel branch records the cancellation.
161
+ *
162
+ * **And no terminal row may be replaced by a *different* terminal state.**
163
+ * The two rules above were written about cancellation and between them left
164
+ * `completed → failed` wide open, which is not hypothetical: a workflow whose
165
+ * `notify` step exhausts its retries throws *after* `complete` durably saved
166
+ * a completed Task, and an abandoned-task recovery above it would then write
167
+ * a generic failure over a real answer and post a callback contradicting it.
168
+ * A turn that succeeded would be recorded as having failed because a webhook
169
+ * was flaky.
170
+ *
171
+ * Same terminal state re-written is still allowed, and must be: a Workflow
172
+ * replay legitimately re-runs `complete` and saves what it already saved, and
173
+ * refusing that would suppress the callback that replay exists to send.
146
174
  */
147
175
  save(task) {
148
176
  const existing = readOne(task.id);
@@ -162,6 +190,11 @@ export function makeTasks(db) {
162
190
  existingState !== TaskState.TASK_STATE_CANCELED) {
163
191
  return false;
164
192
  }
193
+ if (isTerminal(existingState) &&
194
+ isTerminal(incomingState) &&
195
+ incomingState !== existingState) {
196
+ return false;
197
+ }
165
198
  upsert(task);
166
199
  return true;
167
200
  },
@@ -51,8 +51,9 @@ function byRound(branches) {
51
51
  * than carried, from the durable rows that are the record of what happened.
52
52
  *
53
53
  * Failed and skipped branches are included so the model can disclose them rather
54
- * than quietly answering as if the work had been done; their diagnostics are not
55
- * (see `delegateCallOutput`).
54
+ * than quietly answering as if the work had been done and, since a failed
55
+ * branch carries its reason in `output`, so it can tell a wall it should stop
56
+ * at from a hiccup worth retrying (see `delegateCallOutput`).
56
57
  */
57
58
  function delegationPair(taskId, round, replyText, branches) {
58
59
  const toolCallId = delegateToolCallId(taskId, round);
@@ -102,6 +102,13 @@ export interface HandleTaskDeps {
102
102
  * with no change here.
103
103
  */
104
104
  signingKey: string;
105
+ /**
106
+ * Log prefix for the abandoned-task line, conventionally the agent's tenant
107
+ * id. Optional because nothing here needs it to work — but a deployment that
108
+ * mounts several agents on one Worker gets one log stream, and without this
109
+ * every one of them reports going quiet under the same name.
110
+ */
111
+ label?: string;
105
112
  }
106
113
  /**
107
114
  * The caller's agent DO stub — every phase runs through it.
@@ -112,19 +119,29 @@ export interface HandleTaskDeps {
112
119
  */
113
120
  type AgentStub = DurableObjectStub<RoundAgentBase>;
114
121
  /**
115
- * The orchestration itself, split from the `WorkflowEntrypoint` wiring so it can
116
- * be driven with a fake `step` in tests (workerd forbids constructing a
122
+ * The orchestration, split from the `WorkflowEntrypoint` wiring so it can be
123
+ * driven with a fake `step` in tests (workerd forbids constructing a
117
124
  * `WorkflowEntrypoint` outside the runtime) — and so a second agent can reuse it
118
125
  * with different deps.
119
126
  *
120
- * Every `step.do` return here is a small projection a status, an id, a reply.
121
- * Never a Subtask row: a step return is capped at 1 MiB and a Subtask carries
122
- * verbatim history snapshots, so the rows stay in the DO and the Workflow carries
123
- * references to them.
127
+ * ## What the wrapper adds, and why it is not the host's job
128
+ *
129
+ * Core distinguishes two ways a turn ends badly. A **typed** failure is a value
130
+ * and {@link deliver} carries it. A **transient** fault throws, so the step
131
+ * retries and recovers from the durable rows without paying for a second
132
+ * inference. Neither covers a transient fault that never stops being one: the
133
+ * step exhausts its retries, {@link orchestrate} unwinds, the delivery below is
134
+ * never reached, and the instance errors with the Task still in `working` — the
135
+ * user told nothing, and the runtime recording a hang. See
136
+ * {@link deliverAbandonedTask}, which was written for a deployed agent that did
137
+ * exactly this on 2026-08-19.
124
138
  *
125
- * **Step names are durable cache keys.** Everything inside the round loop carries
126
- * its round for that reason: `turn:<round>`, `deadline:<round>`, `scan:<round>`,
127
- * `cancel:<round>`. Renaming one silently re-runs its effect on replay.
139
+ * This is caught **here** rather than left to each `WorkflowEntrypoint` because
140
+ * everything the recovery needs is already in {@link HandleTaskDeps}: the stub
141
+ * (typed on `RoundAgentBase`, so `saveTask` and `sweepTaskChildren` are both
142
+ * reachable), `policy.copy.taskFailed`, and `signingKey`. A host has nothing to
143
+ * add — so asking it to remember buys nothing and costs exactly what it cost the
144
+ * starter, where three of four agents never wrote the `catch` at all.
128
145
  */
129
146
  export declare function runHandleTask(p: HandleTaskParams, step: WorkflowStep, deps: HandleTaskDeps): Promise<void>;
130
147
  export {};
@@ -1,6 +1,6 @@
1
1
  import { CHUNK_STEP, MAX_CHUNKS_PER_BRANCH, STEP_TIMEOUT_MS } from "../platform.js";
2
2
  import { buildCompletedTask, buildFailedTask } from "../a2a/notify.js";
3
- import { deliverTerminalTask } from "../a2a/deliver.js";
3
+ import { deliverAbandonedTask, deliverTerminalTask } from "../a2a/deliver.js";
4
4
  /**
5
5
  * The same retries, and a timeout a **round** can actually be measured against.
6
6
  *
@@ -31,11 +31,67 @@ function turnStep(config) {
31
31
  };
32
32
  }
33
33
  /**
34
- * The orchestration itself, split from the `WorkflowEntrypoint` wiring so it can
35
- * be driven with a fake `step` in tests (workerd forbids constructing a
34
+ * The orchestration, split from the `WorkflowEntrypoint` wiring so it can be
35
+ * driven with a fake `step` in tests (workerd forbids constructing a
36
36
  * `WorkflowEntrypoint` outside the runtime) — and so a second agent can reuse it
37
37
  * with different deps.
38
38
  *
39
+ * ## What the wrapper adds, and why it is not the host's job
40
+ *
41
+ * Core distinguishes two ways a turn ends badly. A **typed** failure is a value
42
+ * and {@link deliver} carries it. A **transient** fault throws, so the step
43
+ * retries and recovers from the durable rows without paying for a second
44
+ * inference. Neither covers a transient fault that never stops being one: the
45
+ * step exhausts its retries, {@link orchestrate} unwinds, the delivery below is
46
+ * never reached, and the instance errors with the Task still in `working` — the
47
+ * user told nothing, and the runtime recording a hang. See
48
+ * {@link deliverAbandonedTask}, which was written for a deployed agent that did
49
+ * exactly this on 2026-08-19.
50
+ *
51
+ * This is caught **here** rather than left to each `WorkflowEntrypoint` because
52
+ * everything the recovery needs is already in {@link HandleTaskDeps}: the stub
53
+ * (typed on `RoundAgentBase`, so `saveTask` and `sweepTaskChildren` are both
54
+ * reachable), `policy.copy.taskFailed`, and `signingKey`. A host has nothing to
55
+ * add — so asking it to remember buys nothing and costs exactly what it cost the
56
+ * starter, where three of four agents never wrote the `catch` at all.
57
+ */
58
+ export async function runHandleTask(p, step, deps) {
59
+ try {
60
+ await orchestrate(p, step, deps);
61
+ }
62
+ catch (cause) {
63
+ // Everything this needs is already in `deps` — which is the argument for it
64
+ // living here rather than in each host's `catch`. Four agents in the starter
65
+ // called this function and only one had written that `catch`; the other three
66
+ // carried the 2026-08-19 failure silently. A guard nobody can forget is worth
67
+ // more than a helper everybody must remember.
68
+ await deliverAbandonedTask(step, cause, {
69
+ push: {
70
+ taskId: p.taskId,
71
+ contextId: p.contextId,
72
+ pushUrl: p.pushUrl,
73
+ pushToken: p.pushToken,
74
+ jku: p.jku
75
+ },
76
+ signingKey: deps.signingKey,
77
+ // Resolved inside each closure, never hoisted — see {@link ResolveAgent}.
78
+ saveTask: (task) => deps.resolveAgent(p.identity).saveTask(task),
79
+ // The round never got far enough to say *which* credential or model was at
80
+ // fault, so `failureCopy` has nothing to answer and the policy's own words
81
+ // are the honest ones. The diagnostic is logged instead.
82
+ text: deps.policy.copy.taskFailed,
83
+ sweep: async () => {
84
+ await deps.resolveAgent(p.identity).sweepTaskChildren(p.taskId);
85
+ },
86
+ label: deps.label
87
+ });
88
+ }
89
+ }
90
+ /**
91
+ * The orchestration proper — every ordinary outcome ends inside here, and
92
+ * anything that escapes is what {@link runHandleTask} turns into a delivered
93
+ * failure.
94
+ *
39
95
  * Every `step.do` return here is a small projection — a status, an id, a reply.
40
96
  * Never a Subtask row: a step return is capped at 1 MiB and a Subtask carries
41
97
  * verbatim history snapshots, so the rows stay in the DO and the Workflow carries
@@ -43,9 +99,11 @@ function turnStep(config) {
43
99
  *
44
100
  * **Step names are durable cache keys.** Everything inside the round loop carries
45
101
  * its round for that reason: `turn:<round>`, `deadline:<round>`, `scan:<round>`,
46
- * `cancel:<round>`. Renaming one silently re-runs its effect on replay.
102
+ * `cancel:<round>`. Renaming one silently re-runs its effect on replay — and the
103
+ * recovery path in {@link runHandleTask} runs under its own prefix for the same
104
+ * reason, so a second delivery cannot be handed this one's cached results.
47
105
  */
48
- export async function runHandleTask(p, step, deps) {
106
+ async function orchestrate(p, step, deps) {
49
107
  const limits = deps.config.mainAgentLimits;
50
108
  // Pre-work. Routing is pure, so it needs no step of its own — but it is
51
109
  // deliberately *not* resolved here into a value the steps below close over.
@@ -61,12 +61,29 @@ export declare function makeDelegateTool(types: SubtaskTypeRegistry, maxSubtasks
61
61
  */
62
62
  export declare function delegateToolCallId(taskId: string, round: number): string;
63
63
  /**
64
- * One branch's outcome, as the tool result carries it. `output` is null for any
65
- * branch that did not complete.
64
+ * One branch's outcome, as the tool result carries it. `output` carries the
65
+ * branch's report when it completed and its failure reason when it did not.
66
66
  *
67
- * There is no `error` field, and that is deliberate: internal diagnostics never
68
- * reach the model. It discloses *that* something failed, in user-safe words; the
69
- * durable row keeps the detail.
67
+ * **A failed branch says why, and that is a reversal worth explaining.** This
68
+ * used to be `null` for anything that did not complete, on the principle that
69
+ * internal diagnostics never reach the model. The principle was right about
70
+ * *diagnostics* and wrong about this field: what a facet writes into `error` is
71
+ * not a stack trace, it is a sentence addressed to the delegating model —
72
+ * "there is no checkout in this workspace yet… clone the repository before
73
+ * delegating", "every credential has reached its limit; send this request again
74
+ * after that". Withholding those left the parent with `status: "failed"` and
75
+ * nothing else, and a parent that cannot tell a transient failure from a
76
+ * permanent one retries. In the run that prompted this it retried twelve times
77
+ * over nine minutes, then apologised to the user for a wall it was never shown.
78
+ *
79
+ * What replaces the old rule is a constraint on the writer rather than a filter
80
+ * here: **an `error` is model-visible, so a facet must write it in words that
81
+ * are safe for one to read and act on.** Bounded by {@link MAX_OUTPUT_CHARS} on
82
+ * the way through, because a facet that ignores that is a context-window
83
+ * problem rather than a disclosure one.
84
+ *
85
+ * Still one field, not two. The model's question is "what came back from this
86
+ * branch", and `status` already says which kind of answer it is getting.
70
87
  *
71
88
  * A type alias, not an interface: this is serialized as the tool result's
72
89
  * `JSONValue`, and only aliases get the implicit index signature that satisfies.
@@ -93,5 +110,11 @@ export type DelegateSubtaskOutcome = {
93
110
  * and carries the durable `subtaskId`.
94
111
  */
95
112
  export declare function delegateCallInput(reply: string, branches: CompositionBranch[]): DecompositionProposal;
96
- /** Rebuild one round's call result from its durable rows, in stable ordinal order. */
113
+ /**
114
+ * Rebuild one round's call result from its durable rows, in stable ordinal order.
115
+ *
116
+ * A completed branch reports its parts; any other branch reports its `error`, or
117
+ * `null` when it has none to give — a cancelled branch usually does not, and
118
+ * inventing a sentence for it would be worse than the absence.
119
+ */
97
120
  export declare function delegateCallOutput(branches: CompositionBranch[]): DelegateSubtaskOutcome[];
@@ -68,6 +68,21 @@ export function makeDelegateTool(types, maxSubtasks) {
68
68
  export function delegateToolCallId(taskId, round) {
69
69
  return `task_${taskId}_round_${round}_delegate`;
70
70
  }
71
+ /**
72
+ * Ceiling on one branch's `output`, applied to both halves of it.
73
+ *
74
+ * A round's history holds every branch of every earlier round, so this is
75
+ * multiplied by the whole delegation history rather than paid once. Generous
76
+ * enough for a report a subagent meant to be read, far short of a build log a
77
+ * failing one dumped into `error`.
78
+ */
79
+ const MAX_OUTPUT_CHARS = 8_000;
80
+ function bounded(text) {
81
+ if (text.length <= MAX_OUTPUT_CHARS)
82
+ return text;
83
+ const suffix = "\n…[truncated]";
84
+ return `${text.slice(0, MAX_OUTPUT_CHARS - suffix.length)}${suffix}`;
85
+ }
71
86
  /**
72
87
  * Rebuild one round's call input from its durable rows, in stable ordinal order.
73
88
  * Typed as {@link DecompositionProposal} — the same type the model's own calls
@@ -95,14 +110,22 @@ export function delegateCallInput(reply, branches) {
95
110
  }))
96
111
  };
97
112
  }
98
- /** Rebuild one round's call result from its durable rows, in stable ordinal order. */
113
+ /**
114
+ * Rebuild one round's call result from its durable rows, in stable ordinal order.
115
+ *
116
+ * A completed branch reports its parts; any other branch reports its `error`, or
117
+ * `null` when it has none to give — a cancelled branch usually does not, and
118
+ * inventing a sentence for it would be worse than the absence.
119
+ */
99
120
  export function delegateCallOutput(branches) {
100
121
  return branches.map((branch) => ({
101
122
  subtaskId: branch.subtaskId,
102
123
  type: branch.type,
103
124
  status: branch.status,
104
125
  output: branch.status === "completed"
105
- ? (branch.resultParts ?? []).map((part) => part.text).join("\n")
106
- : null
126
+ ? bounded((branch.resultParts ?? []).map((part) => part.text).join("\n"))
127
+ : branch.error
128
+ ? bounded(branch.error)
129
+ : null
107
130
  }));
108
131
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@loopingai/core",
3
- "version": "0.8.1",
3
+ "version": "0.8.3",
4
4
  "description": "Shared, mandatory foundation for Looping agents on Cloudflare Workers: zero-trust A2A, durable task lifecycle, delegation and subagent runtime, test harness.",
5
5
  "keywords": [
6
6
  "a2a",