@loopingai/core 0.6.0 → 0.7.1

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.
@@ -0,0 +1,116 @@
1
+ /**
2
+ * `@loopingai/core/alarm` — many deadlines over a Durable Object's one alarm.
3
+ *
4
+ * A Durable Object has exactly **one** alarm, and an object that needs to wake
5
+ * for more than one reason cannot simply call `setAlarm` from each of them: the
6
+ * last writer silently wins, and whatever the loser was waiting on never
7
+ * happens. {@link WakeMap} is the fix — one storage row holding every pending
8
+ * intent, and the only thing in a DO that calls `setAlarm`.
9
+ *
10
+ * **Its own subpath, deliberately.** This is useful to a plain `DurableObject`,
11
+ * not only to a {@link LoopingAgent}, so it must be importable without pulling
12
+ * the agent machinery into a bundle.
13
+ *
14
+ * **Why not `Agent.schedule()`.** The `agents` SDK has the same mechanism, but
15
+ * sells it only as a method on `Agent`: adopting it means the object becomes an
16
+ * `Agent`, whose constructor creates `cf_agents_state`, `cf_agents_mcp_servers`
17
+ * and `cf_agents_queues` in that object's SQLite, builds an `MCPClientManager`,
18
+ * and prototype-patches every public method for tracing. For an object whose
19
+ * SQLite is something else already — a container's filesystem, say — that is a
20
+ * large import for a small one. `agents/schedule` is not an alternative: it is a
21
+ * prompt and a zod schema for parsing natural-language dates, not alarm
22
+ * machinery.
23
+ *
24
+ * This owns *when* an object wakes. What it owes on waking is the object's own:
25
+ * `alarm()` reads {@link WakeMap.due} and dispatches.
26
+ */
27
+ /** The single storage row holding every intent. Small, and written atomically. */
28
+ export const WAKE_KEY = "wake";
29
+ /** How far out {@link WakeMap.repair} re-arms when the handler itself failed. */
30
+ export const WAKE_REPAIR_MS = 60_000;
31
+ export class WakeMap {
32
+ #storage;
33
+ constructor(storage) {
34
+ this.#storage = storage;
35
+ }
36
+ /**
37
+ * Every pending intent, as a **null-prototype** dictionary rebuilt from own
38
+ * entries only.
39
+ *
40
+ * {@link WakeIntent.key} is a caller-supplied string, so an ordinary object
41
+ * literal would let three of them misbehave: `get("toString")` would return an
42
+ * inherited function rather than `undefined`, `clear("constructor")` would
43
+ * treat a key it never held as present, and `set` on `"__proto__"` would hit
44
+ * `Object.prototype`'s setter and change the prototype instead of storing the
45
+ * intent. With no prototype there is nothing to inherit and nothing to poison,
46
+ * and every string round-trips as an ordinary key.
47
+ */
48
+ async all() {
49
+ const stored = await this.#storage.get(WAKE_KEY);
50
+ const intents = Object.create(null);
51
+ // `Object.entries` is own-enumerable-only, so nothing from a prototype can
52
+ // enter here even if the stored value arrived with one.
53
+ if (stored) {
54
+ for (const [key, intent] of Object.entries(stored))
55
+ intents[key] = intent;
56
+ }
57
+ return intents;
58
+ }
59
+ async get(key) {
60
+ return (await this.all())[key];
61
+ }
62
+ async set(intent) {
63
+ const all = await this.all();
64
+ all[intent.key] = intent;
65
+ await this.#storage.put(WAKE_KEY, all);
66
+ await this.rearm();
67
+ }
68
+ async clear(key) {
69
+ const all = await this.all();
70
+ // `hasOwn`, not `in`: the dictionary has no prototype today, and this stays
71
+ // correct if that ever changes.
72
+ if (!Object.hasOwn(all, key))
73
+ return;
74
+ delete all[key];
75
+ await this.#storage.put(WAKE_KEY, all);
76
+ await this.rearm();
77
+ }
78
+ /** Every intent whose time has come, earliest first. */
79
+ async due(now) {
80
+ return Object.values(await this.all())
81
+ .filter((intent) => intent.notBefore <= now)
82
+ .sort((a, b) => a.notBefore - b.notBefore);
83
+ }
84
+ /**
85
+ * Point the alarm at the earliest deadline.
86
+ *
87
+ * Only ever moved **earlier**, never later: an alarm that fires too soon finds
88
+ * nothing due, re-arms, and costs one wake-up, whereas an alarm pushed later
89
+ * by a coincidental write silently delays whatever was already waiting. When
90
+ * no intents remain the alarm is deleted outright, so an idle object does not
91
+ * wake on a schedule it has no use for.
92
+ */
93
+ async rearm() {
94
+ const deadlines = Object.values(await this.all()).map((i) => i.notBefore);
95
+ const existing = await this.#storage.getAlarm();
96
+ if (deadlines.length === 0) {
97
+ if (existing !== null)
98
+ await this.#storage.deleteAlarm();
99
+ return;
100
+ }
101
+ const earliest = Math.min(...deadlines);
102
+ if (existing === null || existing > earliest) {
103
+ await this.#storage.setAlarm(earliest);
104
+ }
105
+ }
106
+ /**
107
+ * Re-arm shortly, for when the handler failed before it could work out what
108
+ * it owed. Distinct from {@link rearm} because that one trusts the map, and
109
+ * the map is what we just failed to read.
110
+ */
111
+ async repair(now) {
112
+ const existing = await this.#storage.getAlarm();
113
+ if (existing === null)
114
+ await this.#storage.setAlarm(now + WAKE_REPAIR_MS);
115
+ }
116
+ }
@@ -69,6 +69,17 @@ export declare abstract class LoopingAgent<TEnv extends Cloudflare.Env & AiEnv &
69
69
  * not depend on it surviving.
