@ultimat3/ai 2.0.0 → 3.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 +161 -3
- package/README.md +139 -1
- package/package.json +10 -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/gateway.ts +17 -9
- 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 +11 -2
- package/src/openai-provider.ts +17 -3
- package/src/provider.ts +18 -3
- package/src/tools.ts +95 -8
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// What a hive run answers, as a TYPE and as a SCHEMA built from the member's own `output`.
|
|
2
|
+
//
|
|
3
|
+
// It is a schema and not a plain interface because `hive()` returns an `action`, and an action's
|
|
4
|
+
// `output:` is what drives `validateOutput`, the OpenAPI response body, the typed client, the MCP
|
|
5
|
+
// tool and the manifest row. A hand-written interface would give the type and none of the six
|
|
6
|
+
// projections — the whole reason a hive is a factory over `action()` rather than a helper.
|
|
7
|
+
|
|
8
|
+
import type { Money } from '@ultimat3/money';
|
|
9
|
+
import type { AnySchema, InferOutput, StandardSchemaV1 } from '@ultimat3/schema';
|
|
10
|
+
import { t } from '@ultimat3/schema';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* One member's outcome. THREE arms, not two: a member that ran and threw and a member that never
|
|
14
|
+
* ran at all are different facts, and the second one is what an aborted sibling is. Collapsing
|
|
15
|
+
* them would make "the hive stopped early" indistinguishable from "every remaining item is bad
|
|
16
|
+
* data", which is the difference between retrying the tail and fixing the source.
|
|
17
|
+
*
|
|
18
|
+
* `index` is the position in `split`'s output on every arm, so a caller can join a result back to
|
|
19
|
+
* the row it came from without depending on array position surviving a filter.
|
|
20
|
+
*/
|
|
21
|
+
export type HiveMember<O> =
|
|
22
|
+
| { readonly status: 'ok'; readonly index: number; readonly value: O }
|
|
23
|
+
| {
|
|
24
|
+
readonly status: 'failed';
|
|
25
|
+
readonly index: number;
|
|
26
|
+
/** The `UltimateError` code the member threw, or `'unknown'` for a foreign throw. */
|
|
27
|
+
readonly code: string;
|
|
28
|
+
readonly reason: string;
|
|
29
|
+
}
|
|
30
|
+
| { readonly status: 'skipped'; readonly index: number; readonly reason: string };
|
|
31
|
+
|
|
32
|
+
export interface HiveResult<O> {
|
|
33
|
+
/** In SPLIT order, always — never completion order, and never with the failures filtered out. */
|
|
34
|
+
readonly members: readonly HiveMember<O>[];
|
|
35
|
+
readonly ok: number;
|
|
36
|
+
readonly failed: number;
|
|
37
|
+
/**
|
|
38
|
+
* Published beside `ok` and `failed` rather than left to be derived: three arms and two counters
|
|
39
|
+
* means `members.length - ok - failed`, and a caller who writes that once writes it wrong once.
|
|
40
|
+
*/
|
|
41
|
+
readonly skipped: number;
|
|
42
|
+
/** Tokens the whole run debited, every member counted, from the hive's own derived ledger. */
|
|
43
|
+
readonly tokens: number;
|
|
44
|
+
readonly cost: Money;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The declared shape of `hive()`'s output, given the member action's own `output`. */
|
|
48
|
+
export type HiveOutput<MOut extends AnySchema> = StandardSchemaV1<
|
|
49
|
+
unknown,
|
|
50
|
+
HiveResult<InferOutput<MOut>>
|
|
51
|
+
>;
|
|
52
|
+
|
|
53
|
+
/** What a member failure means for its siblings. No default: both answers are somebody's bug. */
|
|
54
|
+
export type HiveMemberError =
|
|
55
|
+
/** Stop. Siblings that have not started are `skipped`; ones in flight see the aborted signal. */
|
|
56
|
+
| 'abort'
|
|
57
|
+
/** Record it as `failed` and keep going — a partial harvest is the point of a hive. */
|
|
58
|
+
| 'collect';
|
|
59
|
+
|
|
60
|
+
export const SKIPPED_ABORTED = 'a sibling failed and onMemberError is abort';
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The OTHER way a member never runs, and a different fact: `split` produced nothing at this
|
|
64
|
+
* index. Its own string because `SKIPPED_ABORTED` named a cause that did not happen — a caller
|
|
65
|
+
* reading it retries the tail against a hive that stopped early, when the split is what to fix.
|
|
66
|
+
*/
|
|
67
|
+
export const SKIPPED_NO_INPUT = 'split produced no input at this index';
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The member's `output` embedded verbatim in the `ok` arm, so a hive over `summarisePost` publishes
|
|
71
|
+
* a summary in its OpenAPI response and its MCP `outputSchema` — not an opaque object.
|
|
72
|
+
*
|
|
73
|
+
* A DISCRIMINATED union, not a plain one: `status` routes the parse, so a malformed `ok` reports
|
|
74
|
+
* that arm's issues instead of all three arms' at once.
|
|
75
|
+
*/
|
|
76
|
+
export function hiveResultSchema(output: AnySchema): AnySchema {
|
|
77
|
+
const member = t.discriminatedUnion(
|
|
78
|
+
'status',
|
|
79
|
+
t.object({ status: t.literal('ok'), index: t.number.int(), value: output }),
|
|
80
|
+
t.object({
|
|
81
|
+
status: t.literal('failed'),
|
|
82
|
+
index: t.number.int(),
|
|
83
|
+
code: t.string,
|
|
84
|
+
reason: t.string,
|
|
85
|
+
}),
|
|
86
|
+
t.object({ status: t.literal('skipped'), index: t.number.int(), reason: t.string }),
|
|
87
|
+
);
|
|
88
|
+
return t.object({
|
|
89
|
+
members: t.array(member),
|
|
90
|
+
ok: t.number.int(),
|
|
91
|
+
failed: t.number.int(),
|
|
92
|
+
skipped: t.number.int(),
|
|
93
|
+
tokens: t.number.int(),
|
|
94
|
+
cost: t.money,
|
|
95
|
+
});
|
|
96
|
+
}
|
package/src/hive.ts
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `hive()` — one action fanned out over many inputs, declared as an `action`.
|
|
3
|
+
*
|
|
4
|
+
* The fourth instance of the framework's factory rule, after `llm()`, `backfill()` and `agent()`:
|
|
5
|
+
* a fan-out is still one server-authoritative operation with an input schema, an output schema and
|
|
6
|
+
* a policy, so this returns an `action` and inherits `.tool()`, `.openapi()`, `.client()`,
|
|
7
|
+
* `.job()`, `.contract()` and its manifest row without a line here.
|
|
8
|
+
*
|
|
9
|
+
* It exists because the alternative is a hand-rolled `Promise.all` over `agent()` calls, and that
|
|
10
|
+
* loop gets four things wrong every time: it takes the actor from somewhere other than the request,
|
|
11
|
+
* it reports results in completion order, it cannot tell "ran and failed" from "never ran", and it
|
|
12
|
+
* has no ceiling — so the first bad split spends the whole budget in parallel.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { Action, ActionMcp, ActionPolicy } from '@ultimat3/action';
|
|
16
|
+
import { action, actionName } from '@ultimat3/action';
|
|
17
|
+
import type { Ctx } from '@ultimat3/core';
|
|
18
|
+
import { throwIfAborted, withSpan } from '@ultimat3/core';
|
|
19
|
+
import type { AnySchema, InferInput, InferOutput, StandardSchemaV1 } from '@ultimat3/schema';
|
|
20
|
+
import type { BudgetLimits } from './budget';
|
|
21
|
+
import { BudgetLedger, currentBudget, withBudget } from './budget';
|
|
22
|
+
import { HiveEmptyError } from './hive-errors';
|
|
23
|
+
import { runPool } from './hive-pool';
|
|
24
|
+
import type { HiveMember, HiveMemberError, HiveOutput, HiveResult } from './hive-result';
|
|
25
|
+
import { hiveResultSchema } from './hive-result';
|
|
26
|
+
import type { LlmBudget } from './llm';
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Members in flight at once when the declaration omits one. Small and deliberately arbitrary — it
|
|
30
|
+
* is a floor to start from, not a number measured off any run: the framework cannot know a
|
|
31
|
+
* provider's concurrency allowance, and a default read off one benchmark would be wrong for
|
|
32
|
+
* everybody else's account. Raise it in the declaration once the run demonstrably fits.
|
|
33
|
+
*/
|
|
34
|
+
const DEFAULT_CONCURRENCY = 4;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Below this many members the split is not fanned out at all. A member carries a fixed cost — a
|
|
38
|
+
* child context, a derived ledger, a whole model call's handshake — and paying it in parallel for
|
|
39
|
+
* one or two items buys nothing but a second way for the run to fail.
|
|
40
|
+
*/
|
|
41
|
+
const DEFAULT_MIN_MEMBERS = 2;
|
|
42
|
+
|
|
43
|
+
export interface HiveSplitArgs<TInput extends StandardSchemaV1> {
|
|
44
|
+
readonly input: InferOutput<TInput>;
|
|
45
|
+
readonly ctx: Ctx;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface HiveDef<
|
|
49
|
+
TInput extends StandardSchemaV1,
|
|
50
|
+
MIn extends StandardSchemaV1,
|
|
51
|
+
MOut extends AnySchema,
|
|
52
|
+
> {
|
|
53
|
+
readonly input: TInput;
|
|
54
|
+
/**
|
|
55
|
+
* The action every member runs — an `agent()`, an `llm()`, or any action at all. Its own
|
|
56
|
+
* `policy` decides every member call and its own `input:` parses every member payload, which is
|
|
57
|
+
* what keeps a hive from being a second authz system.
|
|
58
|
+
*/
|
|
59
|
+
readonly member: Action<MIn, MOut>;
|
|
60
|
+
/**
|
|
61
|
+
* The one declared place a run decides what the members are. Derived from `input` and `ctx` and
|
|
62
|
+
* from NOTHING a model emitted — that boundary is the same one `agent()` holds, and the reason
|
|
63
|
+
* both belong in the framework rather than in a loop somebody writes per feature.
|
|
64
|
+
*/
|
|
65
|
+
split(
|
|
66
|
+
args: HiveSplitArgs<TInput>,
|
|
67
|
+
): readonly InferInput<MIn>[] | Promise<readonly InferInput<MIn>[]>;
|
|
68
|
+
/** Members in flight at once. */
|
|
69
|
+
readonly concurrency?: number;
|
|
70
|
+
/** Below this many members the split runs serially instead of fanning out. */
|
|
71
|
+
readonly minMembers?: number;
|
|
72
|
+
readonly onMemberError: HiveMemberError;
|
|
73
|
+
/** Ceilings for the WHOLE fan-out. `tokensPerRun` is the run's, every member counted. */
|
|
74
|
+
readonly budget?: LlmBudget & { readonly tokensPerRun?: number };
|
|
75
|
+
readonly policy: ActionPolicy;
|
|
76
|
+
readonly mcp?: ActionMcp;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function hive<
|
|
80
|
+
TInput extends StandardSchemaV1,
|
|
81
|
+
MIn extends StandardSchemaV1,
|
|
82
|
+
MOut extends AnySchema,
|
|
83
|
+
>(def: HiveDef<TInput, MIn, MOut>): Action<TInput, HiveOutput<MOut>> {
|
|
84
|
+
// The one cast in this file, and it narrows nothing at runtime: `hiveResultSchema` builds the
|
|
85
|
+
// shape `HiveResult<InferOutput<MOut>>` describes, and this restates that in the type system
|
|
86
|
+
// rather than making the caller infer it back out of a nested `t.discriminatedUnion`.
|
|
87
|
+
const output = hiveResultSchema(def.member.output) as HiveOutput<MOut>;
|
|
88
|
+
return action<TInput, HiveOutput<MOut>>({
|
|
89
|
+
input: def.input,
|
|
90
|
+
output,
|
|
91
|
+
policy: def.policy,
|
|
92
|
+
...(def.mcp === undefined ? {} : { mcp: def.mcp }),
|
|
93
|
+
handle: (args) => run(def, args),
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function run<
|
|
98
|
+
TInput extends StandardSchemaV1,
|
|
99
|
+
MIn extends StandardSchemaV1,
|
|
100
|
+
MOut extends AnySchema,
|
|
101
|
+
>(
|
|
102
|
+
def: HiveDef<TInput, MIn, MOut>,
|
|
103
|
+
args: { readonly input: InferOutput<TInput>; readonly ctx: Ctx },
|
|
104
|
+
): Promise<HiveResult<InferOutput<MOut>>> {
|
|
105
|
+
const name = actionName(def.member);
|
|
106
|
+
const inputs = await def.split({ input: args.input, ctx: args.ctx });
|
|
107
|
+
if (inputs.length === 0) throw new HiveEmptyError({ member: name });
|
|
108
|
+
|
|
109
|
+
const floor = def.minMembers ?? DEFAULT_MIN_MEMBERS;
|
|
110
|
+
// A split below the floor still runs every input it produced — dropping one would be silent
|
|
111
|
+
// data loss — it just stops paying for a pool to do it.
|
|
112
|
+
const width =
|
|
113
|
+
inputs.length < floor
|
|
114
|
+
? 1
|
|
115
|
+
: Math.max(1, Math.min(def.concurrency ?? DEFAULT_CONCURRENCY, inputs.length));
|
|
116
|
+
|
|
117
|
+
return withSpan('ai.hive', async (span) => {
|
|
118
|
+
span.setAttributes({
|
|
119
|
+
'hive.member': name,
|
|
120
|
+
'hive.members': inputs.length,
|
|
121
|
+
'hive.concurrency': width,
|
|
122
|
+
'hive.on_member_error': def.onMemberError,
|
|
123
|
+
});
|
|
124
|
+
const ledger = (currentBudget() ?? new BudgetLedger({ limits: {} })).derive(limitsOf(def));
|
|
125
|
+
const result = await withBudget(ledger, () =>
|
|
126
|
+
runPool<InferInput<MIn>, InferOutput<MOut>>({
|
|
127
|
+
inputs,
|
|
128
|
+
width,
|
|
129
|
+
ctx: args.ctx,
|
|
130
|
+
onMemberError: def.onMemberError,
|
|
131
|
+
member: (payload) => def.member(payload),
|
|
132
|
+
}),
|
|
133
|
+
);
|
|
134
|
+
// The caller went away DURING the fan-out. Distinct from `onMemberError: 'abort'`, which is a
|
|
135
|
+
// completed run that stopped early and has a partial harvest worth returning: here there is
|
|
136
|
+
// nobody left to hand it to, so unwind the way every other abort in this package does.
|
|
137
|
+
throwIfAborted(args.ctx);
|
|
138
|
+
const report = await ledger.report();
|
|
139
|
+
const counts = tally(result);
|
|
140
|
+
span.setAttributes({ ...counts, 'hive.tokens': report.requestTokens });
|
|
141
|
+
return {
|
|
142
|
+
members: result,
|
|
143
|
+
ok: counts['hive.ok'],
|
|
144
|
+
failed: counts['hive.failed'],
|
|
145
|
+
skipped: counts['hive.skipped'],
|
|
146
|
+
tokens: report.requestTokens,
|
|
147
|
+
cost: report.cost,
|
|
148
|
+
};
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function tally<O>(members: readonly HiveMember<O>[]): {
|
|
153
|
+
'hive.ok': number;
|
|
154
|
+
'hive.failed': number;
|
|
155
|
+
'hive.skipped': number;
|
|
156
|
+
} {
|
|
157
|
+
return {
|
|
158
|
+
'hive.ok': members.filter((one) => one.status === 'ok').length,
|
|
159
|
+
'hive.failed': members.filter((one) => one.status === 'failed').length,
|
|
160
|
+
'hive.skipped': members.filter((one) => one.status === 'skipped').length,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function limitsOf<
|
|
165
|
+
TInput extends StandardSchemaV1,
|
|
166
|
+
MIn extends StandardSchemaV1,
|
|
167
|
+
MOut extends AnySchema,
|
|
168
|
+
>(def: HiveDef<TInput, MIn, MOut>): BudgetLimits {
|
|
169
|
+
const budget = def.budget;
|
|
170
|
+
return {
|
|
171
|
+
...(budget?.tokensIn === undefined ? {} : { tokensIn: budget.tokensIn }),
|
|
172
|
+
...(budget?.costPerCall === undefined ? {} : { costPerCall: budget.costPerCall }),
|
|
173
|
+
// The ledger's `request` scope accumulates across every call made under it, which for a hive
|
|
174
|
+
// under `withBudget` is every member's every turn.
|
|
175
|
+
...(budget?.tokensPerRun === undefined ? {} : { request: budget.tokensPerRun }),
|
|
176
|
+
};
|
|
177
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -4,8 +4,12 @@
|
|
|
4
4
|
/** Re-exported so an `llm` file needs one import, not two. Same object as schema's. */
|
|
5
5
|
export type { Infer } from '@ultimat3/schema';
|
|
6
6
|
export { t } from '@ultimat3/schema';
|
|
7
|
-
export type { AgentBudget, AgentDef, AgentVarsArgs } from './agent';
|
|
7
|
+
export type { AgentBudget, AgentDef, AgentTurn, AgentVarsArgs } from './agent';
|
|
8
8
|
export { agent } from './agent';
|
|
9
|
+
export type { AgentBudgetFact, AgentFact } from './agent-facts';
|
|
10
|
+
export { describeAgents, resetAgents } from './agent-facts';
|
|
11
|
+
export type { AgentJobOptions } from './agent-job';
|
|
12
|
+
export { agentJob } from './agent-job';
|
|
9
13
|
export type {
|
|
10
14
|
BudgetLedgerInput,
|
|
11
15
|
BudgetLimits,
|
|
@@ -89,6 +93,10 @@ export {
|
|
|
89
93
|
} from './evals';
|
|
90
94
|
export type { CreateGatewayInput, Gateway, GatewayCache, RetryPolicy } from './gateway';
|
|
91
95
|
export { backoffMs, cacheKeyFor, createGateway, DEFAULT_RETRY, isRetryable } from './gateway';
|
|
96
|
+
export type { HiveDef, HiveSplitArgs } from './hive';
|
|
97
|
+
export { hive } from './hive';
|
|
98
|
+
export { HiveEmptyError } from './hive-errors';
|
|
99
|
+
export type { HiveMember, HiveMemberError, HiveOutput, HiveResult } from './hive-result';
|
|
92
100
|
export type {
|
|
93
101
|
LlmAction,
|
|
94
102
|
LlmBudget,
|
|
@@ -201,13 +209,14 @@ export {
|
|
|
201
209
|
numericTolerance,
|
|
202
210
|
} from './scorers';
|
|
203
211
|
export type {
|
|
212
|
+
AgentTool,
|
|
204
213
|
JsonSchema,
|
|
205
214
|
LlmTool,
|
|
206
215
|
LlmToolCall,
|
|
207
216
|
LlmToolResult,
|
|
208
217
|
ProjectableAction,
|
|
209
218
|
} from './tools';
|
|
210
|
-
export { runLlmToolCall, toLlmTool, toLlmTools } from './tools';
|
|
219
|
+
export { asProjectableAction, runLlmToolCall, toLlmTool, toLlmTools } from './tools';
|
|
211
220
|
export type {
|
|
212
221
|
HybridSearchInput,
|
|
213
222
|
MemoryVectorStoreInput,
|
package/src/openai-provider.ts
CHANGED
|
@@ -114,14 +114,22 @@ class OpenAiProvider implements Provider {
|
|
|
114
114
|
async generate(request: GenerateRequest): Promise<GenerateResult> {
|
|
115
115
|
if (requiresStreaming(request)) return this.assemble(request);
|
|
116
116
|
const model = this.modelOf(request);
|
|
117
|
-
const response = await this.send(
|
|
117
|
+
const response = await this.send(
|
|
118
|
+
chatCompletionBody({ request, model, stream: false }),
|
|
119
|
+
false,
|
|
120
|
+
request.signal,
|
|
121
|
+
);
|
|
118
122
|
const answer = parseChatCompletion((await response.json()) as unknown, this.name);
|
|
119
123
|
return this.result(request, model, answer);
|
|
120
124
|
}
|
|
121
125
|
|
|
122
126
|
async *stream(request: GenerateRequest): AsyncIterable<StreamChunk> {
|
|
123
127
|
const model = this.modelOf(request);
|
|
124
|
-
const response = await this.send(
|
|
128
|
+
const response = await this.send(
|
|
129
|
+
chatCompletionBody({ request, model, stream: true }),
|
|
130
|
+
true,
|
|
131
|
+
request.signal,
|
|
132
|
+
);
|
|
125
133
|
if (response.body === null) {
|
|
126
134
|
throw new AiTransportError({
|
|
127
135
|
provider: this.name,
|
|
@@ -183,7 +191,11 @@ class OpenAiProvider implements Provider {
|
|
|
183
191
|
* its status, because the gateway decides whether to retry from that status and a body parsed as
|
|
184
192
|
* if it were a message would read as an empty, successful answer.
|
|
185
193
|
*/
|
|
186
|
-
private async send(
|
|
194
|
+
private async send(
|
|
195
|
+
body: Record<string, unknown>,
|
|
196
|
+
streaming: boolean,
|
|
197
|
+
signal: AbortSignal | undefined,
|
|
198
|
+
): Promise<Response> {
|
|
187
199
|
const apiKey = this.apiKey();
|
|
188
200
|
const doFetch = this.config.fetch ?? fetch;
|
|
189
201
|
const response = await doFetch(this.url(), {
|
|
@@ -199,6 +211,8 @@ class OpenAiProvider implements Provider {
|
|
|
199
211
|
...this.config.headers,
|
|
200
212
|
},
|
|
201
213
|
body: JSON.stringify(body),
|
|
214
|
+
// Attached only when the caller has one — same rule, same reason, as the Anthropic half.
|
|
215
|
+
...(signal === undefined ? {} : { signal }),
|
|
202
216
|
});
|
|
203
217
|
if (!response.ok) {
|
|
204
218
|
throw new AiTransportError({
|
package/src/provider.ts
CHANGED
|
@@ -73,6 +73,15 @@ export interface GenerateRequest {
|
|
|
73
73
|
readonly thinking?: ThinkingMode;
|
|
74
74
|
readonly tools?: readonly LlmTool[];
|
|
75
75
|
readonly stopSequences?: readonly string[];
|
|
76
|
+
/**
|
|
77
|
+
* The caller's abort signal, forwarded to the socket by every provider in this package.
|
|
78
|
+
*
|
|
79
|
+
* Deliberately absent from `cacheKeyFor` and from every estimate: it says whether a request was
|
|
80
|
+
* ABANDONED, never what it asked for, so two calls that differ only in it are the same call and
|
|
81
|
+
* must share a cache entry. Omitted means the call runs to completion — there is no ambient
|
|
82
|
+
* default, because a timeout the caller did not ask for is a truncated answer nothing reports.
|
|
83
|
+
*/
|
|
84
|
+
readonly signal?: AbortSignal;
|
|
76
85
|
}
|
|
77
86
|
|
|
78
87
|
export interface TokenUsage {
|
|
@@ -216,7 +225,7 @@ export class AnthropicProvider implements Provider {
|
|
|
216
225
|
*/
|
|
217
226
|
async generate(request: GenerateRequest): Promise<GenerateResult> {
|
|
218
227
|
if (requiresStreaming(request)) return this.assemble(request);
|
|
219
|
-
const response = await this.send({ ...this.body(request), stream: false });
|
|
228
|
+
const response = await this.send({ ...this.body(request), stream: false }, request.signal);
|
|
220
229
|
const raw = (await response.json()) as Record<string, unknown>;
|
|
221
230
|
return parseMessage(request.model ?? DEFAULT_MODEL, raw);
|
|
222
231
|
}
|
|
@@ -239,7 +248,7 @@ export class AnthropicProvider implements Provider {
|
|
|
239
248
|
*/
|
|
240
249
|
async *stream(request: GenerateRequest): AsyncIterable<StreamChunk> {
|
|
241
250
|
const model = request.model ?? DEFAULT_MODEL;
|
|
242
|
-
const response = await this.send({ ...this.body(request), stream: true });
|
|
251
|
+
const response = await this.send({ ...this.body(request), stream: true }, request.signal);
|
|
243
252
|
if (response.body === null) {
|
|
244
253
|
throw new AiTransportError({
|
|
245
254
|
provider: this.name,
|
|
@@ -286,7 +295,10 @@ export class AnthropicProvider implements Provider {
|
|
|
286
295
|
* carrying its status, because the gateway decides whether to retry from that status and a
|
|
287
296
|
* body parsed as if it were a message would read as an empty, successful answer.
|
|
288
297
|
*/
|
|
289
|
-
private async send(
|
|
298
|
+
private async send(
|
|
299
|
+
body: Record<string, unknown>,
|
|
300
|
+
signal: AbortSignal | undefined,
|
|
301
|
+
): Promise<Response> {
|
|
290
302
|
const apiKey = this.config.apiKey ?? Bun.env[API_KEY_ENV];
|
|
291
303
|
if (apiKey === undefined || apiKey === '') {
|
|
292
304
|
throw new AiKeyMissingError({ provider: this.name, envVar: API_KEY_ENV });
|
|
@@ -302,6 +314,9 @@ export class AnthropicProvider implements Provider {
|
|
|
302
314
|
accept: body['stream'] === true ? 'text/event-stream' : 'application/json',
|
|
303
315
|
},
|
|
304
316
|
body: JSON.stringify(body),
|
|
317
|
+
// Attached only when the caller has one: `exactOptionalPropertyTypes`, and an explicit
|
|
318
|
+
// `signal: undefined` is a different value to some fetch implementations.
|
|
319
|
+
...(signal === undefined ? {} : { signal }),
|
|
305
320
|
});
|
|
306
321
|
if (!response.ok) {
|
|
307
322
|
throw new AiTransportError({
|
package/src/tools.ts
CHANGED
|
@@ -8,13 +8,20 @@
|
|
|
8
8
|
// `run` below is `ProjectableAction`'s — the projection SEAM, which is what carries `invoke`.
|
|
9
9
|
// It is not a member of the action facade: an `action()` is `as`/`tool`/`openapi`/`job`/
|
|
10
10
|
// `contract` and the callable itself, and this header claimed `action.run` until 2026-08.
|
|
11
|
+
// `asProjectableAction` is what BUILDS that seam out of a real `action()`, so an app writes
|
|
12
|
+
// `agent({ tools: [publishPost] })` and never a hand-shaped stand-in — the same union
|
|
13
|
+
// @ultimat3/mcp's `ListedPrimitive` accepts, adapted at this package's own edge because the two
|
|
14
|
+
// wire formats want different schemas (issue #124).
|
|
11
15
|
//
|
|
12
16
|
// The JSON Schema type and the projectable-primitive shape are declared here rather than
|
|
13
17
|
// imported from @ultimat3/mcp: that package is the same tier, so importing it would be a
|
|
14
18
|
// boundary error. Both packages describe the same structural contract.
|
|
15
19
|
|
|
20
|
+
import type { AnyAction } from '@ultimat3/action';
|
|
21
|
+
import { actionName, invoke, isAction } from '@ultimat3/action';
|
|
16
22
|
import type { Actor } from '@ultimat3/core';
|
|
17
|
-
import { isMcpExposed } from '@ultimat3/core';
|
|
23
|
+
import { isMcpExposed, stringField } from '@ultimat3/core';
|
|
24
|
+
import { toMcpInputSchema } from '@ultimat3/schema';
|
|
18
25
|
|
|
19
26
|
/** The JSON Schema subset the framework emits for tool arguments. */
|
|
20
27
|
export interface JsonSchema {
|
|
@@ -68,6 +75,57 @@ export interface ProjectableAction {
|
|
|
68
75
|
run(args: { input: unknown; actor: Actor }): Promise<unknown>;
|
|
69
76
|
}
|
|
70
77
|
|
|
78
|
+
/**
|
|
79
|
+
* What `agent({ tools })` accepts: the real primitive an app writes, or a pre-projected one.
|
|
80
|
+
*
|
|
81
|
+
* The real `action()` comes first because it is what an app has. Until 2026-08 this list took
|
|
82
|
+
* `ProjectableAction` alone, which no `action()` structurally satisfies — an action carries
|
|
83
|
+
* `as`/`tool`/`openapi`/`job`/`contract` and never `run` — so the documented shape
|
|
84
|
+
* `agent({ tools: [publishPost] })` was a `TS2741` and every test in this package hand-built a
|
|
85
|
+
* stand-in, which is why the suite stayed green over an API that did not compile (issue #124).
|
|
86
|
+
* `ProjectableAction` stays in the union for a surface that builds its catalog programmatically
|
|
87
|
+
* and for a test that projects a fake.
|
|
88
|
+
*/
|
|
89
|
+
export type AgentTool = AnyAction | ProjectableAction;
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Adapt whatever the author listed. The same shape @ultimat3/mcp's `asProjectable` produces, from
|
|
93
|
+
* the same `invoke` — an in-app agent and an external MCP client end at one execution path, so one
|
|
94
|
+
* policy decides both. It is not shared code and cannot be: `mcp` is this package's own tier, and
|
|
95
|
+
* the two projections narrow the schema differently on purpose (`toWireSchema` publishes only what
|
|
96
|
+
* that server's arg validator will hold a call to; this one publishes the tool schema the Messages
|
|
97
|
+
* API reads).
|
|
98
|
+
*
|
|
99
|
+
* `isAction` is structural against @ultimat3/action's PRIVATE declaration store, so a look-alike
|
|
100
|
+
* carrying `kind: 'action'` cannot take the first branch — it falls through as the already
|
|
101
|
+
* projectable object it claims to be.
|
|
102
|
+
*/
|
|
103
|
+
export function asProjectableAction(listed: AgentTool): ProjectableAction {
|
|
104
|
+
if (!isAction(listed)) return listed;
|
|
105
|
+
const mcp = listed.mcp;
|
|
106
|
+
return {
|
|
107
|
+
// Throws `X_ACTION_UNREGISTERED` on an unnamed action rather than offering a tool called `''`:
|
|
108
|
+
// a nameless tool is unaddressable by the model, by `runLlmToolCall` and by the author.
|
|
109
|
+
name: actionName(listed),
|
|
110
|
+
...(mcp === undefined ? {} : { mcp }),
|
|
111
|
+
...(mcp?.description === undefined ? {} : { description: mcp.description }),
|
|
112
|
+
inputJsonSchema: toMcpInputSchema(listed.input),
|
|
113
|
+
// The actor rides in on the options and `invoke` swaps it inside the one execution path —
|
|
114
|
+
// the action's own `policy` still decides, and its `input:` still parses what the model sent,
|
|
115
|
+
// which is what drops a `{ actor: 'admin' }` the model invented before any handler sees it.
|
|
116
|
+
run: ({ input, actor }) => invoke(listed, input, { surface: 'mcp', actor }),
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The tool's name for an error message, before anything is registered. Never `actionName()`:
|
|
122
|
+
* `X_AGENT_TOOL_UNEXPOSED` is raised at declaration, where an action beside it in the same module
|
|
123
|
+
* has no name yet, and a naming failure there would hide the exposure failure being reported.
|
|
124
|
+
*/
|
|
125
|
+
export function toolLabel(listed: AgentTool): string {
|
|
126
|
+
return listed.name === '' ? '(an unregistered action)' : listed.name;
|
|
127
|
+
}
|
|
128
|
+
|
|
71
129
|
const EMPTY_SCHEMA: JsonSchema = { type: 'object', properties: {}, additionalProperties: false };
|
|
72
130
|
|
|
73
131
|
/** One action → one LLM tool definition. Opt-in via `mcp.expose`, same flag as MCP. */
|
|
@@ -108,18 +166,47 @@ export async function runLlmToolCall(
|
|
|
108
166
|
}
|
|
109
167
|
try {
|
|
110
168
|
const output = await action.run({ input: call.input, actor });
|
|
111
|
-
return
|
|
169
|
+
return resultOf(call.id, output);
|
|
112
170
|
} catch (error) {
|
|
113
171
|
// A policy denial is an outcome the model should read and react to, not a crash.
|
|
114
172
|
return { toolUseId: call.id, content: describeFailure(error), isError: true };
|
|
115
173
|
}
|
|
116
174
|
}
|
|
117
175
|
|
|
176
|
+
/**
|
|
177
|
+
* One tool's return value as the `tool_result` text. `content` is typed `string` and the agent
|
|
178
|
+
* loop TRUNCATES it, so anything else ends the run in a `TypeError` two frames away — and the
|
|
179
|
+
* value is an app's, so neither of `JSON.stringify`'s two other answers can be assumed away:
|
|
180
|
+
* `undefined` for a handler that returns nothing, and a throw on a bigint, a cycle or a `toJSON`
|
|
181
|
+
* of its own. A throw is reported as what it is — the call SUCCEEDED and its value cannot be
|
|
182
|
+
* read — never as a failure, because a model told the tool failed calls it again and buys its
|
|
183
|
+
* side effects twice.
|
|
184
|
+
*/
|
|
185
|
+
function resultOf(toolUseId: string, output: unknown): LlmToolResult {
|
|
186
|
+
let text: string | undefined;
|
|
187
|
+
try {
|
|
188
|
+
text = JSON.stringify(output);
|
|
189
|
+
} catch {
|
|
190
|
+
return {
|
|
191
|
+
toolUseId,
|
|
192
|
+
content:
|
|
193
|
+
'the tool ran, but its result is not JSON (a bigint, a cycle, or a toJSON that threw) — do not call it again',
|
|
194
|
+
isError: true,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
return { toolUseId, content: text ?? 'null' };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* The thrown value, read STRUCTURALLY and totally. `stringField` from `@ultimat3/core`, never
|
|
202
|
+
* `typeof e.code === 'string'`: the value is whatever an app's handler, its driver or its SDK
|
|
203
|
+
* threw, so each read is a getter call or a `Proxy` trap — and it runs inside the catch block
|
|
204
|
+
* that has nothing left to answer the model with if the probe itself raises.
|
|
205
|
+
*/
|
|
118
206
|
function describeFailure(error: unknown): string {
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
const
|
|
123
|
-
|
|
124
|
-
return fix === '' ? `${e.code}: ${cause}` : `${e.code}: ${cause} (fix: ${fix})`;
|
|
207
|
+
const code = stringField(error, 'code');
|
|
208
|
+
if (code === undefined) return 'tool failed';
|
|
209
|
+
const cause = stringField(error, 'cause') ?? 'unknown';
|
|
210
|
+
const fix = stringField(error, 'fix') ?? '';
|
|
211
|
+
return fix === '' ? `${code}: ${cause}` : `${code}: ${cause} (fix: ${fix})`;
|
|
125
212
|
}
|