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.
package/CLAUDE.md CHANGED
@@ -13,13 +13,13 @@ Even if the skill text is not currently loaded, these are non-negotiable:
13
13
 
14
14
  The full discipline lives in `.claude/skills/tina4-maintainer/SKILL.md`; this block is the always-on floor.
15
15
 
16
- # CLAUDE.md - AI Developer Guide for tina4-nodejs (v3.13.113)
16
+ # CLAUDE.md - AI Developer Guide for tina4-nodejs (v3.13.115)
17
17
 
18
18
  > This file helps AI assistants (Claude, Copilot, Cursor, etc.) understand and work on this codebase effectively.
19
19
 
20
20
  ## What This Project Is
21
21
 
22
- Tina4 for Node.js/TypeScript v3.13.113 - The Intelligent Native Application 4ramework. A convention-over-configuration structural paradigm. The developer writes TypeScript; Tina4 is invisible infrastructure.
22
+ Tina4 for Node.js/TypeScript v3.13.115 - The Intelligent Native Application 4ramework. A convention-over-configuration structural paradigm. The developer writes TypeScript; Tina4 is invisible infrastructure.
23
23
 
24
24
  The philosophy: zero ceremony, batteries included, file system as source of truth.
25
25
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tina4-nodejs",
3
- "version": "3.13.113",
3
+ "version": "3.13.115",
4
4
  "type": "module",
5
5
  "description": "Tina4 for Node.js/TypeScript - native TypeScript conventions and shared Tina4 contracts",
