@loopingai/core 0.7.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.
- package/README.md +36 -3
- package/dist/a2a/caller-token.d.ts +6 -0
- package/dist/a2a/index.d.ts +1 -0
- package/dist/a2a/index.js +1 -0
- package/dist/a2a/self-origin.d.ts +91 -0
- package/dist/a2a/self-origin.js +114 -0
- package/dist/host/agent.d.ts +36 -0
- package/dist/host/agent.js +44 -0
- package/dist/round/agent.js +18 -2
- package/dist/subagent/index.d.ts +26 -5
- package/dist/subagent/index.js +35 -5
- package/package.json +1 -1
- package/scripts/generate-keys.mjs +15 -20
package/README.md
CHANGED
|
@@ -39,11 +39,12 @@ optional is a plugin. Anything opinionated belongs to your app.
|
|
|
39
39
|
npx looping-keys
|
|
40
40
|
```
|
|
41
41
|
|
|
42
|
-
Set the private JWK as `A2A_SIGNING_KEY` (`.
|
|
43
|
-
when deployed) and the origins you accept
|
|
42
|
+
Set the private JWK as `A2A_SIGNING_KEY` (`.env` locally; `wrangler deploy
|
|
43
|
+
--secrets-file .env` or `wrangler secret put` when deployed) and the origins you accept
|
|
44
|
+
calls from as `GATEWAY_ORIGINS`:
|
|
44
45
|
|
|
45
46
|
```ini
|
|
46
|
-
# .
|
|
47
|
+
# .env
|
|
47
48
|
A2A_SIGNING_KEY={"crv":"Ed25519","d":"…","x":"…","kty":"OKP","kid":"a2a-2026-08-01"}
|
|
48
49
|
GATEWAY_ORIGINS=["https://gateway.example.com"]
|
|
49
50
|
```
|
|
@@ -268,6 +269,38 @@ makes the served document a fixed point under the repeated decoding a verifier
|
|
|
268
269
|
performs. A gateway pins the card's `kid` + `jku` on first registration
|
|
269
270
|
(Trust-On-First-Use).
|
|
270
271
|
|
|
272
|
+
### Calling out, and knowing your own origin
|
|
273
|
+
|
|
274
|
+
The same key proves this agent to services that are not the gateway — an inference
|
|
275
|
+
proxy, another agent. `signCallerToken` mints the short-lived token for that: `iss` is
|
|
276
|
+
this deployment's origin, `jku` is derived from it, and the audience is normalized to a
|
|
277
|
+
bare origin because the far side compares it byte-for-byte.
|
|
278
|
+
|
|
279
|
+
Its `iss` is **not** something to configure. Inside a Durable Object it is:
|
|
280
|
+
|
|
281
|
+
```ts
|
|
282
|
+
protected override modelRuntime(model: ModelConfig): ModelRuntime {
|
|
283
|
+
return myProvider(this.env, model, () => this.requireSelfOrigin());
|
|
284
|
+
}
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
`requireSelfOrigin()` (and `selfOrigin()`, which returns `undefined` instead of
|
|
288
|
+
throwing) answer with the origin core already delivers: the executor computes the
|
|
289
|
+
callback `jku` from `new URL(request.url).origin`, and it rides every turn into the DO
|
|
290
|
+
and on into each subagent facet. A `SELF_ORIGIN` secret only restates that, and has to
|
|
291
|
+
be kept byte-identical with the verifier's allowlist by hand in every environment.
|
|
292
|
+
|
|
293
|
+
The first turn an instance serves **pins** it, and nothing is persisted. Pinning is
|
|
294
|
+
what makes it safe to read: turns run concurrently in one Durable Object and a
|
|
295
|
+
credential thunk fires several frames below the turn that set the value, so a mutable
|
|
296
|
+
field could hand one turn another's origin. An agent has one endpoint anyway — the one
|
|
297
|
+
its card advertises and a verifier allowlists — and a fresh isolate on deploy re-learns
|
|
298
|
+
it.
|
|
299
|
+
|
|
300
|
+
It is known **inside a turn or a chunk**: `onStart`, a constructor and a scheduled
|
|
301
|
+
callback all run before any request has said what this deployment is called, and
|
|
302
|
+
`requireSelfOrigin()` throws there saying so.
|
|
303
|
+
|
|
271
304
|
---
|
|
272
305
|
|
|
273
306
|
## Plugins
|
|
@@ -5,6 +5,12 @@ export interface CallerTokenOptions {
|
|
|
5
5
|
* This agent's own origin. It becomes `iss`, and `jku` is derived from it, and
|
|
6
6
|
* the two must agree: a verifier that accepts a `jku` on a different origin
|
|
7
7
|
* than `iss` lets one allowlisted origin impersonate another.
|
|
8
|
+
*
|
|
9
|
+
* **Not something to configure.** Inside a Durable Object it is
|
|
10
|
+
* `requireSelfOrigin()` — see {@link file://./self-origin.ts SelfOrigin},
|
|
11
|
+
* which learns it from the `jku` every turn already carries. A `SELF_ORIGIN`
|
|
12
|
+
* secret restates what the request path knows and has to be kept
|
|
13
|
+
* byte-identical with the verifier's allowlist by hand.
|
|
8
14
|
*/
|
|
9
15
|
issuer: string;
|
|
10
16
|
/**
|
package/dist/a2a/index.d.ts
CHANGED
|
@@ -24,6 +24,7 @@ export { A2A_RPC_PATH, buildBaseCard, signCard, wireCard, parsePrivateJwk, publi
|
|
|
24
24
|
export { NOTIFICATION_TOKEN_HEADER, buildSubmittedTask, buildWorkingTask, buildCompletedTask, buildFailedTask, buildNoReplyCompletedTask, signCallbackJwt, postNotification } from "./notify.js";
|
|
25
25
|
export { deliverTerminalTask, type DeliverTerminalOptions } from "./deliver.js";
|
|
26
26
|
export { signCallerToken, type CallerTokenOptions } from "./caller-token.js";
|
|
27
|
+
export { SelfOrigin } from "./self-origin.js";
|
|
27
28
|
export { callerContext } from "./caller.js";
|
|
28
29
|
export { createPushChannel, type PushChannel, type TurnPushContext } from "./push.js";
|
|
29
30
|
export { taskStateLabel, type PlainArtifact, type PlainMessage, type PlainPart, type PlainStatus, type PlainTask } from "./task.js";
|
package/dist/a2a/index.js
CHANGED
|
@@ -24,6 +24,7 @@ export { A2A_RPC_PATH, buildBaseCard, signCard, wireCard, parsePrivateJwk, publi
|
|
|
24
24
|
export { NOTIFICATION_TOKEN_HEADER, buildSubmittedTask, buildWorkingTask, buildCompletedTask, buildFailedTask, buildNoReplyCompletedTask, signCallbackJwt, postNotification } from "./notify.js";
|
|
25
25
|
export { deliverTerminalTask } from "./deliver.js";
|
|
26
26
|
export { signCallerToken } from "./caller-token.js";
|
|
27
|
+
export { SelfOrigin } from "./self-origin.js";
|
|
27
28
|
export { callerContext } from "./caller.js";
|
|
28
29
|
export { createPushChannel } from "./push.js";
|
|
29
30
|
export { taskStateLabel } from "./task.js";
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This deployment's own public origin — learned from the request path, never
|
|
3
|
+
* configured.
|
|
4
|
+
*
|
|
5
|
+
* ## Why an agent needs it at all
|
|
6
|
+
*
|
|
7
|
+
* A Worker that only *answers* never needs to know its own name.
|
|
8
|
+
* {@link file://../worker/index.ts createA2AWorker} derives its audience, its
|
|
9
|
+
* card and its `jku` from `new URL(request.url).origin`, and none of it outlives
|
|
10
|
+
* the request. An agent that **calls out** mint-signed does need it:
|
|
11
|
+
* {@link file://./caller-token.ts signCallerToken} puts it in `iss` and derives
|
|
12
|
+
* the token's `jku` from it. That call happens inside a Durable Object, where
|
|
13
|
+
* there is no `Request` — which is the whole difficulty.
|
|
14
|
+
*
|
|
15
|
+
* The obvious answer is a `SELF_ORIGIN` secret, and it is the wrong one. It
|
|
16
|
+
* restates a value the request already carries, and it has to be kept
|
|
17
|
+
* byte-identical by hand with the origin allowlist on the far side, in every
|
|
18
|
+
* environment, forever. Both siblings that tried it took it back out:
|
|
19
|
+
* `looping-anthropic-proxy` deleted `PROXY_AUDIENCE` in favour of `url.origin`,
|
|
20
|
+
* and `looping-gateway` discovers its own origin from the first
|
|
21
|
+
* signature-verified request rather than being told.
|
|
22
|
+
*
|
|
23
|
+
* ## Where the value comes from
|
|
24
|
+
*
|
|
25
|
+
* Core already sends the origin into the Durable Object on every turn, one field
|
|
26
|
+
* short of this use. `A2AExecutor` computes `jku` as `${origin}${jwksPath}` and
|
|
27
|
+
* it rides {@link file://./push.ts TurnPushContext} through the Workflow into
|
|
28
|
+
* `runTaskTurn` and `executeSubtaskChunk`.
|
|
29
|
+
*
|
|
30
|
+
* That is the same origin `signCallerToken` needs, and not by coincidence: a
|
|
31
|
+
* caller token's `jku` **must** be the JWKS the verifier fetches, and `iss` must
|
|
32
|
+
* agree with it — the third check in {@link file://./verify.ts verify.ts}. An
|
|
33
|
+
* origin derived from anywhere else is exactly what that check exists to catch,
|
|
34
|
+
* so deriving it from the `jku` core already serves makes the agreement
|
|
35
|
+
* structural instead of clerical.
|
|
36
|
+
*
|
|
37
|
+
* ## Pinned on the first turn, and in memory
|
|
38
|
+
*
|
|
39
|
+
* The first origin an isolate is told wins, and later ones are ignored. That is
|
|
40
|
+
* not laziness about staleness — it is what makes the value safe to *read*.
|
|
41
|
+
*
|
|
42
|
+
* A Durable Object's input gate stays open across a non-storage await, and this
|
|
43
|
+
* package runs concurrent RPCs into one object by design (`round/workflow.ts`
|
|
44
|
+
* runs a round's branches under `Promise.all`). Mutable instance state can
|
|
45
|
+
* therefore change while a turn is awaiting a model call, and the credential
|
|
46
|
+
* thunks that read this are lazy — they run several frames below the turn, when
|
|
47
|
+
* the client is built. Pinned, the field is immutable after its first write, so
|
|
48
|
+
* every concurrent reader in the isolate gets the same string and no turn can
|
|
49
|
+
* sign as another turn's origin.
|
|
50
|
+
*
|
|
51
|
+
* The cost of pinning is what an agent does not have: several identities. An
|
|
52
|
+
* agent has one endpoint — the one its card advertises, the one a gateway calls
|
|
53
|
+
* and a verifier allowlists — so there is nothing to follow. Note the asymmetry
|
|
54
|
+
* with `looping-anthropic-proxy`, which derives its audience per request and
|
|
55
|
+
* refuses to cache: a *verifier* must accept every hostname it answers on, while
|
|
56
|
+
* a *signer* needs one stable identity.
|
|
57
|
+
*
|
|
58
|
+
* Nothing is persisted, which is what keeps a pin from outliving its truth. An
|
|
59
|
+
* isolate is fresh on every `wrangler deploy` and recycles on its own, so a moved
|
|
60
|
+
* deployment re-learns its origin from the next turn it serves.
|
|
61
|
+
*/
|
|
62
|
+
export declare class SelfOrigin {
|
|
63
|
+
private observed?;
|
|
64
|
+
/**
|
|
65
|
+
* Offer the origin of an absolute URL seen on the request path — a `jku`, an
|
|
66
|
+
* endpoint, or a bare origin. Only `.origin` is kept, so a path or a trailing
|
|
67
|
+
* slash cannot reach a token claim.
|
|
68
|
+
*
|
|
69
|
+
* **The first usable value wins**; every later call is a no-op, including one
|
|
70
|
+
* naming a different origin. Called at each RPC entry and again when the push
|
|
71
|
+
* channel is built, so most calls are already no-ops — but the reason for the
|
|
72
|
+
* pin is the read side, not the write side. See the note above the class.
|
|
73
|
+
*
|
|
74
|
+
* Silently ignores anything unusable (absent, relative, or a scheme that has
|
|
75
|
+
* no meaningful origin), and an unusable value never pins: the parse comes
|
|
76
|
+
* first and the field is assigned only on success. This runs at the top of a
|
|
77
|
+
* turn, where a diagnostic value must never be the thing that fails it; the
|
|
78
|
+
* throw belongs at {@link require}, where something actually wanted the value.
|
|
79
|
+
*/
|
|
80
|
+
note(url: string | undefined): void;
|
|
81
|
+
/** The pinned origin, or `undefined` when nothing has carried one yet. */
|
|
82
|
+
peek(): string | undefined;
|
|
83
|
+
/**
|
|
84
|
+
* The pinned origin, for a caller that cannot proceed without it.
|
|
85
|
+
*
|
|
86
|
+
* Throws naming the timing, because that is what the mistake always is: the
|
|
87
|
+
* value arrives with a turn, so `onStart`, a constructor and a scheduled
|
|
88
|
+
* callback all run before any request has said what this deployment is called.
|
|
89
|
+
*/
|
|
90
|
+
require(): string;
|
|
91
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This deployment's own public origin — learned from the request path, never
|
|
3
|
+
* configured.
|
|
4
|
+
*
|
|
5
|
+
* ## Why an agent needs it at all
|
|
6
|
+
*
|
|
7
|
+
* A Worker that only *answers* never needs to know its own name.
|
|
8
|
+
* {@link file://../worker/index.ts createA2AWorker} derives its audience, its
|
|
9
|
+
* card and its `jku` from `new URL(request.url).origin`, and none of it outlives
|
|
10
|
+
* the request. An agent that **calls out** mint-signed does need it:
|
|
11
|
+
* {@link file://./caller-token.ts signCallerToken} puts it in `iss` and derives
|
|
12
|
+
* the token's `jku` from it. That call happens inside a Durable Object, where
|
|
13
|
+
* there is no `Request` — which is the whole difficulty.
|
|
14
|
+
*
|
|
15
|
+
* The obvious answer is a `SELF_ORIGIN` secret, and it is the wrong one. It
|
|
16
|
+
* restates a value the request already carries, and it has to be kept
|
|
17
|
+
* byte-identical by hand with the origin allowlist on the far side, in every
|
|
18
|
+
* environment, forever. Both siblings that tried it took it back out:
|
|
19
|
+
* `looping-anthropic-proxy` deleted `PROXY_AUDIENCE` in favour of `url.origin`,
|
|
20
|
+
* and `looping-gateway` discovers its own origin from the first
|
|
21
|
+
* signature-verified request rather than being told.
|
|
22
|
+
*
|
|
23
|
+
* ## Where the value comes from
|
|
24
|
+
*
|
|
25
|
+
* Core already sends the origin into the Durable Object on every turn, one field
|
|
26
|
+
* short of this use. `A2AExecutor` computes `jku` as `${origin}${jwksPath}` and
|
|
27
|
+
* it rides {@link file://./push.ts TurnPushContext} through the Workflow into
|
|
28
|
+
* `runTaskTurn` and `executeSubtaskChunk`.
|
|
29
|
+
*
|
|
30
|
+
* That is the same origin `signCallerToken` needs, and not by coincidence: a
|
|
31
|
+
* caller token's `jku` **must** be the JWKS the verifier fetches, and `iss` must
|
|
32
|
+
* agree with it — the third check in {@link file://./verify.ts verify.ts}. An
|
|
33
|
+
* origin derived from anywhere else is exactly what that check exists to catch,
|
|
34
|
+
* so deriving it from the `jku` core already serves makes the agreement
|
|
35
|
+
* structural instead of clerical.
|
|
36
|
+
*
|
|
37
|
+
* ## Pinned on the first turn, and in memory
|
|
38
|
+
*
|
|
39
|
+
* The first origin an isolate is told wins, and later ones are ignored. That is
|
|
40
|
+
* not laziness about staleness — it is what makes the value safe to *read*.
|
|
41
|
+
*
|
|
42
|
+
* A Durable Object's input gate stays open across a non-storage await, and this
|
|
43
|
+
* package runs concurrent RPCs into one object by design (`round/workflow.ts`
|
|
44
|
+
* runs a round's branches under `Promise.all`). Mutable instance state can
|
|
45
|
+
* therefore change while a turn is awaiting a model call, and the credential
|
|
46
|
+
* thunks that read this are lazy — they run several frames below the turn, when
|
|
47
|
+
* the client is built. Pinned, the field is immutable after its first write, so
|
|
48
|
+
* every concurrent reader in the isolate gets the same string and no turn can
|
|
49
|
+
* sign as another turn's origin.
|
|
50
|
+
*
|
|
51
|
+
* The cost of pinning is what an agent does not have: several identities. An
|
|
52
|
+
* agent has one endpoint — the one its card advertises, the one a gateway calls
|
|
53
|
+
* and a verifier allowlists — so there is nothing to follow. Note the asymmetry
|
|
54
|
+
* with `looping-anthropic-proxy`, which derives its audience per request and
|
|
55
|
+
* refuses to cache: a *verifier* must accept every hostname it answers on, while
|
|
56
|
+
* a *signer* needs one stable identity.
|
|
57
|
+
*
|
|
58
|
+
* Nothing is persisted, which is what keeps a pin from outliving its truth. An
|
|
59
|
+
* isolate is fresh on every `wrangler deploy` and recycles on its own, so a moved
|
|
60
|
+
* deployment re-learns its origin from the next turn it serves.
|
|
61
|
+
*/
|
|
62
|
+
export class SelfOrigin {
|
|
63
|
+
observed;
|
|
64
|
+
/**
|
|
65
|
+
* Offer the origin of an absolute URL seen on the request path — a `jku`, an
|
|
66
|
+
* endpoint, or a bare origin. Only `.origin` is kept, so a path or a trailing
|
|
67
|
+
* slash cannot reach a token claim.
|
|
68
|
+
*
|
|
69
|
+
* **The first usable value wins**; every later call is a no-op, including one
|
|
70
|
+
* naming a different origin. Called at each RPC entry and again when the push
|
|
71
|
+
* channel is built, so most calls are already no-ops — but the reason for the
|
|
72
|
+
* pin is the read side, not the write side. See the note above the class.
|
|
73
|
+
*
|
|
74
|
+
* Silently ignores anything unusable (absent, relative, or a scheme that has
|
|
75
|
+
* no meaningful origin), and an unusable value never pins: the parse comes
|
|
76
|
+
* first and the field is assigned only on success. This runs at the top of a
|
|
77
|
+
* turn, where a diagnostic value must never be the thing that fails it; the
|
|
78
|
+
* throw belongs at {@link require}, where something actually wanted the value.
|
|
79
|
+
*/
|
|
80
|
+
note(url) {
|
|
81
|
+
if (this.observed || !url)
|
|
82
|
+
return;
|
|
83
|
+
let parsed;
|
|
84
|
+
try {
|
|
85
|
+
parsed = new URL(url);
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
if (parsed.protocol !== "https:" && parsed.protocol !== "http:")
|
|
91
|
+
return;
|
|
92
|
+
this.observed = parsed.origin;
|
|
93
|
+
}
|
|
94
|
+
/** The pinned origin, or `undefined` when nothing has carried one yet. */
|
|
95
|
+
peek() {
|
|
96
|
+
return this.observed;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* The pinned origin, for a caller that cannot proceed without it.
|
|
100
|
+
*
|
|
101
|
+
* Throws naming the timing, because that is what the mistake always is: the
|
|
102
|
+
* value arrives with a turn, so `onStart`, a constructor and a scheduled
|
|
103
|
+
* callback all run before any request has said what this deployment is called.
|
|
104
|
+
*/
|
|
105
|
+
require() {
|
|
106
|
+
if (!this.observed) {
|
|
107
|
+
throw new Error("this deployment's own origin is not known on this instance yet: it is " +
|
|
108
|
+
"learned from the `jku` that arrives with every turn, so it is " +
|
|
109
|
+
"available inside a turn or a subtask chunk — not from onStart, a " +
|
|
110
|
+
"constructor or a scheduled callback");
|
|
111
|
+
}
|
|
112
|
+
return this.observed;
|
|
113
|
+
}
|
|
114
|
+
}
|
package/dist/host/agent.d.ts
CHANGED
|
@@ -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
|
/**
|
package/dist/host/agent.js
CHANGED
|
@@ -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/round/agent.js
CHANGED
|
@@ -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. */
|
package/dist/subagent/index.d.ts
CHANGED
|
@@ -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`
|
|
120
|
-
* separate
|
|
121
|
-
* identically and the cache/resume keys line up. Only
|
|
122
|
-
* throw (nothing cached), so a Workflow retry
|
|
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`
|
package/dist/subagent/index.js
CHANGED
|
@@ -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`
|
|
116
|
-
* separate
|
|
117
|
-
* identically and the cache/resume keys line up. Only
|
|
118
|
-
* throw (nothing cached), so a Workflow retry
|
|
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).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@loopingai/core",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.1",
|
|
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",
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* registration (Trust-On-First-Use). No secret is shared in either direction —
|
|
8
8
|
* see `src/a2a/verify.ts` for the other half of the contract.
|
|
9
9
|
*
|
|
10
|
-
* node scripts/generate-keys.mjs # print
|
|
10
|
+
* node scripts/generate-keys.mjs # print the private JWK
|
|
11
11
|
* node scripts/generate-keys.mjs --kid my-key # choose the key id
|
|
12
12
|
*
|
|
13
13
|
* The `kid` is required, not decorative: it goes in the JWS protected header and
|
|
@@ -24,30 +24,25 @@ const kid =
|
|
|
24
24
|
? args[kidFlag + 1]
|
|
25
25
|
: `a2a-${new Date().toISOString().slice(0, 10)}`;
|
|
26
26
|
|
|
27
|
-
// `extractable` is required to export the private half at all.
|
|
28
|
-
|
|
27
|
+
// `extractable` is required to export the private half at all. Only the
|
|
28
|
+
// private half is printed: the public one is derived from it at runtime and
|
|
29
|
+
// served at the JWKS route, so there is nothing to copy anywhere.
|
|
30
|
+
const { privateKey } = await generateKeyPair("EdDSA", {
|
|
29
31
|
crv: "Ed25519",
|
|
30
32
|
extractable: true
|
|
31
33
|
});
|
|
32
34
|
|
|
33
35
|
const priv = { ...(await exportJWK(privateKey)), kid };
|
|
34
|
-
const pub = { ...(await exportJWK(publicKey)), kid, use: "sig", alg: "EdDSA" };
|
|
35
36
|
|
|
36
|
-
console.log(
|
|
37
|
-
console.log(JSON.stringify(priv));
|
|
37
|
+
console.log(`\nGenerated Ed25519 keypair (kid: ${kid})\n`);
|
|
38
38
|
console.log(
|
|
39
|
-
"
|
|
39
|
+
"Add this line to .env — it is the private half, never commit it:\n"
|
|
40
|
+
);
|
|
41
|
+
console.log(`A2A_SIGNING_KEY=${JSON.stringify(priv)}\n`);
|
|
42
|
+
console.log(
|
|
43
|
+
"To deploy it, push the whole file or just this one secret:\n\n" +
|
|
44
|
+
" npx wrangler deploy --secrets-file .env\n" +
|
|
45
|
+
" npx wrangler secret put A2A_SIGNING_KEY\n\n" +
|
|
46
|
+
"The public half is not configured anywhere — the Worker derives it from\n" +
|
|
47
|
+
"the private key and serves it at the JWKS route.\n"
|
|
40
48
|
);
|
|
41
|
-
console.log(JSON.stringify({ keys: [pub] }, null, 2));
|
|
42
|
-
console.log(`
|
|
43
|
-
Next:
|
|
44
|
-
|
|
45
|
-
# local
|
|
46
|
-
echo 'A2A_SIGNING_KEY=<the private JSON above>' >> .dev.vars
|
|
47
|
-
|
|
48
|
-
# deployed
|
|
49
|
-
npx wrangler secret put A2A_SIGNING_KEY
|
|
50
|
-
|
|
51
|
-
The public half is not configured anywhere — the Worker derives it from the
|
|
52
|
-
private key and serves it at the JWKS route.
|
|
53
|
-
`);
|