70
70
  */
71
71
  private identityKey?;
72
+ /**
73
+ * This deployment's own public origin, learned from the `jku` every turn
74
+ * carries and **pinned on the first one** this instance serves.
75
+ *
76
+ * Unlike {@link identityKey} this is shared by concurrent turns — the object
77
+ * is keyed by caller, not by origin — so it is pinned rather than
78
+ * last-write-wins: an immutable field cannot change under a credential thunk
79
+ * that reads it while a turn awaits a model call. See {@link SelfOrigin} for
80
+ * the full argument, and for why nothing is persisted.
81
+ */
82
+ private readonly selfOriginMemo;
72
83
  /**
73
84
  * Test-only model injection. A **field**, not a constructor argument or an RPC
74
85
  * parameter, so it never appears on the generated DO stub: production callers
@@ -197,6 +208,31 @@ export declare abstract class LoopingAgent<TEnv extends Cloudflare.Env & AiEnv &
197
208
  * exists.
198
209
  */
199
210
  protected requireIdentityKey(): string;
211
+ /**
212
+ * Offer this deployment's own origin from a value that carries it. The first
213
+ * usable one is kept for the life of the instance.
214
+ *
215
+ * Called wherever a {@link TurnPushContext} arrives — here for every agent
216
+ * shape, and at the entry of `RoundAgentBase`'s two RPCs, where the origin is
217
+ * needed *before* this channel would be built. All three matter because any of
218
+ * them can be the call that wakes a fresh isolate. Cheap and unfailing: past
219
+ * the first turn it is one truthiness check, and an unusable value is ignored
220
+ * rather than thrown, because a turn must not fail over this.
221
+ */
222
+ protected noteSelfOrigin(url: string | undefined): void;
223
+ /**
224
+ * This deployment's own public origin, if a turn has carried it to this
225
+ * instance yet. Constant once set, so it reads the same from any turn running
226
+ * on this object. See {@link SelfOrigin}.
227
+ */
228
+ protected selfOrigin(): string | undefined;
229
+ /**
230
+ * The same, for a caller that cannot proceed without it — signing a caller
231
+ * token with {@link file://../a2a/caller-token.ts signCallerToken} above all,
232
+ * whose `iss` this is. Throws naming the timing rather than producing a token
233
+ * with a nonsense issuer.
234
+ */
235
+ protected requireSelfOrigin(): string;
200
236
  /** The gateway callback channel for one turn. See {@link PushChannel}. */
201
237
  protected push(context: TurnPushContext): PushChannel;
202
238
  /**
@@ -5,6 +5,7 @@ import { resolveConfig } from "../config.js";
5
5
  import { AgentDB, stateOf } from "../db/index.js";
6
6
  import { callerContext } from "../a2a/caller.js";
7
7
  import { createPushChannel } from "../a2a/push.js";
8
+ import { SelfOrigin } from "../a2a/self-origin.js";
8
9
  import { buildAgentSession } from "../agent/session.js";
9
10
  import { workersAIModels } from "../agent/workers-ai/index.js";
10
11
  /**
@@ -64,6 +65,17 @@ export class LoopingAgent extends Agent {
64
65
  * not depend on it surviving.
65
66
  */
66
67
  identityKey;
