tina4-nodejs 3.13.113 → 3.13.115

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.
@@ -22,16 +22,47 @@ export interface ChatResponse {
22
22
  /**
23
23
  * A multimodal content part. `text` carries plain UTF-8 prose; `image`
24
24
  * carries a `data:<media_type>;base64,<payload>` URI or an https:// URL
25
- * (the client translates to each provider's shape). ADR-0060.
25
+ * (the client translates to each provider's shape, ADR-0060). `tool_result`
26
+ * carries the Anthropic-style return of a locally-executed tool call
27
+ * (ADR-0061); the client translates it to OpenAI's `{role: "tool", ...}`
28
+ * turn on non-Anthropic providers.
26
29
  */
27
30
  export type ContentPart =
28
31
  | { type: "text"; text: string }
29
- | { type: "image"; source: string };
32
+ | { type: "image"; source: string }
33
+ | { type: "tool_result"; tool_use_id: string; content: string };
30
34
 
31
35
  /** The value a caller may pass for `message.content`. ADR-0060. */
32
36
  export type AiMessageContent = string | ContentPart[];
33
37
 
34
- export interface AiMessage { role: "system" | "user" | "assistant"; content: AiMessageContent }
38
+ /**
39
+ * One conversation turn. The three "chat" roles carry a string OR a
40
+ * content-parts array (ADR-0060). The `tool` role is the OpenAI-style
41
+ * return of a tool call (ADR-0061); the client translates it to the
42
+ * Anthropic user-turn form when the current provider is Anthropic.
43
+ */
44
+ export type AiMessage =
45
+ | { role: "system" | "user" | "assistant"; content: AiMessageContent }
46
+ | { role: "tool"; tool_call_id: string; content: string };
47
+
48
+ /**
49
+ * A tool declaration the model may call (named `AiToolDeclaration` to
50
+ * stay out of the way of {@link ./ai.ts}'s existing `AiTool` interface
51
+ * for AI-coding-tool context installation). `parameters` is a JSON
52
+ * Schema object; it is passed to the provider unchanged (ADR-0061
53
+ * `parameters-passthrough`).
54
+ */
55
+ export interface AiToolDeclaration { name: string; description: string; parameters: Record<string, unknown> }
56
+
57
+ /**
58
+ * How the model picks a tool. Four Tina4 values that span the useful cases
59
+ * across providers (ADR-0061 wire-translation table):
60
+ * 'auto' — model may call any tool or answer with text
61
+ * 'none' — model must not call a tool (Anthropic omits `tools`)
62
+ * 'required' — model must call some tool
63
+ * {name: 'x'} — model must call tool 'x'
64
+ */
65
+ export type AiToolChoice = "auto" | "none" | "required" | { name: string };
35
66
 
36
67
  /**
37
68
  * One event yielded by `Ai.chat(stream: true)`. The four variants
@@ -57,6 +88,14 @@ export interface AiChatOptions {
57
88
  stream?: boolean;
58
89
  timeout?: number;
59
90
  provider?: "local" | "openai" | "anthropic";
91
+ /** Tools the model may call. ADR-0061 — translated per provider. */
92
+ tools?: AiToolDeclaration[];
93
+ /**
94
+ * How the model picks a tool. ADR-0061 — translated per provider. If
95
+ * `'none'` on Anthropic (which has no "none" mode), `tools` is omitted
96
+ * from the outbound body entirely.
97
+ */
98
+ toolChoice?: AiToolChoice;
60
99
  }
61
100
  export interface AiEmbedOptions { model?: string; timeout?: number; provider?: "local" | "openai" | "anthropic" }
62
101
 
