@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/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 { toolUseId: call.id, content: JSON.stringify(output) };
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
- if (typeof error !== 'object' || error === null) return 'tool failed';
120
- const e = error as { code?: unknown; cause?: unknown; fix?: unknown };
121
- if (typeof e.code !== 'string') return 'tool failed';
122
- const cause = typeof e.cause === 'string' ? e.cause : 'unknown';
123
- const fix = typeof e.fix === 'string' ? e.fix : '';
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
  }
package/src/wire.ts CHANGED
@@ -42,7 +42,11 @@ export function parsePartialUsage(raw: unknown): Partial<TokenUsage> {
42
42
  const usage: Partial<Record<keyof TokenUsage, number>> = {};
43
43
  for (const [field, wire] of Object.entries(USAGE_FIELDS) as [keyof TokenUsage, string][]) {
44
44
  const value = record[wire];
45
- if (typeof value === 'number') usage[field] = value;
45
+ // Floored at zero, and finite: usage is the provider's number, a negative one becomes a
46
+ // negative `cost`, and `MemoryBudgetStore.add` reads a negative debit as a CREDIT — releasing
47
+ // an unspent reservation is one — so an unclamped `-1` tops the ledger up instead of
48
+ // under-reporting it. `NaN` propagates the same way through every later sum.
49
+ if (typeof value === 'number' && Number.isFinite(value)) usage[field] = Math.max(0, value);
46
50
  }
47
51
  return usage;
48
52
  }
@@ -81,17 +85,23 @@ export function parseStopDetails(raw: unknown): StopDetails | undefined {
81
85
  * retry rule in the gateway: an overloaded provider is retryable whether it says so with a
82
86
  * 529 on the handshake or with an `overloaded_error` frame ten tokens in.
83
87
  */
84
- const ERROR_STATUS: Readonly<Record<string, number>> = {
85
- invalid_request_error: 400,
86
- authentication_error: 401,
87
- permission_error: 403,
88
- not_found_error: 404,
89
- request_too_large: 413,
90
- rate_limit_error: 429,
91
- api_error: 500,
92
- timeout_error: 504,
93
- overloaded_error: 529,
94
- };
88
+ // A `Map`, not an object literal: `type` is the PROVIDER's string on the one read below, and
89
+ // `ERROR_STATUS['constructor']` on an object answers the `Object` FUNCTION where
90
+ // `AiTransportError.status` is declared `number | undefined`. Same fix, same reason, as
91
+ // `openai-wire.ts`'s twin and `core`'s `error-retry.ts`.
92
+ const ERROR_STATUS: ReadonlyMap<string, number> = new Map(
93
+ Object.entries({
94
+ invalid_request_error: 400,
95
+ authentication_error: 401,
96
+ permission_error: 403,
97
+ not_found_error: 404,
98
+ request_too_large: 413,
99
+ rate_limit_error: 429,
100
+ api_error: 500,
101
+ timeout_error: 504,
102
+ overloaded_error: 529,
103
+ }),
104
+ );
95
105
 
96
106
  /**
97
107
  * A 200 whose body carries an `error` object instead of an answer, refused — how a gateway in
@@ -298,7 +308,7 @@ function inBandFailure(error: Record<string, unknown>): AiTransportError {
298
308
  const message = typeof error['message'] === 'string' ? error['message'] : type;
299
309
  return new AiTransportError({
300
310
  provider: 'anthropic',
301
- status: ERROR_STATUS[type],
311
+ status: ERROR_STATUS.get(type),
302
312
  detail: message,
303
313
  });
304
314
  }