68
+ /**
69
+ * This deployment's own public origin, learned from the `jku` every turn
70
+ * carries and **pinned on the first one** this instance serves.
71
+ *
72
+ * Unlike {@link identityKey} this is shared by concurrent turns — the object
73
+ * is keyed by caller, not by origin — so it is pinned rather than
74
+ * last-write-wins: an immutable field cannot change under a credential thunk
75
+ * that reads it while a turn awaits a model call. See {@link SelfOrigin} for
76
+ * the full argument, and for why nothing is persisted.
77
+ */
78
+ selfOriginMemo = new SelfOrigin();
67
79
  /**
68
80
  * Test-only model injection. A **field**, not a constructor argument or an RPC
69
81
  * parameter, so it never appears on the generated DO stub: production callers
@@ -254,8 +266,40 @@ export class LoopingAgent extends Agent {
254
266
  }
255
267
  return (this.identityKey = key);
256
268
  }
269
+ /**
270
+ * Offer this deployment's own origin from a value that carries it. The first
271
+ * usable one is kept for the life of the instance.
272
+ *
273
+ * Called wherever a {@link TurnPushContext} arrives — here for every agent
274
+ * shape, and at the entry of `RoundAgentBase`'s two RPCs, where the origin is
275
+ * needed *before* this channel would be built. All three matter because any of
276
+ * them can be the call that wakes a fresh isolate. Cheap and unfailing: past
277
+ * the first turn it is one truthiness check, and an unusable value is ignored
278
+ * rather than thrown, because a turn must not fail over this.
279
+ */
280
+ noteSelfOrigin(url) {
281
+ this.selfOriginMemo.note(url);
282
+ }
283
+ /**
284
+ * This deployment's own public origin, if a turn has carried it to this
285
+ * instance yet. Constant once set, so it reads the same from any turn running
286
+ * on this object. See {@link SelfOrigin}.
287
+ */
288
+ selfOrigin() {
289
+ return this.selfOriginMemo.peek();
290
+ }
291
+ /**
292
+ * The same, for a caller that cannot proceed without it — signing a caller
293
+ * token with {@link file://../a2a/caller-token.ts signCallerToken} above all,
294
+ * whose `iss` this is. Throws naming the timing rather than producing a token
295
+ * with a nonsense issuer.
296
+ */
297
+ requireSelfOrigin() {
298
+ return this.selfOriginMemo.require();
299
+ }
257
300
  /** The gateway callback channel for one turn. See {@link PushChannel}. */
258
301
  push(context) {
302
+ this.noteSelfOrigin(context.jku);
259
303
  return createPushChannel(this.env.A2A_SIGNING_KEY, context);
260
304
  }
261
305
  /**
package/dist/index.d.ts CHANGED
@@ -14,6 +14,6 @@ export type { DelegationNames, RecipeLimits, ResolvedRecipe, SubtaskParams, Subt
14
14
  export { RecipeValidationError, resolveLimits, validateRecipe, type RecipePolicy } from "./contract/validation.js";
15
15
  export { ConfigError, DEFAULT_CORE_CONFIG, resolveConfig, type AgentLimits, type CoreConfig, type CoreConfigOverrides, type ModelConfig, type SessionConfig } from "./config.js";
16
16
  export { parseGatewayOrigins, type A2ASecretsEnv, type AiEnv, type CoreEnv } from "./env.js";
17
- export { CHUNK_SOFT_MS, MAX_CHUNKS_PER_BRANCH, MAX_TOOL_CALL_MS, STEP_TIMEOUT_MS, STEPS_PER_INSTANCE } from "./platform.js";
17
+ export { CHUNK_SOFT_MS, CHUNK_STEP, MAX_CHUNKS_PER_BRANCH, MAX_TOOL_CALL_MS, STEP_TIMEOUT_MS, STEPS_PER_INSTANCE } from "./platform.js";
18
18
  export type { PluginStore } from "./db/db.js";
19
19
  export { makeWorkspaceHandle, memoryWorkspaceBacking, WorkspaceLimitError, WORKSPACE_MAX_FILES, WORKSPACE_MAX_FILE_BYTES, type WorkspaceBacking, type WorkspaceEntry, type WorkspaceHandle } from "./subagent/workspace.js";
package/dist/index.js CHANGED
@@ -13,5 +13,5 @@ export { PLUGIN_CONTRACT_VERSION, definePlugin, restrictMainAgentTools } from ".
13
13
  export { RecipeValidationError, resolveLimits, validateRecipe } from "./contract/validation.js";
14
14
  export { ConfigError, DEFAULT_CORE_CONFIG, resolveConfig } from "./config.js";
15
15
  export { parseGatewayOrigins } from "./env.js";
16
- export { CHUNK_SOFT_MS, MAX_CHUNKS_PER_BRANCH, MAX_TOOL_CALL_MS, STEP_TIMEOUT_MS, STEPS_PER_INSTANCE } from "./platform.js";
16
+ export { CHUNK_SOFT_MS, CHUNK_STEP, MAX_CHUNKS_PER_BRANCH, MAX_TOOL_CALL_MS, STEP_TIMEOUT_MS, STEPS_PER_INSTANCE } from "./platform.js";
17
17
  export { makeWorkspaceHandle, memoryWorkspaceBacking, WorkspaceLimitError, WORKSPACE_MAX_FILES, WORKSPACE_MAX_FILE_BYTES } from "./subagent/workspace.js";
@@ -1,3 +1,4 @@
1
+ import type { WorkflowStepConfig } from "cloudflare:workers";
1
2
  /**
2
3
  * What the Cloudflare Workflows runtime imposes, and the two numbers derived from
3
4
  * it. Nothing here is a budget or a preference — see {@link file://./config.ts}
@@ -107,3 +108,31 @@ export declare const MAX_TOOL_CALL_MS: number;
107
108
  * 2. The worst-case step product stays under {@link STEPS_PER_INSTANCE}.
108
109
  */
