@ultimat3/ai 1.2.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/src/tools.ts CHANGED
@@ -2,14 +2,26 @@
2
2
  //
3
3
  // This is the SAME projection @ultimat3/mcp performs, in a different wire format: an
4
4
  // in-app agent calling a tool through the gateway and an external agent calling it over
5
- // MCP both end at `action.run`, so they authorize identically. There is no "LLM
5
+ // MCP both end at the same `invoke`, so they authorize identically. There is no "LLM
6
6
  // permissions" concept in Ultimate, because there is no second authz system.
7
7
  //
8
+ // `run` below is `ProjectableAction`'s — the projection SEAM, which is what carries `invoke`.
9
+ // It is not a member of the action facade: an `action()` is `as`/`tool`/`openapi`/`job`/
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).
15
+ //
8
16
  // The JSON Schema type and the projectable-primitive shape are declared here rather than
9
17
  // imported from @ultimat3/mcp: that package is the same tier, so importing it would be a
10
18
  // boundary error. Both packages describe the same structural contract.
11
19
 
20
+ import type { AnyAction } from '@ultimat3/action';
21
+ import { actionName, invoke, isAction } from '@ultimat3/action';
12
22
  import type { Actor } from '@ultimat3/core';
23
+ import { isMcpExposed, stringField } from '@ultimat3/core';
24
+ import { toMcpInputSchema } from '@ultimat3/schema';
13
25
 
14
26
  /** The JSON Schema subset the framework emits for tool arguments. */
15
27
  export interface JsonSchema {
@@ -63,6 +75,57 @@ export interface ProjectableAction {
63
75
  run(args: { input: unknown; actor: Actor }): Promise<unknown>;
64
76
  }
65
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
+
66
129
  const EMPTY_SCHEMA: JsonSchema = { type: 'object', properties: {}, additionalProperties: false };
67
130
 
68
131
  /** One action → one LLM tool definition. Opt-in via `mcp.expose`, same flag as MCP. */
@@ -76,10 +139,14 @@ export function toLlmTool(action: ProjectableAction): LlmTool {
76
139
  };
77
140
  }
78
141
 
79
- /** Every exposed action as a tool definition, in stable name order. */
142
+ /**
143
+ * Every exposed action as a tool definition, in stable name order. The gateway and MCP ask
144
+ * `isMcpExposed` — @ultimat3/core's one predicate — so an in-app agent and an external one are
145
+ * offered exactly the same tools.
146
+ */
80
147
  export function toLlmTools(actions: readonly ProjectableAction[]): readonly LlmTool[] {
81
148
  return actions
82
- .filter((a) => a.mcp?.expose === true)
149
+ .filter((a) => isMcpExposed(a.mcp))
83
150
  .map(toLlmTool)
84
151
  .sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
85
152
  }
@@ -93,24 +160,53 @@ export async function runLlmToolCall(
93
160
  call: LlmToolCall,
94
161
  actor: Actor,
95
162
  ): Promise<LlmToolResult> {
96
- const action = actions.find((a) => a.name === call.name && a.mcp?.expose === true);
163
+ const action = actions.find((a) => a.name === call.name && isMcpExposed(a.mcp));
97
164
  if (action === undefined) {
98
165
  return { toolUseId: call.id, content: `unknown tool: ${call.name}`, isError: true };
99
166
  }
100
167
  try {
101
168
  const output = await action.run({ input: call.input, actor });
102
- return { toolUseId: call.id, content: JSON.stringify(output) };
169
+ return resultOf(call.id, output);
103
170
  } catch (error) {
104
171
  // A policy denial is an outcome the model should read and react to, not a crash.
105
172
  return { toolUseId: call.id, content: describeFailure(error), isError: true };
106
173
  }
107
174
  }
