@ultimat3/ai 27.8.1 → 27.10.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.
@@ -0,0 +1,88 @@
1
+ // Single responsibility: reading a tool call's argument STRING, and deciding what an unreadable
2
+ // one means once the turn's stop reason is known. One copy for both wire formats, streamed or not.
3
+ //
4
+ // "The arguments are not JSON" is two different events, and neither is the provider being
5
+ // unavailable — which is what both wires used to report it as, under the one code this package
6
+ // classifies retryable, with the answer already billed:
7
+ // - the turn was CUT OFF mid-arguments. The stop reason says so, and the fragment is dropped: a
8
+ // fragment is never a call, and `llm()` / `agent()` refuse a truncated turn before reading it.
9
+ // - the turn FINISHED and the model wrote broken JSON. That is `X_LLM_TOOL_ARGUMENTS_INVALID`,
10
+ // thrown with the usage the provider reported, so the gateway records the bill instead of
11
+ // releasing the reservation and nothing retries a request that was answered.
12
+
13
+ import type { Money } from '@ultimat3/money';
14
+ import { AiProviderUnexpectedError, LlmToolArgumentsError } from './answer-errors';
15
+ import { billedCall } from './billed';
16
+ import type { ModelId } from './models';
17
+ import type { StopReason, TokenUsage } from './provider';
18
+ import { isTruncated } from './provider';
19
+
20
+ /** A tool call whose arguments could not be read. Never the text: it is the model's, unscreened. */
21
+ export interface MalformedToolCall {
22
+ readonly name: string;
23
+ /** Characters of argument text that arrived. */
24
+ readonly chars: number;
25
+ /**
26
+ * The fragments carried neither an `index` nor an `id`, and more than one named a tool: two
27
+ * calls that cannot be told apart. Refused whole — never run as one call with merged arguments.
28
+ */
29
+ readonly unkeyed?: boolean;
30
+ }
31
+
32
+ /**
33
+ * The arguments as an object, or `undefined` when the string is not JSON. A blank string is `{}`:
34
+ * a tool that takes no arguments sends `''` or `'{}'`, and neither is a fault. JSON that is not an
35
+ * object is `{}` too — `input` is untrusted, and a string under `Record<string, unknown>` is a
36
+ * type lie every later reader indexes into.
37
+ */
38
+ export function parseToolArguments(raw: string): Record<string, unknown> | undefined {
39
+ if (raw.trim() === '') return {};
40
+ let parsed: unknown;
41
+ try {
42
+ parsed = JSON.parse(raw);
43
+ } catch {
44
+ return undefined;
45
+ }
46
+ return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)
47
+ ? (parsed as Record<string, unknown>)
48
+ : {};
49
+ }
50
+
51
+ /**
52
+ * Refuse a FINISHED turn that carried unreadable arguments. Called by a provider once usage is
53
+ * known — on a stream that is the last frame, long after the call closed — so the error can say
54
+ * what the answer cost. A truncated turn passes: its fragments were already dropped.
55
+ */
56
+ export function assertToolArguments(turn: {
57
+ readonly provider: string;
58
+ readonly model: ModelId;
59
+ readonly stopReason: StopReason;
60
+ readonly malformed: readonly MalformedToolCall[];
61
+ readonly usage: TokenUsage;
62
+ readonly cost: Money;
63
+ /** The raw finish reason, when the wire did not know it (`openai-wire.ts`). */
64
+ readonly unknownFinish?: string | undefined;
65
+ }): void {
66
+ // A reason nobody knows is not truncation. A turn that is whole was read as a stop; one that is
67
+ // not — arguments that do not parse — or one the endpoint itself called an `error`, is refused
68
+ // by name, with its bill, instead of being guessed at.
69
+ const unknown = turn.unknownFinish;
70
+ if (unknown !== undefined && (turn.malformed.length > 0 || unknown.toLowerCase() === 'error')) {
71
+ throw new AiProviderUnexpectedError({
72
+ provider: turn.provider,
73
+ model: turn.model,
74
+ reason: unknown,
75
+ billed: billedCall(turn.usage, turn.cost),
76
+ });
77
+ }
78
+ const first = turn.malformed[0];
79
+ if (first === undefined || isTruncated(turn.stopReason)) return;
80
+ throw new LlmToolArgumentsError({
81
+ provider: turn.provider,
82
+ model: turn.model,
83
+ tool: first.name,
84
+ chars: first.chars,
85
+ unkeyed: first.unkeyed === true,
86
+ billed: billedCall(turn.usage, turn.cost),
87
+ });
88
+ }
package/src/tools.ts CHANGED
@@ -24,7 +24,7 @@ import type { Actor } from '@ultimat3/core';
24
24
  import { isMcpExposed, tryUseContext } from '@ultimat3/core';
25
25
  import type { WireJsonSchema } from '@ultimat3/schema';
26
26
  import { toWireSchema } from '@ultimat3/schema';
27
- import { discloseFailure } from './failure-disclosure';
27
+ import { discloseFailure, discloseOwnFailure } from './failure-disclosure';
28
28
  import { takeToolIdempotencyKey, withToolIdempotencyKey } from './tool-idempotency';