109
110
  export declare const MAX_CHUNKS_PER_BRANCH = 40;
111
+ /**
112
+ * What a step holding a model call or a container command configures instead of
113
+ * inheriting Workflows' defaults. Both defaults were measured wrong for this
114
+ * workload.
115
+ *
116
+ * **`timeout`.** The default is ten minutes. A step here holds a model call and
117
+ * its provider retries, or a container command running a project's test suite;
118
+ * neither fits in ten minutes reliably, and neither uses meaningful CPU while it
119
+ * waits. Left inherited, that default silently became the ceiling
120
+ * {@link CHUNK_SOFT_MS} was sized against.
121
+ *
122
+ * **`retries`.** The default is five attempts with exponential backoff from ten
123
+ * seconds. Against a fault that is not transient — a severed Durable Object stub
124
+ * — that produced five failures in under 10ms each, spread across 160 seconds of
125
+ * backoff that bought nothing. Three attempts still cover a genuinely transient
126
+ * fault, since the model call has its own provider-level retry underneath this,
127
+ * and a flat five-second delay stops a fast permanent failure being paid for at
128
+ * exponential rates.
129
+ *
130
+ * Here rather than in `/round` because the agent that most needs it may not be a
131
+ * round agent: a single-inference agent runs one model call in one step and has
132
+ * the same two problems, and importing this from `/round` would put the whole
133
+ * delegation engine in its bundle.
134
+ *
135
+ * For the **short** bookkeeping steps — `working`, `complete`, `notify` and
136
+ * friends — the defaults are fine and a shared config would only hide that.
137
+ */
138
+ export declare const CHUNK_STEP: WorkflowStepConfig;
package/dist/platform.js CHANGED
@@ -107,3 +107,34 @@ export const MAX_TOOL_CALL_MS = 10 * 60_000;
107
107
  * 2. The worst-case step product stays under {@link STEPS_PER_INSTANCE}.
108
108
  */
109
109
  export const MAX_CHUNKS_PER_BRANCH = 40;