108
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
+ */
109
206
  function describeFailure(error: unknown): string {
110
- if (typeof error !== 'object' || error === null) return 'tool failed';
111
- const e = error as { code?: unknown; cause?: unknown; fix?: unknown };
112
- if (typeof e.code !== 'string') return 'tool failed';
113
- const cause = typeof e.cause === 'string' ? e.cause : 'unknown';
114
- const fix = typeof e.fix === 'string' ? e.fix : '';
115
- 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})`;
116
212
  }
package/src/vector.ts CHANGED
Binary file
package/src/wire.ts CHANGED
@@ -93,6 +93,27 @@ const ERROR_STATUS: Readonly<Record<string, number>> = {
93
93
  overloaded_error: 529,
94
94
  };
95
95
 
96
+ /**
97
+ * A 200 whose body carries an `error` object instead of an answer, refused — how a gateway in
98
+ * front of a model reports a fault it noticed after the headers were sent. Exported because the
99
+ * non-streaming read needs the identical rule and the identical status table: the envelope arrives
100
+ * on either transport, and one copy of the mapping is what keeps the gateway's retry decision the
101
+ * same on both. The twin of `openai-wire.ts`'s.
102
+ */
103
+ export function throwInBandError(payload: Record<string, unknown>): void {
104
+ const error = asRecord(payload['error']);
105
+ if (error === undefined) return;
106
+ throw inBandFailure(error);
107
+ }
108
+
109
+ /**
110
+ * A tool call's arguments, or `{}`. Never a cast: `input` is untrusted, so a string or an array
111
+ * arriving under `Record<string, unknown>` is a type lie every later reader indexes into.
112
+ */
113
+ export function asToolInput(value: unknown): Record<string, unknown> {
114
+ return asRecord(value) ?? {};
115
+ }
116
+
96
117
  interface PendingTool {
97
118
  readonly id: string;
98
119
  readonly name: string;
@@ -156,7 +177,10 @@ export class MessageStream {
156
177
  return {
157
178
  text: this.text,
158
179
  toolCalls: [...this.toolCalls],
159
- stopReason: this.stopReason,
180
+ // A refusal detail is a refusal whatever the stop reason says. `parseStopReason` answers
181
+ // `end_turn` for a spelling this build has never seen, and every consumer branches on the
182
+ // REASON, so the pair would read as a complete answer that happens to be empty.
183
+ stopReason: this.stopDetails === undefined ? this.stopReason : 'refusal',
160
184
  stopDetails: this.stopDetails,
161
185
  usage: this.usage,
162
186
  };
@@ -237,8 +261,7 @@ export class MessageStream {
237
261
  private inputOf(tool: PendingTool): Record<string, unknown> {
238
262
  if (tool.json === '') return {};
239
263
  try {
240
- const parsed: unknown = JSON.parse(tool.json);
241
- return asRecord(parsed) ?? {};
264
+ return asToolInput(JSON.parse(tool.json));
242
265
  } catch (error) {
243
266
  throw new AiTransportError({
244
267
  provider: 'anthropic',
@@ -261,18 +284,25 @@ export class MessageStream {
261
284
  return [];
262
285
  }
263
286
 
287
+ /**
288
+ * An `error` EVENT is a failure whether or not it carried a detail — the event type is itself
289
+ * the report. A body has no such signal, which is why `throwInBandError` reads the object first.
290
+ */
264
291
  private onError(payload: Record<string, unknown>): never {
265
- const error = asRecord(payload['error']);
266
- const type = typeof error?.['type'] === 'string' ? error['type'] : 'api_error';
267
- const message = typeof error?.['message'] === 'string' ? error['message'] : type;
268
- throw new AiTransportError({
269
- provider: 'anthropic',
270
- status: ERROR_STATUS[type],
271
- detail: message,
272
- });
292
+ throw inBandFailure(asRecord(payload['error']) ?? {});
273
293
  }
274
294
  }
275
295
 
296
+ function inBandFailure(error: Record<string, unknown>): AiTransportError {
297
+ const type = typeof error['type'] === 'string' ? error['type'] : 'api_error';
298
+ const message = typeof error['message'] === 'string' ? error['message'] : type;
299
+ return new AiTransportError({
300
+ provider: 'anthropic',
301
+ status: ERROR_STATUS[type],
302
+ detail: message,
303
+ });
304
+ }
305
+
276
306
  function asRecord(value: unknown): Record<string, unknown> | undefined {
277
307
  if (typeof value !== 'object' || value === null || Array.isArray(value)) return undefined;
278
308
  return value as Record<string, unknown>;