@ultimat3/ai 2.0.0 → 4.0.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/CLAUDE.md +205 -4
- package/README.md +149 -4
- package/package.json +11 -9
- package/src/agent-facts.ts +70 -0
- package/src/agent-job.ts +97 -0
- package/src/agent-transcript.ts +94 -0
- package/src/agent.ts +170 -61
- package/src/errors.ts +2 -0
- package/src/fetch-seam.ts +15 -0
- package/src/gateway.ts +25 -11
- package/src/hive-errors.ts +30 -0
- package/src/hive-pool.ts +90 -0
- package/src/hive-result.ts +96 -0
- package/src/hive.ts +177 -0
- package/src/index.ts +15 -10
- package/src/llm-cache.ts +111 -0
- package/src/llm.ts +14 -66
- package/src/openai-provider.ts +21 -6
- package/src/openai-wire.ts +59 -25
- package/src/prompt.ts +6 -1
- package/src/provider.ts +34 -7
- package/src/rag.ts +66 -6
- package/src/remote-embedder.ts +3 -2
- package/src/runtime.ts +19 -5
- package/src/sse.ts +32 -1
- package/src/tools.ts +95 -8
- package/src/wire.ts +23 -13
package/src/agent-job.ts
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `agentJob()` — an agent as durable, resumable, budgeted background work.
|
|
3
|
+
*
|
|
4
|
+
* The bridge `packages/action/src/job-handle.ts` names in its own header and could not build:
|
|
5
|
+
* `isJobHandle` needs `kind === 'job'` PLUS membership of a WeakMap only `job()` writes, so no
|
|
6
|
+
* externally-shaped object reaches the registry, the queue or the worker — and `action` and `jobs`
|
|
7
|
+
* are both tier 3, so neither may import the other. This package is tier 4 and may import both,
|
|
8
|
+
* which is exactly where that header says the adapter belongs.
|
|
9
|
+
*
|
|
10
|
+
* It composes `job()` rather than re-implementing a handle: the returned value IS one `job()`
|
|
11
|
+
* seated, so `.enqueue()`, the outbox, the worker's cancellation, the dead-letter path,
|
|
12
|
+
* `x jobs show` and its manifest row all arrive without a line here.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { Action, ActionJobHandle } from '@ultimat3/action';
|
|
16
|
+
import type { JobHandle, JobTenant, RetryPolicy } from '@ultimat3/jobs';
|
|
17
|
+
import { job } from '@ultimat3/jobs';
|
|
18
|
+
import type { InferInput, InferOutput, StandardSchemaV1 } from '@ultimat3/schema';
|
|
19
|
+
|
|
20
|
+
export interface AgentJobOptions<I> {
|
|
21
|
+
/**
|
|
22
|
+
* The durable queue key. REQUIRED and never derived from the agent's export name: a job name is
|
|
23
|
+
* what queued, retrying and dead-lettered rows already carry, so renaming an export must not
|
|
24
|
+
* move where they are delivered.
|
|
25
|
+
*/
|
|
26
|
+
readonly name: string;
|
|
27
|
+
/**
|
|
28
|
+
* REQUIRED, no default, and the org this run's body acts under. `jobs` states why: every
|
|
29
|
+
* candidate default is a cross-tenant read waiting for the first job that takes an org id in its
|
|
30
|
+
* input. `tenant: 'none'` is the explicit statement that this agent touches no tenant-scoped
|
|
31
|
+
* table, and then every scoped read inside it fails closed.
|
|
32
|
+
*/
|
|
33
|
+
readonly tenant: JobTenant<I>;
|
|
34
|
+
/** REQUIRED, no default. A model call fails transiently; how many times is nobody else's guess. */
|
|
35
|
+
readonly retry: RetryPolicy;
|
|
36
|
+
readonly queue?: string;
|
|
37
|
+
/**
|
|
38
|
+
* Defaults to the action projection's own key — `action:<name>:<fingerprint of input>` — which
|
|
39
|
+
* is stable across retries and derived from the payload alone.
|
|
40
|
+
*
|
|
41
|
+
* **It dedupes the ENQUEUE, never the ATTEMPT.** Two `enqueue` calls with the same payload are
|
|
42
|
+
* one row; one row that a worker claims, half-runs and loses the lease on is claimed again, and
|
|
43
|
+
* the agent runs a second time from the top. Combined with `backfill()`, whose `handle` is at
|
|
44
|
+
* least once by construction, a replayed page re-runs every agent on it.
|
|
45
|
+
*
|
|
46
|
+
* What that means for `tools`: **every tool the agent may call has to be idempotent** — an
|
|
47
|
+
* `upsertAll`, an `updateWhere`, a statement whose second run changes nothing — because a
|
|
48
|
+
* replayed attempt issues a second `issueRefund` otherwise. The framework does NOT check this and
|
|
49
|
+
* cannot: `mutates` is not a fact an `action()` declares (`@ultimat3/mcp` sets it to `true` for
|
|
50
|
+
* every action it projects), so a read-only `lookupOrder` and a destructive `issueRefund` are
|
|
51
|
+
* indistinguishable here, and a rule refusing both would be a wrong refusal. See the README.
|
|
52
|
+
*/
|
|
53
|
+
idempotencyKey?(input: I): string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Wrap an `agent()` — or any action — as a real job handle.
|
|
58
|
+
*
|
|
59
|
+
* Both reads of the underlying projection are LAZY, and that is load-bearing: `target.job()` calls
|
|
60
|
+
* `actionName()`, which throws `X_ACTION_UNREGISTERED` until `registerAction` stamps the export
|
|
61
|
+
* name at boot — and `agentJob()` is evaluated at module scope, right beside the `agent()` it
|
|
62
|
+
* wraps. The queue key comes from `options.name` for the same reason it is required.
|
|
63
|
+
*/
|
|
64
|
+
export function agentJob<TInput extends StandardSchemaV1, TOutput extends StandardSchemaV1>(
|
|
65
|
+
target: Action<TInput, TOutput>,
|
|
66
|
+
options: AgentJobOptions<InferOutput<TInput>>,
|
|
67
|
+
): JobHandle<InferOutput<TInput>> {
|
|
68
|
+
let projected: ActionJobHandle<TInput, TOutput> | undefined;
|
|
69
|
+
const bridge = (): ActionJobHandle<TInput, TOutput> => {
|
|
70
|
+
if (projected === undefined) projected = target.job();
|
|
71
|
+
return projected;
|
|
72
|
+
};
|
|
73
|
+
return job<InferOutput<TInput>>({
|
|
74
|
+
name: options.name,
|
|
75
|
+
input: target.input as StandardSchemaV1<unknown, InferOutput<TInput>>,
|
|
76
|
+
tenant: options.tenant,
|
|
77
|
+
retry: options.retry,
|
|
78
|
+
...(options.queue === undefined ? {} : { queue: options.queue }),
|
|
79
|
+
idempotencyKey: (input) =>
|
|
80
|
+
options.idempotencyKey?.(input) ?? bridge().idempotencyKey(asInput<TInput>(input)),
|
|
81
|
+
// ONE execution path, and it is the action's. `ActionJobHandle.invoke` is `invoke(target,
|
|
82
|
+
// input, { surface: 'job', ctx })`, so the agent's policy, its input parse, its budget scope
|
|
83
|
+
// and its span all apply — and `ctx` is the WORKER's, so `ctx.signal` aborting at the attempt
|
|
84
|
+
// timeout reaches the agent's turn loop.
|
|
85
|
+
run: ({ input, ctx }) => bridge().invoke(asInput<TInput>(input), ctx),
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The job parsed with the action's OWN schema, so what it hands back is a value that schema
|
|
91
|
+
* accepts — `invoke` re-parses it regardless, which is what makes this safe rather than merely
|
|
92
|
+
* convenient. The cast exists because `InferOutput` and `InferInput` are different types wherever a
|
|
93
|
+
* field has a default, and nothing at this seam can prove they meet.
|
|
94
|
+
*/
|
|
95
|
+
function asInput<TInput extends StandardSchemaV1>(value: InferOutput<TInput>): InferInput<TInput> {
|
|
96
|
+
return value as InferInput<TInput>;
|
|
97
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The transcript one agent turn leaves behind: the assistant message replaying what the model
|
|
3
|
+
* emitted, and the user message answering it. One file because the Messages API's rule spans both
|
|
4
|
+
* halves — every `tool_use` block replayed here must be answered by a `tool_result` in the very
|
|
5
|
+
* next message — and a rule split across two call sites is a rule one of them will miss.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { RESPOND } from './llm';
|
|
9
|
+
import type { AiContentBlock, AiMessage, GenerateResult } from './provider';
|
|
10
|
+
import type { LlmToolCall, LlmToolResult } from './tools';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The model's turn, replayed as the transcript the next request needs. `tool_use` blocks survive
|
|
14
|
+
* as themselves here — unlike `llm()`'s repair turn, which flattens them to text precisely
|
|
15
|
+
* because it has no `tool_result` to follow them with, and the API demands one.
|
|
16
|
+
*/
|
|
17
|
+
export function assistantTurn(result: GenerateResult): AiMessage {
|
|
18
|
+
const blocks: AiContentBlock[] = [];
|
|
19
|
+
if (result.text !== '') blocks.push({ type: 'text', text: result.text });
|
|
20
|
+
for (const call of result.toolCalls) {
|
|
21
|
+
blocks.push({ type: 'tool_use', id: call.id, name: call.name, input: call.input });
|
|
22
|
+
}
|
|
23
|
+
// A turn with neither text nor a tool call would be an empty content array, which is a 400.
|
|
24
|
+
return blocks.length === 0
|
|
25
|
+
? { role: 'assistant', content: '(no answer)' }
|
|
26
|
+
: { role: 'assistant', content: blocks };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* What the model is told when it answered in the same turn it asked for a tool. Parallel tool use
|
|
31
|
+
* is normal, and the answer that comes with it was written before the tool result existed — so it
|
|
32
|
+
* is superseded, not wrong, and saying which is what stops the next turn repeating it verbatim.
|
|
33
|
+
*/
|
|
34
|
+
const SUPERSEDED = `This answer arrived in the same turn as the tool calls above, so it was written before their results existed. Read the results in this message, then call the "${RESPOND}" tool again.`;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The tool results of one turn, plus a `tool_result` for every `respond` call the loop did not
|
|
38
|
+
* accept. The second half is not optional: `respond` is filtered out of the calls that get RUN,
|
|
39
|
+
* but it is replayed as a `tool_use` like any other, and a `tool_use` the next message does not
|
|
40
|
+
* answer is a 400 (`tool_use ids were found without tool_result blocks`) — the whole run lost to
|
|
41
|
+
* an `X_AI_PROVIDER_UNAVAILABLE` because the model did the ordinary thing.
|
|
42
|
+
*
|
|
43
|
+
* Real results stay first: they are what the model asked for, and the rejection reads as the
|
|
44
|
+
* instruction that follows them.
|
|
45
|
+
*/
|
|
46
|
+
export function toolResultTurn(
|
|
47
|
+
results: readonly LlmToolResult[],
|
|
48
|
+
chars: number,
|
|
49
|
+
unaccepted: readonly LlmToolCall[],
|
|
50
|
+
): AiMessage {
|
|
51
|
+
return {
|
|
52
|
+
role: 'user',
|
|
53
|
+
content: [
|
|
54
|
+
...results.map((result) => ({
|
|
55
|
+
type: 'tool_result' as const,
|
|
56
|
+
tool_use_id: result.toolUseId,
|
|
57
|
+
content: truncate(result.content, chars),
|
|
58
|
+
// A denial or a failure is an outcome the model should read and react to, flagged so it
|
|
59
|
+
// does not read as data.
|
|
60
|
+
...(result.isError === true ? { is_error: true } : {}),
|
|
61
|
+
})),
|
|
62
|
+
...rejections(unaccepted, SUPERSEDED),
|
|
63
|
+
],
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The correction after an answer that failed its schema, in whichever shape the turn it corrects
|
|
69
|
+
* demands: a `tool_result` when the answer came through `respond` (the dominant path, and a
|
|
70
|
+
* `tool_use` the API insists on seeing answered), a plain user message when the model answered in
|
|
71
|
+
* prose and there is no id to name. Framework text, so never truncated by `maxToolResultChars`.
|
|
72
|
+
*/
|
|
73
|
+
export function repairTurn(unaccepted: readonly LlmToolCall[], issues: string): AiMessage {
|
|
74
|
+
const text = `That answer failed its schema: ${issues}. Call the "${RESPOND}" tool with a value that satisfies it. Answer only through the tool.`;
|
|
75
|
+
return unaccepted.length === 0
|
|
76
|
+
? { role: 'user', content: text }
|
|
77
|
+
: { role: 'user', content: rejections(unaccepted, text) };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** A `tool_result` saying an answer was not taken, flagged so the model does not read it as data. */
|
|
81
|
+
function rejections(calls: readonly LlmToolCall[], text: string): readonly AiContentBlock[] {
|
|
82
|
+
return calls.map((call) => ({
|
|
83
|
+
type: 'tool_result' as const,
|
|
84
|
+
tool_use_id: call.id,
|
|
85
|
+
content: text,
|
|
86
|
+
is_error: true,
|
|
87
|
+
}));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Truncation says so. A silently shortened tool result is a model reasoning over half a table. */
|
|
91
|
+
function truncate(text: string, chars: number): string {
|
|
92
|
+
if (text.length <= chars) return text;
|
|
93
|
+
return `${text.slice(0, chars)}\n[truncated: ${text.length - chars} more characters]`;
|
|
94
|
+
}
|
package/src/agent.ts
CHANGED
|
@@ -20,10 +20,14 @@
|
|
|
20
20
|
|
|
21
21
|
import type { Action, ActionMcp, ActionPolicy } from '@ultimat3/action';
|
|
22
22
|
import { action } from '@ultimat3/action';
|
|
23
|
-
import type { Ctx } from '@ultimat3/core';
|
|
24
|
-
import { withSpan } from '@ultimat3/core';
|
|
23
|
+
import type { Ctx, Span } from '@ultimat3/core';
|
|
24
|
+
import { isMcpExposed, throwIfAborted, withSpan } from '@ultimat3/core';
|
|
25
|
+
import type { Money } from '@ultimat3/money';
|
|
25
26
|
import type { InferOutput, StandardSchemaV1 } from '@ultimat3/schema';
|
|
26
27
|
import { formatIssues, validateAsync } from '@ultimat3/schema';
|
|
28
|
+
import type { AgentFact } from './agent-facts';
|
|
29
|
+
import { registerAgentFact } from './agent-facts';
|
|
30
|
+
import { assistantTurn, repairTurn, toolResultTurn } from './agent-transcript';
|
|
27
31
|
import type { BudgetLimits } from './budget';
|
|
28
32
|
import { BudgetLedger, currentBudget, withBudget } from './budget';
|
|
29
33
|
import {
|
|
@@ -38,11 +42,17 @@ import { answerAttributes, RESPOND, respondToolFor, structuredOutputOf } from '.
|
|
|
38
42
|
import type { ModelId } from './models';
|
|
39
43
|
import { DEFAULT_MODEL, moreCapableThan } from './models';
|
|
40
44
|
import type { Prompt, PromptVars } from './prompt';
|
|
41
|
-
import type {
|
|
45
|
+
import type {
|
|
46
|
+
AiMessage,
|
|
47
|
+
GenerateRequest,
|
|
48
|
+
GenerateResult,
|
|
49
|
+
StopReason,
|
|
50
|
+
TokenUsage,
|
|
51
|
+
} from './provider';
|
|
42
52
|
import { assertNoSecrets } from './redaction';
|
|
43
53
|
import { aiGateway, aiRedactor } from './runtime';
|
|
44
|
-
import type { LlmTool, LlmToolResult, ProjectableAction } from './tools';
|
|
45
|
-
import { runLlmToolCall, toLlmTools } from './tools';
|
|
54
|
+
import type { AgentTool, LlmTool, LlmToolResult, ProjectableAction } from './tools';
|
|
55
|
+
import { asProjectableAction, runLlmToolCall, toLlmTools, toolLabel } from './tools';
|
|
46
56
|
|
|
47
57
|
/**
|
|
48
58
|
* Turn ceiling when the declaration omits one. Low on purpose: a loop that needs more than this
|
|
@@ -91,7 +101,7 @@ export interface AgentDef<
|
|
|
91
101
|
* exactly the same tools. Listing one that is not exposed is refused at declaration rather than
|
|
92
102
|
* dropped, because a tool that reads as offered and silently is not is the worst of both.
|
|
93
103
|
*/
|
|
94
|
-
readonly tools: readonly
|
|
104
|
+
readonly tools: readonly AgentTool[];
|
|
95
105
|
/** Hard ceiling on model turns. Reaching it is `X_AGENT_MAX_TURNS`, never a partial answer. */
|
|
96
106
|
readonly maxTurns?: number;
|
|
97
107
|
readonly maxToolResultChars?: number;
|
|
@@ -100,6 +110,36 @@ export interface AgentDef<
|
|
|
100
110
|
readonly mcp?: ActionMcp;
|
|
101
111
|
/** Enforced completion ceiling PER TURN. The model never sees it. */
|
|
102
112
|
readonly maxTokens?: number;
|
|
113
|
+
/**
|
|
114
|
+
* Called once per completed model turn, before the answer or the tool calls are acted on. The
|
|
115
|
+
* one thing a multi-turn run could not do until 2026-08: a 90-second loop emitted nothing until
|
|
116
|
+
* it returned, so a progress indicator, a per-turn spend line and a transcript log all had to be
|
|
117
|
+
* hand-rolled outside the framework — which is exactly the loop `agent()` exists to replace.
|
|
118
|
+
*
|
|
119
|
+
* Observation only: it cannot steer the loop, cannot see the transcript and cannot reach the
|
|
120
|
+
* actor. A throw from it FAILS the run rather than being swallowed — it is the app's code on the
|
|
121
|
+
* run's own path, and an observer that silently stopped working would be indistinguishable from
|
|
122
|
+
* one that is fine. The same facts land on the span as an `agent.turn` event, so a run that
|
|
123
|
+
* declares no hook is still readable in a trace.
|
|
124
|
+
*
|
|
125
|
+
* NOT `.stream()`. Tokens on a screen is a different contract, and `llm()`'s streamed half is
|
|
126
|
+
* one turn deep by construction; this is per TURN.
|
|
127
|
+
*/
|
|
128
|
+
onTurn?(event: AgentTurn): void | Promise<void>;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** One completed model turn, as an observer sees it. Facts only — no transcript, no actor. */
|
|
132
|
+
export interface AgentTurn {
|
|
133
|
+
/** 1-based, so `turn === maxTurns` is the last one this run will take. */
|
|
134
|
+
readonly turn: number;
|
|
135
|
+
readonly maxTurns: number;
|
|
136
|
+
readonly model: ModelId;
|
|
137
|
+
/** Tools this turn asked for, in the order the model emitted them. `respond` is not one. */
|
|
138
|
+
readonly toolCalls: readonly string[];
|
|
139
|
+
readonly stopReason: StopReason;
|
|
140
|
+
readonly usage: TokenUsage;
|
|
141
|
+
/** This turn's cost alone, integer minor units — never the run's running total. */
|
|
142
|
+
readonly cost: Money;
|
|
103
143
|
}
|
|
104
144
|
|
|
105
145
|
export function agent<
|
|
@@ -109,21 +149,69 @@ export function agent<
|
|
|
109
149
|
>(def: AgentDef<TInput, TOutput, V>): Action<TInput, TOutput> {
|
|
110
150
|
const respond = respondToolFor(def.output);
|
|
111
151
|
// At declaration, because the tools are values by then and a run that discovers this at the
|
|
112
|
-
// first request has already been declared, registered and projected as if it worked.
|
|
113
|
-
|
|
114
|
-
|
|
152
|
+
// first request has already been declared, registered and projected as if it worked. Asked of
|
|
153
|
+
// the DECLARATION rather than of the projection: `isMcpExposed` needs no name, and a real
|
|
154
|
+
// `action()` beside this one in the same module has none until `registerActions` runs at boot.
|
|
155
|
+
const unexposed = def.tools.filter((tool) => !isMcpExposed(tool.mcp));
|
|
156
|
+
if (unexposed.length > 0) {
|
|
115
157
|
throw new AgentToolUnexposedError({
|
|
116
158
|
agent: def.prompt.ref,
|
|
117
|
-
tools:
|
|
159
|
+
tools: unexposed.map(toolLabel),
|
|
118
160
|
});
|
|
119
161
|
}
|
|
120
|
-
|
|
162
|
+
// Projected on FIRST RUN, memoised, for the reason above: `agent()` is evaluated at module
|
|
163
|
+
// scope and a tool's name is stamped by `registerAction` at boot, so naming it here would make
|
|
164
|
+
// the ordinary `export const publishPost = action(...)` beside it `X_ACTION_UNREGISTERED`.
|
|
165
|
+
let adapted: Adapted | undefined;
|
|
166
|
+
const adapt = (): Adapted => {
|
|
167
|
+
if (adapted === undefined) adapted = project(def.tools);
|
|
168
|
+
return adapted;
|
|
169
|
+
};
|
|
170
|
+
const built = action<TInput, TOutput>({
|
|
121
171
|
input: def.input,
|
|
122
172
|
output: def.output,
|
|
123
173
|
policy: def.policy,
|
|
124
174
|
...(def.mcp === undefined ? {} : { mcp: def.mcp }),
|
|
125
|
-
handle: (args) => run(def, respond,
|
|
175
|
+
handle: (args) => run(def, respond, adapt(), args),
|
|
126
176
|
});
|
|
177
|
+
// A thunk, resolved when the manifest asks: every name in the row is stamped at boot, and this
|
|
178
|
+
// line runs at module scope.
|
|
179
|
+
registerAgentFact(built, () => factsOf(def));
|
|
180
|
+
return built;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function factsOf<
|
|
184
|
+
TInput extends StandardSchemaV1,
|
|
185
|
+
TOutput extends StandardSchemaV1,
|
|
186
|
+
V extends PromptVars,
|
|
187
|
+
>(def: AgentDef<TInput, TOutput, V>): Omit<AgentFact, 'name'> {
|
|
188
|
+
const budget = def.budget;
|
|
189
|
+
return {
|
|
190
|
+
prompt: def.prompt.ref,
|
|
191
|
+
promptId: def.prompt.id,
|
|
192
|
+
promptHash: def.prompt.hash,
|
|
193
|
+
model: def.model ?? def.prompt.model ?? DEFAULT_MODEL,
|
|
194
|
+
maxTurns: def.maxTurns ?? DEFAULT_MAX_TURNS,
|
|
195
|
+
maxToolResultChars: def.maxToolResultChars ?? DEFAULT_TOOL_RESULT_CHARS,
|
|
196
|
+
tools: [...def.tools.map(toolLabel)].sort(),
|
|
197
|
+
budget: {
|
|
198
|
+
tokensIn: budget?.tokensIn ?? null,
|
|
199
|
+
tokensPerRun: budget?.tokensPerRun ?? null,
|
|
200
|
+
costPerCall: budget?.costPerCall ?? null,
|
|
201
|
+
},
|
|
202
|
+
mcp: isMcpExposed(def.mcp),
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** The tools as `runLlmToolCall` consumes them, and as the request offers them. One projection. */
|
|
207
|
+
interface Adapted {
|
|
208
|
+
readonly tools: readonly ProjectableAction[];
|
|
209
|
+
readonly offered: readonly LlmTool[];
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function project(tools: readonly AgentTool[]): Adapted {
|
|
213
|
+
const projected = tools.map(asProjectableAction);
|
|
214
|
+
return { tools: projected, offered: toLlmTools(projected) };
|
|
127
215
|
}
|
|
128
216
|
|
|
129
217
|
async function run<
|
|
@@ -133,7 +221,7 @@ async function run<
|
|
|
133
221
|
>(
|
|
134
222
|
def: AgentDef<TInput, TOutput, V>,
|
|
135
223
|
respond: LlmTool,
|
|
136
|
-
|
|
224
|
+
adapted: Adapted,
|
|
137
225
|
args: { readonly input: InferOutput<TInput>; readonly ctx: Ctx },
|
|
138
226
|
): Promise<InferOutput<TOutput>> {
|
|
139
227
|
const { prompt } = def;
|
|
@@ -153,7 +241,7 @@ async function run<
|
|
|
153
241
|
'agent.model': model,
|
|
154
242
|
'agent.prompt': name,
|
|
155
243
|
'agent.prompt.hash': prompt.hash,
|
|
156
|
-
'agent.tools': offered.length,
|
|
244
|
+
'agent.tools': adapted.offered.length,
|
|
157
245
|
'agent.max_turns': maxTurns,
|
|
158
246
|
'llm.redacted': rendered !== rawPrompt || system !== prompt.system,
|
|
159
247
|
});
|
|
@@ -167,7 +255,10 @@ async function run<
|
|
|
167
255
|
maxTokens: def.maxTokens ?? DEFAULT_MAX_TOKENS,
|
|
168
256
|
...(prompt.effort === undefined ? {} : { effort: prompt.effort }),
|
|
169
257
|
...(prompt.thinking === undefined ? {} : { thinking: prompt.thinking }),
|
|
170
|
-
tools: [...offered, respond],
|
|
258
|
+
tools: [...adapted.offered, respond],
|
|
259
|
+
// The caller's own signal, forwarded to the transport. A disconnect has to reach the socket,
|
|
260
|
+
// not just the top of the next turn: a provider call already in flight is the expensive one.
|
|
261
|
+
signal: args.ctx.signal,
|
|
171
262
|
};
|
|
172
263
|
|
|
173
264
|
return withBudget(ledger, async () => {
|
|
@@ -180,20 +271,57 @@ async function run<
|
|
|
180
271
|
let issues: string | undefined;
|
|
181
272
|
|
|
182
273
|
for (let turn = 1; turn <= maxTurns; turn += 1) {
|
|
274
|
+
// The caller is gone, so unwind instead of finishing. Every turn re-sends the WHOLE
|
|
275
|
+
// transcript, so a loop that keeps going after a disconnect spends the rest of the run's
|
|
276
|
+
// budget, runs every remaining tool's side effects, and discards the answer.
|
|
277
|
+
throwIfAborted(args.ctx);
|
|
183
278
|
const result = await gateway.generate({ ...base, messages });
|
|
279
|
+
const requested = result.toolCalls.filter((call) => call.name !== RESPOND);
|
|
280
|
+
// The same turn's `respond` calls, kept rather than forgotten: they are never RUN, but
|
|
281
|
+
// `assistantTurn` replays them, and every replayed `tool_use` has to be answered.
|
|
282
|
+
const answers = result.toolCalls.filter((call) => call.name === RESPOND);
|
|
184
283
|
span.setAttributes({
|
|
185
284
|
'agent.turns': turn,
|
|
186
285
|
'agent.tool_calls': calls,
|
|
187
286
|
...answerAttributes(result),
|
|
188
287
|
});
|
|
288
|
+
await observeTurn(def, span, {
|
|
289
|
+
turn,
|
|
290
|
+
maxTurns,
|
|
291
|
+
model: result.model,
|
|
292
|
+
toolCalls: requested.map((call) => call.name),
|
|
293
|
+
stopReason: result.stopReason,
|
|
294
|
+
usage: result.usage,
|
|
295
|
+
cost: result.cost,
|
|
296
|
+
});
|
|
189
297
|
assertAnswerable(result, name);
|
|
190
298
|
|
|
191
|
-
const requested = result.toolCalls.filter((call) => call.name !== RESPOND);
|
|
192
299
|
if (requested.length > 0) {
|
|
193
|
-
|
|
194
|
-
for
|
|
300
|
+
// Concurrent, and deliberately unbounded WITHIN one turn: the batch is what a single
|
|
301
|
+
// model turn asked for, each entry is an ordinary `action` carrying its own policy and
|
|
302
|
+
// its own `rateLimit`, and a second ceiling here would be a throttle competing with
|
|
303
|
+
// those. A tool that calls a model still queues on the ledger's root turnstile, so the
|
|
304
|
+
// budget holds. Serial cost 5x wall clock for a turn that asked for 5 tools, and nothing
|
|
305
|
+
// in the types or the docs ever said so.
|
|
306
|
+
//
|
|
307
|
+
// Order is by INDEX, not by completion: `Promise.all` resolves positionally and each
|
|
308
|
+
// result carries the `tool_use` id `runLlmToolCall` was handed, so a fast tool answering
|
|
309
|
+
// first cannot be paired with a slow tool's call.
|
|
310
|
+
throwIfAborted(args.ctx);
|
|
311
|
+
const results: readonly LlmToolResult[] = await Promise.all(
|
|
312
|
+
requested.map((call) => runLlmToolCall(adapted.tools, call, actor)),
|
|
313
|
+
);
|
|
195
314
|
calls += requested.length;
|
|
196
|
-
messages = [
|
|
315
|
+
messages = [
|
|
316
|
+
...messages,
|
|
317
|
+
assistantTurn(result),
|
|
318
|
+
// Parallel tool use — one turn asking for a tool AND answering — is normal, and the
|
|
319
|
+
// answer is speculative: it was written before the result it asked for existed. So
|
|
320
|
+
// the loop continues, and the answer is REJECTED on the wire rather than dropped,
|
|
321
|
+
// because a replayed `respond` block with no `tool_result` is a 400 and the whole run
|
|
322
|
+
// becomes an `X_AI_PROVIDER_UNAVAILABLE`.
|
|
323
|
+
toolResultTurn(results, chars, answers),
|
|
324
|
+
];
|
|
197
325
|
continue;
|
|
198
326
|
}
|
|
199
327
|
|
|
@@ -205,7 +333,7 @@ async function run<
|
|
|
205
333
|
if (result.stopReason === 'max_tokens') {
|
|
206
334
|
throw new LlmTruncatedError({ prompt: name, maxTokens: base.maxTokens });
|
|
207
335
|
}
|
|
208
|
-
messages = [...messages, assistantTurn(result),
|
|
336
|
+
messages = [...messages, assistantTurn(result), repairTurn(answers, issues)];
|
|
209
337
|
}
|
|
210
338
|
|
|
211
339
|
// Two different exhaustions, so two different causes: a loop that kept calling tools and
|
|
@@ -218,6 +346,28 @@ async function run<
|
|
|
218
346
|
});
|
|
219
347
|
}
|
|
220
348
|
|
|
349
|
+
/**
|
|
350
|
+
* One turn, reported twice: onto the span every run already opens, and to the declaration's own
|
|
351
|
+
* hook. The span event is free — nothing to install, and a trace is where a stalled run is
|
|
352
|
+
* actually diagnosed — and the hook is what a progress indicator or a per-turn spend line reads.
|
|
353
|
+
*
|
|
354
|
+
* Awaited, and not guarded: a throw from `onTurn` fails the run. It is the app's code on the run's
|
|
355
|
+
* path, and an observer that quietly stopped working reads exactly like one that is fine.
|
|
356
|
+
*/
|
|
357
|
+
async function observeTurn<
|
|
358
|
+
TInput extends StandardSchemaV1,
|
|
359
|
+
TOutput extends StandardSchemaV1,
|
|
360
|
+
V extends PromptVars,
|
|
361
|
+
>(def: AgentDef<TInput, TOutput, V>, span: Span, event: AgentTurn): Promise<void> {
|
|
362
|
+
span.addEvent('agent.turn', {
|
|
363
|
+
'agent.turn': event.turn,
|
|
364
|
+
'agent.turn.tool_calls': event.toolCalls.length,
|
|
365
|
+
'llm.stop': event.stopReason,
|
|
366
|
+
'llm.cost.minor': event.cost.minor,
|
|
367
|
+
});
|
|
368
|
+
await def.onTurn?.(event);
|
|
369
|
+
}
|
|
370
|
+
|
|
221
371
|
/** Refuse before the answer is read, for the reason `llm()` does: a refusal is a 200 with no answer. */
|
|
222
372
|
function assertAnswerable(result: GenerateResult, name: string): void {
|
|
223
373
|
if (result.stopReason !== 'refusal') return;
|
|
@@ -230,47 +380,6 @@ function assertAnswerable(result: GenerateResult, name: string): void {
|
|
|
230
380
|
});
|
|
231
381
|
}
|
|
232
382
|
|
|
233
|
-
/**
|
|
234
|
-
* The model's turn, replayed as the transcript the next request needs. `tool_use` blocks survive
|
|
235
|
-
* as themselves here — unlike `llm()`'s repair turn, which flattens them to text precisely
|
|
236
|
-
* because it has no `tool_result` to follow them with, and the API demands one.
|
|
237
|
-
*/
|
|
238
|
-
function assistantTurn(result: GenerateResult): AiMessage {
|
|
239
|
-
const blocks: AiContentBlock[] = [];
|
|
240
|
-
if (result.text !== '') blocks.push({ type: 'text', text: result.text });
|
|
241
|
-
for (const call of result.toolCalls) {
|
|
242
|
-
blocks.push({ type: 'tool_use', id: call.id, name: call.name, input: call.input });
|
|
243
|
-
}
|
|
244
|
-
// A turn with neither text nor a tool call would be an empty content array, which is a 400.
|
|
245
|
-
return blocks.length === 0
|
|
246
|
-
? { role: 'assistant', content: '(no answer)' }
|
|
247
|
-
: { role: 'assistant', content: blocks };
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
function toolResults(results: readonly LlmToolResult[], chars: number): AiMessage {
|
|
251
|
-
return {
|
|
252
|
-
role: 'user',
|
|
253
|
-
content: results.map((result) => ({
|
|
254
|
-
type: 'tool_result' as const,
|
|
255
|
-
tool_use_id: result.toolUseId,
|
|
256
|
-
content: truncate(result.content, chars),
|
|
257
|
-
// A denial or a failure is an outcome the model should read and react to, flagged so it
|
|
258
|
-
// does not read as data.
|
|
259
|
-
...(result.isError === true ? { is_error: true } : {}),
|
|
260
|
-
})),
|
|
261
|
-
};
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
/** Truncation says so. A silently shortened tool result is a model reasoning over half a table. */
|
|
265
|
-
function truncate(text: string, chars: number): string {
|
|
266
|
-
if (text.length <= chars) return text;
|
|
267
|
-
return `${text.slice(0, chars)}\n[truncated: ${text.length - chars} more characters]`;
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
function repair(issues: string): string {
|
|
271
|
-
return `That answer failed its schema: ${issues}. Call the "${RESPOND}" tool with a value that satisfies it. Answer only through the tool.`;
|
|
272
|
-
}
|
|
273
|
-
|
|
274
383
|
function limitsOf<
|
|
275
384
|
TInput extends StandardSchemaV1,
|
|
276
385
|
TOutput extends StandardSchemaV1,
|
package/src/errors.ts
CHANGED
|
@@ -18,6 +18,7 @@ export const AI_ERROR_CODES = [
|
|
|
18
18
|
'X_LLM_STREAM_INVALID',
|
|
19
19
|
'X_AGENT_MAX_TURNS',
|
|
20
20
|
'X_AGENT_TOOL_UNEXPOSED',
|
|
21
|
+
'X_HIVE_EMPTY',
|
|
21
22
|
'X_EVAL_THRESHOLD',
|
|
22
23
|
'X_EVAL_BASELINE_MISSING',
|
|
23
24
|
'X_EVAL_BASELINE_INVALID',
|
|
@@ -45,6 +46,7 @@ export const AI_ERROR_TITLES: Readonly<Record<AiErrorCode, string>> = {
|
|
|
45
46
|
X_LLM_STREAM_INVALID: 'a streamed answer failed its output schema, and a stream cannot repair',
|
|
46
47
|
X_AGENT_MAX_TURNS: 'an agent hit its turn ceiling without answering',
|
|
47
48
|
X_AGENT_TOOL_UNEXPOSED: 'an agent lists a tool that is not an MCP-exposed action',
|
|
49
|
+
X_HIVE_EMPTY: 'a hive split produced no members, so the run did nothing',
|
|
48
50
|
X_EVAL_THRESHOLD: 'an eval scored below its tolerance',
|
|
49
51
|
X_EVAL_BASELINE_MISSING: 'an eval has no recorded baseline to gate against',
|
|
50
52
|
X_EVAL_BASELINE_INVALID: 'a recorded baseline cannot be read',
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// Single responsibility: the one injectable HTTP call every transport in this package takes.
|
|
2
|
+
//
|
|
3
|
+
// Shared by all three rather than declared three times: both chat providers and the embedder hand
|
|
4
|
+
// a URL and a `RequestInit` to something that answers a `Response`, and three separate spellings
|
|
5
|
+
// of that is three places a test double has to be kept assignable to.
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Just the call. `typeof fetch` also carries `preconnect`, which no test double should have to —
|
|
9
|
+
* and none can supply, so every fake written against `typeof fetch` here needed
|
|
10
|
+
* `as unknown as typeof fetch` to compile: an option no caller could fill without a double cast.
|
|
11
|
+
*
|
|
12
|
+
* The same seam `@ultimat3/cache` (`PurgeFetch`), `@ultimat3/auth` (`OAuthFetch`),
|
|
13
|
+
* `@ultimat3/mail` (`MailFetch`) and `@ultimat3/scraping` (`ScrapeFetch`) already name.
|
|
14
|
+
*/
|
|
15
|
+
export type AiFetch = (input: string, init: RequestInit) => Promise<Response>;
|
package/src/gateway.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// budgeted, and cost-accounted in integer minor units. Everything an app does with a model
|
|
5
5
|
// goes through here, so budgets and accounting cannot be bypassed by a stray fetch.
|
|
6
6
|
|
|
7
|
+
import { renderThrowable } from '@ultimat3/core';
|
|
7
8
|
import type { Money } from '@ultimat3/money';
|
|
8
9
|
import type { BudgetLimits, BudgetStore } from './budget';
|
|
9
10
|
import { BudgetLedger, currentBudget, estimateSpend, withBudget } from './budget';
|
|
@@ -126,8 +127,14 @@ class GatewayImpl implements Gateway {
|
|
|
126
127
|
const ledger = currentBudget();
|
|
127
128
|
const reservation = await ledger?.reserve(estimateSpend(resolved));
|
|
128
129
|
|
|
129
|
-
//
|
|
130
|
-
//
|
|
130
|
+
// The streaming path does not retry AT ALL — not mid-flight, and not on the handshake either.
|
|
131
|
+
// Mid-flight is the obvious one: the consumer has already been handed tokens, and replaying
|
|
132
|
+
// from the top would duplicate them. The handshake is not separable from it here, because
|
|
133
|
+
// `provider.stream()` is one call that yields — there is no point at which the connection is
|
|
134
|
+
// open and no chunk has been delivered for a retry to hide behind. So `providerFor` picks the
|
|
135
|
+
// single provider serving this model and that call stands or throws; `attempt`'s backoff and
|
|
136
|
+
// its fallback across providers belong to `generate` alone. A caller that wants either uses
|
|
137
|
+
// `generate`, or reconnects itself and knows what it has already shown.
|
|
131
138
|
const provider = this.providerFor(model);
|
|
132
139
|
let settled = false;
|
|
133
140
|
try {
|
|
@@ -183,7 +190,11 @@ class GatewayImpl implements Gateway {
|
|
|
183
190
|
try {
|
|
184
191
|
return { ...(await call(provider)), provider: provider.name };
|
|
185
192
|
} catch (error) {
|
|
186
|
-
|
|
193
|
+
// `renderThrowable`, never `error.message` or `String(error)`: this line becomes the
|
|
194
|
+
// `cause` of `X_AI_PROVIDER_UNAVAILABLE`, and a renderer that throws replaces the coded
|
|
195
|
+
// refusal with a `TypeError` nothing downstream can catch by code. It bounds the text
|
|
196
|
+
// too — a provider's 1MB body is not a cause.
|
|
197
|
+
failures.push(`${provider.name}#${attempt}: ${renderThrowable(error)}`);
|
|
187
198
|
if (!isRetryable(error) || attempt === this.retry.attempts) break;
|
|
188
199
|
await this.sleep(backoffMs(this.retry, attempt));
|
|
189
200
|
}
|
|
@@ -205,14 +216,17 @@ export function backoffMs(policy: RetryPolicy, attempt: number): number {
|
|
|
205
216
|
*/
|
|
206
217
|
export function isRetryable(error: unknown): boolean {
|
|
207
218
|
if (typeof error !== 'object' || error === null) return false;
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
219
|
+
// A `Provider` is the APP's object, so the value it rejected with is one the framework did not
|
|
220
|
+
// build: `e.status` is a getter call and, on a `Proxy`, a trap. A value that fights being read
|
|
221
|
+
// cannot be SHOWN to be retryable, and this runs inside the catch block that has nothing left
|
|
222
|
+
// to answer with — so it fails closed rather than raising.
|
|
223
|
+
try {
|
|
224
|
+
const e = error as { status?: unknown; code?: unknown };
|
|
225
|
+
if (typeof e.status === 'number') return e.status === 429 || e.status >= 500;
|
|
226
|
+
return e.code === 'ETIMEDOUT' || e.code === 'ECONNRESET';
|
|
227
|
+
} catch {
|
|
228
|
+
return false;
|
|
229
|
+
}
|
|
216
230
|
}
|
|
217
231
|
|
|
218
232
|
/**
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// The X_HIVE_* codes, apart from ./errors only because one file has one job and that catalogue is
|
|
2
|
+
// already at its ceiling — the same split `eval-errors.ts` made. The codes, their titles and the
|
|
3
|
+
// single `registerErrorCodes` call stay in ./errors: one owner, one registration, one place a
|
|
4
|
+
// duplicate can surface.
|
|
5
|
+
|
|
6
|
+
import { UltimateError } from '@ultimat3/core';
|
|
7
|
+
import type { AiErrorCode } from './errors';
|
|
8
|
+
|
|
9
|
+
const docsFor = (code: AiErrorCode): string => `https://ultimate.dev/errors/${code}`;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* `split` handed back an empty list, so the hive fanned out to nobody and would have reported a
|
|
13
|
+
* successful run of zero members.
|
|
14
|
+
*
|
|
15
|
+
* Refused rather than returned, because the two readings of "0 ok, 0 failed" are "there was
|
|
16
|
+
* genuinely nothing to do" and "the query behind `split` returned no rows and nobody noticed",
|
|
17
|
+
* and only the caller can tell them apart. A hive whose empty case is legitimate says so by not
|
|
18
|
+
* being called: guard the `split` source at the call site, where the emptiness is visible.
|
|
19
|
+
*/
|
|
20
|
+
export class HiveEmptyError extends UltimateError {
|
|
21
|
+
constructor(input: { member: string }) {
|
|
22
|
+
super({
|
|
23
|
+
code: 'X_HIVE_EMPTY',
|
|
24
|
+
cause: `the hive over "${input.member}" split into 0 members, so no member ran`,
|
|
25
|
+
fix: `return at least one member input from the hive's split() over "${input.member}", or skip the hive call when the source is empty — a hive reporting 0 ok and 0 failed cannot be told apart from one whose query returned no rows`,
|
|
26
|
+
docs: docsFor('X_HIVE_EMPTY'),
|
|
27
|
+
meta: { member: input.member },
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
}
|