29
29
 
30
30
  /**
@@ -69,6 +69,14 @@ export interface ProjectableAction {
69
69
  readonly description?: string;
70
70
  readonly mcp?: { readonly expose?: boolean; readonly description?: string };
71
71
  readonly inputJsonSchema?: JsonSchema;
72
+ /**
73
+ * The tool runs IN PROCESS — the app's own code, called by its own agent — and is not the
74
+ * projection of an exposed action. Its typed refusal is then shown to the model whole, as a
75
+ * structured error (`discloseOwnFailure`); a projected tool keeps the rule MCP follows.
76
+ */
77
+ readonly inProcess?: boolean;
78
+ /** In-process only: `false` withholds its refusals as a projected tool's are. Default `true`. */
79
+ readonly discloseErrors?: boolean;
72
80
  run(args: { input: unknown; actor: Actor }): Promise<unknown>;
73
81
  }
74
82
 
@@ -147,6 +155,16 @@ export function toLlmTool(action: ProjectableAction): LlmTool {
147
155
  };
148
156
  }
149
157
 
158
+ /**
159
+ * Whether an agent may be OFFERED this tool: an action an MCP surface exposes (`isMcpExposed`,
160
+ * core's one predicate — an in-app agent and an external one see the same catalogue of ACTIONS), or
161
+ * a tool that is this process's alone (`inProcessTool()`), which no surface but the agent sees.
162
+ */
163
+ export const isOfferedTool = (tool: {
164
+ readonly mcp?: ProjectableAction['mcp'];
165
+ readonly inProcess?: boolean;
166
+ }): boolean => tool.inProcess === true || isMcpExposed(tool.mcp);
167
+
150
168
  /**
151
169
  * Every exposed action as a tool definition, in stable name order. The gateway and MCP ask
152
170
  * `isMcpExposed` — @ultimat3/core's one predicate — so an in-app agent and an external one are
@@ -154,7 +172,7 @@ export function toLlmTool(action: ProjectableAction): LlmTool {
154
172
  */
155
173
  export function toLlmTools(actions: readonly ProjectableAction[]): readonly LlmTool[] {
156
174
  return actions
157
- .filter((a) => isMcpExposed(a.mcp))
175
+ .filter(isOfferedTool)
158
176
  .map(toLlmTool)
159
177
  .sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
160
178
  }
@@ -167,8 +185,10 @@ export async function runLlmToolCall(
167
185
  actions: readonly ProjectableAction[],
168
186
  call: LlmToolCall,
169
187
  actor: Actor,
188
+ /** `discloseErrors: false` withholds every in-process tool's refusal (`agent({ discloseToolErrors })`). */
189
+ options: { readonly discloseErrors?: boolean } = {},
170
190
  ): Promise<LlmToolResult> {
171
- const action = actions.find((a) => a.name === call.name && isMcpExposed(a.mcp));
191
+ const action = actions.find((a) => a.name === call.name && isOfferedTool(a));
172
192
  if (action === undefined) {
173
193
  return { toolUseId: call.id, content: `unknown tool: ${call.name}`, isError: true };
174
194
  }
@@ -177,7 +197,8 @@ export async function runLlmToolCall(
177
197
  return resultOf(call.id, output);
178
198
  } catch (error) {
179
199
  // A policy denial is an outcome the model should read and react to, not a crash.
180
- return { toolUseId: call.id, content: describeFailure(error, call.name), isError: true };
200
+ const content = describeFailure(error, action, options.discloseErrors !== false);
201
+ return { toolUseId: call.id, content, isError: true };
181
202
  }
182
203
  }
183
204
 
