@dynamicagents/core 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +522 -0
- package/dist/a2a/agent-stub.d.ts +60 -0
- package/dist/a2a/agent-stub.js +1 -0
- package/dist/a2a/caller-token.d.ts +44 -0
- package/dist/a2a/caller-token.js +61 -0
- package/dist/a2a/caller.d.ts +23 -0
- package/dist/a2a/caller.js +33 -0
- package/dist/a2a/card.d.ts +158 -0
- package/dist/a2a/card.js +163 -0
- package/dist/a2a/context.d.ts +19 -0
- package/dist/a2a/context.js +61 -0
- package/dist/a2a/deliver.d.ts +159 -0
- package/dist/a2a/deliver.js +185 -0
- package/dist/a2a/executor.d.ts +84 -0
- package/dist/a2a/executor.js +106 -0
- package/dist/a2a/index.d.ts +35 -0
- package/dist/a2a/index.js +34 -0
- package/dist/a2a/notify.d.ts +106 -0
- package/dist/a2a/notify.js +180 -0
- package/dist/a2a/parts.d.ts +45 -0
- package/dist/a2a/parts.js +94 -0
- package/dist/a2a/push.d.ts +70 -0
- package/dist/a2a/push.js +53 -0
- package/dist/a2a/self-origin.d.ts +91 -0
- package/dist/a2a/self-origin.js +114 -0
- package/dist/a2a/task-store.d.ts +44 -0
- package/dist/a2a/task-store.js +99 -0
- package/dist/a2a/task.d.ts +85 -0
- package/dist/a2a/task.js +15 -0
- package/dist/a2a/verify.d.ts +80 -0
- package/dist/a2a/verify.js +143 -0
- package/dist/agent/budget.d.ts +46 -0
- package/dist/agent/budget.js +42 -0
- package/dist/agent/control.d.ts +109 -0
- package/dist/agent/control.js +115 -0
- package/dist/agent/errors.d.ts +85 -0
- package/dist/agent/errors.js +64 -0
- package/dist/agent/final-reply.d.ts +49 -0
- package/dist/agent/final-reply.js +68 -0
- package/dist/agent/history.d.ts +97 -0
- package/dist/agent/history.js +133 -0
- package/dist/agent/index.d.ts +29 -0
- package/dist/agent/index.js +29 -0
- package/dist/agent/inference.d.ts +110 -0
- package/dist/agent/inference.js +120 -0
- package/dist/agent/model.d.ts +90 -0
- package/dist/agent/model.js +1 -0
- package/dist/agent/session.d.ts +100 -0
- package/dist/agent/session.js +82 -0
- package/dist/agent/workers-ai/index.d.ts +23 -0
- package/dist/agent/workers-ai/index.js +23 -0
- package/dist/agent/workers-ai/runtime.d.ts +42 -0
- package/dist/agent/workers-ai/runtime.js +63 -0
- package/dist/alarm/index.d.ts +77 -0
- package/dist/alarm/index.js +116 -0
- package/dist/config.d.ts +202 -0
- package/dist/config.js +135 -0
- package/dist/contract/index.d.ts +9 -0
- package/dist/contract/index.js +8 -0
- package/dist/contract/plugin.d.ts +324 -0
- package/dist/contract/plugin.js +114 -0
- package/dist/contract/recipe.d.ts +180 -0
- package/dist/contract/recipe.js +1 -0
- package/dist/contract/validation.d.ts +91 -0
- package/dist/contract/validation.js +84 -0
- package/dist/db/db.d.ts +147 -0
- package/dist/db/db.js +90 -0
- package/dist/db/index.d.ts +8 -0
- package/dist/db/index.js +8 -0
- package/dist/db/migrations/index.d.ts +20 -0
- package/dist/db/migrations/index.js +60 -0
- package/dist/db/models/subtasks.d.ts +100 -0
- package/dist/db/models/subtasks.js +241 -0
- package/dist/db/models/tasks.d.ts +118 -0
- package/dist/db/models/tasks.js +274 -0
- package/dist/db/schema.d.ts +468 -0
- package/dist/db/schema.js +88 -0
- package/dist/env.d.ts +53 -0
- package/dist/env.js +47 -0
- package/dist/host/agent.d.ts +305 -0
- package/dist/host/agent.js +400 -0
- package/dist/host/index.d.ts +20 -0
- package/dist/host/index.js +19 -0
- package/dist/host/plugin-host.d.ts +42 -0
- package/dist/host/plugin-host.js +1 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.js +17 -0
- package/dist/job/index.d.ts +21 -0
- package/dist/job/index.js +21 -0
- package/dist/job/lifecycle.d.ts +176 -0
- package/dist/job/lifecycle.js +230 -0
- package/dist/job/state.d.ts +92 -0
- package/dist/job/state.js +40 -0
- package/dist/platform.d.ts +138 -0
- package/dist/platform.js +140 -0
- package/dist/round/agent.d.ts +271 -0
- package/dist/round/agent.js +678 -0
- package/dist/round/index.d.ts +25 -0
- package/dist/round/index.js +23 -0
- package/dist/round/policy.d.ts +98 -0
- package/dist/round/policy.js +1 -0
- package/dist/round/subagent.d.ts +87 -0
- package/dist/round/subagent.js +108 -0
- package/dist/round/turn.d.ts +249 -0
- package/dist/round/turn.js +564 -0
- package/dist/round/workflow.d.ts +147 -0
- package/dist/round/workflow.js +387 -0
- package/dist/runtime/index.d.ts +127 -0
- package/dist/runtime/index.js +186 -0
- package/dist/runtime/tool-families.d.ts +32 -0
- package/dist/runtime/tool-families.js +61 -0
- package/dist/subagent/fingerprint.d.ts +37 -0
- package/dist/subagent/fingerprint.js +92 -0
- package/dist/subagent/index.d.ts +169 -0
- package/dist/subagent/index.js +330 -0
- package/dist/subagent/prompt.d.ts +32 -0
- package/dist/subagent/prompt.js +37 -0
- package/dist/subagent/run.d.ts +157 -0
- package/dist/subagent/run.js +540 -0
- package/dist/subagent/workspace.d.ts +85 -0
- package/dist/subagent/workspace.js +127 -0
- package/dist/subtasks/catalog.d.ts +40 -0
- package/dist/subtasks/catalog.js +36 -0
- package/dist/subtasks/decomposition.d.ts +85 -0
- package/dist/subtasks/decomposition.js +156 -0
- package/dist/subtasks/delegate.d.ts +120 -0
- package/dist/subtasks/delegate.js +131 -0
- package/dist/subtasks/index.d.ts +9 -0
- package/dist/subtasks/index.js +9 -0
- package/dist/subtasks/subtask-types.d.ts +91 -0
- package/dist/subtasks/subtask-types.js +103 -0
- package/dist/subtasks/types.d.ts +295 -0
- package/dist/subtasks/types.js +15 -0
- package/dist/testing/auth.d.ts +34 -0
- package/dist/testing/auth.js +35 -0
- package/dist/testing/do.d.ts +29 -0
- package/dist/testing/do.js +25 -0
- package/dist/testing/fake-session.d.ts +26 -0
- package/dist/testing/fake-session.js +37 -0
- package/dist/testing/fixtures.d.ts +64 -0
- package/dist/testing/fixtures.js +104 -0
- package/dist/testing/harness.d.ts +97 -0
- package/dist/testing/harness.js +138 -0
- package/dist/testing/index.d.ts +31 -0
- package/dist/testing/index.js +35 -0
- package/dist/testing/mock-model.d.ts +77 -0
- package/dist/testing/mock-model.js +136 -0
- package/dist/testing/node.d.ts +56 -0
- package/dist/testing/node.js +56 -0
- package/dist/testing/vcr-global-setup.d.ts +12 -0
- package/dist/testing/vcr-global-setup.js +15 -0
- package/dist/testing/vcr-shared.d.ts +38 -0
- package/dist/testing/vcr-shared.js +33 -0
- package/dist/testing/vcr-spec.d.ts +25 -0
- package/dist/testing/vcr-spec.js +124 -0
- package/dist/testing/vcr-store.d.ts +86 -0
- package/dist/testing/vcr-store.js +191 -0
- package/dist/testing/vcr.d.ts +117 -0
- package/dist/testing/vcr.js +275 -0
- package/dist/worker/define-agent.d.ts +123 -0
- package/dist/worker/define-agent.js +20 -0
- package/dist/worker/index.d.ts +218 -0
- package/dist/worker/index.js +369 -0
- package/eslint-rules/index.js +31 -0
- package/eslint-rules/no-deprecated-object-properties.js +81 -0
- package/package.json +178 -0
- package/scripts/generate-keys.mjs +48 -0
package/README.md
ADDED
|
@@ -0,0 +1,522 @@
|
|
|
1
|
+
# @dynamicagents/core
|
|
2
|
+
|
|
3
|
+
**The mandatory foundation for a Dynamic Agents agent on Cloudflare Workers.**
|
|
4
|
+
|
|
5
|
+
Zero-trust A2A (signed AgentCard, gatekeeper-JWT verification, no shared secrets), the
|
|
6
|
+
durable task lifecycle, the delegation and subagent runtime, and the test harness.
|
|
7
|
+
|
|
8
|
+
You bring the loop and the prompts. Core brings everything you cannot choose not to have.
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
npm install @dynamicagents/core
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
> Part of a three-package split:
|
|
15
|
+
> **`@dynamicagents/core`** (this) ·
|
|
16
|
+
> [`plugins`](https://github.com/dynamicagents/plugins) (optional, composable capabilities) ·
|
|
17
|
+
> [`starter`](https://github.com/dynamicagents/starter) (a working agent that composes them).
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## Why this exists
|
|
22
|
+
|
|
23
|
+
An agent that talks to other agents has to answer one question before anything else:
|
|
24
|
+
_is the caller who they claim to be, and can they prove it without a shared secret?_
|
|
25
|
+
That answer — and the durable machinery for accepting a turn, decomposing it, and
|
|
26
|
+
delivering a result out of band — is identical for every agent. It is also the part
|
|
27
|
+
that is easy to get subtly and silently wrong.
|
|
28
|
+
|
|
29
|
+
So it ships once, here, with the security-critical paths pinned by tests. Anything
|
|
30
|
+
optional is a plugin. Anything opinionated belongs to your app.
|
|
31
|
+
|
|
32
|
+
---
|
|
33
|
+
|
|
34
|
+
## Quick start
|
|
35
|
+
|
|
36
|
+
### 1. Generate a signing key
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
npx da-keys
|
|
40
|
+
```
|
|
41
|
+
|
|
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 `GATEKEEPER_ORIGINS`:
|
|
45
|
+
|
|
46
|
+
```ini
|
|
47
|
+
# .env
|
|
48
|
+
A2A_SIGNING_KEY={"crv":"Ed25519","d":"…","x":"…","kty":"OKP","kid":"a2a-2026-08-01"}
|
|
49
|
+
GATEKEEPER_ORIGINS=["https://gatekeeper.example.com"]
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
The public half is never configured anywhere — the Worker derives it from the private
|
|
53
|
+
key and serves it at the card's `jku`.
|
|
54
|
+
|
|
55
|
+
### 2. Put the A2A edge in front of your Durable Object
|
|
56
|
+
|
|
57
|
+
```ts
|
|
58
|
+
import { createA2AWorker } from "@dynamicagents/core/worker";
|
|
59
|
+
|
|
60
|
+
const manifest = {
|
|
61
|
+
name: "my-agent",
|
|
62
|
+
description: "Does a useful thing.",
|
|
63
|
+
version: "1.0.0",
|
|
64
|
+
capabilities: { streaming: false, pushNotifications: true, extensions: [] },
|
|
65
|
+
defaultInputModes: ["text/plain"],
|
|
66
|
+
defaultOutputModes: ["text/plain"],
|
|
67
|
+
skills: []
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
export default {
|
|
71
|
+
fetch: createA2AWorker({
|
|
72
|
+
// The stub card at /.well-known/agent-card.json. It describes the origin,
|
|
73
|
+
// not an agent — see below.
|
|
74
|
+
manifest: hostManifest,
|
|
75
|
+
tenants: {
|
|
76
|
+
"my-agent": {
|
|
77
|
+
manifest,
|
|
78
|
+
// One DO instance per verified caller — this is what makes a task
|
|
79
|
+
// unreachable from any other caller by construction.
|
|
80
|
+
resolveAgent: (identity) =>
|
|
81
|
+
env.MY_AGENT.get(env.MY_AGENT.idFromName(identity.key!)),
|
|
82
|
+
// Must be idempotent: the gatekeeper retries dispatch.
|
|
83
|
+
startTurn: async (turn) => {
|
|
84
|
+
await env.TURN_WORKFLOW.create({ id: turn.messageId, params: turn });
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
})
|
|
89
|
+
} satisfies ExportedHandler<Env>;
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
That handler serves three routes: the public JWKS, a **signed** stub AgentCard at
|
|
93
|
+
`/.well-known/agent-card.json`, and gatekeeper-authenticated JSON-RPC. Every POST is verified
|
|
94
|
+
before a Durable Object is ever addressed.
|
|
95
|
+
|
|
96
|
+
#### Agents are tenants
|
|
97
|
+
|
|
98
|
+
Agents are keyed by **tenant id**, and one is required on every request — there is no
|
|
99
|
+
default agent and no implicit routing. This is the A2A mechanism for exactly this case:
|
|
100
|
+
`AgentInterface.tenant` is _"an opaque string used for routing requests to a specific agent
|
|
101
|
+
or tenant when multiple agents are served behind a single A2A endpoint"_, and §8.3.2
|
|
102
|
+
requires a client to send the value the interface it selected declared.
|
|
103
|
+
|
|
104
|
+
So one origin serves any number of agents over **one endpoint, one signing key and one
|
|
105
|
+
card** at the well-known path. It works the same for one agent as for twenty; nothing about
|
|
106
|
+
the shape changes.
|
|
107
|
+
|
|
108
|
+
The card is the reason it has to be this way rather than a path prefix per agent. Its
|
|
109
|
+
location is a **well-known URI**, which RFC 8615 defines per-authority, so exactly one card
|
|
110
|
+
per origin is discoverable at the path A2A registered with IANA. A gatekeeper resolving
|
|
111
|
+
`/.well-known/agent-card.json` against the origin finds that one card whatever prefix an
|
|
112
|
+
agent is mounted behind — and pins its key for all of them.
|
|
113
|
+
|
|
114
|
+
Which is why the card served there is a **stub**: it describes the deployment, advertises
|
|
115
|
+
the endpoint and `extendedAgentCard`, and names no tenant. A tenant's real card — its name,
|
|
116
|
+
skills and signature — comes from `GetExtendedAgentCard`, the spec's own tenant-aware card
|
|
117
|
+
method:
|
|
118
|
+
|
|
119
|
+
```jsonc
|
|
120
|
+
// POST /a2a
|
|
121
|
+
{
|
|
122
|
+
"jsonrpc": "2.0",
|
|
123
|
+
"id": 1,
|
|
124
|
+
"method": "GetExtendedAgentCard",
|
|
125
|
+
"params": { "tenant": "my-agent" }
|
|
126
|
+
}
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
A card carries one interface entry and clients take the first, so the stub cannot list its
|
|
130
|
+
siblings — put their names in `description` for a human, and register them out of band.
|
|
131
|
+
|
|
132
|
+
Two independent checks keep one tenant's traffic out of another's:
|
|
133
|
+
|
|
134
|
+
| check | proves |
|
|
135
|
+
| ------------ | -------------------------------------------- |
|
|
136
|
+
| `aud` | the token was minted for **this deployment** |
|
|
137
|
+
| tenant claim | …and for **this agent on it** |
|
|
138
|
+
|
|
139
|
+
The second is load-bearing. Every tenant shares one endpoint and therefore one audience, so
|
|
140
|
+
the audience cannot distinguish them: without the claim, `tenant` would be an
|
|
141
|
+
unauthenticated field in the request body and a token minted for one agent could be replayed
|
|
142
|
+
against any sibling. A token carrying no tenant claim is rejected rather than treated as a
|
|
143
|
+
wildcard.
|
|
144
|
+
|
|
145
|
+
> **Breaking.** Requires a gatekeeper that mints both the endpoint audience and the tenant
|
|
146
|
+
> claim, and registers agents with a tenant id — slack-gatekeeper
|
|
147
|
+
> [#62](https://github.com/dynamicagents/slack-gatekeeper/pull/62). The two sides do not
|
|
148
|
+
> interoperate across this change in either direction, so they deploy together and
|
|
149
|
+
> registered agents are re-registered.
|
|
150
|
+
|
|
151
|
+
### 3. Write your Durable Object
|
|
152
|
+
|
|
153
|
+
`DynamicAgent` is the DO body every agent has: the runtime and database built once
|
|
154
|
+
per instance, one continuous Session per verified caller, the gatekeeper callback
|
|
155
|
+
channel, and the task lifecycle a Workflow drives. Three seams are yours.
|
|
156
|
+
|
|
157
|
+
```ts
|
|
158
|
+
import { DynamicAgent, type PluginHost } from "@dynamicagents/core/host";
|
|
159
|
+
|
|
160
|
+
export class MyAgent extends DynamicAgent<Env> {
|
|
161
|
+
protected agentConfig() {
|
|
162
|
+
return {
|
|
163
|
+
model: {
|
|
164
|
+
chatModelId: "@cf/zai-org/glm-5.2",
|
|
165
|
+
fallbackChatModelId: "@cf/meta/llama-4-scout-17b-16e-instruct"
|
|
166
|
+
}
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
protected agentPlugins(host: PluginHost<Env>) {
|
|
170
|
+
return [scraper({ apiKey: host.env.SCRAPER_API_KEY })];
|
|
171
|
+
}
|
|
172
|
+
protected agentSoul(capabilities: string) {
|
|
173
|
+
return soulPrompt(capabilities);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
Everything that would otherwise be a module-level constant is resolved from those,
|
|
179
|
+
once per instance. Resolving a registry at _import_ time is the one thing this
|
|
180
|
+
package exists to prevent: it freezes the registry before `env` exists (which on
|
|
181
|
+
Workers is always), defeats tree-shaking, and makes runtime plugin selection
|
|
182
|
+
impossible.
|
|
183
|
+
|
|
184
|
+
`createAgentRuntime` and `AgentDB` are still exported and still work on a bare
|
|
185
|
+
`Agent<Env>` — but everything the base class does is lifecycle with an ordering
|
|
186
|
+
that is load-bearing and invisible (migrations awaited before the first RPC, the
|
|
187
|
+
guarded terminal write, the cancellation verdict that must be read and not
|
|
188
|
+
probed for), and hand-rolling it is how two agents in one repo drift apart.
|
|
189
|
+
|
|
190
|
+
### 4. Delegate, if your agent delegates
|
|
191
|
+
|
|
192
|
+
`@dynamicagents/core/round` adds the other half: durable Subtasks, concurrent
|
|
193
|
+
execution, isolated subagents, and the round loop over them.
|
|
194
|
+
|
|
195
|
+
```ts
|
|
196
|
+
import { RoundAgentBase, type RoundPolicy } from "@dynamicagents/core/round";
|
|
197
|
+
|
|
198
|
+
export class MyAgent extends RoundAgentBase<Env> {
|
|
199
|
+
// …the three seams above, plus:
|
|
200
|
+
protected roundPolicy(): RoundPolicy {
|
|
201
|
+
return policy;
|
|
202
|
+
}
|
|
203
|
+
protected subagentClass() {
|
|
204
|
+
return MySubagent;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
#### The round policy
|
|
210
|
+
|
|
211
|
+
Core ships the machine and none of the words. `RoundPolicy` is every string the
|
|
212
|
+
loop emits — the round contract the model is held to, the note appended when the
|
|
213
|
+
budget is spent, and the three user-facing messages. Nothing has a default: a
|
|
214
|
+
lent-out round contract is exactly the house prompt copy this package refuses to
|
|
215
|
+
have.
|
|
216
|
+
|
|
217
|
+
---
|
|
218
|
+
|
|
219
|
+
## Exports
|
|
220
|
+
|
|
221
|
+
No root barrel. Each area is its own subpath, so importing the delegation layer does
|
|
222
|
+
not drag in the A2A adapter, and the test harness cannot reach a production bundle.
|
|
223
|
+
|
|
224
|
+
| Subpath | What's in it |
|
|
225
|
+
| ---------------------------------- | ---------------------------------------------------------------------------- |
|
|
226
|
+
| `@dynamicagents/core` | `createAgentRuntime`, the plugin contract, config shapes, platform facts |
|
|
227
|
+
| `@dynamicagents/core/a2a` | card signing, JWKS, gatekeeper-JWT verify, push notify, task store, executor |
|
|
228
|
+
| `@dynamicagents/core/worker` | `createA2AWorker()` — the whole zero-trust edge |
|
|
229
|
+
| `@dynamicagents/core/agent` | session, history, models + Workers AI, inference, budget, control tools |
|
|
230
|
+
| `@dynamicagents/core/host` | `DynamicAgent` — the Durable Object body — and `PluginHost` |
|
|
231
|
+
| `@dynamicagents/core/round` | the delegating round loop: `RoundAgentBase`, `runHandleTask`, `runTurn` |
|
|
232
|
+
| `@dynamicagents/core/subtasks` | delegation types, decomposition, the `delegate` tool |
|
|
233
|
+
| `@dynamicagents/core/subagent` | `RecipeSubagentBase`, resumable runs, fingerprinting, workspace |
|
|
234
|
+
| `@dynamicagents/core/db` | `AgentDB`, `notify_tasks` + `subtasks` schema, migrations, `PluginStore` |
|
|
235
|
+
| `@dynamicagents/core/testing` | VCR, `FakeSession`, `mockModel`, DO helpers, JWK fixtures — _workerd realm_ |
|
|
236
|
+
| `@dynamicagents/core/testing/node` | the VCR recorder + cassette store — _Node realm, never import from a spec_ |
|
|
237
|
+
| `@dynamicagents/core/eslint` | the `no-deprecated-object-properties` rule |
|
|
238
|
+
|
|
239
|
+
`/testing*` and `/eslint` are structurally incapable of entering a runtime graph, and
|
|
240
|
+
`npm run verify:exports` asserts exactly that before every publish.
|
|
241
|
+
|
|
242
|
+
---
|
|
243
|
+
|
|
244
|
+
## The zero-trust model
|
|
245
|
+
|
|
246
|
+
No secret ever crosses the boundary, in either direction.
|
|
247
|
+
|
|
248
|
+
```
|
|
249
|
+
Gatekeeper ──── EdDSA JWT, jku → its public JWKS ────▶ Agent "the agent knows the gatekeeper"
|
|
250
|
+
Agent ──── signed AgentCard, jku → its JWKS ────▶ Gatekeeper "the gatekeeper knows the agent"
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
`verifyGatekeeperToken` runs four checks, in this order, on every single call:
|
|
254
|
+
|
|
255
|
+
1. **`jku` present** in the protected header (RFC 7515 §4.1.2).
|
|
256
|
+
2. **`jku` origin is allowlisted** — validated _before_ the fetch, so an attacker
|
|
257
|
+
cannot point `jku` at a JWKS they control.
|
|
258
|
+
3. **`iss` origin equals `jku` origin** — one listed gatekeeper cannot impersonate another.
|
|
259
|
+
4. **`jwtVerify` pinned to EdDSA.**
|
|
260
|
+
|
|
261
|
+
All four are load-bearing. Do not make any of them optional, and do not add a
|
|
262
|
+
local-development bypass — run a local gatekeeper instead. `verify.spec.ts` asserts each
|
|
263
|
+
one negatively, including that an unlisted `jku` is rejected _before_ any network
|
|
264
|
+
call happens.
|
|
265
|
+
|
|
266
|
+
The agent's card is signed over its **wire (protobuf-JSON) encoding**, which is what
|
|
267
|
+
makes the served document a fixed point under the repeated decoding a verifier
|
|
268
|
+
performs. A gatekeeper pins the card's `kid` + `jku` on first registration
|
|
269
|
+
(Trust-On-First-Use).
|
|
270
|
+
|
|
271
|
+
### Calling out, and knowing your own origin
|
|
272
|
+
|
|
273
|
+
The same key proves this agent to services that are not the gatekeeper — another agent,
|
|
274
|
+
or any service that verifies against the published JWKS. `signCallerToken` mints the
|
|
275
|
+
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
|
+
|
|
304
|
+
---
|
|
305
|
+
|
|
306
|
+
## Plugins
|
|
307
|
+
|
|
308
|
+
A capability is a plugin. Core never imports one — your app registers it, which keeps
|
|
309
|
+
bundle size proportional to what you actually installed.
|
|
310
|
+
|
|
311
|
+
```ts
|
|
312
|
+
import { definePlugin } from "@dynamicagents/core";
|
|
313
|
+
|
|
314
|
+
export const scraper = (config: { apiKey: string }) =>
|
|
315
|
+
definePlugin({
|
|
316
|
+
key: "scraper",
|
|
317
|
+
subtaskType: {
|
|
318
|
+
key: "scrape",
|
|
319
|
+
description: "fetch a page and summarize it",
|
|
320
|
+
params: z.object({ url: z.string().describe("page to fetch") }),
|
|
321
|
+
recipe
|
|
322
|
+
},
|
|
323
|
+
toolFamilies: { web: (ctx) => ({ tools: { fetchPage: /* … */ } }) },
|
|
324
|
+
capability: "You can scrape a page and summarize it.",
|
|
325
|
+
requires: { secrets: ["SCRAPER_API_KEY"] },
|
|
326
|
+
store: { plugin: "scraper", version: 1, ensureTables: (sql, from) => { /* … */ } }
|
|
327
|
+
});
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
`createAgentRuntime` fails at DO start — never mid-request — on a duplicate plugin
|
|
331
|
+
key, a duplicate tool family, a missing declared binding, or a
|
|
332
|
+
`PLUGIN_CONTRACT_VERSION` mismatch. Because core, plugins, and starter publish from
|
|
333
|
+
separate repos, one of them is always briefly behind; that version assert turns the
|
|
334
|
+
skew into a readable sentence instead of a structural-type error several frames from
|
|
335
|
+
its cause.
|
|
336
|
+
|
|
337
|
+
The contract is **additive-only within a major**: new capabilities arrive as optional
|
|
338
|
+
fields on `AgentPlugin`.
|
|
339
|
+
|
|
340
|
+
### Plugin-owned tables
|
|
341
|
+
|
|
342
|
+
A plugin owns its tables outright, through `store: PluginStore` — but it must stay out of
|
|
343
|
+
core's migration journal. `drizzle-orm/durable-sqlite/migrator` keeps one flat integer
|
|
344
|
+
journal and one global `__drizzle_migrations` table, and two independently-versioned
|
|
345
|
+
packages cannot share that index space.
|
|
346
|
+
|
|
347
|
+
That is a prohibition on exactly **one import**, not on drizzle. The query builder holds
|
|
348
|
+
no journal and no connection state, so a plugin declares its tables with `sqliteTable`,
|
|
349
|
+
writes idempotent DDL in `ensureTables`, and queries through its own handle:
|
|
350
|
+
|
|
351
|
+
```ts
|
|
352
|
+
export const scrapes = sqliteTable("scraper_scrapes", { url: text("url").primaryKey() });
|
|
353
|
+
|
|
354
|
+
store: {
|
|
355
|
+
plugin: "scraper",
|
|
356
|
+
version: 1,
|
|
357
|
+
// Re-run on every hibernation wake-up, so it must be idempotent.
|
|
358
|
+
ensureTables: (sql) => sql.exec(`CREATE TABLE IF NOT EXISTS scraper_scrapes (…)`)
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// …and anywhere the plugin queries:
|
|
362
|
+
const db = drizzle(storage, { schema: { scrapes } });
|
|
363
|
+
```
|
|
364
|
+
|
|
365
|
+
Core records each store's version in a `plugin_migrations` row, so `ensureTables` receives
|
|
366
|
+
the version last seen on disk and an upgrade path can branch on it.
|
|
367
|
+
|
|
368
|
+
### Session hooks
|
|
369
|
+
|
|
370
|
+
`onMessagesDisplaced` hands over the raw messages a compaction is about to fold into a
|
|
371
|
+
summary. Core performs the compaction, so core announces the loss; it neither stores the
|
|
372
|
+
messages nor knows who wants them. An episodic-memory plugin, an audit log, and a
|
|
373
|
+
cold-storage dump all want exactly this callback, and each gets it:
|
|
374
|
+
|
|
375
|
+
```ts
|
|
376
|
+
// in your DO, wiring the runtime's fan-out into the session
|
|
377
|
+
buildAgentSession(this, model, {
|
|
378
|
+
…,
|
|
379
|
+
onMessagesDisplaced: this.runtime.onMessagesDisplaced
|
|
380
|
+
});
|
|
381
|
+
```
|
|
382
|
+
|
|
383
|
+
Best-effort in both directions — a listener that throws never aborts compaction (history
|
|
384
|
+
must still shorten when a side store is down), and the fan-out is `Promise.allSettled`, so
|
|
385
|
+
one plugin's outage cannot cost another its notification.
|
|
386
|
+
|
|
387
|
+
`shouldHandleTurn` is the other side of the session: a gate that decides whether a turn
|
|
388
|
+
runs at all, before the loop builds or calls anything. An agent that sees every message in
|
|
389
|
+
its channels is mostly seeing messages that are not for it, and asking a model already
|
|
390
|
+
trying to be helpful to stay quiet degrades _invisibly_ — failing to call a decline-tool
|
|
391
|
+
looks identical to deciding not to. Every declaring plugin is consulted and the answers are
|
|
392
|
+
AND-ed, so any one gate may decline.
|
|
393
|
+
|
|
394
|
+
```ts
|
|
395
|
+
if (!(await this.runtime.shouldHandleTurn({ history }))) return; // declined
|
|
396
|
+
```
|
|
397
|
+
|
|
398
|
+
It **fails open**: a gate that throws is counted as `true`. The two mistakes are not
|
|
399
|
+
symmetric — a wrong reply is noise the user can see and ignore, while a wrong silence is
|
|
400
|
+
invisible to the person who needed an answer.
|
|
401
|
+
|
|
402
|
+
### The workspace backend
|
|
403
|
+
|
|
404
|
+
Core declares the `WorkspaceBacking` shape and enforces the caps, but ships no backend —
|
|
405
|
+
the predecessor's was `@cloudflare/shell`, which is experimental, and an agent that never
|
|
406
|
+
delegates file work should not carry it. A plugin supplies one via `workspaceBacking`; at
|
|
407
|
+
most one may, and an agent that installs none gets `memoryWorkspaceBacking`. So
|
|
408
|
+
`runtime.workspaceBacking` is always defined and your `SubagentRuntime` never needs a null
|
|
409
|
+
check.
|
|
410
|
+
|
|
411
|
+
---
|
|
412
|
+
|
|
413
|
+
## Testing
|
|
414
|
+
|
|
415
|
+
The harness both predecessor agents grew, shipped so you don't grow it a third time.
|
|
416
|
+
|
|
417
|
+
```ts
|
|
418
|
+
import {
|
|
419
|
+
FakeSession,
|
|
420
|
+
mockModel,
|
|
421
|
+
makeGatekeeperToken,
|
|
422
|
+
makeDoHelpers
|
|
423
|
+
} from "@dynamicagents/core/testing";
|
|
424
|
+
|
|
425
|
+
const { withDb } = makeDoHelpers(env.MY_AGENT);
|
|
426
|
+
|
|
427
|
+
await withDb("accepts a turn once", async (db) => {
|
|
428
|
+
await db.ensureReady();
|
|
429
|
+
db.tasks.begin({ messageId: "m1", taskId: "t1", contextId: "c1" });
|
|
430
|
+
});
|
|
431
|
+
```
|
|
432
|
+
|
|
433
|
+
- **VCR** — record/replay real HTTP against on-disk cassettes, split across the Node
|
|
434
|
+
and workerd realms because specs run in workerd, which has no filesystem. The
|
|
435
|
+
recorder is a Miniflare `outboundService`, so it works on any
|
|
436
|
+
`@cloudflare/vitest-pool-workers` from 0.18 up and needs no `undici`:
|
|
437
|
+
|
|
438
|
+
```ts
|
|
439
|
+
// vitest.config.ts
|
|
440
|
+
const vcr = createVcr({
|
|
441
|
+
snapshotsDir: path.resolve(import.meta.dirname, "test/snapshots"),
|
|
442
|
+
record: recordFromEnv(), // RECORD=1
|
|
443
|
+
excludeHeaders: ["authorization", "x-api-key"] // never written to a cassette
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
cloudflareTest({ miniflare: { outboundService: vcr.outboundService } });
|
|
447
|
+
```
|
|
448
|
+
|
|
449
|
+
Then `setupRecording()` at the top of a spec gives every `it` its own cassette,
|
|
450
|
+
auto-named from the file + describe + test names. Cassettes match on method, URL
|
|
451
|
+
and body — never on headers, so a runtime upgrade cannot invalidate them — and a
|
|
452
|
+
request with no active cassette is blocked rather than reaching the network.
|
|
453
|
+
Point vitest's `globalSetup` at `@dynamicagents/core/testing/vcr-global-setup`.
|
|
454
|
+
|
|
455
|
+
- **Fakes** — `FakeSession` (a `SessionLike` reference implementation) and `mockModel`
|
|
456
|
+
(a scripted `LanguageModel`), so a loop can be driven with no model call at all.
|
|
457
|
+
- **Fixtures** — Ed25519 keypairs and a gatekeeper-JWT signer, so the zero-trust path is
|
|
458
|
+
exercisable end to end without a real gatekeeper.
|
|
459
|
+
- **`createAgentHarness`** — the assembly of all of the above: send one A2A turn the
|
|
460
|
+
way a gatekeeper does, and capture what comes back.
|
|
461
|
+
|
|
462
|
+
```ts
|
|
463
|
+
const harness = createAgentHarness({ worker, env, tenant: "reactive" });
|
|
464
|
+
using _ = harness.interceptGatekeeper();
|
|
465
|
+
|
|
466
|
+
const accepted = await harness.send("what's the weather?");
|
|
467
|
+
expect(accepted.status.state).toBe(TaskState.TASK_STATE_SUBMITTED);
|
|
468
|
+
```
|
|
469
|
+
|
|
470
|
+
It exists because the pieces above were never the hard part. The audience is the
|
|
471
|
+
**endpoint**, not the origin; the tenant claim has to match the tenant in the
|
|
472
|
+
body; `SendMessage` is refused without a push config; and the gatekeeper's JWKS has
|
|
473
|
+
to be reachable or every spec below it reports a 401 about something else. Four
|
|
474
|
+
facts, wrong the first time in every consumer that wrote this by hand.
|
|
475
|
+
|
|
476
|
+
---
|
|
477
|
+
|
|
478
|
+
## What core deliberately does _not_ contain
|
|
479
|
+
|
|
480
|
+
- **Prompt copy of any kind.** Not a soul, not a round contract, not a user-facing
|
|
481
|
+
failure message. `@dynamicagents/core/round` ships the whole delegating loop but takes
|
|
482
|
+
every word it says from a [`RoundPolicy`](#the-round-policy) you write, because a
|
|
483
|
+
run must never execute under an identity nobody chose.
|
|
484
|
+
- **A loop you cannot replace.** `/round` is opt-in and its own subpath. An agent
|
|
485
|
+
whose turn is a single inference extends `DynamicAgent` directly, writes its own
|
|
486
|
+
loop, and carries none of the delegation machinery in its bundle.
|
|
487
|
+
- The main agent's soul. Core ships no prompt copy.
|
|
488
|
+
- Config _values_ — model ids, budgets, limits. Core ships the shapes and safe
|
|
489
|
+
defaults, and `resolveConfig` validates your overrides.
|
|
490
|
+
- Vectorize recall, browser tools, shell. All optional → plugins. Core contains no
|
|
491
|
+
embedding code at all: it ships the `onMessagesDisplaced` hook and nothing about what
|
|
492
|
+
a listener does with the messages — no embedding model, no index, no dimension.
|
|
493
|
+
|
|
494
|
+
---
|
|
495
|
+
|
|
496
|
+
## Requirements
|
|
497
|
+
|
|
498
|
+
- **Node** ≥ 24 (for build and test only — the package itself runs on workerd)
|
|
499
|
+
- **Bindings:** `AI`, one Durable Object, one Workflow
|
|
500
|
+
- **Secrets:** `A2A_SIGNING_KEY`, `GATEKEEPER_ORIGINS`
|
|
501
|
+
- **Peers, never bundled:** `agents`, `ai`, `workers-ai-provider`
|
|
502
|
+
|
|
503
|
+
That last point is not stylistic: two copies of `agents` in one Worker breaks the
|
|
504
|
+
`Session` / `SessionMessage` types and every `instanceof`. For local development
|
|
505
|
+
across the three repos use `file:` overrides, or `npm pack` plus a tarball install —
|
|
506
|
+
**not `npm link`**, which duplicates peer dependencies.
|
|
507
|
+
|
|
508
|
+
---
|
|
509
|
+
|
|
510
|
+
## Contributing
|
|
511
|
+
|
|
512
|
+
[`AGENTS.md`](./AGENTS.md) documents the constraints this package is guardian of.
|
|
513
|
+
|
|
514
|
+
```bash
|
|
515
|
+
npm run check # prettier + eslint + tsc (src) + tsc (test) + build
|
|
516
|
+
npm test # vitest, inside real workerd
|
|
517
|
+
npm run verify:exports # the publish gate: subpaths, ESM specifiers, realm isolation
|
|
518
|
+
```
|
|
519
|
+
|
|
520
|
+
## License
|
|
521
|
+
|
|
522
|
+
[Apache-2.0](./LICENSE).
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { Task, TaskState } from "@a2a-js/sdk";
|
|
2
|
+
import type { GatekeeperIdentity } from "./verify.js";
|
|
3
|
+
import type { PlainTask } from "./task.js";
|
|
4
|
+
/**
|
|
5
|
+
* The task-lifecycle surface core calls on an agent Durable Object, declared
|
|
6
|
+
* structurally so core never imports a consumer's DO class.
|
|
7
|
+
*
|
|
8
|
+
* A consumer's `DurableObjectStub<MyAgent>` satisfies this by construction:
|
|
9
|
+
* Cloudflare's RPC type mapping wraps each return in `Promise<Serializable<T>>`,
|
|
10
|
+
* and every type below is already `Serializable` — that is precisely why
|
|
11
|
+
* {@link PlainTask} exists (see {@link file://./task.ts}).
|
|
12
|
+
*
|
|
13
|
+
* `listTasks` is optional. `ListTasks` is new in A2A v1.0 and an agent that does
|
|
14
|
+
* not keep a queryable task history simply omits it; {@link DurableTaskStore}
|
|
15
|
+
* then answers the RPC with `UnsupportedOperationError` instead of failing at
|
|
16
|
+
* the binding. Everything else is mandatory — they are the accept-and-notify
|
|
17
|
+
* contract.
|
|
18
|
+
*/
|
|
19
|
+
export interface TaskAgent {
|
|
20
|
+
/**
|
|
21
|
+
* Record (or reuse) the `submitted` task for a turn. **Must be idempotent on
|
|
22
|
+
* `messageId`**: the gatekeeper retries dispatch, and the executor relies on this
|
|
23
|
+
* plus a deterministic workflow id to make a turn run exactly once.
|
|
24
|
+
*/
|
|
25
|
+
beginTask(input: {
|
|
26
|
+
messageId: string;
|
|
27
|
+
taskId: string;
|
|
28
|
+
contextId: string;
|
|
29
|
+
}): Promise<PlainTask>;
|
|
30
|
+
getTask(taskId: string): Promise<PlainTask | null>;
|
|
31
|
+
/** Returns false when the write was refused (e.g. the task is already terminal). */
|
|
32
|
+
saveTask(task: Task): Promise<boolean>;
|
|
33
|
+
/** Marks the task canceled and returns it, or null when there is no such task. */
|
|
34
|
+
cancelTask(taskId: string): Promise<PlainTask | null>;
|
|
35
|
+
listTasks?(query: TaskListQuery): Promise<TaskListPage>;
|
|
36
|
+
}
|
|
37
|
+
export interface TaskListQuery {
|
|
38
|
+
contextId?: string;
|
|
39
|
+
state?: TaskState;
|
|
40
|
+
/** Epoch ms; matches the `updated_at` column stamped on every status write. */
|
|
41
|
+
updatedAfter?: number;
|
|
42
|
+
includeArtifacts: boolean;
|
|
43
|
+
historyLength?: number;
|
|
44
|
+
limit: number;
|
|
45
|
+
offset: number;
|
|
46
|
+
}
|
|
47
|
+
export interface TaskListPage {
|
|
48
|
+
tasks: Task[];
|
|
49
|
+
/** Total matching rows, ignoring `limit`/`offset` — drives `nextPageToken`. */
|
|
50
|
+
totalSize: number;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Resolve the agent DO stub for a verified caller.
|
|
54
|
+
*
|
|
55
|
+
* Supplied by the consumer because addressing is theirs: core does not know the
|
|
56
|
+
* binding name, and the identity→instance mapping is a product decision. The
|
|
57
|
+
* conventional implementation keys one DO instance per `identity.key`, which is
|
|
58
|
+
* what makes a task unreachable from any other caller by construction.
|
|
59
|
+
*/
|
|
60
|
+
export type AgentResolver = (identity: GatekeeperIdentity) => TaskAgent;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
export interface CallerTokenOptions {
|
|
2
|
+
/** This agent's raw private JWK JSON — the same `A2A_SIGNING_KEY` its card is signed with. */
|
|
3
|
+
signingKey: string;
|
|
4
|
+
/**
|
|
5
|
+
* This agent's own origin. It becomes `iss`, and `jku` is derived from it, and
|
|
6
|
+
* the two must agree: a verifier that accepts a `jku` on a different origin
|
|
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.
|
|
14
|
+
*/
|
|
15
|
+
issuer: string;
|
|
16
|
+
/**
|
|
17
|
+
* Who the token is for. Normalized to a bare origin, because a verifier
|
|
18
|
+
* typically derives what it expects from `new URL(request.url).origin` and
|
|
19
|
+
* `jose` compares `aud` byte-for-byte — a trailing slash or a stray path is a
|
|
20
|
+
* 401 on every request with nothing to catch it. Throws on a value that is not
|
|
21
|
+
* an absolute URL, which is the right moment for that to fail.
|
|
22
|
+
*/
|
|
23
|
+
audience: string;
|
|
24
|
+
/** The identity this agent asserts. */
|
|
25
|
+
identity: Record<string, unknown>;
|
|
26
|
+
/** Which tenant of this deployment the token speaks for. */
|
|
27
|
+
tenant: string;
|
|
28
|
+
/** Lifetime in seconds. Defaults to 120. */
|
|
29
|
+
ttlSeconds?: number;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Sign a short-lived token identifying **this agent as a caller** to another
|
|
33
|
+
* service that trusts its card key.
|
|
34
|
+
*
|
|
35
|
+
* The production sibling of `makeGatekeeperToken`, which core previously shipped
|
|
36
|
+
* only from `/testing` — so an agent that had to call out mint-signed had to
|
|
37
|
+
* hand-write this shape, and every deployment that did so wrote its own subtly
|
|
38
|
+
* different version of the `iss`/`jku` agreement above.
|
|
39
|
+
*
|
|
40
|
+
* Distinct from {@link signCallbackJwt}, which carries **no** claims: that one
|
|
41
|
+
* proves "the agent you called is calling you back about this task", where this
|
|
42
|
+
* proves "this is who I am and which tenant I speak for".
|
|
43
|
+
*/
|
|
44
|
+
export declare function signCallerToken(options: CallerTokenOptions): Promise<string>;
|