@loopingai/core 0.5.1 → 0.6.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/README.md +6 -4
- package/dist/a2a/notify.d.ts +4 -3
- package/dist/a2a/notify.js +4 -3
- package/dist/agent/anthropic/index.d.ts +15 -0
- package/dist/agent/anthropic/index.js +19 -0
- package/dist/agent/anthropic/language-model.d.ts +59 -0
- package/dist/agent/anthropic/language-model.js +442 -0
- package/dist/agent/anthropic/prompt.d.ts +84 -0
- package/dist/agent/anthropic/prompt.js +541 -0
- package/dist/agent/anthropic/runtime.d.ts +79 -0
- package/dist/agent/anthropic/runtime.js +130 -0
- package/dist/agent/control.js +10 -9
- package/dist/agent/errors.d.ts +85 -0
- package/dist/agent/errors.js +64 -0
- package/dist/agent/final-reply.d.ts +14 -13
- package/dist/agent/final-reply.js +28 -11
- package/dist/agent/history.d.ts +3 -3
- package/dist/agent/history.js +2 -2
- package/dist/agent/index.d.ts +4 -2
- package/dist/agent/index.js +4 -2
- package/dist/agent/inference.d.ts +58 -1
- package/dist/agent/inference.js +44 -0
- package/dist/agent/model.d.ts +42 -25
- package/dist/agent/model.js +1 -48
- package/dist/agent/session.d.ts +6 -7
- package/dist/agent/session.js +3 -3
- 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/config.d.ts +49 -15
- package/dist/config.js +30 -1
- package/dist/contract/plugin.d.ts +63 -3
- package/dist/contract/plugin.js +76 -0
- package/dist/contract/recipe.d.ts +16 -17
- package/dist/db/db.d.ts +0 -1
- package/dist/db/migrations/index.js +8 -1
- package/dist/db/models/subtasks.d.ts +24 -25
- package/dist/db/models/subtasks.js +33 -76
- package/dist/db/schema.d.ts +2 -21
- package/dist/db/schema.js +2 -4
- package/dist/host/agent.d.ts +58 -4
- package/dist/host/agent.js +63 -9
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/platform.d.ts +74 -11
- package/dist/platform.js +76 -13
- package/dist/round/agent.d.ts +36 -31
- package/dist/round/agent.js +61 -89
- package/dist/round/index.d.ts +3 -2
- package/dist/round/index.js +2 -2
- package/dist/round/policy.d.ts +2 -2
- package/dist/round/subagent.d.ts +19 -1
- package/dist/round/subagent.js +22 -5
- package/dist/round/turn.d.ts +32 -13
- package/dist/round/turn.js +83 -16
- package/dist/round/workflow.d.ts +23 -7
- package/dist/round/workflow.js +132 -65
- package/dist/runtime/index.d.ts +4 -2
- package/dist/runtime/index.js +6 -0
- package/dist/subagent/fingerprint.d.ts +2 -2
- package/dist/subagent/fingerprint.js +8 -17
- package/dist/subagent/index.d.ts +6 -4
- package/dist/subagent/index.js +8 -6
- package/dist/subagent/prompt.d.ts +4 -5
- package/dist/subagent/prompt.js +0 -8
- package/dist/subagent/run.d.ts +8 -1
- package/dist/subagent/run.js +59 -9
- package/dist/subtasks/catalog.d.ts +1 -1
- package/dist/subtasks/catalog.js +1 -1
- package/dist/subtasks/decomposition.d.ts +16 -20
- package/dist/subtasks/decomposition.js +27 -75
- package/dist/subtasks/delegate.d.ts +20 -1
- package/dist/subtasks/delegate.js +21 -16
- package/dist/subtasks/index.d.ts +1 -2
- package/dist/subtasks/index.js +1 -2
- package/dist/subtasks/subtask-types.d.ts +0 -8
- package/dist/subtasks/subtask-types.js +0 -7
- package/dist/subtasks/types.d.ts +45 -70
- package/dist/testing/mock-model.d.ts +35 -0
- package/dist/testing/mock-model.js +75 -0
- package/dist/testing/vcr-global-setup.d.ts +1 -3
- package/dist/testing/vcr-global-setup.js +1 -3
- package/dist/worker/index.d.ts +5 -12
- package/dist/worker/index.js +5 -12
- package/package.json +19 -1
- package/dist/subtasks/scheduler.d.ts +0 -48
- package/dist/subtasks/scheduler.js +0 -47
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import Anthropic from "@anthropic-ai/sdk";
|
|
2
|
+
import type { ModelConfig } from "../../config.js";
|
|
3
|
+
import type { CredentialRejectedBy } from "../errors.js";
|
|
4
|
+
import type { ModelRuntime } from "../model.js";
|
|
5
|
+
import type { CacheTtl } from "./prompt.js";
|
|
6
|
+
/**
|
|
7
|
+
* A {@link ModelRuntime} backed by Claude, for agents whose work justifies it.
|
|
8
|
+
*
|
|
9
|
+
* `ModelRuntime` / `ModelPair` were already the right interface — the Workers AI
|
|
10
|
+
* implementation in {@link file://../model.ts model.ts} is one implementation of
|
|
11
|
+
* it, not the definition. This is a sibling, so an agent switches providers by
|
|
12
|
+
* overriding `modelRuntime()` and changes nothing else.
|
|
13
|
+
*
|
|
14
|
+
* Provider choice is per **agent**, not per deployment: the fleet keeps running
|
|
15
|
+
* on Workers AI and one tenant opts into Claude.
|
|
16
|
+
*/
|
|
17
|
+
export interface AnthropicRuntimeDeps {
|
|
18
|
+
/**
|
|
19
|
+
* Base URL for the Messages API.
|
|
20
|
+
*
|
|
21
|
+
* A thunk because the useful value — `env.AI.gateway(id).getUrl("anthropic")`
|
|
22
|
+
* — is only obtainable once bindings exist. Routing through AI Gateway is what
|
|
23
|
+
* keeps Claude calls in the same logs as every Workers AI call; point it at
|
|
24
|
+
* `https://api.anthropic.com` to bypass the gateway.
|
|
25
|
+
*/
|
|
26
|
+
baseUrl: () => string | Promise<string>;
|
|
27
|
+
/**
|
|
28
|
+
* The bearer credential, resolved lazily so a rotated secret is picked up by
|
|
29
|
+
* the next isolate without a redeploy.
|
|
30
|
+
*
|
|
31
|
+
* Async-capable, like {@link baseUrl}, because the credential is not always a
|
|
32
|
+
* stored secret. A deployment that puts an authenticated intermediary in
|
|
33
|
+
* front of Anthropic mints a short-lived token per request instead — signing
|
|
34
|
+
* is asynchronous, and a token with a lifetime measured in minutes cannot be
|
|
35
|
+
* captured once and reused, which is why the client is rebuilt per call
|
|
36
|
+
* rather than memoized. See the note above `clientFor`.
|
|
37
|
+
*/
|
|
38
|
+
authToken: () => string | Promise<string>;
|
|
39
|
+
/**
|
|
40
|
+
* AI Gateway token, when the gateway has **Authenticated Gateway** enabled.
|
|
41
|
+
*
|
|
42
|
+
* Required in that case and only that case, which is why it is optional:
|
|
43
|
+
* pointing `baseUrl` at `api.anthropic.com`, or at a gateway with
|
|
44
|
+
* authentication off, must keep working with no token at all.
|
|
45
|
+
*
|
|
46
|
+
* Easy to miss, because the agents on Workers AI never need it — `env.AI.run()`
|
|
47
|
+
* reaches the gateway through the binding, which the platform authenticates.
|
|
48
|
+
* Only a client calling the **provider-native URL** presents credentials of
|
|
49
|
+
* its own, and a gateway with authentication on rejects it at the door: a
|
|
50
|
+
* `401` that never reaches Anthropic and never appears in the gateway's own
|
|
51
|
+
* call log, which makes it look like the model credential was refused.
|
|
52
|
+
*/
|
|
53
|
+
gatewayToken?: () => string | undefined;
|
|
54
|
+
/** Model ids, gateway slug and output ceiling — the agent's resolved config. */
|
|
55
|
+
config: ModelConfig;
|
|
56
|
+
/**
|
|
57
|
+
* `output_config.effort`. Coding and agentic work wants `"xhigh"`; the API
|
|
58
|
+
* default is `"high"`. Core does not choose this — the agent does.
|
|
59
|
+
*/
|
|
60
|
+
effort?: "low" | "medium" | "high" | "xhigh" | "max";
|
|
61
|
+
/**
|
|
62
|
+
* Prompt-cache TTL. `"1h"` costs 2x on write instead of 1.25x and is the right
|
|
63
|
+
* call for an agent whose rounds are separated by minutes of tool work — a
|
|
64
|
+
* container boot, an install, a test suite — because a 5-minute entry has
|
|
65
|
+
* expired by the next round and the whole prefix is re-billed at full price.
|
|
66
|
+
*/
|
|
67
|
+
cache?: CacheTtl | false;
|
|
68
|
+
/**
|
|
69
|
+
* Recognise a deployment-specific authority in a rejected-credential body —
|
|
70
|
+
* notably the intermediary {@link gatewayToken}'s note describes, which fails
|
|
71
|
+
* with the same `401` as the gateway and the provider and has a completely
|
|
72
|
+
* different remedy. See
|
|
73
|
+
* {@link file://./language-model.ts AnthropicModelDeps.classifyAuthFailure}.
|
|
74
|
+
*/
|
|
75
|
+
classifyAuthFailure?: (body: unknown) => CredentialRejectedBy | undefined;
|
|
76
|
+
/** Escape hatch for tests: use this instead of constructing a real client. */
|
|
77
|
+
clientOverride?: Anthropic;
|
|
78
|
+
}
|
|
79
|
+
export declare function createAnthropicModelRuntime(deps: AnthropicRuntimeDeps): ModelRuntime;
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import Anthropic from "@anthropic-ai/sdk";
|
|
2
|
+
import { createAnthropicLanguageModel } from "./language-model.js";
|
|
3
|
+
/**
|
|
4
|
+
* The beta Anthropic requires to accept a bearer credential rather than an
|
|
5
|
+
* `x-api-key`.
|
|
6
|
+
*/
|
|
7
|
+
const OAUTH_BETA = "oauth-2025-04-20";
|
|
8
|
+
/**
|
|
9
|
+
* AI Gateway's per-request log metadata header.
|
|
10
|
+
*
|
|
11
|
+
* Core already stamps `{taskId, round}` on a turn and `{taskId, subtaskId}` on a
|
|
12
|
+
* subagent chunk so a model call can be tied back to the work that made it; the
|
|
13
|
+
* Workers AI provider carries that in its settings object. Anthropic has no such
|
|
14
|
+
* field, so it rides as a header instead — same correlation, different envelope.
|
|
15
|
+
* Capped at five entries by the gateway; extra keys are dropped there, so trim
|
|
16
|
+
* here rather than sending something that silently truncates.
|
|
17
|
+
*/
|
|
18
|
+
const GATEWAY_METADATA_HEADER = "cf-aig-metadata";
|
|
19
|
+
const GATEWAY_METADATA_MAX_ENTRIES = 5;
|
|
20
|
+
/**
|
|
21
|
+
* AI Gateway's own authorization header — deliberately *not* `Authorization`,
|
|
22
|
+
* which already carries the model provider's credential on the same request.
|
|
23
|
+
* Two independent authorities, two headers.
|
|
24
|
+
*
|
|
25
|
+
* A client header rather than a per-pair one: it authenticates the caller to the
|
|
26
|
+
* gateway, which does not vary by task or round, unlike
|
|
27
|
+
* {@link GATEWAY_METADATA_HEADER}.
|
|
28
|
+
*/
|
|
29
|
+
const GATEWAY_AUTH_HEADER = "cf-aig-authorization";
|
|
30
|
+
function metadataHeaders(metadata) {
|
|
31
|
+
if (!metadata)
|
|
32
|
+
return {};
|
|
33
|
+
const entries = Object.entries(metadata).slice(0, GATEWAY_METADATA_MAX_ENTRIES);
|
|
34
|
+
if (entries.length === 0)
|
|
35
|
+
return {};
|
|
36
|
+
return {
|
|
37
|
+
[GATEWAY_METADATA_HEADER]: JSON.stringify(Object.fromEntries(entries))
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
export function createAnthropicModelRuntime(deps) {
|
|
41
|
+
const { config } = deps;
|
|
42
|
+
/**
|
|
43
|
+
* The resolved base URL, memoized — **not** the client.
|
|
44
|
+
*
|
|
45
|
+
* Resolving is what is expensive and what must not happen at module scope:
|
|
46
|
+
* `wrangler deploy` evaluates module scope to validate the new version and
|
|
47
|
+
* bindings are unpopulated at that point, so an eager `baseUrl()` would throw
|
|
48
|
+
* during deploy. That argument is about the *URL*, which is why it is the URL
|
|
49
|
+
* that is cached.
|
|
50
|
+
*
|
|
51
|
+
* Assigned only on success, so a failed gateway lookup is retried on the next
|
|
52
|
+
* round rather than cached as a rejected promise for the life of the isolate.
|
|
53
|
+
* Two concurrent first calls may each resolve one; they are identical, and
|
|
54
|
+
* the cost is a spare string.
|
|
55
|
+
*/
|
|
56
|
+
let resolvedBaseUrl;
|
|
57
|
+
/**
|
|
58
|
+
* A client per call, deliberately.
|
|
59
|
+
*
|
|
60
|
+
* This used to memoize the `Anthropic` instance, which bakes the credential
|
|
61
|
+
* in at construction. That is correct only while the credential outlives the
|
|
62
|
+
* isolate. It does not when {@link AnthropicRuntimeDeps.authToken} mints a
|
|
63
|
+
* short-lived token per request: the first call succeeds, and every call
|
|
64
|
+
* after the token's lifetime gets a `401` — intermittently, only under
|
|
65
|
+
* sustained load, and pointing at the wrong secret.
|
|
66
|
+
*
|
|
67
|
+
* Rebuilding is close to free. `new Anthropic({...})` is pure config
|
|
68
|
+
* assembly — no network, no handshake — so the per-call cost is an object
|
|
69
|
+
* allocation, against a correctness bug that only appears in production.
|
|
70
|
+
* The per-pair headers ride on the request rather than the client, so nothing
|
|
71
|
+
* else depended on the instance being shared.
|
|
72
|
+
*/
|
|
73
|
+
const clientFor = async () => {
|
|
74
|
+
if (deps.clientOverride)
|
|
75
|
+
return deps.clientOverride;
|
|
76
|
+
// Awaited, not cast. `env.AI.gateway(id).getUrl()` returns a PROMISE:
|
|
77
|
+
// handing it to `baseURL` unresolved type-checks only behind an `as
|
|
78
|
+
// string`, then fails deep inside the SDK on `baseURL.endsWith is not a
|
|
79
|
+
// function`, on every request, with nothing naming the gateway. The await
|
|
80
|
+
// is why this thunk is async and why `client` is awaited at the call site.
|
|
81
|
+
resolvedBaseUrl ??= await deps.baseUrl();
|
|
82
|
+
const gatewayToken = deps.gatewayToken?.();
|
|
83
|
+
return new Anthropic({
|
|
84
|
+
authToken: await deps.authToken(),
|
|
85
|
+
// Explicitly null, or the SDK falls back to resolving credentials from
|
|
86
|
+
// config files and env vars — which on Workers means a confusing failure
|
|
87
|
+
// far from the actual misconfiguration.
|
|
88
|
+
apiKey: null,
|
|
89
|
+
baseURL: resolvedBaseUrl,
|
|
90
|
+
defaultHeaders: {
|
|
91
|
+
"anthropic-beta": OAUTH_BETA,
|
|
92
|
+
...(gatewayToken
|
|
93
|
+
? { [GATEWAY_AUTH_HEADER]: `Bearer ${gatewayToken}` }
|
|
94
|
+
: {})
|
|
95
|
+
},
|
|
96
|
+
// Retry lives one layer up, in the AI SDK, which is the only layer that
|
|
97
|
+
// honours the provider's own `retry-after` — see `ModelConfig.maxRetries`
|
|
98
|
+
// and the `APICallError` mapping that feeds it. A second layer here would
|
|
99
|
+
// multiply that wait and defeat the fallback's timing.
|
|
100
|
+
maxRetries: 0
|
|
101
|
+
});
|
|
102
|
+
};
|
|
103
|
+
return {
|
|
104
|
+
createModelPair(overrides = {}) {
|
|
105
|
+
const primaryId = overrides.primaryModelId ?? config.chatModelId;
|
|
106
|
+
const fallbackId = overrides.fallbackModelId ?? config.fallbackChatModelId;
|
|
107
|
+
const headers = metadataHeaders(overrides.metadata);
|
|
108
|
+
const build = (modelId) => createAnthropicLanguageModel({
|
|
109
|
+
client: clientFor,
|
|
110
|
+
modelId,
|
|
111
|
+
defaultMaxTokens: config.maxOutputTokens,
|
|
112
|
+
...(deps.effort ? { effort: deps.effort } : {}),
|
|
113
|
+
...(deps.cache !== undefined ? { cache: deps.cache } : {}),
|
|
114
|
+
...(deps.classifyAuthFailure
|
|
115
|
+
? { classifyAuthFailure: deps.classifyAuthFailure }
|
|
116
|
+
: {}),
|
|
117
|
+
headers
|
|
118
|
+
});
|
|
119
|
+
let primary;
|
|
120
|
+
let fallback;
|
|
121
|
+
return {
|
|
122
|
+
primary: () => (primary ??= overrides.model ?? build(primaryId)),
|
|
123
|
+
fallback: () => (fallback ??=
|
|
124
|
+
overrides.fallbackModel ?? overrides.model ?? build(fallbackId)),
|
|
125
|
+
primaryId: () => primaryId,
|
|
126
|
+
fallbackId: () => fallbackId
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
}
|
package/dist/agent/control.js
CHANGED
|
@@ -64,9 +64,8 @@ export function controlTools(opts) {
|
|
|
64
64
|
if (!parsed.success) {
|
|
65
65
|
throw new ControlCallError(`${DELEGATE_TOOL_NAME} input is invalid — ${issues(parsed.error)}`);
|
|
66
66
|
}
|
|
67
|
-
// Everything past the schema — unknown reference indexes,
|
|
68
|
-
//
|
|
69
|
-
// for the model.
|
|
67
|
+
// Everything past the schema — unknown reference indexes, a type's
|
|
68
|
+
// required params — throws its own error, already worded for the model.
|
|
70
69
|
const { reply, drafts } = resolveDecomposition({
|
|
71
70
|
...parsed.data,
|
|
72
71
|
subtasks: parsed.data.subtasks.map((s) => ({
|
|
@@ -84,11 +83,12 @@ export function controlTools(opts) {
|
|
|
84
83
|
* Drop params the model sent as an explicit `undefined`.
|
|
85
84
|
*
|
|
86
85
|
* The delegate schema declares the union of every type's param keys, all optional,
|
|
87
|
-
* so one tool schema can serve every type (see
|
|
88
|
-
* that names a key and leaves it
|
|
89
|
-
*
|
|
90
|
-
* as a *present* one. Whether
|
|
91
|
-
*
|
|
86
|
+
* so one tool schema can serve every type (see
|
|
87
|
+
* `SubtaskTypeRegistry.paramProperties`). A model that names a key and leaves it
|
|
88
|
+
* empty has sent no param, and forwarding the key with an `undefined` value would
|
|
89
|
+
* only make a missing required param report itself as a *present* one. Whether
|
|
90
|
+
* what survives satisfies the type is still
|
|
91
|
+
* `SubtaskTypeRegistry.validateParams`'s call, downstream.
|
|
92
92
|
*/
|
|
93
93
|
function definedParams(params) {
|
|
94
94
|
if (!params)
|
|
@@ -100,7 +100,8 @@ function definedParams(params) {
|
|
|
100
100
|
}
|
|
101
101
|
/**
|
|
102
102
|
* A zod failure as one line the model can act on: which field, and what was wrong.
|
|
103
|
-
* The same rendering
|
|
103
|
+
* The same rendering
|
|
104
|
+
* {@link file://../subtasks/subtask-types.ts SubtaskTypeRegistry.validateParams}
|
|
104
105
|
* uses, so every rejection a control call can produce reads the same way.
|
|
105
106
|
*/
|
|
106
107
|
function issues(error) {
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Failures the loops must treat specially, whichever provider raised them.
|
|
3
|
+
*
|
|
4
|
+
* A sibling of {@link file://./model.ts model.ts} and neutral for the same
|
|
5
|
+
* reason: a provider directory may throw these, and nothing here may know that
|
|
6
|
+
* any particular one exists. `nonRecoverableKind` in
|
|
7
|
+
* {@link file://./inference.ts inference.ts} keys on this file, so a provider
|
|
8
|
+
* written outside core — the thing `ModelRuntimeFactory` exists to make cheap —
|
|
9
|
+
* gets the same handling as `agent/anthropic` with no change to core.
|
|
10
|
+
*
|
|
11
|
+
* ## Why a third classification was needed at all
|
|
12
|
+
*
|
|
13
|
+
* Core's attempt ladder splits every failure two ways — transient conditions
|
|
14
|
+
* throw out so the Workflow step retries the whole round, everything else burns
|
|
15
|
+
* the model slot and hands over to the fallback (see
|
|
16
|
+
* {@link file://./inference.ts isTransientAiError}). An expired credential fits
|
|
17
|
+
* neither: retrying spends the Workflow's budget on a request that can never
|
|
18
|
+
* succeed, and falling back spends the second slot on the *same* rejected token.
|
|
19
|
+
* It has to stop the round and say what a human must do.
|
|
20
|
+
*
|
|
21
|
+
* Note that being non-transient is not enough on its own — "not transient" is
|
|
22
|
+
* precisely the signal that means "try the fallback".
|
|
23
|
+
*/
|
|
24
|
+
/**
|
|
25
|
+
* Who refused the request, when a `401` came back.
|
|
26
|
+
*
|
|
27
|
+
* There can be up to three authorities on the path, each with its own
|
|
28
|
+
* credential: the AI Gateway (`cf-aig-authorization`), an optional intermediary
|
|
29
|
+
* a deployment puts between the gateway and the provider (`"proxy"`), and the
|
|
30
|
+
* model provider itself (`Authorization`). They fail with the same status code
|
|
31
|
+
* and completely different remedies, so a rejection that does not say which one
|
|
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`).
|
|
41
|
+
*
|
|
42
|
+
* `"unknown"` is a real answer and the default. Guessing `"provider"` for an
|
|
43
|
+
* unrecognised body is how the misdiagnosis happens; saying "one of these, here
|
|
44
|
+
* is how to check each" is worse copy and better information.
|
|
45
|
+
*/
|
|
46
|
+
export type CredentialRejectedBy = "provider" | "gateway" | "proxy" | "unknown";
|
|
47
|
+
/**
|
|
48
|
+
* A credential on the path to the model was rejected (HTTP 401 / 403).
|
|
49
|
+
*
|
|
50
|
+
* Deliberately not an `APICallError`: the AI SDK's classifier treats those as
|
|
51
|
+
* potentially retryable, and this never is.
|
|
52
|
+
* {@link file://./inference.ts nonRecoverableKind} maps it to one of the
|
|
53
|
+
* credential kinds — which one depends on {@link source} — and that is what
|
|
54
|
+
* stops the round before the fallback slot; `isTransientAiError` additionally
|
|
55
|
+
* returns `false` so the message text can never be mistaken for a rate limit.
|
|
56
|
+
*
|
|
57
|
+
* The round then fails carrying that kind, and the host supplies the
|
|
58
|
+
* operator-facing copy through `HandleTaskDeps.failureCopy` — core owns the
|
|
59
|
+
* signal and the delivery, never the wording.
|
|
60
|
+
*/
|
|
61
|
+
export declare class CredentialRejectedError extends Error {
|
|
62
|
+
readonly name = "CredentialRejectedError";
|
|
63
|
+
/** The upstream status, when one was available. */
|
|
64
|
+
readonly status: number | undefined;
|
|
65
|
+
/** Which authority rejected it. See {@link CredentialRejectedBy}. */
|
|
66
|
+
readonly source: CredentialRejectedBy;
|
|
67
|
+
constructor(message: string, options?: {
|
|
68
|
+
status?: number;
|
|
69
|
+
source?: CredentialRejectedBy;
|
|
70
|
+
cause?: unknown;
|
|
71
|
+
});
|
|
72
|
+
/**
|
|
73
|
+
* Structural check rather than `instanceof`.
|
|
74
|
+
*
|
|
75
|
+
* A Worker bundle can end up with two copies of this module (core linked as a
|
|
76
|
+
* tarball while a plugin resolves its own), and `instanceof` fails across
|
|
77
|
+
* them — the same realm hazard `AGENTS.md` calls out for `agents`. The name is
|
|
78
|
+
* a readonly literal, so this is as strong in practice and survives bundling.
|
|
79
|
+
*
|
|
80
|
+
* It is also what lets a provider outside core raise one: anything named
|
|
81
|
+
* `CredentialRejectedError` with a `source` is honoured, no shared class
|
|
82
|
+
* identity required.
|
|
83
|
+
*/
|
|
84
|
+
static isInstance(err: unknown): err is CredentialRejectedError;
|
|
85
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Failures the loops must treat specially, whichever provider raised them.
|
|
3
|
+
*
|
|
4
|
+
* A sibling of {@link file://./model.ts model.ts} and neutral for the same
|
|
5
|
+
* reason: a provider directory may throw these, and nothing here may know that
|
|
6
|
+
* any particular one exists. `nonRecoverableKind` in
|
|
7
|
+
* {@link file://./inference.ts inference.ts} keys on this file, so a provider
|
|
8
|
+
* written outside core — the thing `ModelRuntimeFactory` exists to make cheap —
|
|
9
|
+
* gets the same handling as `agent/anthropic` with no change to core.
|
|
10
|
+
*
|
|
11
|
+
* ## Why a third classification was needed at all
|
|
12
|
+
*
|
|
13
|
+
* Core's attempt ladder splits every failure two ways — transient conditions
|
|
14
|
+
* throw out so the Workflow step retries the whole round, everything else burns
|
|
15
|
+
* the model slot and hands over to the fallback (see
|
|
16
|
+
* {@link file://./inference.ts isTransientAiError}). An expired credential fits
|
|
17
|
+
* neither: retrying spends the Workflow's budget on a request that can never
|
|
18
|
+
* succeed, and falling back spends the second slot on the *same* rejected token.
|
|
19
|
+
* It has to stop the round and say what a human must do.
|
|
20
|
+
*
|
|
21
|
+
* Note that being non-transient is not enough on its own — "not transient" is
|
|
22
|
+
* precisely the signal that means "try the fallback".
|
|
23
|
+
*/
|
|
24
|
+
/**
|
|
25
|
+
* A credential on the path to the model was rejected (HTTP 401 / 403).
|
|
26
|
+
*
|
|
27
|
+
* Deliberately not an `APICallError`: the AI SDK's classifier treats those as
|
|
28
|
+
* potentially retryable, and this never is.
|
|
29
|
+
* {@link file://./inference.ts nonRecoverableKind} maps it to one of the
|
|
30
|
+
* credential kinds — which one depends on {@link source} — and that is what
|
|
31
|
+
* stops the round before the fallback slot; `isTransientAiError` additionally
|
|
32
|
+
* returns `false` so the message text can never be mistaken for a rate limit.
|
|
33
|
+
*
|
|
34
|
+
* The round then fails carrying that kind, and the host supplies the
|
|
35
|
+
* operator-facing copy through `HandleTaskDeps.failureCopy` — core owns the
|
|
36
|
+
* signal and the delivery, never the wording.
|
|
37
|
+
*/
|
|
38
|
+
export class CredentialRejectedError extends Error {
|
|
39
|
+
name = "CredentialRejectedError";
|
|
40
|
+
/** The upstream status, when one was available. */
|
|
41
|
+
status;
|
|
42
|
+
/** Which authority rejected it. See {@link CredentialRejectedBy}. */
|
|
43
|
+
source;
|
|
44
|
+
constructor(message, options) {
|
|
45
|
+
super(message, { cause: options?.cause });
|
|
46
|
+
this.status = options?.status;
|
|
47
|
+
this.source = options?.source ?? "unknown";
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Structural check rather than `instanceof`.
|
|
51
|
+
*
|
|
52
|
+
* A Worker bundle can end up with two copies of this module (core linked as a
|
|
53
|
+
* tarball while a plugin resolves its own), and `instanceof` fails across
|
|
54
|
+
* them — the same realm hazard `AGENTS.md` calls out for `agents`. The name is
|
|
55
|
+
* a readonly literal, so this is as strong in practice and survives bundling.
|
|
56
|
+
*
|
|
57
|
+
* It is also what lets a provider outside core raise one: anything named
|
|
58
|
+
* `CredentialRejectedError` with a `source` is honoured, no shared class
|
|
59
|
+
* identity required.
|
|
60
|
+
*/
|
|
61
|
+
static isInstance(err) {
|
|
62
|
+
return err instanceof Error && err.name === "CredentialRejectedError";
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type Tool } from "ai";
|
|
1
2
|
import { z } from "zod";
|
|
2
3
|
/**
|
|
3
4
|
* The `final_reply` tool — the main agent answering the user itself, and the other
|
|
@@ -12,7 +13,7 @@ import { z } from "zod";
|
|
|
12
13
|
* So prose is no longer an outcome. Both endings are now named tools, the round runs
|
|
13
14
|
* with `toolChoice: "required"`, and a round that ends any other way has failed its
|
|
14
15
|
* attempt. The *choice* is still entirely the model's — the point of the design (see
|
|
15
|
-
* {@link file
|
|
16
|
+
* {@link file://../round/turn.ts turn.ts}) was never that the model be steered toward
|
|
16
17
|
* delegating, only that it not be forced. Picking between two named tools is also a
|
|
17
18
|
* far easier discrimination for a small model than picking between prose and a tool,
|
|
18
19
|
* which is what the weaker fallback models kept getting wrong.
|
|
@@ -20,16 +21,6 @@ import { z } from "zod";
|
|
|
20
21
|
* This lives outside `subtasks/` deliberately: replying is not a subtask concept.
|
|
21
22
|
*/
|
|
22
23
|
export declare const FINAL_REPLY_TOOL_NAME = "final_reply";
|
|
23
|
-
/**
|
|
24
|
-
* The tool as the model sees it. **Without `execute`**, exactly like
|
|
25
|
-
* {@link file://./subtasks/delegate.ts delegateTool}: the call *is* the round's
|
|
26
|
-
* output, so there is nothing for the SDK to run and the loop halts on it.
|
|
27
|
-
*
|
|
28
|
-
* Unlike `delegate`, this call is never reconstructed in a later round's history —
|
|
29
|
-
* a past reply is stored as, and replayed as, ordinary assistant text (history is
|
|
30
|
-
* text-only by design). The tool exists to constrain *generation*, not to become a
|
|
31
|
-
* new shape in the transcript.
|
|
32
|
-
*/
|
|
33
24
|
/**
|
|
34
25
|
* The call's input, exported as the zod schema rather than only as the tool's
|
|
35
26
|
* `inputSchema`, because the round has to run it itself.
|
|
@@ -43,6 +34,16 @@ export declare const FINAL_REPLY_TOOL_NAME = "final_reply";
|
|
|
43
34
|
export declare const finalReplyInputSchema: z.ZodObject<{
|
|
44
35
|
text: z.ZodString;
|
|
45
36
|
}, z.core.$strip>;
|
|
46
|
-
|
|
37
|
+
/**
|
|
38
|
+
* The tool as the model sees it. **Without `execute`**, exactly like
|
|
39
|
+
* {@link file://../subtasks/delegate.ts delegateTool}: the call *is* the round's
|
|
40
|
+
* output, so there is nothing for the SDK to run and the loop halts on it.
|
|
41
|
+
*
|
|
42
|
+
* Unlike `delegate`, this call is never reconstructed in a later round's history —
|
|
43
|
+
* a past reply is stored as, and replayed as, ordinary assistant text (history is
|
|
44
|
+
* text-only by design). The tool exists to constrain *generation*, not to become a
|
|
45
|
+
* new shape in the transcript.
|
|
46
|
+
*/
|
|
47
|
+
export declare const finalReplyTool: Tool<{
|
|
47
48
|
text: string;
|
|
48
|
-
}
|
|
49
|
+
}>;
|
|
@@ -14,7 +14,7 @@ import { nonBlank } from "../subtasks/decomposition.js";
|
|
|
14
14
|
* So prose is no longer an outcome. Both endings are now named tools, the round runs
|
|
15
15
|
* with `toolChoice: "required"`, and a round that ends any other way has failed its
|
|
16
16
|
* attempt. The *choice* is still entirely the model's — the point of the design (see
|
|
17
|
-
* {@link file
|
|
17
|
+
* {@link file://../round/turn.ts turn.ts}) was never that the model be steered toward
|
|
18
18
|
* delegating, only that it not be forced. Picking between two named tools is also a
|
|
19
19
|
* far easier discrimination for a small model than picking between prose and a tool,
|
|
20
20
|
* which is what the weaker fallback models kept getting wrong.
|
|
@@ -22,16 +22,6 @@ import { nonBlank } from "../subtasks/decomposition.js";
|
|
|
22
22
|
* This lives outside `subtasks/` deliberately: replying is not a subtask concept.
|
|
23
23
|
*/
|
|
24
24
|
export const FINAL_REPLY_TOOL_NAME = "final_reply";
|
|
25
|
-
/**
|
|
26
|
-
* The tool as the model sees it. **Without `execute`**, exactly like
|
|
27
|
-
* {@link file://./subtasks/delegate.ts delegateTool}: the call *is* the round's
|
|
28
|
-
* output, so there is nothing for the SDK to run and the loop halts on it.
|
|
29
|
-
*
|
|
30
|
-
* Unlike `delegate`, this call is never reconstructed in a later round's history —
|
|
31
|
-
* a past reply is stored as, and replayed as, ordinary assistant text (history is
|
|
32
|
-
* text-only by design). The tool exists to constrain *generation*, not to become a
|
|
33
|
-
* new shape in the transcript.
|
|
34
|
-
*/
|
|
35
25
|
/**
|
|
36
26
|
* The call's input, exported as the zod schema rather than only as the tool's
|
|
37
27
|
* `inputSchema`, because the round has to run it itself.
|
|
@@ -45,6 +35,33 @@ export const FINAL_REPLY_TOOL_NAME = "final_reply";
|
|
|
45
35
|
export const finalReplyInputSchema = z.object({
|
|
46
36
|
text: nonBlank("text").describe("Your reply, in your own voice. This is shown to the user verbatim, so it must be the complete answer and must not be blank.")
|
|
47
37
|
});
|
|
38
|
+
/**
|
|
39
|
+
* The tool as the model sees it. **Without `execute`**, exactly like
|
|
40
|
+
* {@link file://../subtasks/delegate.ts delegateTool}: the call *is* the round's
|
|
41
|
+
* output, so there is nothing for the SDK to run and the loop halts on it.
|
|
42
|
+
*
|
|
43
|
+
* Unlike `delegate`, this call is never reconstructed in a later round's history —
|
|
44
|
+
* a past reply is stored as, and replayed as, ordinary assistant text (history is
|
|
45
|
+
* text-only by design). The tool exists to constrain *generation*, not to become a
|
|
46
|
+
* new shape in the transcript.
|
|
47
|
+
*/
|
|
48
|
+
/*
|
|
49
|
+
* Annotated, not inferred, and the annotation is a **packaging** constraint
|
|
50
|
+
* rather than a style choice.
|
|
51
|
+
*
|
|
52
|
+
* `tool()` returns `Tool<Input, Output, Context>`, and `Context` lives in
|
|
53
|
+
* `@ai-sdk/provider-utils` — an internal of `ai`, which resolves it as a nested
|
|
54
|
+
* copy. Left inferred, `tsc` emits `import("@ai-sdk/provider-utils").Context`
|
|
55
|
+
* into this module's `.d.ts`, so every consumer typechecking
|
|
56
|
+
* `@loopingai/core/agent` needs a package this one does not declare and cannot
|
|
57
|
+
* usefully declare: pinning it here installs a *second*, different major
|
|
58
|
+
* alongside `ai`'s own, and TypeScript then refuses the reference outright as
|
|
59
|
+
* unportable.
|
|
60
|
+
*
|
|
61
|
+
* Naming the type through `ai`'s own re-export keeps the emitted declaration
|
|
62
|
+
* pointing at a package that is already a required peer. `verify:exports` walks
|
|
63
|
+
* the declaration graph for exactly this.
|
|
64
|
+
*/
|
|
48
65
|
export const finalReplyTool = tool({
|
|
49
66
|
description: "Answer the user and end this round. Use this whenever the request is yours to answer — anything about this conversation, your own history, memory, or tools, and anything you can settle with the tools available to you here, including work that has already come back to you.",
|
|
50
67
|
inputSchema: finalReplyInputSchema
|
package/dist/agent/history.d.ts
CHANGED
|
@@ -3,7 +3,7 @@ import type { SessionMessage } from "agents/experimental/memory/session";
|
|
|
3
3
|
/**
|
|
4
4
|
* Session-history glue for the agent runtime: parse the gateway-authored `<turn>`
|
|
5
5
|
* provenance wrapper, and bridge between plain text and the Agents-SDK Sessions
|
|
6
|
-
* store. No A2A types cross this boundary — the {@link file://../a2a/
|
|
6
|
+
* store. No A2A types cross this boundary — the {@link file://../a2a/parts.ts
|
|
7
7
|
* A2A adapter} has already reduced the inbound message to a plain string.
|
|
8
8
|
*
|
|
9
9
|
* The gateway inlines a `<turn from="…" id="…" channel="…" at="…">…</turn>` tag
|
|
@@ -53,8 +53,8 @@ export declare function taskUserMessageId(taskId: string): string;
|
|
|
53
53
|
* Id of the acknowledgment a **delegating** round publishes — the message the
|
|
54
54
|
* user sees while that round's Subtasks run. Also the anchor a later round finds
|
|
55
55
|
* to reattach the round's `delegate` call to (see
|
|
56
|
-
* {@link file
|
|
57
|
-
* the round rather than stored.
|
|
56
|
+
* {@link file://../round/turn.ts renderTurnMessages}), which is why it is
|
|
57
|
+
* derived from the round rather than stored.
|
|
58
58
|
*/
|
|
59
59
|
export declare function roundAckMessageId(taskId: string, round: number): string;
|
|
60
60
|
/**
|
package/dist/agent/history.js
CHANGED
|
@@ -61,8 +61,8 @@ export function taskUserMessageId(taskId) {
|
|
|
61
61
|
* Id of the acknowledgment a **delegating** round publishes — the message the
|
|
62
62
|
* user sees while that round's Subtasks run. Also the anchor a later round finds
|
|
63
63
|
* to reattach the round's `delegate` call to (see
|
|
64
|
-
* {@link file
|
|
65
|
-
* the round rather than stored.
|
|
64
|
+
* {@link file://../round/turn.ts renderTurnMessages}), which is why it is
|
|
65
|
+
* derived from the round rather than stored.
|
|
66
66
|
*/
|
|
67
67
|
export function roundAckMessageId(taskId, round) {
|
|
68
68
|
return `task:${taskId}:round:${round}:ack`;
|
package/dist/agent/index.d.ts
CHANGED
|
@@ -19,9 +19,11 @@
|
|
|
19
19
|
* exactly what is here.
|
|
20
20
|
*/
|
|
21
21
|
export { newTurnBudget, stepAllowance, type TurnBudget } from "./budget.js";
|
|
22
|
-
export {
|
|
22
|
+
export { type GatewayMetadata, type ModelOverrides, type ModelPair, type ModelRuntime, type ModelRuntimeFactory } from "./model.js";
|
|
23
|
+
export { CredentialRejectedError, type CredentialRejectedBy } from "./errors.js";
|
|
24
|
+
export { createWorkersAIModelRuntime, workersAIModels, type WorkersAIRuntimeDeps } from "./workers-ai/index.js";
|
|
23
25
|
export { appendOnce, buildAgentSession, notifyingCompaction, type AgentSessionOptions, type SessionHost, type SessionLike } from "./session.js";
|
|
24
26
|
export { deterministicSessionMessage, finalReplyMessageId, parseRoundAckMessageId, parseTurn, roundAckMessageId, sessionMessage, sessionText, taskUserMessageId, toModelMessages, type ParsedTurn } from "./history.js";
|
|
25
|
-
export { buildIntermediateContentHandler, isTransientAiError, type OnContent } from "./inference.js";
|
|
27
|
+
export { buildIntermediateContentHandler, isTransientAiError, nonRecoverableKind, type NonRecoverableKind, type OnContent, type RoundFailureKind } from "./inference.js";
|
|
26
28
|
export { ControlCallError, controlTools, controlToolSet, type ControlTool, type TurnDecision } from "./control.js";
|
|
27
29
|
export { FINAL_REPLY_TOOL_NAME, finalReplyInputSchema, finalReplyTool } from "./final-reply.js";
|
package/dist/agent/index.js
CHANGED
|
@@ -19,9 +19,11 @@
|
|
|
19
19
|
* exactly what is here.
|
|
20
20
|
*/
|
|
21
21
|
export { newTurnBudget, stepAllowance } from "./budget.js";
|
|
22
|
-
export {
|
|
22
|
+
export {} from "./model.js";
|
|
23
|
+
export { CredentialRejectedError } from "./errors.js";
|
|
24
|
+
export { createWorkersAIModelRuntime, workersAIModels } from "./workers-ai/index.js";
|
|
23
25
|
export { appendOnce, buildAgentSession, notifyingCompaction } from "./session.js";
|
|
24
26
|
export { deterministicSessionMessage, finalReplyMessageId, parseRoundAckMessageId, parseTurn, roundAckMessageId, sessionMessage, sessionText, taskUserMessageId, toModelMessages } from "./history.js";
|
|
25
|
-
export { buildIntermediateContentHandler, isTransientAiError } from "./inference.js";
|
|
27
|
+
export { buildIntermediateContentHandler, isTransientAiError, nonRecoverableKind } from "./inference.js";
|
|
26
28
|
export { ControlCallError, controlTools, controlToolSet } from "./control.js";
|
|
27
29
|
export { FINAL_REPLY_TOOL_NAME, finalReplyInputSchema, finalReplyTool } from "./final-reply.js";
|
|
@@ -5,7 +5,7 @@ import type { StepResult, ToolSet } from "ai";
|
|
|
5
5
|
*
|
|
6
6
|
* The two loops themselves are deliberately separate, not layered on a common
|
|
7
7
|
* one: the main agent's Session-coupled round lives in
|
|
8
|
-
* {@link file
|
|
8
|
+
* {@link file://../round/turn.ts turn.ts}, and the Session-less subagent loop in
|
|
9
9
|
* {@link file://../subagent/run.ts run.ts}. They share error classification and
|
|
10
10
|
* progress streaming; their control flow has nothing in common worth abstracting.
|
|
11
11
|
*/
|
|
@@ -34,6 +34,63 @@ export type OnContent = (text: string, stepIndex: number) => void | Promise<void
|
|
|
34
34
|
* error codes, which arrive as prose on a plain `Error`.
|
|
35
35
|
*/
|
|
36
36
|
export declare function isTransientAiError(err: unknown): boolean;
|
|
37
|
+
/**
|
|
38
|
+
* Why a round stopped without a second attempt being worth making.
|
|
39
|
+
*
|
|
40
|
+
* A stable string rather than the error itself, because this value crosses two
|
|
41
|
+
* serialization boundaries — the DO's RPC return and a Workflow step result —
|
|
42
|
+
* and an `Error` survives neither reliably. The host maps it to operator-facing
|
|
43
|
+
* copy; core never owns that wording.
|
|
44
|
+
*
|
|
45
|
+
* The credential kinds are separate strings rather than one, because they have
|
|
46
|
+
* different remedies and the host cannot tell them apart afterwards:
|
|
47
|
+
*
|
|
48
|
+
* - `credential` — the model provider rejected the token. Rotate that one.
|
|
49
|
+
* - `gateway-credential` — the gateway *in front of* the provider rejected the
|
|
50
|
+
* request, which the provider therefore never saw. Rotate the gateway's token
|
|
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
|
+
* - `unknown-credential` — a `401`/`403` matching none of the shapes. Says so,
|
|
57
|
+
* rather than picking one and sending an operator to rotate a working secret.
|
|
58
|
+
*/
|
|
59
|
+
export type NonRecoverableKind = "credential" | "gateway-credential" | "proxy-credential" | "unknown-credential";
|
|
60
|
+
/**
|
|
61
|
+
* Why a round ended with no answer — one terminal status, two situations.
|
|
62
|
+
*
|
|
63
|
+
* `exhausted` is the ladder run to the end: both slots tried, every repair
|
|
64
|
+
* spent, nothing usable produced. Every other member is the ladder stopping
|
|
65
|
+
* early, because nothing further could have cleared the fault — see
|
|
66
|
+
* {@link nonRecoverableKind}.
|
|
67
|
+
*
|
|
68
|
+
* The distinction is a *reason*, not an outcome: both deliver a failed Task with
|
|
69
|
+
* the same shape. What it decides is the words, and only the host has those (see
|
|
70
|
+
* `HandleTaskDeps.failureCopy`) — which is why this is a total union rather than
|
|
71
|
+
* an optional field. A consumer that maps kinds to copy is then a `Record` the
|
|
72
|
+
* compiler checks, and a new kind cannot be silently ignored by any of them.
|
|
73
|
+
*/
|
|
74
|
+
export type RoundFailureKind = "exhausted" | NonRecoverableKind;
|
|
75
|
+
/**
|
|
76
|
+
* Whether an error is one that **no** further attempt can clear, and the reason.
|
|
77
|
+
*
|
|
78
|
+
* This is the third classification, and the one the other two cannot express.
|
|
79
|
+
* {@link isTransientAiError} splits failures into "retry the step" (`true`) and
|
|
80
|
+
* "burn this slot, try the fallback" (`false`) — and for a rejected credential
|
|
81
|
+
* *both* are wrong. Retrying spends the Workflow's budget on a request that can
|
|
82
|
+
* never succeed; falling back spends the second slot presenting the *same* dead
|
|
83
|
+
* token. Returning `false` from the transient check only avoids the first.
|
|
84
|
+
*
|
|
85
|
+
* So the attempt ladders check this **before** entering the fallback slot and
|
|
86
|
+
* stop there, and `runHandleTask` ends the Task with copy the host supplies.
|
|
87
|
+
* Nothing is retried and nothing is spent proving the obvious twice.
|
|
88
|
+
*
|
|
89
|
+
* Keyed on {@link file://./errors.ts CredentialRejectedError}, which is neutral
|
|
90
|
+
* and structurally matched — so a provider outside core raises one and gets this
|
|
91
|
+
* handling with nothing here to change.
|
|
92
|
+
*/
|
|
93
|
+
export declare function nonRecoverableKind(err: unknown): NonRecoverableKind | undefined;
|
|
37
94
|
/**
|
|
38
95
|
* Returns a fresh `onStepEnd` callback for one `generateText` attempt.
|
|
39
96
|
* Fires `onContent` for each intermediate step (text that accompanies tool
|