@@ -212,14 +233,38 @@ function resultOf(toolUseId: string, output: unknown): LlmToolResult {
212
233
  export const HIDDEN_TOOL_CAUSE =
213
234
  'the tool failed inside the server; the details are withheld from this conversation; the server log records its code and where it failed';
214
235
 
236
+ /** The most of an in-process refusal's `cause` / `fix` a model is sent. */
237
+ export const OWN_CAUSE_CHARS = 1_000;
238
+ export const OWN_FIX_CHARS = 500;
239
+
240
+ const clip = (text: string, chars: number): string =>
241
+ text.length <= chars ? text : `${text.slice(0, chars - 1)}…`;
242
+
215
243
  /**
216
244
  * `discloseFailure`'s verdict, rendered as the `tool_result` line — the same rule a hive member's
217
245
  * reason follows, so what an agent may read of a throw is decided once. An uncoded throw says only
218
246
  * that it failed; a withheld cause becomes `HIDDEN_TOOL_CAUSE`, and the log has the rest.
247
+ *
248
+ * An in-process tool's OWN refusal is rendered as JSON — `{ error: { code, cause, fix } }` — the
249
+ * structured form of the three authored fields, and nothing but them.
219
250
  */
220
- function describeFailure(error: unknown, toolName: string): string {
221
- const shown = discloseFailure(error, tryUseContext()?.logger, `tool ${toolName}`);
251
+ function describeFailure(error: unknown, tool: ProjectableAction, disclose: boolean): string {
252
+ const logger = tryUseContext()?.logger;
253
+ const where = `tool ${tool.name}`;
254
+ const own = disclose && tool.inProcess === true && tool.discloseErrors !== false;
255
+ const shown = own
256
+ ? discloseOwnFailure(error, logger, where)
257
+ : discloseFailure(error, logger, where);
222
258
  if (shown.code === undefined) return 'tool failed';
259
+ if (own && shown.cause !== undefined) {
260
+ // Bounded BEFORE it is serialised: the loop truncates a tool result at `maxToolResultChars`,
261
+ // and a cut through the middle of this JSON is a string the model cannot read.
262
+ const cause = clip(shown.cause, OWN_CAUSE_CHARS);
263
+ const fix = shown.fix === undefined ? undefined : clip(shown.fix, OWN_FIX_CHARS);
264
+ return JSON.stringify({
265
+ error: { code: clip(shown.code, 128), cause, ...(fix === undefined ? {} : { fix }) },
266
+ });
267
+ }
223
268
  const line = `${shown.code}: ${shown.cause ?? HIDDEN_TOOL_CAUSE}`;
224
269
  return shown.fix === undefined ? line : `${line} (fix: ${shown.fix})`;
225
270
  }
package/src/wire.ts CHANGED
@@ -11,6 +11,8 @@ import { renderThrowable } from '@ultimat3/core';
11
11
  import { AiTransportError } from './errors';
12
12
  import type { StopDetails, StopReason, StreamChunk, TokenUsage } from './provider';
13
13
  import type { SseFrame } from './sse';
14
+ import type { MalformedToolCall } from './tool-arguments';
15
+ import { parseToolArguments } from './tool-arguments';
14
16
  import type { LlmToolCall } from './tools';
15
17
 
16
18
  export const ZERO_USAGE: TokenUsage = {
@@ -141,6 +143,8 @@ interface PendingTool {
141
143
  export interface StreamState {
142
144
  readonly text: string;
143
145
  readonly toolCalls: readonly LlmToolCall[];
146
+ /** Calls whose arguments were not JSON — never among `toolCalls`; `openai-wire.ts`'s twin. */
147
+ readonly malformed: readonly MalformedToolCall[];
144
148
  readonly stopReason: StopReason;
145
149
  readonly stopDetails: StopDetails | undefined;
146
150
  readonly usage: TokenUsage;
@@ -154,6 +158,7 @@ export interface StreamState {
154
158
  export class MessageStream {
155
159
  private text = '';
156
160
  private readonly toolCalls: LlmToolCall[] = [];
161
+ private readonly malformed: MalformedToolCall[] = [];
157
162
  private stopReason: StopReason = 'end_turn';
158
163
  private stopDetails: StopDetails | undefined;
159
164
  private usage: TokenUsage = ZERO_USAGE;
@@ -199,6 +204,7 @@ export class MessageStream {
199
204
  return {
200
205
  text: this.text,
201
206
  toolCalls: [...this.toolCalls],
207
+ malformed: [...this.malformed],
202
208
  // A refusal detail is a refusal whatever the stop reason says. `parseStopReason` answers
203
209
  // `end_turn` for a spelling this build has never seen, and every consumer branches on the
204
210
  // REASON, so the pair would read as a complete answer that happens to be empty.
@@ -272,24 +278,19 @@ export class MessageStream {
272
278
  const tool = this.pending.get(index);
273
279
  if (tool === undefined) return [];
274
280
  this.pending.delete(index);
275
- const call: LlmToolCall = { id: tool.id, name: tool.name, input: this.inputOf(tool) };
281
+ // A tool with no arguments streams no `input_json_delta` at all, which is `{}`, not a fault.
282
+ const input = parseToolArguments(tool.json);
283
+ if (input === undefined) {
284
+ // Not thrown here: the block closes BEFORE `message_delta` says why the turn stopped, and
285
+ // that reason is what tells a cut-off call from broken JSON. The provider decides, at the end.
286
+ this.malformed.push({ name: tool.name, chars: tool.json.length });
287
+ return [];
288
+ }
289
+ const call: LlmToolCall = { id: tool.id, name: tool.name, input };
276
290
  this.toolCalls.push(call);
277
291
  return [{ type: 'tool-call', call }];
278
292
  }
279
293
 
280
- /** A tool with no arguments streams no `input_json_delta` at all, which is `{}`, not a fault. */
281
- private inputOf(tool: PendingTool): Record<string, unknown> {
282
- if (tool.json === '') return {};
283
- try {
284
- return asToolInput(JSON.parse(tool.json));
285
- } catch (error) {
286
- throw new AiTransportError({
287
- provider: 'anthropic',
288
- detail: `tool "${tool.name}" streamed arguments that are not JSON: ${renderThrowable(error)}`,
289
- });
290
- }
291
- }
292
-
293
294
  private onMessageDelta(payload: Record<string, unknown>): readonly StreamChunk[] {
294
295
  const delta = asRecord(payload['delta']);
295
296
  // ABSENT is not a report, and neither is `null`. `!== null` alone admitted the absent key, so