110
+ /**
111
+ * What a step holding a model call or a container command configures instead of
112
+ * inheriting Workflows' defaults. Both defaults were measured wrong for this
113
+ * workload.
114
+ *
115
+ * **`timeout`.** The default is ten minutes. A step here holds a model call and
116
+ * its provider retries, or a container command running a project's test suite;
117
+ * neither fits in ten minutes reliably, and neither uses meaningful CPU while it
118
+ * waits. Left inherited, that default silently became the ceiling
119
+ * {@link CHUNK_SOFT_MS} was sized against.
120
+ *
121
+ * **`retries`.** The default is five attempts with exponential backoff from ten
122
+ * seconds. Against a fault that is not transient — a severed Durable Object stub
123
+ * — that produced five failures in under 10ms each, spread across 160 seconds of
124
+ * backoff that bought nothing. Three attempts still cover a genuinely transient
125
+ * fault, since the model call has its own provider-level retry underneath this,
126
+ * and a flat five-second delay stops a fast permanent failure being paid for at
127
+ * exponential rates.
128
+ *
129
+ * Here rather than in `/round` because the agent that most needs it may not be a
130
+ * round agent: a single-inference agent runs one model call in one step and has
131
+ * the same two problems, and importing this from `/round` would put the whole
132
+ * delegation engine in its bundle.
133
+ *
134
+ * For the **short** bookkeeping steps — `working`, `complete`, `notify` and
135
+ * friends — the defaults are fine and a shared config would only hide that.
136
+ */
137
+ export const CHUNK_STEP = {
138
+ timeout: STEP_TIMEOUT_MS,
139
+ retries: { limit: 3, delay: 5_000, backoff: "constant" }
140
+ };
@@ -83,6 +83,9 @@ export class RoundAgentBase extends LoopingAgent {
83
83
  * number, and none can report the wrong one.
84
84
  */
85
85
  async runTaskTurn(input) {
86
+ // Before anything can reach a model: a round that calls out mint-signed
87
+ // needs this deployment's own origin, and this is where it arrives.
88
+ this.noteSelfOrigin(input.push?.jku);
86
89
  const budget = newTurnBudget(input.turnsRemaining);
87
90
  const verdict = await this.decideRound(input, budget);
88
91
  return { ...verdict, turns: budget.spent };
@@ -300,6 +303,10 @@ export class RoundAgentBase extends LoopingAgent {
300
303
  * not outcomes.
301
304
  */
302
305
  async executeSubtaskChunk(id, chunk, push) {
306
+ // Recorded here rather than left to `this.push(push)` below, which runs only
307
+ // after the chunk has already executed — and the child is handed this
308
+ // origin on the way in.
309
+ this.noteSelfOrigin(push?.jku);
303
310
  const prepared = await this.prepareChunk(id);
304
311
  if (prepared.kind === "terminal") {
305
312
  return { done: true, status: prepared.subtask.status, progress: [] };
@@ -482,9 +489,18 @@ export class RoundAgentBase extends LoopingAgent {
482
489
  * a second mismatch is a genuine lifecycle bug and must surface).
483
490
  */
484
491
  async executeChunkInChild(name, request, chunk, runtime) {
492
+ // A facet has no request path of its own: it is reached only from here, so
493
+ // this is the only way it can learn what this deployment is called. Passed
494
+ // as its own argument, never folded into `request`, for the same reason
495
+ // `chunk` is — the request is fingerprinted, and this is not part of what
496
+ // the execution *is*, so it must not be able to make a retry look like a
497
+ // different one. Pinned on both sides, so it cannot change under a run;
498
+ // undefined only on an instance no turn has reached, where the facet's own
499
+ // `requireSelfOrigin` produces the readable error.
500
+ const selfOrigin = this.selfOrigin();
485
501
  const child = await this.subAgent(this.subagentClass(), name);
486
502
  try {
487
- return await child.executeChunk(request, chunk, runtime);
503
+ return await child.executeChunk(request, chunk, runtime, selfOrigin);
488
504
  }
489
505
  catch (err) {
490
506
  if (!String(err).includes(FINGERPRINT_MISMATCH))
@@ -492,7 +508,7 @@ export class RoundAgentBase extends LoopingAgent {
492
508
  console.warn("[agent] stale subagent state, recreating", { name });
493
509
  await this.deleteSubAgent(this.subagentClass(), name);
494
510
  const fresh = await this.subAgent(this.subagentClass(), name);
495
- return await fresh.executeChunk(request, chunk, runtime);
511
+ return await fresh.executeChunk(request, chunk, runtime, selfOrigin);
496
512
  }
497
513
  }
498
514
  /** Let the owning plugin release whatever `resolveRuntime` acquired. */
@@ -1,36 +1,6 @@
1
- import { MAX_CHUNKS_PER_BRANCH, STEP_TIMEOUT_MS } from "../platform.js";
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 { createPushChannel } from "../a2a/push.js";
4
- /**
5
- * What the long steps configure instead of inheriting.
6
- *
7
- * Both halves of this were previously left at Workflows' defaults, and both
8
- * defaults were wrong for this workload.
9
- *
10
- * **`timeout`.** The default is ten minutes. Nothing here passed a config, so that
11
- * default silently became the ceiling {@link CHUNK_SOFT_MS} was sized against —
12
- * see the note on `STEP_TIMEOUT_MS` in `platform.ts` for what that cost. A step
13
- * here holds a model call and its provider retries, or a container command running
14
- * a project's test suite; neither fits in ten minutes reliably and neither uses
15
- * meaningful CPU while it waits.
16
- *
17
- * **`retries`.** The default is five attempts with exponential backoff from ten
18
- * seconds. The failure documented on {@link ResolveAgent} is what that produces
19
- * when the fault is not transient: five retries against a severed stub, each
20
- * failing in under 10ms, spread across 160 seconds of backoff that bought nothing.
21
- * Three attempts still cover a genuinely transient fault — the model call has its
22
- * own provider-level retry underneath this — and a flat five-second delay stops a
23
- * fast, permanent failure from being paid for at exponential rates.
24
- *
25
- * Deliberately applied only to the chunk steps — see {@link turnStep} for why a
26
- * round does not share it. The short bookkeeping steps (`working`, `deadline:`,
27
- * `scan:`, `complete`, `notify`) are sub-second projections where the defaults
28
- * are fine and a shared config would only hide that.
29
- */
30
- const CHUNK_STEP = {
31
- timeout: STEP_TIMEOUT_MS,
32
- retries: { limit: 3, delay: 5_000, backoff: "constant" }
33
- };
3
+ import { deliverTerminalTask } from "../a2a/deliver.js";
34
4
  /**
35
5
  * The same retries, and a timeout a **round** can actually be measured against.
36
6
  *
@@ -323,62 +293,37 @@ async function runBranch(p, step, agent, id, push) {
323
293
  * and is deliberately not given a kind of its own until something needs to tell
324
294
  * it apart.
325
295
  *
326
- * The Task is built **inside** the step and returned, so `notify` posts exactly
327
- * what was persisted: building it in the body would re-stamp `new Date()` on
328
- * every replay and post a Task that differs from the stored one.
329
- *
330
- * **The guarded write is the cancellation check.** `saveTask` refuses to write a
331
- * terminal state over a `canceled` row and says so, and it does that read and
332
- * write in one synchronous pass inside the DO. Probing first and saving second
333
- * would leave a window — between the two calls, and again between this step and
334
- * `notify` — in which a `tasks/cancel` lands and the gateway still receives a
335
- * `completed` callback. Keying the notify on "did the write apply" closes it.
296
+ * The delivery itself is {@link deliverTerminalTask}, which is shared with agents
297
+ * that never delegate. What is a round's own is the two things passed to it: the
298
+ * choice of terminal Task, and the child sweep.
336
299
  */
337
300
  async function deliver(p, step, agent, reply, deps, failure) {
338
301
  // Resolved outside the step body so a replay cannot take a different branch
339
302
  // than the write it is replaying.
340
303
  const failedText = (failure && deps.failureCopy?.(failure.kind, failure.detail)) ||
341
304
  deps.policy.copy.taskFailed;
342
- const task = await step.do("complete", async () => {
343
- const terminal = reply !== null
344
- ? buildCompletedTask(p.taskId, p.contextId, reply)
345
- : buildFailedTask(p.taskId, p.contextId, failedText);
346
- return (await agent().saveTask(terminal)) ? terminal : null;
347
- });
348
- if (!task)
349
- return;
350
- // Sweep this Task's managed children now that it is terminal and every `execute`
351
- // step has unwound. Deleting them here — rather than right after each successful
352
- // chunk — keeps `deleteSubAgent`'s facet-abort from landing on a still-open
353
- // `executeChunk` RPC, which telemetry mis-records as a failure. Best-effort and
354
- // idempotent, so it is safe on replay.
355
- //
356
- // Caught, not left to propagate: the terminal Task is already durably saved, so
357
- // a sweep that still fails once the step's own retries are exhausted must not
358
- // block `notify` below — the gateway is owed its result regardless of whether
359
- // this Task's children were reclaimed.
360
- try {
361
- await step.do("sweep", async () => {
362
- await agent().sweepTaskChildren(p.taskId);
363
- });
364
- }
365
- catch (err) {
366
- console.error("[handle-task] sweep failed after retries", {
367
- taskId: p.taskId,
368
- err: String(err)
369
- });
370
- }
371
- // Notify the gateway: a card-key-signed callback POST. Retried by the step on a
372
- // non-2xx; the terminal messageId is deterministic and the gateway is
373
- // idempotent/single-use, so retries are safe. If it ultimately fails, the
374
- // gateway's own reaction backstop clears the pending marker.
375
- await step.do("notify", async () => {
376
- await createPushChannel(deps.signingKey, {
305
+ await deliverTerminalTask(step, {
306
+ push: {
377
307
  taskId: p.taskId,
378
308
  contextId: p.contextId,
379
309
  pushUrl: p.pushUrl,
380
310
  pushToken: p.pushToken,
381
311
  jku: p.jku
382
- }).deliver(task);
312
+ },
313
+ signingKey: deps.signingKey,
314
+ // `agent()` inside the body, never hoisted: a stub is a live connection and
315
+ // a severed one never reconnects.
316
+ saveTask: (task) => agent().saveTask(task),
317
+ terminal: () => reply !== null
318
+ ? buildCompletedTask(p.taskId, p.contextId, reply)
319
+ : buildFailedTask(p.taskId, p.contextId, failedText),
320
+ // Sweep this Task's managed children now that it is terminal and every
321
+ // `execute` step has unwound. Deleting them here — rather than right after
322
+ // each successful chunk — keeps `deleteSubAgent`'s facet-abort from landing
323
+ // on a still-open `executeChunk` RPC, which telemetry mis-records as a
324
+ // failure. Best-effort and idempotent, so it is safe on replay.
325
+ sweep: async () => {
326
+ await agent().sweepTaskChildren(p.taskId);
327
+ }
383
328
  });
384
329
  }
@@ -102,6 +102,14 @@ export declare abstract class RecipeSubagentBase<TEnv extends Cloudflare.Env = C
102
102
  * to interrupt. See {@link abortRun}.
103
103
  */
104
104
  private inflight?;
105
+ /**
106
+ * This deployment's own public origin, as the parent DO passes it on every
107
+ * chunk, pinned from the first. In memory for the same reason {@link inflight}
108
+ * is: a facet is reached only through {@link executeChunk}, so an instance that
109
+ * lost it is an instance that will be told again before it can run anything.
110
+ * See {@link SelfOrigin}.
111
+ */
112
+ private readonly selfOriginMemo;
105
113
  onStart(): Promise<void>;
106
114
  /**
107
115
  * Idempotent schema bootstrap. Also called lazily from the RPCs so
@@ -111,17 +119,30 @@ export declare abstract class RecipeSubagentBase<TEnv extends Cloudflare.Env = C
111
119
  private ensureTables;
112
120
  /** The recipe's durable workspace, backed by this facet's own SQLite storage. */
113
121
  private workspace;
122
+ /**
123
+ * This deployment's own public origin, if the parent has passed it to this
124
+ * instance yet. See {@link SelfOrigin}.
125
+ */
126
+ protected selfOrigin(): string | undefined;
127
+ /**
128
+ * The same, for a caller that cannot proceed without it — a facet that signs
129
+ * its own caller tokens, above all. Mirrors `LoopingAgent.requireSelfOrigin`,
130
+ * because a facet must run on the same provider, and so the same credential
131
+ * path, as the parent that delegated to it.
132
+ */
133
+ protected requireSelfOrigin(): string;
114
134
  /**
115
135
  * Execute one durable chunk of a Subtask under the parent's resolved Recipe.
116
136
  *
117
137
  * A terminal outcome (completed / failed) is cached and replayed on retry. A
118
138
  * mid-run chunk persists its rolling state to `run_state` and returns a
119
- * `done: false` yield for the Workflow to run another chunk. `chunk` is a
120
- * separate argument — never part of `request` — so every chunk fingerprints
121
- * identically and the cache/resume keys line up. Only transient platform faults
122
- * throw (nothing cached), so a Workflow retry resumes from the last checkpoint.
139
+ * `done: false` yield for the Workflow to run another chunk. `chunk` and
140
+ * `selfOrigin` are separate arguments — never part of `request` — so every
141
+ * chunk fingerprints identically and the cache/resume keys line up. Only
142
+ * transient platform faults throw (nothing cached), so a Workflow retry
143
+ * resumes from the last checkpoint.
123
144
  */
124
- executeChunk(request: RecipeExecutionRequest, _chunk: number, runtime?: SubtaskRuntime): Promise<RecipeChunkResult>;
145
+ executeChunk(request: RecipeExecutionRequest, _chunk: number, runtime?: SubtaskRuntime, selfOrigin?: string): Promise<RecipeChunkResult>;
125
146
  /**
126
147
  * Interrupt the chunk running here right now, so a cancellation lands on the
127
148
  * current model call instead of at the next chunk boundary (up to `chunkSoftMs`
@@ -4,6 +4,7 @@ import { CHUNK_SOFT_MS } from "../platform.js";
4
4
  import { buildRecipeTools } from "../runtime/tool-families.js";
5
5
  import { RecipeValidationError, validateRecipe } from "../contract/validation.js";
6
6
  import { SubtaskParamsError } from "../subtasks/subtask-types.js";
7
+ import { SelfOrigin } from "../a2a/self-origin.js";
7
8
  import { renderSubagentPrompt } from "./prompt.js";
8
9
  import { makeWorkspaceHandle } from "./workspace.js";
9
10
  import { fingerprintRequest } from "./fingerprint.js";
@@ -77,6 +78,14 @@ export class RecipeSubagentBase extends Agent {
77
78
  * to interrupt. See {@link abortRun}.
78
79
  */
79
80
  inflight;
81
+ /**
82
+ * This deployment's own public origin, as the parent DO passes it on every
83
+ * chunk, pinned from the first. In memory for the same reason {@link inflight}
84
+ * is: a facet is reached only through {@link executeChunk}, so an instance that
85
+ * lost it is an instance that will be told again before it can run anything.
86
+ * See {@link SelfOrigin}.
87
+ */
88
+ selfOriginMemo = new SelfOrigin();
80
89
  async onStart() {
81
90
  this.ensureTables();
82
91
  }
@@ -107,18 +116,39 @@ export class RecipeSubagentBase extends Agent {
107
116
  workspace() {
108
117
  return (this._workspace ??= this.subagentRuntime().workspaceBacking(this.ctx.storage.sql, () => this.name));
109
118
  }
119
+ /**
120
+ * This deployment's own public origin, if the parent has passed it to this
121
+ * instance yet. See {@link SelfOrigin}.
122
+ */
123
+ selfOrigin() {
124
+ return this.selfOriginMemo.peek();
125
+ }
126
+ /**
127
+ * The same, for a caller that cannot proceed without it — a facet that signs
128
+ * its own caller tokens, above all. Mirrors `LoopingAgent.requireSelfOrigin`,
129
+ * because a facet must run on the same provider, and so the same credential
130
+ * path, as the parent that delegated to it.
131
+ */
132
+ requireSelfOrigin() {
133
+ return this.selfOriginMemo.require();
134
+ }
110
135
  /**
111
136
  * Execute one durable chunk of a Subtask under the parent's resolved Recipe.
112
137
  *
113
138
  * A terminal outcome (completed / failed) is cached and replayed on retry. A
114
139
  * mid-run chunk persists its rolling state to `run_state` and returns a
115
- * `done: false` yield for the Workflow to run another chunk. `chunk` is a
116
- * separate argument — never part of `request` — so every chunk fingerprints
117
- * identically and the cache/resume keys line up. Only transient platform faults
118
- * throw (nothing cached), so a Workflow retry resumes from the last checkpoint.
140
+ * `done: false` yield for the Workflow to run another chunk. `chunk` and
141
+ * `selfOrigin` are separate arguments — never part of `request` — so every
142
+ * chunk fingerprints identically and the cache/resume keys line up. Only
143
+ * transient platform faults throw (nothing cached), so a Workflow retry
144
+ * resumes from the last checkpoint.
119
145
  */
120
- async executeChunk(request, _chunk, runtime = {}) {
146
+ async executeChunk(request, _chunk, runtime = {}, selfOrigin) {
121
147
  this.ensureTables();
148
+ // Before `subagentRuntime()`, which is where a host builds its model runtime
149
+ // — and a facet running on a provider it authenticates to mint-signed reads
150
+ // this origin from there.
151
+ this.selfOriginMemo.note(selfOrigin);
122
152
  const rt = this.subagentRuntime();
123
153
  const fingerprint = await fingerprintRequest(request);
124
154
  // A terminal result already exists → replay it (idempotent retry).
@@ -8,11 +8,11 @@ import { AgentDB, type AgentDBOptions } from "../db/db.js";
8
8
  * and {@link DoTestHelpers.withDb} hands over the whole `AgentDB` rather than
9
9
  * pre-selecting a table.
10
10
  */
11
- export interface DoTestHelpers {
11
+ export interface DoTestHelpers<T extends Rpc.DurableObjectBranded | undefined> {
12
12
  /** Fresh, unique DO stub per test — state never leaks between tests. */
13
- freshStub(label: string): DurableObjectStub;
13
+ freshStub(label: string): DurableObjectStub<T>;
14
14
  /** Run `fn` inside a fresh DO instance with a migrated {@link AgentDB}. */
15
- withDb<T>(label: string, fn: (db: AgentDB) => T): Promise<T>;
15
+ withDb<R>(label: string, fn: (db: AgentDB) => R): Promise<R>;
16
16
  }
17
17
  /**
18
18
  * `ctx` is protected in the DO type system but public at runtime. Cast once so
@@ -26,4 +26,4 @@ export declare function doStorage(instance: unknown): DurableObjectStorage;
26
26
  * const { freshStub, withDb } = makeDoHelpers(env.MyAgent);
27
27
  * ```
28
28
  */
29
- export declare function makeDoHelpers(ns: DurableObjectNamespace, defaults?: AgentDBOptions): DoTestHelpers;
29
+ export declare function makeDoHelpers<T extends Rpc.DurableObjectBranded | undefined = undefined>(ns: DurableObjectNamespace<T>, defaults?: AgentDBOptions): DoTestHelpers<T>;
@@ -24,7 +24,7 @@
24
24
  export { setupRecording, cassetteNameFor, type SetupRecordingOptions } from "./vcr-spec.js";
25
25
  export { VCR_CONTROL_ORIGIN, VCR_MARKER_HEADER, CASSETTE_NAME_RE, type VcrReleaseResult } from "./vcr-shared.js";
26
26
  export { FakeSession } from "./fake-session.js";
27
- export { mockModel, finalReply, type MockStep } from "./mock-model.js";
27
+ export { mockModel, finalReply, throwingModel, countingModel, rateLimitedModel, type MockStep } from "./mock-model.js";
28
28
  export { makeGatewayToken, type GatewayTokenOptions } from "./auth.js";
29
29
  export { AGENT_ORIGIN, GATEWAY_ORIGIN, TEST_AGENT_PRIVATE_JWK, TEST_GATEWAY_PRIVATE_JWK, TEST_MODELS, gatewayPublicJwks, testAgentMessage, testStatus, testTask } from "./fixtures.js";
30
30
  export { doStorage, makeDoHelpers, type DoTestHelpers } from "./do.js";