6
6
  "keywords": [
@@ -36727,18 +36727,24 @@ async function runGlobalMiddlewarePass(middleware, req2, res) {
36727
36727
  if (!res.raw.writableEnded) res.raw.end();
36728
36728
  return true;
36729
36729
  }
36730
- async function invokeRouteHandler(match, req2, res) {
36731
- const routeParams = req2.params || {};
36732
- const fnStr = match.handler.toString();
36730
+ function resolveHandlerArgs(handler, req2, res, routeParams) {
36731
+ const fnStr = handler.toString();
36733
36732
  const argMatch = fnStr.match(/^(?:async\s*)?(?:function\s*\w*)?\s*\(([^)]*)\)/);
36734
36733
  const argNames = argMatch?.[1]?.split(",").map((a) => a.trim().replace(/[:=].*/, "")) ?? [];
36735
36734
  const filteredArgs = argNames.filter((n) => n.length > 0);
36736
- if (filteredArgs.length === 0) return await match.handler();
36737
- const args = filteredArgs.map((name) => {
36735
+ if (filteredArgs.length === 0) return [];
36736
+ let unmatchedPos = 0;
36737
+ return filteredArgs.map((name) => {
36738
36738
  if (name in routeParams) return routeParams[name];
36739
36739
  if (name === "request" || name === "req") return req2;
36740
- return res;
36740
+ if (name === "response" || name === "res") return res;
36741
+ return unmatchedPos++ === 0 ? req2 : res;
36741
36742
  });
36743
+ }
36744
+ async function invokeRouteHandler(match, req2, res) {
36745
+ const routeParams = req2.params || {};
36746
+ const args = resolveHandlerArgs(match.handler, req2, res, routeParams);
36747
+ if (args.length === 0) return await match.handler();
36742
36748
  return await match.handler(...args);
36743
36749
  }
36744
36750
  async function renderIfTemplateRoute(match, res, result) {
@@ -42043,6 +42049,8 @@ var init_aiClient = __esm({
42043
42049
  Ai = class {
42044
42050
  static chat(messages, options = {}) {
42045
42051
  this.validateMessages(messages);
42052
+ if (options.tools !== void 0) this.validateTools(options.tools);
42053
+ if (options.toolChoice !== void 0) this.validateToolChoice(options.toolChoice);
42046
42054
  const config = this.config("chat", options);
42047
42055
  const body = this.chatBody(config, messages, options);
42048
42056
  const headers = this.headers(config);
@@ -42072,15 +42080,31 @@ var init_aiClient = __esm({
42072
42080
  }
42073
42081
  /**
42074
42082
  * Validate role + content shape. Content may be a string OR a non-empty
42075
- * list of {type:'text'|'image', ...} parts (ADR-0060). Malformed parts
42076
- * fail fast with AiConfigError, never reaching the wire.
42083
+ * list of {type:'text'|'image'|'tool_result', ...} parts (ADR-0060 +
42084
+ * ADR-0061). The `tool` role is the OpenAI-style tool-result turn
42085
+ * (ADR-0061). Malformed parts fail fast with AiConfigError, never
42086
+ * reaching the wire.
42077
42087
  */
42078
42088
  static validateMessages(messages) {
42079
42089
  if (!Array.isArray(messages) || messages.length === 0) {
42080
42090
  throw new AiConfigError("AI messages must contain supported roles and string content");
42081
42091
  }
42082
- for (const message of messages) {
42083
- if (!message || !["system", "user", "assistant"].includes(message.role)) {
42092
+ for (const raw of messages) {
42093
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
42094
+ throw new AiConfigError("AI messages must contain supported roles and string content");
42095
+ }
42096
+ const message = raw;
42097
+ const role = message.role;
42098
+ if (role === "tool") {
42099
+ if (typeof message.tool_call_id !== "string" || message.tool_call_id.length === 0) {
42100
+ throw new AiConfigError("AI tool message requires a non-empty string 'tool_call_id'");
42101
+ }
42102
+ if (typeof message.content !== "string") {
42103
+ throw new AiConfigError("AI tool message requires a string 'content'");
42104
+ }
42105
+ continue;
42106
+ }
42107
+ if (role !== "system" && role !== "user" && role !== "assistant") {
42084
42108
  throw new AiConfigError("AI messages must contain supported roles and string content");
42085
42109
  }
42086
42110
  this.validateContent(message.content);
@@ -42111,11 +42135,64 @@ var init_aiClient = __esm({
42111
42135
  if (record.source.startsWith("data:") && !/^data:[^;,\s]+;base64,[A-Za-z0-9+/=]+$/.test(record.source)) {
42112
42136
  throw new AiConfigError("AI image data URI must be data:<media_type>;base64,<payload>");
42113
42137
  }
42138
+ } else if (partType === "tool_result") {
42139
+ if (typeof record.tool_use_id !== "string" || record.tool_use_id.length === 0) {
42140
+ throw new AiConfigError("AI tool_result part requires a non-empty string 'tool_use_id'");
42141
+ }
42142
+ if (typeof record.content !== "string") {
42143
+ throw new AiConfigError("AI tool_result part requires a string 'content'");
42144
+ }
42114
42145
  } else {
42115
42146
  throw new AiConfigError(`AI content part has unknown type '${String(partType)}'`);
42116
42147
  }
42117
42148
  }
42118
42149
  }
42150
+ /**
42151
+ * Validate the outbound tool declarations (ADR-0061). Each tool needs a
42152
+ * non-empty `name`, a string `description`, and a JSON-Schema-shaped
42153
+ * `parameters` object. Malformed tools fail fast with AiConfigError,
42154
+ * never reaching the wire.
42155
+ */
42156
+ static validateTools(tools) {
42157
+ if (!Array.isArray(tools) || tools.length === 0) {
42158
+ throw new AiConfigError("AI tools must be a non-empty list of {name, description, parameters}");
42159
+ }
42160
+ for (const tool of tools) {
42161
+ if (!tool || typeof tool !== "object" || Array.isArray(tool)) {
42162
+ throw new AiConfigError("AI tool must be an object with name, description, parameters");
42163
+ }
42164
+ const record = tool;
42165
+ if (typeof record.name !== "string" || record.name.length === 0) {
42166
+ throw new AiConfigError("AI tool requires a non-empty string 'name'");
42167
+ }
42168
+ if (typeof record.description !== "string") {
42169
+ throw new AiConfigError("AI tool requires a string 'description'");
42170
+ }
42171
+ if (!record.parameters || typeof record.parameters !== "object" || Array.isArray(record.parameters)) {
42172
+ throw new AiConfigError("AI tool requires a JSON-Schema object 'parameters'");
42173
+ }
42174
+ }
42175
+ }
42176
+ /**
42177
+ * Validate the outbound tool_choice value (ADR-0061). The four accepted
42178
+ * shapes are 'auto', 'none', 'required', and {name: 'x'}.
42179
+ */
42180
+ static validateToolChoice(choice) {
42181
+ if (typeof choice === "string") {
42182
+ if (choice !== "auto" && choice !== "none" && choice !== "required") {
42183
+ throw new AiConfigError("AI toolChoice string must be 'auto', 'none', or 'required'");
42184
+ }
42185
+ return;
42186
+ }
42187
+ if (choice && typeof choice === "object" && !Array.isArray(choice)) {
42188
+ const record = choice;
42189
+ if (typeof record.name !== "string" || record.name.length === 0) {
42190
+ throw new AiConfigError("AI toolChoice object requires a non-empty string 'name'");
42191
+ }
42192
+ return;
42193
+ }
42194
+ throw new AiConfigError("AI toolChoice must be 'auto'|'none'|'required' or {name: string}");
42195
+ }
42119
42196
  static number(name, fallback, minimum) {
42120
42197
  const value = process.env[name] === void 0 ? fallback : Number(process.env[name]);
42121
42198
  if (!Number.isFinite(value) || value < minimum) throw new AiConfigError(`${name} must be numeric and at least ${minimum}`);
@@ -42164,25 +42241,100 @@ var init_aiClient = __esm({
42164
42241
  }
42165
42242
  /**
42166
42243
  * Build the provider-specific request body from a Tina4-shaped message
42167
- * list. Multimodal parts are translated per provider (ADR-0060):
42168
- * - OpenAI/local: {type:'image_url', image_url:{url}}
42169
- * - Anthropic: {type:'image', source:{type:'base64'|'url', ...}}
42244
+ * list plus optional tool declarations (ADR-0060 + ADR-0061).
42245
+ *
42246
+ * Content parts translate per provider:
42247
+ * - OpenAI/local: image → {type:'image_url', image_url:{url}}
42248
+ * - Anthropic: image → {type:'image', source:{type:'base64'|'url', ...}}
42170
42249
  * String content is preserved verbatim in the OpenAI/local shape and
42171
42250
  * likewise for Anthropic (both accept a bare string).
42251
+ *
42252
+ * Tool-result turns are normalised to the current provider's expected
42253
+ * shape (either the OpenAI `{role:"tool", tool_call_id, content}` turn or
42254
+ * the Anthropic `{role:"user", content:[{type:"tool_result", ...}]}`
42255
+ * turn), so an agent-loop written against Tina4 never has to fork on
42256
+ * TINA4_AI_PROVIDER (ADR-0061 wire translation).
42172
42257
  */
42173
42258
  static chatBody(config, messages, options) {
42174
- const translate = (list) => list.map((message) => ({ role: message.role, content: this.translateContent(message.content, config.provider) }));
42175
- const body = { model: config.model, messages: translate(messages), stream: options.stream ?? false };
42259
+ const normalized = this.normalizeMessagesForProvider(messages, config.provider);
42260
+ const body = { model: config.model, messages: normalized, stream: options.stream ?? false };
42176
42261
  if (options.temperature !== void 0) body.temperature = options.temperature;
42177
42262
  if (options.maxTokens !== void 0) body.max_tokens = options.maxTokens;
42178
42263
  if (config.provider === "anthropic") {
42179
- const systemParts = messages.filter((message) => message.role === "system").map((message) => typeof message.content === "string" ? message.content : this.contentToPlainText(message.content));
42180
- body.messages = translate(messages.filter((message) => message.role !== "system"));
42264
+ const systemParts = [];
42265
+ for (const message of messages) {
42266
+ if (message.role !== "system") continue;
42267
+ const content = message.content;
42268
+ systemParts.push(typeof content === "string" ? content : this.contentToPlainText(content));
42269
+ }
42270
+ body.messages = normalized.filter((message) => message.role !== "system");
42181
42271
  body.max_tokens = options.maxTokens ?? 1024;
42182
42272
  if (systemParts.length) body.system = systemParts.join("\n\n");
42183
42273
  }
42274
+ this.applyTools(body, config.provider, options);
42184
42275
  return body;
42185
42276
  }
42277
+ /**
42278
+ * Normalise the Tina4-shaped messages into the provider's on-wire shape.
42279
+ * The `tool` role and the `tool_result` content part are translated
42280
+ * between the OpenAI and Anthropic forms so either input works against
42281
+ * either provider (ADR-0061 return-path table).
42282
+ */
42283
+ static normalizeMessagesForProvider(messages, provider) {
42284
+ const out = [];
42285
+ for (const message of messages) {
42286
+ if (message.role === "tool") {
42287
+ if (provider === "anthropic") {
42288
+ out.push({
42289
+ role: "user",
42290
+ content: [{ type: "tool_result", tool_use_id: message.tool_call_id, content: message.content }]
42291
+ });
42292
+ } else {
42293
+ out.push({ role: "tool", tool_call_id: message.tool_call_id, content: message.content });
42294
+ }
42295
+ continue;
42296
+ }
42297
+ if (Array.isArray(message.content) && message.content.some((part) => part.type === "tool_result")) {
42298
+ if (provider === "anthropic") {
42299
+ out.push({ role: message.role, content: this.translateContent(message.content, provider) });
42300
+ } else {
42301
+ for (const part of message.content) {
42302
+ if (part.type === "tool_result") {
42303
+ out.push({ role: "tool", tool_call_id: part.tool_use_id, content: part.content });
42304
+ }
42305
+ }
42306
+ }
42307
+ continue;
42308
+ }
42309
+ out.push({ role: message.role, content: this.translateContent(message.content, provider) });
42310
+ }
42311
+ return out;
42312
+ }
42313
+ /**
42314
+ * Attach the outbound `tools` and `tool_choice` (ADR-0061 outbound
42315
+ * translation tables) to the body in place. When toolChoice is 'none'
42316
+ * on Anthropic (Anthropic has no "none" mode) the tools list is omitted
42317
+ * entirely — the model cannot call what it cannot see.
42318
+ */
42319
+ static applyTools(body, provider, options) {
42320
+ const choice = options.toolChoice;
42321
+ const suppressToolsForAnthropic = provider === "anthropic" && choice === "none";
42322
+ if (options.tools !== void 0 && !suppressToolsForAnthropic) {
42323
+ body.tools = options.tools.map(
42324
+ (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 } }
42325
+ );
42326
+ }
42327
+ if (choice === void 0) return;
42328
+ if (provider === "anthropic") {
42329
+ if (choice === "none") return;
42330
+ if (choice === "auto") body.tool_choice = { type: "auto" };
42331
+ else if (choice === "required") body.tool_choice = { type: "any" };
42332
+ else body.tool_choice = { type: "tool", name: choice.name };
42333
+ } else {
42334
+ if (typeof choice === "string") body.tool_choice = choice;
42335
+ else body.tool_choice = { type: "function", function: { name: choice.name } };
42336
+ }
42337
+ }
42186
42338
  /**
42187
42339
  * Translate one message content value into the provider's on-wire shape.
42188
42340
  * A plain string is passed through (both providers accept a string
@@ -42193,6 +42345,7 @@ var init_aiClient = __esm({
42193
42345
  if (provider === "anthropic") {
42194
42346
  return content.map((part) => {
42195
42347
  if (part.type === "text") return { type: "text", text: part.text };
42348
+ if (part.type === "tool_result") return { type: "tool_result", tool_use_id: part.tool_use_id, content: part.content };
42196
42349
  if (part.source.startsWith("data:")) {
42197
42350
  const parsed = this.parseDataUri(part.source);
42198
42351
  return { type: "image", source: { type: "base64", media_type: parsed.mediaType, data: parsed.data } };
@@ -42202,6 +42355,9 @@ var init_aiClient = __esm({
42202
42355
  }
42203
42356
  return content.map((part) => {
42204
42357
  if (part.type === "text") return { type: "text", text: part.text };
42358
+ if (part.type === "tool_result") {
42359
+ return { type: "text", text: part.content };
42360
+ }
42205
42361
  return { type: "image_url", image_url: { url: part.source } };
42206
42362
  });
42207
42363
  }
@@ -36706,18 +36706,24 @@ async function runGlobalMiddlewarePass(middleware, req2, res) {
36706
36706
  if (!res.raw.writableEnded) res.raw.end();
36707
36707
  return true;
36708
36708
  }
36709
- async function invokeRouteHandler(match, req2, res) {
36710
- const routeParams = req2.params || {};
36711
- const fnStr = match.handler.toString();
36709
+ function resolveHandlerArgs(handler, req2, res, routeParams) {
36710
+ const fnStr = handler.toString();
36712
36711
  const argMatch = fnStr.match(/^(?:async\s*)?(?:function\s*\w*)?\s*\(([^)]*)\)/);
36713
36712
  const argNames = argMatch?.[1]?.split(",").map((a) => a.trim().replace(/[:=].*/, "")) ?? [];
36714
36713
  const filteredArgs = argNames.filter((n) => n.length > 0);
36715
- if (filteredArgs.length === 0) return await match.handler();
36716
- const args = filteredArgs.map((name) => {
36714
+ if (filteredArgs.length === 0) return [];
36715
+ let unmatchedPos = 0;
36716
+ return filteredArgs.map((name) => {
36717
36717
  if (name in routeParams) return routeParams[name];
36718
36718
  if (name === "request" || name === "req") return req2;
36719
- return res;
36719
+ if (name === "response" || name === "res") return res;
36720
+ return unmatchedPos++ === 0 ? req2 : res;
36720
36721
  });
36722
+ }
36723
+ async function invokeRouteHandler(match, req2, res) {
36724
+ const routeParams = req2.params || {};
36725
+ const args = resolveHandlerArgs(match.handler, req2, res, routeParams);
36726
+ if (args.length === 0) return await match.handler();
36721
36727
  return await match.handler(...args);
36722
36728
  }
36723
36729
  async function renderIfTemplateRoute(match, res, result) {
@@ -42004,6 +42010,8 @@ var init_aiClient = __esm({
42004
42010
  Ai = class {
42005
42011
  static chat(messages, options = {}) {
42006
42012
  this.validateMessages(messages);
42013
+ if (options.tools !== void 0) this.validateTools(options.tools);
42014
+ if (options.toolChoice !== void 0) this.validateToolChoice(options.toolChoice);
42007
42015
  const config = this.config("chat", options);
42008
42016
  const body = this.chatBody(config, messages, options);
42009
42017
  const headers = this.headers(config);
@@ -42033,15 +42041,31 @@ var init_aiClient = __esm({
42033
42041
  }
42034
42042
  /**
42035
42043
  * Validate role + content shape. Content may be a string OR a non-empty
42036
- * list of {type:'text'|'image', ...} parts (ADR-0060). Malformed parts
42037
- * fail fast with AiConfigError, never reaching the wire.
42044
+ * list of {type:'text'|'image'|'tool_result', ...} parts (ADR-0060 +
42045
+ * ADR-0061). The `tool` role is the OpenAI-style tool-result turn
42046
+ * (ADR-0061). Malformed parts fail fast with AiConfigError, never
42047
+ * reaching the wire.
42038
42048
  */
42039
42049
  static validateMessages(messages) {
42040
42050
  if (!Array.isArray(messages) || messages.length === 0) {
42041
42051
  throw new AiConfigError("AI messages must contain supported roles and string content");
42042
42052
  }
42043
- for (const message of messages) {
42044
- if (!message || !["system", "user", "assistant"].includes(message.role)) {
42053
+ for (const raw of messages) {
42054
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
42055
+ throw new AiConfigError("AI messages must contain supported roles and string content");
42056
+ }
42057
+ const message = raw;
42058
+ const role = message.role;
42059
+ if (role === "tool") {
42060
+ if (typeof message.tool_call_id !== "string" || message.tool_call_id.length === 0) {
42061
+ throw new AiConfigError("AI tool message requires a non-empty string 'tool_call_id'");
42062
+ }
42063
+ if (typeof message.content !== "string") {
42064
+ throw new AiConfigError("AI tool message requires a string 'content'");
42065
+ }
42066
+ continue;
42067
+ }
42068
+ if (role !== "system" && role !== "user" && role !== "assistant") {
42045
42069
  throw new AiConfigError("AI messages must contain supported roles and string content");
42046
42070
  }
42047
42071
  this.validateContent(message.content);
@@ -42072,11 +42096,64 @@ var init_aiClient = __esm({
42072
42096
  if (record.source.startsWith("data:") && !/^data:[^;,\s]+;base64,[A-Za-z0-9+/=]+$/.test(record.source)) {
42073
42097
  throw new AiConfigError("AI image data URI must be data:<media_type>;base64,<payload>");
42074
42098
  }
42099
+ } else if (partType === "tool_result") {
42100
+ if (typeof record.tool_use_id !== "string" || record.tool_use_id.length === 0) {
42101
+ throw new AiConfigError("AI tool_result part requires a non-empty string 'tool_use_id'");
42102
+ }
42103
+ if (typeof record.content !== "string") {
42104
+ throw new AiConfigError("AI tool_result part requires a string 'content'");
42105
+ }
42075
42106
  } else {
42076
42107
  throw new AiConfigError(`AI content part has unknown type '${String(partType)}'`);
42077
42108
  }
42078
42109
  }
42079
42110
  }
42111
+ /**
42112
+ * Validate the outbound tool declarations (ADR-0061). Each tool needs a
42113
+ * non-empty `name`, a string `description`, and a JSON-Schema-shaped
42114
+ * `parameters` object. Malformed tools fail fast with AiConfigError,
42115
+ * never reaching the wire.
42116
+ */
42117
+ static validateTools(tools) {
42118
+ if (!Array.isArray(tools) || tools.length === 0) {
42119
+ throw new AiConfigError("AI tools must be a non-empty list of {name, description, parameters}");
42120
+ }
42121
+ for (const tool of tools) {
42122
+ if (!tool || typeof tool !== "object" || Array.isArray(tool)) {
42123
+ throw new AiConfigError("AI tool must be an object with name, description, parameters");
42124
+ }
42125
+ const record = tool;
42126
+ if (typeof record.name !== "string" || record.name.length === 0) {
42127
+ throw new AiConfigError("AI tool requires a non-empty string 'name'");
42128
+ }
42129
+ if (typeof record.description !== "string") {
42130
+ throw new AiConfigError("AI tool requires a string 'description'");
42131
+ }
42132
+ if (!record.parameters || typeof record.parameters !== "object" || Array.isArray(record.parameters)) {
42133
+ throw new AiConfigError("AI tool requires a JSON-Schema object 'parameters'");
42134
+ }
42135
+ }
42136
+ }
42137
+ /**
42138
+ * Validate the outbound tool_choice value (ADR-0061). The four accepted
42139
+ * shapes are 'auto', 'none', 'required', and {name: 'x'}.
42140
+ */
42141
+ static validateToolChoice(choice) {
42142
+ if (typeof choice === "string") {
42143
+ if (choice !== "auto" && choice !== "none" && choice !== "required") {
42144
+ throw new AiConfigError("AI toolChoice string must be 'auto', 'none', or 'required'");
42145
+ }
42146
+ return;
42147
+ }
42148
+ if (choice && typeof choice === "object" && !Array.isArray(choice)) {
42149
+ const record = choice;
42150
+ if (typeof record.name !== "string" || record.name.length === 0) {
42151
+ throw new AiConfigError("AI toolChoice object requires a non-empty string 'name'");
42152
+ }
42153
+ return;
42154
+ }
42155
+ throw new AiConfigError("AI toolChoice must be 'auto'|'none'|'required' or {name: string}");
42156
+ }
42080
42157
  static number(name, fallback, minimum) {
42081
42158
  const value = process.env[name] === void 0 ? fallback : Number(process.env[name]);
42082
42159
  if (!Number.isFinite(value) || value < minimum) throw new AiConfigError(`${name} must be numeric and at least ${minimum}`);
@@ -42125,25 +42202,100 @@ var init_aiClient = __esm({
42125
42202
  }
42126
42203
  /**
42127
42204
  * Build the provider-specific request body from a Tina4-shaped message
42128
- * list. Multimodal parts are translated per provider (ADR-0060):
42129
- * - OpenAI/local: {type:'image_url', image_url:{url}}
42130
- * - Anthropic: {type:'image', source:{type:'base64'|'url', ...}}
42205
+ * list plus optional tool declarations (ADR-0060 + ADR-0061).
42206
+ *
42207
+ * Content parts translate per provider:
42208
+ * - OpenAI/local: image → {type:'image_url', image_url:{url}}
42209
+ * - Anthropic: image → {type:'image', source:{type:'base64'|'url', ...}}
42131
42210
  * String content is preserved verbatim in the OpenAI/local shape and
42132
42211
  * likewise for Anthropic (both accept a bare string).
42212
+ *
42213
+ * Tool-result turns are normalised to the current provider's expected
42214
+ * shape (either the OpenAI `{role:"tool", tool_call_id, content}` turn or
42215
+ * the Anthropic `{role:"user", content:[{type:"tool_result", ...}]}`
42216
+ * turn), so an agent-loop written against Tina4 never has to fork on
42217
+ * TINA4_AI_PROVIDER (ADR-0061 wire translation).
42133
42218
  */
42134
42219
  static chatBody(config, messages, options) {
42135
- const translate = (list) => list.map((message) => ({ role: message.role, content: this.translateContent(message.content, config.provider) }));
42136
- const body = { model: config.model, messages: translate(messages), stream: options.stream ?? false };
42220
+ const normalized = this.normalizeMessagesForProvider(messages, config.provider);
42221
+ const body = { model: config.model, messages: normalized, stream: options.stream ?? false };
42137
42222
  if (options.temperature !== void 0) body.temperature = options.temperature;
42138
42223
  if (options.maxTokens !== void 0) body.max_tokens = options.maxTokens;
42139
42224
  if (config.provider === "anthropic") {
42140
- const systemParts = messages.filter((message) => message.role === "system").map((message) => typeof message.content === "string" ? message.content : this.contentToPlainText(message.content));
42141
- body.messages = translate(messages.filter((message) => message.role !== "system"));
42225
+ const systemParts = [];
42226
+ for (const message of messages) {
42227
+ if (message.role !== "system") continue;
42228
+ const content = message.content;
42229
+ systemParts.push(typeof content === "string" ? content : this.contentToPlainText(content));
42230
+ }
42231
+ body.messages = normalized.filter((message) => message.role !== "system");
42142
42232
  body.max_tokens = options.maxTokens ?? 1024;
42143
42233
  if (systemParts.length) body.system = systemParts.join("\n\n");
42144
42234
  }
42235
+ this.applyTools(body, config.provider, options);
42145
42236
  return body;
42146
42237
  }
42238
+ /**
42239
+ * Normalise the Tina4-shaped messages into the provider's on-wire shape.
42240
+ * The `tool` role and the `tool_result` content part are translated
42241
+ * between the OpenAI and Anthropic forms so either input works against
42242
+ * either provider (ADR-0061 return-path table).
42243
+ */
42244
+ static normalizeMessagesForProvider(messages, provider) {
42245
+ const out = [];
42246
+ for (const message of messages) {
42247
+ if (message.role === "tool") {
42248
+ if (provider === "anthropic") {
42249
+ out.push({
42250
+ role: "user",
42251
+ content: [{ type: "tool_result", tool_use_id: message.tool_call_id, content: message.content }]
42252
+ });
42253
+ } else {
42254
+ out.push({ role: "tool", tool_call_id: message.tool_call_id, content: message.content });
42255
+ }
42256
+ continue;
42257
+ }
42258
+ if (Array.isArray(message.content) && message.content.some((part) => part.type === "tool_result")) {
42259
+ if (provider === "anthropic") {
42260
+ out.push({ role: message.role, content: this.translateContent(message.content, provider) });
42261
+ } else {
42262
+ for (const part of message.content) {
42263
+ if (part.type === "tool_result") {
42264
+ out.push({ role: "tool", tool_call_id: part.tool_use_id, content: part.content });
42265
+ }
42266
+ }
42267
+ }
42268
+ continue;
42269
+ }
42270
+ out.push({ role: message.role, content: this.translateContent(message.content, provider) });
42271
+ }
42272
+ return out;
42273
+ }
42274
+ /**
42275
+ * Attach the outbound `tools` and `tool_choice` (ADR-0061 outbound
42276
+ * translation tables) to the body in place. When toolChoice is 'none'
42277
+ * on Anthropic (Anthropic has no "none" mode) the tools list is omitted
42278
+ * entirely — the model cannot call what it cannot see.
42279
+ */
42280
+ static applyTools(body, provider, options) {
42281
+ const choice = options.toolChoice;
42282
+ const suppressToolsForAnthropic = provider === "anthropic" && choice === "none";
42283
+ if (options.tools !== void 0 && !suppressToolsForAnthropic) {
42284
+ body.tools = options.tools.map(
42285
+ (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 } }
42286
+ );
42287
+ }
42288
+ if (choice === void 0) return;
42289
+ if (provider === "anthropic") {
42290
+ if (choice === "none") return;
42291
+ if (choice === "auto") body.tool_choice = { type: "auto" };
42292
+ else if (choice === "required") body.tool_choice = { type: "any" };
42293
+ else body.tool_choice = { type: "tool", name: choice.name };
42294
+ } else {
42295
+ if (typeof choice === "string") body.tool_choice = choice;
42296
+ else body.tool_choice = { type: "function", function: { name: choice.name } };
42297
+ }
42298
+ }
42147
42299
  /**
42148
42300
  * Translate one message content value into the provider's on-wire shape.
42149
42301
  * A plain string is passed through (both providers accept a string
@@ -42154,6 +42306,7 @@ var init_aiClient = __esm({
42154
42306
  if (provider === "anthropic") {
42155
42307
  return content.map((part) => {
42156
42308
  if (part.type === "text") return { type: "text", text: part.text };
42309
+ if (part.type === "tool_result") return { type: "tool_result", tool_use_id: part.tool_use_id, content: part.content };
42157
42310
  if (part.source.startsWith("data:")) {
42158
42311
  const parsed = this.parseDataUri(part.source);
42159
42312
  return { type: "image", source: { type: "base64", media_type: parsed.mediaType, data: parsed.data } };
@@ -42163,6 +42316,9 @@ var init_aiClient = __esm({
42163
42316
  }
42164
42317
  return content.map((part) => {
42165
42318
  if (part.type === "text") return { type: "text", text: part.text };
42319
+ if (part.type === "tool_result") {
42320
+ return { type: "text", text: part.content };
42321
+ }
42166
42322
  return { type: "image_url", image_url: { url: part.source } };
42167
42323
  });
42168
42324
  }