@loopingai/core 0.7.1 → 0.8.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 +3 -4
- package/dist/a2a/self-origin.d.ts +7 -7
- package/dist/a2a/self-origin.js +7 -7
- package/dist/agent/errors.d.ts +16 -16
- package/dist/agent/errors.js +1 -1
- package/dist/agent/inference.d.ts +7 -5
- package/dist/agent/inference.js +0 -2
- package/dist/agent/model.d.ts +11 -5
- package/dist/agent/workers-ai/index.d.ts +13 -13
- package/dist/agent/workers-ai/index.js +13 -13
- package/dist/config.d.ts +3 -18
- package/dist/config.js +0 -4
- package/dist/host/agent.d.ts +5 -6
- package/dist/host/agent.js +5 -6
- 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/round/subagent.d.ts +7 -4
- package/dist/round/subagent.js +7 -4
- package/dist/subtasks/delegate.d.ts +4 -3
- package/dist/subtasks/delegate.js +4 -3
- package/dist/testing/mock-model.js +1 -1
- package/package.json +8 -16
- package/dist/agent/anthropic/index.d.ts +0 -15
- package/dist/agent/anthropic/index.js +0 -19
- package/dist/agent/anthropic/language-model.d.ts +0 -59
- package/dist/agent/anthropic/language-model.js +0 -442
- package/dist/agent/anthropic/prompt.d.ts +0 -84
- package/dist/agent/anthropic/prompt.js +0 -541
- package/dist/agent/anthropic/runtime.d.ts +0 -79
- package/dist/agent/anthropic/runtime.js +0 -130
package/README.md
CHANGED
|
@@ -229,7 +229,6 @@ not drag in the A2A adapter, and the test harness cannot reach a production bund
|
|
|
229
229
|
| `@loopingai/core/agent` | session, history, models + Workers AI, inference, budget, control tools |
|
|
230
230
|
| `@loopingai/core/host` | `LoopingAgent` — the Durable Object body — and `PluginHost` |
|
|
231
231
|
| `@loopingai/core/round` | the delegating round loop: `RoundAgentBase`, `runHandleTask`, `runTurn` |
|
|
232
|
-
| `@loopingai/core/anthropic` | Claude as a second provider — _optional peer on `@anthropic-ai/sdk`_ |
|
|
233
232
|
| `@loopingai/core/subtasks` | delegation types, decomposition, the `delegate` tool |
|
|
234
233
|
| `@loopingai/core/subagent` | `RecipeSubagentBase`, resumable runs, fingerprinting, workspace |
|
|
235
234
|
| `@loopingai/core/db` | `AgentDB`, `notify_tasks` + `subtasks` schema, migrations, `PluginStore` |
|
|
@@ -271,8 +270,9 @@ performs. A gateway pins the card's `kid` + `jku` on first registration
|
|
|
271
270
|
|
|
272
271
|
### Calling out, and knowing your own origin
|
|
273
272
|
|
|
274
|
-
The same key proves this agent to services that are not the gateway —
|
|
275
|
-
|
|
273
|
+
The same key proves this agent to services that are not the gateway — another agent,
|
|
274
|
+
or any service that verifies against the published JWKS. `signCallerToken` mints the
|
|
275
|
+
short-lived token for that: `iss` is
|
|
276
276
|
this deployment's origin, `jku` is derived from it, and the audience is normalized to a
|
|
277
277
|
bare origin because the far side compares it byte-for-byte.
|
|
278
278
|
|
|
@@ -499,7 +499,6 @@ await withDb("accepts a turn once", async (db) => {
|
|
|
499
499
|
- **Bindings:** `AI`, one Durable Object, one Workflow
|
|
500
500
|
- **Secrets:** `A2A_SIGNING_KEY`, `GATEWAY_ORIGINS`
|
|
501
501
|
- **Peers, never bundled:** `agents`, `ai`, `workers-ai-provider`
|
|
502
|
-
- **Optional peer:** `@anthropic-ai/sdk`, needed only by `@loopingai/core/anthropic`
|
|
503
502
|
|
|
504
503
|
That last point is not stylistic: two copies of `agents` in one Worker breaks the
|
|
505
504
|
`Session` / `SessionMessage` types and every `instanceof`. For local development
|
|
@@ -15,10 +15,10 @@
|
|
|
15
15
|
* The obvious answer is a `SELF_ORIGIN` secret, and it is the wrong one. It
|
|
16
16
|
* restates a value the request already carries, and it has to be kept
|
|
17
17
|
* byte-identical by hand with the origin allowlist on the far side, in every
|
|
18
|
-
* environment, forever.
|
|
19
|
-
* `looping-
|
|
20
|
-
* and
|
|
21
|
-
*
|
|
18
|
+
* environment, forever. Every sibling that tried it took it back out —
|
|
19
|
+
* `looping-gateway` discovers its own origin from the first signature-verified
|
|
20
|
+
* request rather than being told, and a verifying Worker that once carried a
|
|
21
|
+
* configured audience replaced it with `url.origin`.
|
|
22
22
|
*
|
|
23
23
|
* ## Where the value comes from
|
|
24
24
|
*
|
|
@@ -51,9 +51,9 @@
|
|
|
51
51
|
* The cost of pinning is what an agent does not have: several identities. An
|
|
52
52
|
* agent has one endpoint — the one its card advertises, the one a gateway calls
|
|
53
53
|
* and a verifier allowlists — so there is nothing to follow. Note the asymmetry
|
|
54
|
-
* with
|
|
55
|
-
*
|
|
56
|
-
*
|
|
54
|
+
* with a *verifier*, which derives the audience it expects per request and must
|
|
55
|
+
* not cache it: a verifier has to accept every hostname it answers on, while a
|
|
56
|
+
* signer needs one stable identity.
|
|
57
57
|
*
|
|
58
58
|
* Nothing is persisted, which is what keeps a pin from outliving its truth. An
|
|
59
59
|
* isolate is fresh on every `wrangler deploy` and recycles on its own, so a moved
|
package/dist/a2a/self-origin.js
CHANGED
|
@@ -15,10 +15,10 @@
|
|
|
15
15
|
* The obvious answer is a `SELF_ORIGIN` secret, and it is the wrong one. It
|
|
16
16
|
* restates a value the request already carries, and it has to be kept
|
|
17
17
|
* byte-identical by hand with the origin allowlist on the far side, in every
|
|
18
|
-
* environment, forever.
|
|
19
|
-
* `looping-
|
|
20
|
-
* and
|
|
21
|
-
*
|
|
18
|
+
* environment, forever. Every sibling that tried it took it back out —
|
|
19
|
+
* `looping-gateway` discovers its own origin from the first signature-verified
|
|
20
|
+
* request rather than being told, and a verifying Worker that once carried a
|
|
21
|
+
* configured audience replaced it with `url.origin`.
|
|
22
22
|
*
|
|
23
23
|
* ## Where the value comes from
|
|
24
24
|
*
|
|
@@ -51,9 +51,9 @@
|
|
|
51
51
|
* The cost of pinning is what an agent does not have: several identities. An
|
|
52
52
|
* agent has one endpoint — the one its card advertises, the one a gateway calls
|
|
53
53
|
* and a verifier allowlists — so there is nothing to follow. Note the asymmetry
|
|
54
|
-
* with
|
|
55
|
-
*
|
|
56
|
-
*
|
|
54
|
+
* with a *verifier*, which derives the audience it expects per request and must
|
|
55
|
+
* not cache it: a verifier has to accept every hostname it answers on, while a
|
|
56
|
+
* signer needs one stable identity.
|
|
57
57
|
*
|
|
58
58
|
* Nothing is persisted, which is what keeps a pin from outliving its truth. An
|
|
59
59
|
* isolate is fresh on every `wrangler deploy` and recycles on its own, so a moved
|
package/dist/agent/errors.d.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* any particular one exists. `nonRecoverableKind` in
|
|
7
7
|
* {@link file://./inference.ts inference.ts} keys on this file, so a provider
|
|
8
8
|
* written outside core — the thing `ModelRuntimeFactory` exists to make cheap —
|
|
9
|
-
* gets the same handling as
|
|
9
|
+
* gets the same handling as core's own, with no change to core.
|
|
10
10
|
*
|
|
11
11
|
* ## Why a third classification was needed at all
|
|
12
12
|
*
|
|
@@ -24,26 +24,26 @@
|
|
|
24
24
|
/**
|
|
25
25
|
* Who refused the request, when a `401` came back.
|
|
26
26
|
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
* it was sends an operator to rotate the wrong secret — which is exactly what
|
|
33
|
-
* happened before this existed.
|
|
34
|
-
*
|
|
35
|
-
* `"proxy"` is the odd one out: it is generally *not* a secret to rotate. An
|
|
36
|
-
* intermediary that mints its caller credential per request fails for reasons
|
|
37
|
-
* upstream of any stored secret — configuration drift, a rotated signing key,
|
|
38
|
-
* clock skew — so the remedy is to look, not to rotate. Core recognises no
|
|
39
|
-
* particular intermediary; a deployment that has one supplies its own classifier
|
|
40
|
-
* (see `AnthropicModelDeps.classifyAuthFailure`).
|
|
27
|
+
* Two authorities sit on the path, each with its own credential: the AI Gateway
|
|
28
|
+
* (`cf-aig-authorization`) and the model provider itself (`Authorization`). They
|
|
29
|
+
* fail with the same status code and have completely different remedies, so a
|
|
30
|
+
* rejection that does not say which one it was sends an operator to rotate the
|
|
31
|
+
* wrong secret — which is exactly what happened before this existed.
|
|
41
32
|
*
|
|
42
33
|
* `"unknown"` is a real answer and the default. Guessing `"provider"` for an
|
|
43
34
|
* unrecognised body is how the misdiagnosis happens; saying "one of these, here
|
|
44
35
|
* is how to check each" is worse copy and better information.
|
|
36
|
+
*
|
|
37
|
+
* A third arm, `"proxy"`, named an optional intermediary between the two — the
|
|
38
|
+
* shape where a deployment terminates the gateway's request at its own Worker to
|
|
39
|
+
* attach a credential. It was removed in 0.8.0 with the deployment that had one.
|
|
40
|
+
* If you build that topology again, the honest classification for its refusals
|
|
41
|
+
* is `"unknown"` until you widen this union, because the remedy genuinely
|
|
42
|
+
* differs: an intermediary minting its caller credential per request fails for
|
|
43
|
+
* reasons upstream of any stored secret, so the fix is to look rather than to
|
|
44
|
+
* rotate.
|
|
45
45
|
*/
|
|
46
|
-
export type CredentialRejectedBy = "provider" | "gateway" | "
|
|
46
|
+
export type CredentialRejectedBy = "provider" | "gateway" | "unknown";
|
|
47
47
|
/**
|
|
48
48
|
* A credential on the path to the model was rejected (HTTP 401 / 403).
|
|
49
49
|
*
|
package/dist/agent/errors.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* any particular one exists. `nonRecoverableKind` in
|
|
7
7
|
* {@link file://./inference.ts inference.ts} keys on this file, so a provider
|
|
8
8
|
* written outside core — the thing `ModelRuntimeFactory` exists to make cheap —
|
|
9
|
-
* gets the same handling as
|
|
9
|
+
* gets the same handling as core's own, with no change to core.
|
|
10
10
|
*
|
|
11
11
|
* ## Why a third classification was needed at all
|
|
12
12
|
*
|
|
@@ -49,14 +49,16 @@ export declare function isTransientAiError(err: unknown): boolean;
|
|
|
49
49
|
* - `gateway-credential` — the gateway *in front of* the provider rejected the
|
|
50
50
|
* request, which the provider therefore never saw. Rotate the gateway's token
|
|
51
51
|
* instead; the model credential is very likely fine.
|
|
52
|
-
* - `proxy-credential` — an intermediary between the gateway and the provider
|
|
53
|
-
* rejected the caller. Notably **not** a token to rotate: the credential it
|
|
54
|
-
* refused is minted per request, so the fault is upstream of the secret —
|
|
55
|
-
* configuration drift, a rotated signing key, or clock skew.
|
|
56
52
|
* - `unknown-credential` — a `401`/`403` matching none of the shapes. Says so,
|
|
57
53
|
* rather than picking one and sending an operator to rotate a working secret.
|
|
54
|
+
*
|
|
55
|
+
* A fourth, `proxy-credential`, was removed in 0.8.0 along with
|
|
56
|
+
* {@link file://./errors.ts CredentialRejectedBy}'s `"proxy"` arm. Adding a kind
|
|
57
|
+
* back is a breaking change for every consumer, because the `Record` they map it
|
|
58
|
+
* with is total — which is the property that makes a new kind impossible to
|
|
59
|
+
* ignore, and the reason to remove one rather than leave it unreachable.
|
|
58
60
|
*/
|
|
59
|
-
export type NonRecoverableKind = "credential" | "gateway-credential" | "
|
|
61
|
+
export type NonRecoverableKind = "credential" | "gateway-credential" | "unknown-credential";
|
|
60
62
|
/**
|
|
61
63
|
* Why a round ended with no answer — one terminal status, two situations.
|
|
62
64
|
*
|
package/dist/agent/inference.js
CHANGED
|
@@ -80,8 +80,6 @@ export function nonRecoverableKind(err) {
|
|
|
80
80
|
return "credential";
|
|
81
81
|
case "gateway":
|
|
82
82
|
return "gateway-credential";
|
|
83
|
-
case "proxy":
|
|
84
|
-
return "proxy-credential";
|
|
85
83
|
// Includes an error that crossed a realm boundary carrying no `source` at
|
|
86
84
|
// all: `isInstance` is structural, so that is reachable, and "unknown" is
|
|
87
85
|
// the honest reading of it.
|
package/dist/agent/model.d.ts
CHANGED
|
@@ -10,11 +10,17 @@ import type { ModelConfig } from "../config.js";
|
|
|
10
10
|
* more than a `LanguageModel` from `ai`. It never learns which provider produced
|
|
11
11
|
* it.
|
|
12
12
|
*
|
|
13
|
-
*
|
|
14
|
-
* {@link file://./workers-ai/index.ts `./workers-ai`}
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
13
|
+
* Core ships exactly one implementation —
|
|
14
|
+
* {@link file://./workers-ai/index.ts `./workers-ai`} — as a sibling directory
|
|
15
|
+
* under `agent/`. A second provider is a second directory exporting one
|
|
16
|
+
* {@link ModelRuntimeFactory}, and nothing here has to change to admit it;
|
|
17
|
+
* a consumer can equally supply one from outside the package, which is the
|
|
18
|
+
* point of the seam.
|
|
19
|
+
*
|
|
20
|
+
* There was a `./anthropic` sibling until 0.8.0 — a hand-written Messages API
|
|
21
|
+
* adapter behind an optional peer dependency. It was removed with the only
|
|
22
|
+
* deployment that used it. Nothing about this contract changed when it went,
|
|
23
|
+
* which is the strongest thing that can be said for the contract.
|
|
18
24
|
*
|
|
19
25
|
* Which is why this file has no runtime imports at all. The Workers AI factory
|
|
20
26
|
* used to live in it, and a contract that ships one implementation inline reads
|
|
@@ -1,23 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Workers AI — core's default model provider.
|
|
3
3
|
*
|
|
4
|
-
* A
|
|
5
|
-
* provider rather than a capability: it ships no tools, no prompt copy and no
|
|
4
|
+
* A provider rather than a capability: it ships no tools, no prompt copy and no
|
|
6
5
|
* policy; it satisfies {@link file://../model.ts ModelRuntime} and stops.
|
|
7
6
|
*
|
|
8
|
-
*
|
|
7
|
+
* Two things about its shape are worth stating, because both look like
|
|
8
|
+
* omissions:
|
|
9
9
|
*
|
|
10
10
|
* **No `LanguageModel` adapter.** `workers-ai-provider` ships one, so this
|
|
11
|
-
* directory is a factory and nothing else
|
|
12
|
-
* adapter
|
|
13
|
-
*
|
|
11
|
+
* directory is a factory and nothing else — one file rather than the five a
|
|
12
|
+
* hand-written adapter costs (a prompt mapping, a cache-control policy, an
|
|
13
|
+
* error taxonomy). That is what makes it the cheap default.
|
|
14
14
|
*
|
|
15
|
-
* **No package subpath.**
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
15
|
+
* **No package subpath.** `workers-ai-provider` is a *required* peer and this is
|
|
16
|
+
* the default every un-overridden seam reaches through `LoopingAgent` itself, so
|
|
17
|
+
* it is in every consumer's module graph already and a subpath would buy
|
|
18
|
+
* nothing. It is exported from `@loopingai/core/agent` — one symbol, one import
|
|
19
|
+
* path. A provider behind an *optional* peer would want its own subpath instead,
|
|
20
|
+
* so that an agent never calling it does not pay for it; `./anthropic` was one
|
|
21
|
+
* until 0.8.0.
|
|
22
22
|
*/
|
|
23
23
|
export { createWorkersAIModelRuntime, workersAIModels, type WorkersAIRuntimeDeps } from "./runtime.js";
|
|
@@ -1,23 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Workers AI — core's default model provider.
|
|
3
3
|
*
|
|
4
|
-
* A
|
|
5
|
-
* provider rather than a capability: it ships no tools, no prompt copy and no
|
|
4
|
+
* A provider rather than a capability: it ships no tools, no prompt copy and no
|
|
6
5
|
* policy; it satisfies {@link file://../model.ts ModelRuntime} and stops.
|
|
7
6
|
*
|
|
8
|
-
*
|
|
7
|
+
* Two things about its shape are worth stating, because both look like
|
|
8
|
+
* omissions:
|
|
9
9
|
*
|
|
10
10
|
* **No `LanguageModel` adapter.** `workers-ai-provider` ships one, so this
|
|
11
|
-
* directory is a factory and nothing else
|
|
12
|
-
* adapter
|
|
13
|
-
*
|
|
11
|
+
* directory is a factory and nothing else — one file rather than the five a
|
|
12
|
+
* hand-written adapter costs (a prompt mapping, a cache-control policy, an
|
|
13
|
+
* error taxonomy). That is what makes it the cheap default.
|
|
14
14
|
*
|
|
15
|
-
* **No package subpath.**
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
15
|
+
* **No package subpath.** `workers-ai-provider` is a *required* peer and this is
|
|
16
|
+
* the default every un-overridden seam reaches through `LoopingAgent` itself, so
|
|
17
|
+
* it is in every consumer's module graph already and a subpath would buy
|
|
18
|
+
* nothing. It is exported from `@loopingai/core/agent` — one symbol, one import
|
|
19
|
+
* path. A provider behind an *optional* peer would want its own subpath instead,
|
|
20
|
+
* so that an agent never calling it does not pay for it; `./anthropic` was one
|
|
21
|
+
* until 0.8.0.
|
|
22
22
|
*/
|
|
23
23
|
export { createWorkersAIModelRuntime, workersAIModels } from "./runtime.js";
|
package/dist/config.d.ts
CHANGED
|
@@ -27,21 +27,6 @@ export interface ModelConfig {
|
|
|
27
27
|
fallbackChatModelId: string;
|
|
28
28
|
/** AI Gateway slug; `"default"` auto-provisions on first request. */
|
|
29
29
|
aiGatewayId: string;
|
|
30
|
-
/**
|
|
31
|
-
* The segment after the gateway slug in `.../{gateway}/{provider}` — either a
|
|
32
|
-
* provider-native path like `"anthropic"` or a
|
|
33
|
-
* [custom provider](https://developers.cloudflare.com/ai-gateway/configuration/custom-providers/)
|
|
34
|
-
* slug. Core never reads it; a `ModelRuntime` that builds its own base URL
|
|
35
|
-
* does.
|
|
36
|
-
*
|
|
37
|
-
* Config rather than a literal at that call site because the parent agent and
|
|
38
|
-
* its subagent facet resolve their config independently, and a value that
|
|
39
|
-
* drifts between them routes delegated subtasks through a different provider
|
|
40
|
-
* than the round that delegated them — silently, since both satisfy
|
|
41
|
-
* `ModelRuntime`. Which path to pick, and why, belongs with the agent that
|
|
42
|
-
* picks it.
|
|
43
|
-
*/
|
|
44
|
-
aiGatewayProvider: string;
|
|
45
30
|
/**
|
|
46
31
|
* Output-token ceiling for every chat call. Left unset, the binding applies a
|
|
47
32
|
* per-model default which a reasoning model spends on `reasoning_content`
|
|
@@ -71,9 +56,9 @@ export interface ModelConfig {
|
|
|
71
56
|
* The AI SDK does the waiting, and it does it properly:
|
|
72
57
|
* `retryWithExponentialBackoffRespectingRetryHeaders` honours `retry-after-ms`
|
|
73
58
|
* and `retry-after` and falls back to exponential backoff. It only fires for
|
|
74
|
-
* an `APICallError` carrying `isRetryable`,
|
|
75
|
-
*
|
|
76
|
-
*
|
|
59
|
+
* an `APICallError` carrying `isRetryable`, so a provider written outside core
|
|
60
|
+
* has to map its errors into that shape rather than rethrowing them raw, or
|
|
61
|
+
* none of this fires for it.
|
|
77
62
|
*
|
|
78
63
|
* Bounded at 4 by `resolveConfig`. The retries happen *inside*
|
|
79
64
|
* `step.do("turn:<round>")`, and the SDK caps a single honoured `retry-after`
|
package/dist/config.js
CHANGED
|
@@ -32,10 +32,6 @@ export const DEFAULT_CORE_CONFIG = {
|
|
|
32
32
|
// Not a model: an AI Gateway slug, and `"default"` is Cloudflare's own
|
|
33
33
|
// auto-provision behaviour rather than a choice core is making for anyone.
|
|
34
34
|
aiGatewayId: "default",
|
|
35
|
-
// The provider-native Anthropic endpoint, which is what a deployment with
|
|
36
|
-
// no custom provider registered has. Agents that must own the credential
|
|
37
|
-
// themselves override this with their `custom-*` slug.
|
|
38
|
-
aiGatewayProvider: "anthropic",
|
|
39
35
|
maxOutputTokens: 16_384,
|
|
40
36
|
reasoningEffort: "medium",
|
|
41
37
|
// Two, which is the AI SDK's own default and enough to ride out the kind of
|
package/dist/host/agent.d.ts
CHANGED
|
@@ -118,12 +118,11 @@ export declare abstract class LoopingAgent<TEnv extends Cloudflare.Env & AiEnv &
|
|
|
118
118
|
* a trap that only shows up as a performance bug. This is called once.
|
|
119
119
|
*
|
|
120
120
|
* `ModelRuntime` is the whole contract: return anything satisfying it and
|
|
121
|
-
* every loop in core keeps working unchanged. Core ships
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
*
|
|
125
|
-
*
|
|
126
|
-
* anything on this path.
|
|
121
|
+
* every loop in core keeps working unchanged. Core ships one implementation,
|
|
122
|
+
* {@link file://../agent/workers-ai/index.ts `agent/workers-ai`} (the default
|
|
123
|
+
* below); a second provider is one more
|
|
124
|
+
* {@link file://../agent/model.ts ModelRuntimeFactory}, defined here or in the
|
|
125
|
+
* consumer, not a change to anything on this path.
|
|
127
126
|
*
|
|
128
127
|
* Takes the resolved {@link ModelConfig} rather than reading `this.config`, so
|
|
129
128
|
* that this signature matches
|
package/dist/host/agent.js
CHANGED
|
@@ -125,12 +125,11 @@ export class LoopingAgent extends Agent {
|
|
|
125
125
|
* a trap that only shows up as a performance bug. This is called once.
|
|
126
126
|
*
|
|
127
127
|
* `ModelRuntime` is the whole contract: return anything satisfying it and
|
|
128
|
-
* every loop in core keeps working unchanged. Core ships
|
|
129
|
-
*
|
|
130
|
-
*
|
|
131
|
-
*
|
|
132
|
-
*
|
|
133
|
-
* anything on this path.
|
|
128
|
+
* every loop in core keeps working unchanged. Core ships one implementation,
|
|
129
|
+
* {@link file://../agent/workers-ai/index.ts `agent/workers-ai`} (the default
|
|
130
|
+
* below); a second provider is one more
|
|
131
|
+
* {@link file://../agent/model.ts ModelRuntimeFactory}, defined here or in the
|
|
132
|
+
* consumer, not a change to anything on this path.
|
|
134
133
|
*
|
|
135
134
|
* Takes the resolved {@link ModelConfig} rather than reading `this.config`, so
|
|
136
135
|
* that this signature matches
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@loopingai/core/job` — a long job a Durable Object owns through its alarm.
|
|
3
|
+
*
|
|
4
|
+
* **The sibling of `@loopingai/core/alarm`, and the pairing is the point.**
|
|
5
|
+
* `WakeMap` owns *when* an object wakes; this owns *what a job owes on waking*.
|
|
6
|
+
* Neither depends on the other's reason for existing, and both are useful to a
|
|
7
|
+
* plain `DurableObject` rather than only to a `LoopingAgent` — which is why they
|
|
8
|
+
* are subpaths and not part of the agent machinery.
|
|
9
|
+
*
|
|
10
|
+
* **Mechanism only.** Nothing here knows what a job *does*: no command, no
|
|
11
|
+
* container, no filesystem, no vendor library. A consumer supplies the handle
|
|
12
|
+
* and the meaning; this supplies the four rules that are wrong in the same way
|
|
13
|
+
* every time — arming before the work starts, one job at a time, a drain that
|
|
14
|
+
* can outlive its job, and a job nobody is draining. See {@link JobLifecycle}.
|
|
15
|
+
*
|
|
16
|
+
* Deliberately **not** called `task`. Core already has a `Task` — the A2A one,
|
|
17
|
+
* with its own lifecycle, its own guarded writes and its own table — and two
|
|
18
|
+
* unrelated meanings in one namespace is a cost paid forever by every reader.
|
|
19
|
+
*/
|
|
20
|
+
export { isRearmable, isRunning, type DoneJob, type FailedJob, type IdleJob, type JobState, type RunningJob, type SkippedJob } from "./state.js";
|
|
21
|
+
export { JobLifecycle, type JobContext, type JobHandle, type JobLifecycleOptions, type JobResult } from "./lifecycle.js";
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@loopingai/core/job` — a long job a Durable Object owns through its alarm.
|
|
3
|
+
*
|
|
4
|
+
* **The sibling of `@loopingai/core/alarm`, and the pairing is the point.**
|
|
5
|
+
* `WakeMap` owns *when* an object wakes; this owns *what a job owes on waking*.
|
|
6
|
+
* Neither depends on the other's reason for existing, and both are useful to a
|
|
7
|
+
* plain `DurableObject` rather than only to a `LoopingAgent` — which is why they
|
|
8
|
+
* are subpaths and not part of the agent machinery.
|
|
9
|
+
*
|
|
10
|
+
* **Mechanism only.** Nothing here knows what a job *does*: no command, no
|
|
11
|
+
* container, no filesystem, no vendor library. A consumer supplies the handle
|
|
12
|
+
* and the meaning; this supplies the four rules that are wrong in the same way
|
|
13
|
+
* every time — arming before the work starts, one job at a time, a drain that
|
|
14
|
+
* can outlive its job, and a job nobody is draining. See {@link JobLifecycle}.
|
|
15
|
+
*
|
|
16
|
+
* Deliberately **not** called `task`. Core already has a `Task` — the A2A one,
|
|
17
|
+
* with its own lifecycle, its own guarded writes and its own table — and two
|
|
18
|
+
* unrelated meanings in one namespace is a cost paid forever by every reader.
|
|
19
|
+
*/
|
|
20
|
+
export { isRearmable, isRunning } from "./state.js";
|
|
21
|
+
export { JobLifecycle } from "./lifecycle.js";
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import type { WakeMap } from "../alarm/index.js";
|
|
2
|
+
import { type JobState, type RunningJob } from "./state.js";
|
|
3
|
+
/**
|
|
4
|
+
* The choreography around a long job a Durable Object owns through its alarm.
|
|
5
|
+
*
|
|
6
|
+
* The job itself — what command, where, and what its output means — belongs to
|
|
7
|
+
* the owner. What lives here is the part that is the same every time and is
|
|
8
|
+
* wrong in the same four ways every time:
|
|
9
|
+
*
|
|
10
|
+
* 1. **Arming writes `running` before anything runs.** The alarm has not fired
|
|
11
|
+
* yet, and a `done` record in that window lets a gated caller through against
|
|
12
|
+
* a workspace that is not ready. Writing `running` first also makes arming
|
|
13
|
+
* self-limiting: the next call sees it and stops.
|
|
14
|
+
* 2. **One job at a time**, guarded by a read that goes *through* the staleness
|
|
15
|
+
* bound — so a `running` record left by a dead isolate resolves rather than
|
|
16
|
+
* blocking every retry forever.
|
|
17
|
+
* 3. **A drain can outlive the job it watched.** `ctx.waitUntil` keeps running
|
|
18
|
+
* after the RPC returns, and a late drain writing its verdict over a record
|
|
19
|
+
* describing a *live* job is silent corruption. {@link generation} is the
|
|
20
|
+
* marker that makes it harmless.
|
|
21
|
+
* 4. **Nobody may be draining at all.** A watch intent re-attaches to a job
|
|
22
|
+
* whose isolate went away mid-flight.
|
|
23
|
+
*
|
|
24
|
+
* What is deliberately *not* here is the drain loop. Two real consumers want
|
|
25
|
+
* different ones — an install runs to completion under `waitUntil` and writes a
|
|
26
|
+
* single verdict; a coding-agent run is drained in bounded windows and reports
|
|
27
|
+
* partial progress between them. They share the four rules above and nothing
|
|
28
|
+
* below them, so the loop stays with the owner.
|
|
29
|
+
*
|
|
30
|
+
* ## Storage keys
|
|
31
|
+
*
|
|
32
|
+
* Derived from {@link JobLifecycleOptions.id} so one object can own several
|
|
33
|
+
* jobs. For `id: "install"` they come out as `install`, `install:armed`,
|
|
34
|
+
* `install:last-armed`, `install:context`, and the wake intents `install-run`
|
|
35
|
+
* and `install-watch` — the exact keys the predecessor wrote by hand, which is
|
|
36
|
+
* why adopting this needs no storage migration.
|
|
37
|
+
*/
|
|
38
|
+
/** What a job's result looks like to the lifecycle. Deliberately minimal. */
|
|
39
|
+
export interface JobResult {
|
|
40
|
+
exitCode: number;
|
|
41
|
+
stdout: string;
|
|
42
|
+
stderr: string;
|
|
43
|
+
}
|
|
44
|
+
/** A running command, reduced to what the lifecycle needs of it. */
|
|
45
|
+
export interface JobHandle {
|
|
46
|
+
result(): Promise<JobResult>;
|
|
47
|
+
[Symbol.dispose](): void;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* The per-job record naming what is running and *which* run it is.
|
|
51
|
+
*
|
|
52
|
+
* `startedAt` is the generation marker, so it is the one required field: a drain
|
|
53
|
+
* compares the stamp it captured against the stamp on disk, and a mismatch means
|
|
54
|
+
* it has been superseded and has nothing useful left to say.
|
|
55
|
+
*/
|
|
56
|
+
export interface JobContext {
|
|
57
|
+
startedAt: number;
|
|
58
|
+
}
|
|
59
|
+
export interface JobLifecycleOptions {
|
|
60
|
+
/** Namespaces every key and intent. Also the state record's own key. */
|
|
61
|
+
id: string;
|
|
62
|
+
storage: DurableObjectStorage;
|
|
63
|
+
wake: WakeMap;
|
|
64
|
+
/**
|
|
65
|
+
* How long a `running` record may stand before it is presumed dead.
|
|
66
|
+
*
|
|
67
|
+
* Measured from `startedAt` and compared against the job's own timeout plus
|
|
68
|
+
* this, never against this alone — the point is to outlast a job that is
|
|
69
|
+
* merely slow, and only then to declare one that is gone.
|
|
70
|
+
*/
|
|
71
|
+
staleMs?: number;
|
|
72
|
+
/** How often the watch intent re-checks a job nobody is draining. */
|
|
73
|
+
watchMs?: number;
|
|
74
|
+
/**
|
|
75
|
+
* The floor between two arming attempts.
|
|
76
|
+
*
|
|
77
|
+
* Without it a job that cannot start re-arms on every call into the object.
|
|
78
|
+
*/
|
|
79
|
+
armCooldownMs?: number;
|
|
80
|
+
}
|
|
81
|
+
export declare class JobLifecycle<TExtra extends object = Record<never, never>, TContext extends JobContext = JobContext> {
|
|
82
|
+
#private;
|
|
83
|
+
/** `install` — the state record. */
|
|
84
|
+
readonly stateKey: string;
|
|
85
|
+
/** `install:armed` — the stamp the arming path wrote, for the alarm to match. */
|
|
86
|
+
readonly armedKey: string;
|
|
87
|
+
/** `install:last-armed` — the cooldown floor. */
|
|
88
|
+
readonly lastArmedKey: string;
|
|
89
|
+
/** `install:context` — where the generation marker lives. */
|
|
90
|
+
readonly contextKey: string;
|
|
91
|
+
/** `install-run` — the intent that *runs* a job. */
|
|
92
|
+
readonly runIntent: string;
|
|
93
|
+
/** `install-watch` — the intent that re-attaches to one nobody is draining. */
|
|
94
|
+
readonly watchIntent: string;
|
|
95
|
+
constructor(options: JobLifecycleOptions);
|
|
96
|
+
/** The raw record, with no staleness repair. `idle` when nothing is written. */
|
|
97
|
+
read(): Promise<JobState<TExtra>>;
|
|
98
|
+
write(state: JobState<TExtra>): Promise<void>;
|
|
99
|
+
context(): Promise<TContext | undefined>;
|
|
100
|
+
/**
|
|
101
|
+
* Record which run this is, **before** spawning.
|
|
102
|
+
*
|
|
103
|
+
* The order is the whole point: a drain captures `startedAt` after the spawn,
|
|
104
|
+
* so a context written afterwards would let two runs share a generation.
|
|
105
|
+
*/
|
|
106
|
+
putContext(context: TContext): Promise<void>;
|
|
107
|
+
/**
|
|
108
|
+
* Hand a cold job to the alarm, if one is not already pending.
|
|
109
|
+
*
|
|
110
|
+
* Returns the stamp it armed with, or `undefined` when it declined — the
|
|
111
|
+
* caller needs the stamp because it is what the alarm must present to
|
|
112
|
+
* {@link claim} to get past the single-flight guard.
|
|
113
|
+
*
|
|
114
|
+
* An arming caller must **not** own the run. The predecessor handed one to
|
|
115
|
+
* `ctx.waitUntil` from a gate poll that returned in milliseconds, and the
|
|
116
|
+
* drain was disposed underneath it mid-command. An alarm invocation belongs to
|
|
117
|
+
* the object rather than to any request, so nothing it awaits can be cut short.
|
|
118
|
+
*/
|
|
119
|
+
arm(placeholder: Omit<RunningJob<TExtra>, "state" | "startedAt">): Promise<number | undefined>;
|
|
120
|
+
/** The stamp {@link arm} wrote, so the alarm can recognise its own placeholder. */
|
|
121
|
+
armedAt(): Promise<number | undefined>;
|
|
122
|
+
clearArmed(): Promise<void>;
|
|
123
|
+
/**
|
|
124
|
+
* Decide whether a new run may start.
|
|
125
|
+
*
|
|
126
|
+
* `takeOverArmedAt` is the one exemption and it is narrow on purpose. The
|
|
127
|
+
* alarm's placeholder *is* a `running` record for a job that has not started,
|
|
128
|
+
* so the alarm has to pass its own guard — and only its own. Matching the
|
|
129
|
+
* exact stamp it wrote is what stops this becoming "take over any running
|
|
130
|
+
* job", which is the displacement bug the guard exists to prevent: three
|
|
131
|
+
* callers spawning under one exec id in fifty seconds, each displacing the
|
|
132
|
+
* last, every displaced drain still attached and still writing verdicts.
|
|
133
|
+
*
|
|
134
|
+
* Applies the staleness bound **itself**, rather than trusting the caller to
|
|
135
|
+
* have repaired the record first. An earlier draft took an
|
|
136
|
+
* "already-repaired" state and said so in prose, which enforced nothing: the
|
|
137
|
+
* repaired and raw types are identical, so a caller passing a raw read got a
|
|
138
|
+
* `running` record that could never be claimed and a job wedged forever.
|
|
139
|
+
* `timeoutMs` is the job's own budget; see {@link isStale}.
|
|
140
|
+
*/
|
|
141
|
+
claim(state: JobState<TExtra>, timeoutMs: number, takeOverArmedAt?: number): {
|
|
142
|
+
ok: true;
|
|
143
|
+
} | {
|
|
144
|
+
ok: false;
|
|
145
|
+
current: RunningJob<TExtra>;
|
|
146
|
+
};
|
|
147
|
+
/**
|
|
148
|
+
* Whether a `running` record has stood long enough to be presumed dead.
|
|
149
|
+
*
|
|
150
|
+
* `timeoutMs` is the job's own budget; the bound is that plus `staleMs`, so a
|
|
151
|
+
* job that is merely slow is never declared gone.
|
|
152
|
+
*/
|
|
153
|
+
isStale(state: RunningJob<TExtra>, timeoutMs: number, now?: number): boolean;
|
|
154
|
+
/** Arm the watchdog that re-attaches to a job nobody is draining. */
|
|
155
|
+
armWatch(now?: number): Promise<void>;
|
|
156
|
+
/**
|
|
157
|
+
* Disarm the watchdog.
|
|
158
|
+
*
|
|
159
|
+
* Never call this from a superseded drain: the watchdog belongs to whichever
|
|
160
|
+
* run owns the record *now*, and clearing it there disarms the one recovery
|
|
161
|
+
* path the live run has.
|
|
162
|
+
*/
|
|
163
|
+
clearWatch(): Promise<void>;
|
|
164
|
+
/**
|
|
165
|
+
* A predicate a drain calls before every write, to ask whether it still owns
|
|
166
|
+
* the record.
|
|
167
|
+
*
|
|
168
|
+
* Captures the stamp once, at drain start, and compares it against disk each
|
|
169
|
+
* time. The closure also latches, so a drain can ask afterwards whether it was
|
|
170
|
+
* superseded — which is what decides if it may touch the watchdog.
|
|
171
|
+
*/
|
|
172
|
+
generation(startedAt: number): {
|
|
173
|
+
stillMine: () => Promise<boolean>;
|
|
174
|
+
superseded: () => boolean;
|
|
175
|
+
};
|
|
176
|
+
}
|