@@ -76,6 +115,8 @@ export class Ai {
76
115
  static chat(messages: AiMessage[], options?: AiChatOptions & { stream?: false }): Promise<ChatResponse>;
77
116
  static chat(messages: AiMessage[], options: AiChatOptions = {}): Promise<ChatResponse> | AsyncGenerator<AiEvent> {
78
117
  this.validateMessages(messages);
118
+ if (options.tools !== undefined) this.validateTools(options.tools);
119
+ if (options.toolChoice !== undefined) this.validateToolChoice(options.toolChoice);
79
120
  const config = this.config("chat", options);
80
121
  const body = this.chatBody(config, messages, options);
81
122
  const headers = this.headers(config);
@@ -108,15 +149,31 @@ export class Ai {
108
149
 
109
150
  /**
110
151
  * Validate role + content shape. Content may be a string OR a non-empty
111
- * list of {type:'text'|'image', ...} parts (ADR-0060). Malformed parts
112
- * fail fast with AiConfigError, never reaching the wire.
152
+ * list of {type:'text'|'image'|'tool_result', ...} parts (ADR-0060 +
153
+ * ADR-0061). The `tool` role is the OpenAI-style tool-result turn
154
+ * (ADR-0061). Malformed parts fail fast with AiConfigError, never
155
+ * reaching the wire.
113
156
  */
114
157
  private static validateMessages(messages: AiMessage[]): void {
115
158
  if (!Array.isArray(messages) || messages.length === 0) {
116
159
  throw new AiConfigError("AI messages must contain supported roles and string content");
117
160
  }
118
- for (const message of messages) {
119
- if (!message || !["system", "user", "assistant"].includes(message.role)) {
161
+ for (const raw of messages) {
162
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
163
+ throw new AiConfigError("AI messages must contain supported roles and string content");
164
+ }
165
+ const message = raw as Record<string, unknown>;
166
+ const role = message.role;
167
+ if (role === "tool") {
168
+ if (typeof message.tool_call_id !== "string" || message.tool_call_id.length === 0) {
169
+ throw new AiConfigError("AI tool message requires a non-empty string 'tool_call_id'");
170
+ }
171
+ if (typeof message.content !== "string") {
172
+ throw new AiConfigError("AI tool message requires a string 'content'");
173
+ }
174
+ continue;
175
+ }
176
+ if (role !== "system" && role !== "user" && role !== "assistant") {
120
177
  throw new AiConfigError("AI messages must contain supported roles and string content");
121
178
  }
122
179
  this.validateContent(message.content);
@@ -148,12 +205,67 @@ export class Ai {
148
205
  if (record.source.startsWith("data:") && !/^data:[^;,\s]+;base64,[A-Za-z0-9+/=]+$/.test(record.source)) {
149
206
  throw new AiConfigError("AI image data URI must be data:<media_type>;base64,<payload>");
150
207
  }
208
+ } else if (partType === "tool_result") {
209
+ if (typeof record.tool_use_id !== "string" || record.tool_use_id.length === 0) {
210
+ throw new AiConfigError("AI tool_result part requires a non-empty string 'tool_use_id'");
211
+ }
212
+ if (typeof record.content !== "string") {
213
+ throw new AiConfigError("AI tool_result part requires a string 'content'");
214
+ }
151
215
  } else {
152
216
  throw new AiConfigError(`AI content part has unknown type '${String(partType)}'`);
153
217
  }
154
218
  }
155
219
  }
156
220
 
221
+ /**
222
+ * Validate the outbound tool declarations (ADR-0061). Each tool needs a
223
+ * non-empty `name`, a string `description`, and a JSON-Schema-shaped
224
+ * `parameters` object. Malformed tools fail fast with AiConfigError,
225
+ * never reaching the wire.
226
+ */
227
+ private static validateTools(tools: unknown): void {
228
+ if (!Array.isArray(tools) || tools.length === 0) {
229
+ throw new AiConfigError("AI tools must be a non-empty list of {name, description, parameters}");
230
+ }
231
+ for (const tool of tools) {
232
+ if (!tool || typeof tool !== "object" || Array.isArray(tool)) {
233
+ throw new AiConfigError("AI tool must be an object with name, description, parameters");
234
+ }
235
+ const record = tool as Record<string, unknown>;
236
+ if (typeof record.name !== "string" || record.name.length === 0) {
237
+ throw new AiConfigError("AI tool requires a non-empty string 'name'");
238
+ }
239
+ if (typeof record.description !== "string") {
240
+ throw new AiConfigError("AI tool requires a string 'description'");
241
+ }
242
+ if (!record.parameters || typeof record.parameters !== "object" || Array.isArray(record.parameters)) {
243
+ throw new AiConfigError("AI tool requires a JSON-Schema object 'parameters'");
244
+ }
245
+ }
246
+ }
247
+
248
+ /**
249
+ * Validate the outbound tool_choice value (ADR-0061). The four accepted
250
+ * shapes are 'auto', 'none', 'required', and {name: 'x'}.
251
+ */
252
+ private static validateToolChoice(choice: unknown): void {
253
+ if (typeof choice === "string") {
254
+ if (choice !== "auto" && choice !== "none" && choice !== "required") {
255
+ throw new AiConfigError("AI toolChoice string must be 'auto', 'none', or 'required'");
256
+ }
257
+ return;
258
+ }
259
+ if (choice && typeof choice === "object" && !Array.isArray(choice)) {
260
+ const record = choice as Record<string, unknown>;
261
+ if (typeof record.name !== "string" || record.name.length === 0) {
262
+ throw new AiConfigError("AI toolChoice object requires a non-empty string 'name'");
263
+ }
264
+ return;
265
+ }
266
+ throw new AiConfigError("AI toolChoice must be 'auto'|'none'|'required' or {name: string}");
267
+ }
268
+
157
269
  private static number(name: string, fallback: number, minimum: number): number {
158
270
  const value = process.env[name] === undefined ? fallback : Number(process.env[name]);
159
271
  if (!Number.isFinite(value) || value < minimum) throw new AiConfigError(`${name} must be numeric and at least ${minimum}`);
@@ -199,29 +311,110 @@ export class Ai {
199
311
 
200
312
  /**
201
313
  * Build the provider-specific request body from a Tina4-shaped message
202
- * list. Multimodal parts are translated per provider (ADR-0060):
203
- * - OpenAI/local: {type:'image_url', image_url:{url}}
204
- * - Anthropic: {type:'image', source:{type:'base64'|'url', ...}}
314
+ * list plus optional tool declarations (ADR-0060 + ADR-0061).
315
+ *
316
+ * Content parts translate per provider:
317
+ * - OpenAI/local: image → {type:'image_url', image_url:{url}}
318
+ * - Anthropic: image → {type:'image', source:{type:'base64'|'url', ...}}
205
319
  * String content is preserved verbatim in the OpenAI/local shape and
206
320
  * likewise for Anthropic (both accept a bare string).
321
+ *
322
+ * Tool-result turns are normalised to the current provider's expected
323
+ * shape (either the OpenAI `{role:"tool", tool_call_id, content}` turn or
324
+ * the Anthropic `{role:"user", content:[{type:"tool_result", ...}]}`
325
+ * turn), so an agent-loop written against Tina4 never has to fork on
326
+ * TINA4_AI_PROVIDER (ADR-0061 wire translation).
207
327
  */
208
328
  private static chatBody(config: Config, messages: AiMessage[], options: AiChatOptions): Record<string, unknown> {
209
- const translate = (list: AiMessage[]): Array<Record<string, unknown>> =>
210
- list.map((message) => ({ role: message.role, content: this.translateContent(message.content, config.provider) }));
211
- const body: Record<string, unknown> = { model: config.model, messages: translate(messages), stream: options.stream ?? false };
329
+ const normalized = this.normalizeMessagesForProvider(messages, config.provider);
330
+ const body: Record<string, unknown> = { model: config.model, messages: normalized, stream: options.stream ?? false };
212
331
  if (options.temperature !== undefined) body.temperature = options.temperature;
213
332
  if (options.maxTokens !== undefined) body.max_tokens = options.maxTokens;
214
333
  if (config.provider === "anthropic") {
215
- const systemParts = messages
216
- .filter((message) => message.role === "system")
217
- .map((message) => (typeof message.content === "string" ? message.content : this.contentToPlainText(message.content)));
218
- body.messages = translate(messages.filter((message) => message.role !== "system"));
334
+ const systemParts: string[] = [];
335
+ for (const message of messages) {
336
+ if (message.role !== "system") continue;
337
+ const content = message.content; // narrowed away from tool variant
338
+ systemParts.push(typeof content === "string" ? content : this.contentToPlainText(content));
339
+ }
340
+ body.messages = normalized.filter((message) => message.role !== "system");
219
341
  body.max_tokens = options.maxTokens ?? 1024;
220
342
  if (systemParts.length) body.system = systemParts.join("\n\n");
221
343
  }
344
+ this.applyTools(body, config.provider, options);
222
345
  return body;
223
346
  }
224
347
 
348
+ /**
349
+ * Normalise the Tina4-shaped messages into the provider's on-wire shape.
350
+ * The `tool` role and the `tool_result` content part are translated
351
+ * between the OpenAI and Anthropic forms so either input works against
352
+ * either provider (ADR-0061 return-path table).
353
+ */
354
+ private static normalizeMessagesForProvider(messages: AiMessage[], provider: Config["provider"]): Array<Record<string, unknown>> {
355
+ const out: Array<Record<string, unknown>> = [];
356
+ for (const message of messages) {
357
+ if (message.role === "tool") {
358
+ // OpenAI-style tool-result turn. Passthrough on OpenAI/local;
359
+ // translate to Anthropic's user-turn form on Anthropic.
360
+ if (provider === "anthropic") {
361
+ out.push({
362
+ role: "user",
363
+ content: [{ type: "tool_result", tool_use_id: message.tool_call_id, content: message.content }],
364
+ });
365
+ } else {
366
+ out.push({ role: "tool", tool_call_id: message.tool_call_id, content: message.content });
367
+ }
368
+ continue;
369
+ }
370
+ if (Array.isArray(message.content) && message.content.some((part) => part.type === "tool_result")) {
371
+ // Anthropic-style tool-result turn inside a user message.
372
+ // Passthrough on Anthropic; on OpenAI/local, split each tool_result
373
+ // part into its own {role:'tool', ...} turn.
374
+ if (provider === "anthropic") {
375
+ out.push({ role: message.role, content: this.translateContent(message.content, provider) });
376
+ } else {
377
+ for (const part of message.content) {
378
+ if (part.type === "tool_result") {
379
+ out.push({ role: "tool", tool_call_id: part.tool_use_id, content: part.content });
380
+ }
381
+ }
382
+ }
383
+ continue;
384
+ }
385
+ out.push({ role: message.role, content: this.translateContent(message.content, provider) });
386
+ }
387
+ return out;
388
+ }
389
+
390
+ /**
391
+ * Attach the outbound `tools` and `tool_choice` (ADR-0061 outbound
392
+ * translation tables) to the body in place. When toolChoice is 'none'
393
+ * on Anthropic (Anthropic has no "none" mode) the tools list is omitted
394
+ * entirely — the model cannot call what it cannot see.
395
+ */
396
+ private static applyTools(body: Record<string, unknown>, provider: Config["provider"], options: AiChatOptions): void {
397
+ const choice = options.toolChoice;
398
+ const suppressToolsForAnthropic = provider === "anthropic" && choice === "none";
399
+ if (options.tools !== undefined && !suppressToolsForAnthropic) {
400
+ body.tools = options.tools.map((tool) =>
401
+ provider === "anthropic"
402
+ ? { name: tool.name, description: tool.description, input_schema: tool.parameters }
403
+ : { type: "function", function: { name: tool.name, description: tool.description, parameters: tool.parameters } },
404
+ );
405
+ }
406
+ if (choice === undefined) return;
407
+ if (provider === "anthropic") {
408
+ if (choice === "none") return; // omit tools + tool_choice
409
+ if (choice === "auto") body.tool_choice = { type: "auto" };
410
+ else if (choice === "required") body.tool_choice = { type: "any" };
411
+ else body.tool_choice = { type: "tool", name: choice.name };
412
+ } else {
413
+ if (typeof choice === "string") body.tool_choice = choice; // 'auto' | 'none' | 'required'
414
+ else body.tool_choice = { type: "function", function: { name: choice.name } };
415
+ }
416
+ }
417
+
225
418
  /**
226
419
  * Translate one message content value into the provider's on-wire shape.
227
420
  * A plain string is passed through (both providers accept a string
@@ -232,6 +425,7 @@ export class Ai {
232
425
  if (provider === "anthropic") {
233
426
  return content.map((part) => {
234
427
  if (part.type === "text") return { type: "text", text: part.text };
428
+ if (part.type === "tool_result") return { type: "tool_result", tool_use_id: part.tool_use_id, content: part.content };
235
429
  if (part.source.startsWith("data:")) {
236
430
  const parsed = this.parseDataUri(part.source);
237
431
  return { type: "image", source: { type: "base64", media_type: parsed.mediaType, data: parsed.data } };
@@ -241,6 +435,13 @@ export class Ai {
241
435
  }
242
436
  return content.map((part) => {
243
437
  if (part.type === "text") return { type: "text", text: part.text };
438
+ if (part.type === "tool_result") {
439
+ // Reached only when a non-tool_result part sits next to a
440
+ // tool_result in a user message on OpenAI/local; the tool_result
441
+ // parts are split out by normalizeMessagesForProvider(), so this
442
+ // branch is a safe no-op fallback.
443
+ return { type: "text", text: part.content };
444
+ }
244
445
  return { type: "image_url", image_url: { url: part.source } };
245
446
  });
246
447
  }
@@ -110,7 +110,7 @@ export type { AiTool } from "./ai.js";
110
110
  export { Sso, SSO, SsoError } from "./sso.js";
111
111
  export type { SsoOptions } from "./sso.js";
112
112
  export { Ai, AiError, AiConfigError, AiHTTPError, AiTimeoutError, AiParseError } from "./aiClient.js";
113
- export type { ChatResponse, AiMessage, AiChatOptions, AiEmbedOptions, AiEvent, ContentPart, AiMessageContent } from "./aiClient.js";
113
+ export type { ChatResponse, AiMessage, AiChatOptions, AiEmbedOptions, AiEvent, ContentPart, AiMessageContent, AiToolDeclaration, AiToolChoice } from "./aiClient.js";
114
114
  export type { ImapMessage, ImapFullMessage, ImapAttachment } from "./messenger.js";
115
115
  export { LiteBackend } from "./queueBackends/liteBackend.js";
116
116
  export { RabbitMQBackend, parseAmqpUrl } from "./queueBackends/rabbitmqBackend.js";
@@ -999,24 +999,49 @@ async function runGlobalMiddlewarePass(
999
999
  * or nothing - and each parameter is resolved by its name: a path param wins,
1000
1000
  * then `request`/`req`, then the response.
1001
1001
  */
1002
- async function invokeRouteHandler(
1003
- match: { handler: unknown },
1002
+ /**
1003
+ * Resolve a handler's argument list to `[reqOrParam, resOrParam, ...]` values.
1004
+ *
1005
+ * By-name path preserved (route-param names, `req`/`request`, `res`/`response`)
1006
+ * so existing code keeps its DX. Any remaining unmatched name falls back to
1007
+ * POSITIONAL binding: first unmatched -> request, rest -> response. That makes
1008
+ * dispatch bundler-safe by construction: Bun `--compile` and terser/esbuild
1009
+ * identifier-mangling rename `(req, res)` to `(req2, r$0)` — the by-name path
1010
+ * misses, the positional fallback catches, and the handler still receives the
1011
+ * request as its first argument. Fixes #56.
1012
+ *
1013
+ * Exported so the regression test can pin the behaviour directly, without a
1014
+ * live HTTP server.
1015
+ */
1016
+ export function resolveHandlerArgs(
1017
+ handler: unknown,
1004
1018
  req: Tina4Request,
1005
1019
  res: Tina4Response,
1006
- ): Promise<unknown> {
1007
- const routeParams = req.params || {};
1008
- const fnStr = (match.handler as { toString(): string }).toString();
1020
+ routeParams: Record<string, unknown>,
1021
+ ): unknown[] {
1022
+ const fnStr = (handler as { toString(): string }).toString();
1009
1023
  const argMatch = fnStr.match(/^(?:async\s*)?(?:function\s*\w*)?\s*\(([^)]*)\)/);
1010
1024
  const argNames = argMatch?.[1]?.split(",").map((a: string) => a.trim().replace(/[:=].*/, "")) ?? [];
1011
1025
  const filteredArgs = argNames.filter((n: string) => n.length > 0);
1026
+ if (filteredArgs.length === 0) return [];
1012
1027
 
1013
- if (filteredArgs.length === 0) return await (match.handler as any)();
1014
-
1015
- const args = filteredArgs.map((name: string) => {
1028
+ let unmatchedPos = 0;
1029
+ return filteredArgs.map((name: string) => {
1016
1030
  if (name in routeParams) return routeParams[name];
1017
1031
  if (name === "request" || name === "req") return req;
1018
- return res;
1032
+ if (name === "response" || name === "res") return res;
1033
+ return (unmatchedPos++ === 0) ? req : res;
1019
1034
  });
1035
+ }
1036
+
1037
+ async function invokeRouteHandler(
1038
+ match: { handler: unknown },
1039
+ req: Tina4Request,
1040
+ res: Tina4Response,
1041
+ ): Promise<unknown> {
1042
+ const routeParams = req.params || {};
1043
+ const args = resolveHandlerArgs(match.handler, req, res, routeParams);
1044
+ if (args.length === 0) return await (match.handler as any)();
1020
1045
  return await (match.handler as any)(...args);
1021
1046
  }
1022
1047
 
@@ -25065,18 +25065,24 @@ async function runGlobalMiddlewarePass(middleware, req2, res) {
25065
25065
  if (!res.raw.writableEnded) res.raw.end();
25066
25066
  return true;
25067
25067
  }
25068
- async function invokeRouteHandler(match, req2, res) {
25069
- const routeParams = req2.params || {};
25070
- const fnStr = match.handler.toString();
25068
+ function resolveHandlerArgs(handler, req2, res, routeParams) {
25069
+ const fnStr = handler.toString();
25071
25070
  const argMatch = fnStr.match(/^(?:async\s*)?(?:function\s*\w*)?\s*\(([^)]*)\)/);
25072
25071
  const argNames = argMatch?.[1]?.split(",").map((a) => a.trim().replace(/[:=].*/, "")) ?? [];
25073
25072
  const filteredArgs = argNames.filter((n) => n.length > 0);
25074
- if (filteredArgs.length === 0) return await match.handler();
25075
- const args = filteredArgs.map((name) => {
25073
+ if (filteredArgs.length === 0) return [];
25074
+ let unmatchedPos = 0;
25075
+ return filteredArgs.map((name) => {
25076
25076
  if (name in routeParams) return routeParams[name];
25077
25077
  if (name === "request" || name === "req") return req2;
25078
- return res;
25078
+ if (name === "response" || name === "res") return res;
25079
+ return unmatchedPos++ === 0 ? req2 : res;
25079
25080
  });
25081
+ }
25082
+ async function invokeRouteHandler(match, req2, res) {
25083
+ const routeParams = req2.params || {};
25084
+ const args = resolveHandlerArgs(match.handler, req2, res, routeParams);
25085
+ if (args.length === 0) return await match.handler();
25080
25086
  return await match.handler(...args);
25081
25087
  }
25082
25088
  async function renderIfTemplateRoute(match, res, result) {
@@ -30831,6 +30837,8 @@ var init_aiClient = __esm({
30831
30837
  Ai = class {
30832
30838
  static chat(messages, options = {}) {
30833
30839
  this.validateMessages(messages);
30840
+ if (options.tools !== void 0) this.validateTools(options.tools);
30841
+ if (options.toolChoice !== void 0) this.validateToolChoice(options.toolChoice);
30834
30842
  const config = this.config("chat", options);
30835
30843
  const body = this.chatBody(config, messages, options);
30836
30844
  const headers = this.headers(config);
@@ -30860,15 +30868,31 @@ var init_aiClient = __esm({
30860
30868
  }
30861
30869
  /**
30862
30870
  * Validate role + content shape. Content may be a string OR a non-empty
30863
- * list of {type:'text'|'image', ...} parts (ADR-0060). Malformed parts
30864
- * fail fast with AiConfigError, never reaching the wire.
30871
+ * list of {type:'text'|'image'|'tool_result', ...} parts (ADR-0060 +
30872
+ * ADR-0061). The `tool` role is the OpenAI-style tool-result turn
30873
+ * (ADR-0061). Malformed parts fail fast with AiConfigError, never
30874
+ * reaching the wire.
30865
30875
  */
30866
30876
  static validateMessages(messages) {
30867
30877
  if (!Array.isArray(messages) || messages.length === 0) {
30868
30878
  throw new AiConfigError("AI messages must contain supported roles and string content");
30869
30879
  }
30870
- for (const message of messages) {
30871
- if (!message || !["system", "user", "assistant"].includes(message.role)) {
30880
+ for (const raw of messages) {
30881
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
30882
+ throw new AiConfigError("AI messages must contain supported roles and string content");
30883
+ }
30884
+ const message = raw;
30885
+ const role = message.role;
30886
+ if (role === "tool") {
30887
+ if (typeof message.tool_call_id !== "string" || message.tool_call_id.length === 0) {
30888
+ throw new AiConfigError("AI tool message requires a non-empty string 'tool_call_id'");
30889
+ }
30890
+ if (typeof message.content !== "string") {
30891
+ throw new AiConfigError("AI tool message requires a string 'content'");
30892
+ }
30893
+ continue;
30894
+ }
30895
+ if (role !== "system" && role !== "user" && role !== "assistant") {
30872
30896
  throw new AiConfigError("AI messages must contain supported roles and string content");
30873
30897
  }
30874
30898
  this.validateContent(message.content);
@@ -30899,11 +30923,64 @@ var init_aiClient = __esm({
30899
30923
  if (record.source.startsWith("data:") && !/^data:[^;,\s]+;base64,[A-Za-z0-9+/=]+$/.test(record.source)) {
30900
30924
  throw new AiConfigError("AI image data URI must be data:<media_type>;base64,<payload>");
30901
30925
  }
30926
+ } else if (partType === "tool_result") {
30927
+ if (typeof record.tool_use_id !== "string" || record.tool_use_id.length === 0) {
30928
+ throw new AiConfigError("AI tool_result part requires a non-empty string 'tool_use_id'");
30929
+ }
30930
+ if (typeof record.content !== "string") {
30931
+ throw new AiConfigError("AI tool_result part requires a string 'content'");
30932
+ }
30902
30933
  } else {
30903
30934
  throw new AiConfigError(`AI content part has unknown type '${String(partType)}'`);
30904
30935
  }
30905
30936
  }
30906
30937
  }
30938
+ /**
30939
+ * Validate the outbound tool declarations (ADR-0061). Each tool needs a
30940
+ * non-empty `name`, a string `description`, and a JSON-Schema-shaped
30941
+ * `parameters` object. Malformed tools fail fast with AiConfigError,
30942
+ * never reaching the wire.
30943
+ */
30944
+ static validateTools(tools) {
30945
+ if (!Array.isArray(tools) || tools.length === 0) {
30946
+ throw new AiConfigError("AI tools must be a non-empty list of {name, description, parameters}");
30947
+ }
30948
+ for (const tool of tools) {
30949
+ if (!tool || typeof tool !== "object" || Array.isArray(tool)) {
30950
+ throw new AiConfigError("AI tool must be an object with name, description, parameters");
30951
+ }
30952
+ const record = tool;
30953
+ if (typeof record.name !== "string" || record.name.length === 0) {
30954
+ throw new AiConfigError("AI tool requires a non-empty string 'name'");
30955
+ }
30956
+ if (typeof record.description !== "string") {
30957
+ throw new AiConfigError("AI tool requires a string 'description'");
30958
+ }
30959
+ if (!record.parameters || typeof record.parameters !== "object" || Array.isArray(record.parameters)) {
30960
+ throw new AiConfigError("AI tool requires a JSON-Schema object 'parameters'");
30961
+ }
30962
+ }
30963
+ }
30964
+ /**
30965
+ * Validate the outbound tool_choice value (ADR-0061). The four accepted
30966
+ * shapes are 'auto', 'none', 'required', and {name: 'x'}.
30967
+ */
30968
+ static validateToolChoice(choice) {
30969
+ if (typeof choice === "string") {
30970
+ if (choice !== "auto" && choice !== "none" && choice !== "required") {
30971
+ throw new AiConfigError("AI toolChoice string must be 'auto', 'none', or 'required'");
30972
+ }
30973
+ return;
30974
+ }
30975
+ if (choice && typeof choice === "object" && !Array.isArray(choice)) {
30976
+ const record = choice;
30977
+ if (typeof record.name !== "string" || record.name.length === 0) {
30978
+ throw new AiConfigError("AI toolChoice object requires a non-empty string 'name'");
30979
+ }
30980
+ return;
30981
+ }
30982
+ throw new AiConfigError("AI toolChoice must be 'auto'|'none'|'required' or {name: string}");
30983
+ }
30907
30984
  static number(name, fallback, minimum) {
30908
30985
  const value = process.env[name] === void 0 ? fallback : Number(process.env[name]);
30909
30986
  if (!Number.isFinite(value) || value < minimum) throw new AiConfigError(`${name} must be numeric and at least ${minimum}`);
@@ -30952,25 +31029,100 @@ var init_aiClient = __esm({
30952
31029
  }
30953
31030
  /**
30954
31031
  * Build the provider-specific request body from a Tina4-shaped message
30955
- * list. Multimodal parts are translated per provider (ADR-0060):
30956
- * - OpenAI/local: {type:'image_url', image_url:{url}}
30957
- * - Anthropic: {type:'image', source:{type:'base64'|'url', ...}}
31032
+ * list plus optional tool declarations (ADR-0060 + ADR-0061).
31033
+ *
31034
+ * Content parts translate per provider:
31035
+ * - OpenAI/local: image → {type:'image_url', image_url:{url}}
31036
+ * - Anthropic: image → {type:'image', source:{type:'base64'|'url', ...}}
30958
31037
  * String content is preserved verbatim in the OpenAI/local shape and
30959
31038
  * likewise for Anthropic (both accept a bare string).
31039
+ *
31040
+ * Tool-result turns are normalised to the current provider's expected
31041
+ * shape (either the OpenAI `{role:"tool", tool_call_id, content}` turn or
31042
+ * the Anthropic `{role:"user", content:[{type:"tool_result", ...}]}`
31043
+ * turn), so an agent-loop written against Tina4 never has to fork on
31044
+ * TINA4_AI_PROVIDER (ADR-0061 wire translation).
30960
31045
  */
30961
31046
  static chatBody(config, messages, options) {
30962
- const translate = (list) => list.map((message) => ({ role: message.role, content: this.translateContent(message.content, config.provider) }));
30963
- const body = { model: config.model, messages: translate(messages), stream: options.stream ?? false };
31047
+ const normalized = this.normalizeMessagesForProvider(messages, config.provider);
31048
+ const body = { model: config.model, messages: normalized, stream: options.stream ?? false };
30964
31049
  if (options.temperature !== void 0) body.temperature = options.temperature;
30965
31050
  if (options.maxTokens !== void 0) body.max_tokens = options.maxTokens;
30966
31051
  if (config.provider === "anthropic") {
30967
- const systemParts = messages.filter((message) => message.role === "system").map((message) => typeof message.content === "string" ? message.content : this.contentToPlainText(message.content));
30968
- body.messages = translate(messages.filter((message) => message.role !== "system"));
31052
+ const systemParts = [];
31053
+ for (const message of messages) {
31054
+ if (message.role !== "system") continue;
31055
+ const content = message.content;
31056
+ systemParts.push(typeof content === "string" ? content : this.contentToPlainText(content));
31057
+ }
31058
+ body.messages = normalized.filter((message) => message.role !== "system");
30969
31059
  body.max_tokens = options.maxTokens ?? 1024;
30970
31060
  if (systemParts.length) body.system = systemParts.join("\n\n");
30971
31061
  }
31062
+ this.applyTools(body, config.provider, options);
30972
31063
  return body;
30973
31064
  }
31065
+ /**
31066
+ * Normalise the Tina4-shaped messages into the provider's on-wire shape.
31067
+ * The `tool` role and the `tool_result` content part are translated
31068
+ * between the OpenAI and Anthropic forms so either input works against
31069
+ * either provider (ADR-0061 return-path table).
31070
+ */
31071
+ static normalizeMessagesForProvider(messages, provider) {
31072
+ const out = [];
31073
+ for (const message of messages) {
31074
+ if (message.role === "tool") {
31075
+ if (provider === "anthropic") {
31076
+ out.push({
31077
+ role: "user",
31078
+ content: [{ type: "tool_result", tool_use_id: message.tool_call_id, content: message.content }]
31079
+ });
31080
+ } else {
31081
+ out.push({ role: "tool", tool_call_id: message.tool_call_id, content: message.content });
31082
+ }
31083
+ continue;
31084
+ }
31085
+ if (Array.isArray(message.content) && message.content.some((part) => part.type === "tool_result")) {
31086
+ if (provider === "anthropic") {
31087
+ out.push({ role: message.role, content: this.translateContent(message.content, provider) });
31088
+ } else {
31089
+ for (const part of message.content) {
31090
+ if (part.type === "tool_result") {
31091
+ out.push({ role: "tool", tool_call_id: part.tool_use_id, content: part.content });
31092
+ }
31093
+ }
31094
+ }
31095
+ continue;
31096
+ }
31097
+ out.push({ role: message.role, content: this.translateContent(message.content, provider) });
31098
+ }
31099
+ return out;
31100
+ }
31101
+ /**
31102
+ * Attach the outbound `tools` and `tool_choice` (ADR-0061 outbound
31103
+ * translation tables) to the body in place. When toolChoice is 'none'
31104
+ * on Anthropic (Anthropic has no "none" mode) the tools list is omitted
31105
+ * entirely — the model cannot call what it cannot see.
31106
+ */
31107
+ static applyTools(body, provider, options) {
31108
+ const choice = options.toolChoice;
31109
+ const suppressToolsForAnthropic = provider === "anthropic" && choice === "none";
31110
+ if (options.tools !== void 0 && !suppressToolsForAnthropic) {
31111
+ body.tools = options.tools.map(
31112
+ (tool) => provider === "anthropic" ? { name: tool.name, description: tool.description, input_schema: tool.parameters } : { type: "function", function: { name: tool.name, description: tool.description, parameters: tool.parameters } }
31113
+ );
31114
+ }
31115
+ if (choice === void 0) return;
31116
+ if (provider === "anthropic") {
31117
+ if (choice === "none") return;
31118
+ if (choice === "auto") body.tool_choice = { type: "auto" };
31119
+ else if (choice === "required") body.tool_choice = { type: "any" };
31120
+ else body.tool_choice = { type: "tool", name: choice.name };
31121
+ } else {
31122
+ if (typeof choice === "string") body.tool_choice = choice;
31123
+ else body.tool_choice = { type: "function", function: { name: choice.name } };
31124
+ }
31125
+ }
30974
31126
  /**
30975
31127
  * Translate one message content value into the provider's on-wire shape.
30976
31128
  * A plain string is passed through (both providers accept a string
@@ -30981,6 +31133,7 @@ var init_aiClient = __esm({
30981
31133
  if (provider === "anthropic") {
30982
31134
  return content.map((part) => {
30983
31135
  if (part.type === "text") return { type: "text", text: part.text };
31136
+ if (part.type === "tool_result") return { type: "tool_result", tool_use_id: part.tool_use_id, content: part.content };
30984
31137
  if (part.source.startsWith("data:")) {
30985
31138
  const parsed = this.parseDataUri(part.source);
30986
31139
  return { type: "image", source: { type: "base64", media_type: parsed.mediaType, data: parsed.data } };
@@ -30990,6 +31143,9 @@ var init_aiClient = __esm({
30990
31143
  }
30991
31144
  return content.map((part) => {
30992
31145
  if (part.type === "text") return { type: "text", text: part.text };
31146
+ if (part.type === "tool_result") {
31147
+ return { type: "text", text: part.content };
31148
+ }
30993
31149
  return { type: "image_url", image_url: { url: part.source } };
30994
31150
  });
30995
31151
  }