tina4-nodejs 3.13.113 → 3.13.114

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.114)
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.114 - 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.114",
4
4
  "type": "module",
5
5
  "description": "Tina4 for Node.js/TypeScript - native TypeScript conventions and shared Tina4 contracts",
6
6
  "keywords": [
@@ -42043,6 +42043,8 @@ var init_aiClient = __esm({
42043
42043
  Ai = class {
42044
42044
  static chat(messages, options = {}) {
42045
42045
  this.validateMessages(messages);
42046
+ if (options.tools !== void 0) this.validateTools(options.tools);
42047
+ if (options.toolChoice !== void 0) this.validateToolChoice(options.toolChoice);
42046
42048
  const config = this.config("chat", options);
42047
42049
  const body = this.chatBody(config, messages, options);
42048
42050
  const headers = this.headers(config);
@@ -42072,15 +42074,31 @@ var init_aiClient = __esm({
42072
42074
  }
42073
42075
  /**
42074
42076
  * 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.
42077
+ * list of {type:'text'|'image'|'tool_result', ...} parts (ADR-0060 +
42078
+ * ADR-0061). The `tool` role is the OpenAI-style tool-result turn
42079
+ * (ADR-0061). Malformed parts fail fast with AiConfigError, never
42080
+ * reaching the wire.
42077
42081
  */
42078
42082
  static validateMessages(messages) {
42079
42083
  if (!Array.isArray(messages) || messages.length === 0) {
42080
42084
  throw new AiConfigError("AI messages must contain supported roles and string content");
42081
42085
  }
42082
- for (const message of messages) {
42083
- if (!message || !["system", "user", "assistant"].includes(message.role)) {
42086
+ for (const raw of messages) {
42087
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
42088
+ throw new AiConfigError("AI messages must contain supported roles and string content");
42089
+ }
42090
+ const message = raw;
42091
+ const role = message.role;
42092
+ if (role === "tool") {
42093
+ if (typeof message.tool_call_id !== "string" || message.tool_call_id.length === 0) {
42094
+ throw new AiConfigError("AI tool message requires a non-empty string 'tool_call_id'");
42095
+ }
42096
+ if (typeof message.content !== "string") {
42097
+ throw new AiConfigError("AI tool message requires a string 'content'");
42098
+ }
42099
+ continue;
42100
+ }
42101
+ if (role !== "system" && role !== "user" && role !== "assistant") {
42084
42102
  throw new AiConfigError("AI messages must contain supported roles and string content");
42085
42103
  }
42086
42104
  this.validateContent(message.content);
@@ -42111,11 +42129,64 @@ var init_aiClient = __esm({
42111
42129
  if (record.source.startsWith("data:") && !/^data:[^;,\s]+;base64,[A-Za-z0-9+/=]+$/.test(record.source)) {
42112
42130
  throw new AiConfigError("AI image data URI must be data:<media_type>;base64,<payload>");
42113
42131
  }
42132
+ } else if (partType === "tool_result") {
42133
+ if (typeof record.tool_use_id !== "string" || record.tool_use_id.length === 0) {
42134
+ throw new AiConfigError("AI tool_result part requires a non-empty string 'tool_use_id'");
42135
+ }
42136
+ if (typeof record.content !== "string") {
42137
+ throw new AiConfigError("AI tool_result part requires a string 'content'");
42138
+ }
42114
42139
  } else {
42115
42140
  throw new AiConfigError(`AI content part has unknown type '${String(partType)}'`);
42116
42141
  }
42117
42142
  }
42118
42143
  }
42144
+ /**
42145
+ * Validate the outbound tool declarations (ADR-0061). Each tool needs a
42146
+ * non-empty `name`, a string `description`, and a JSON-Schema-shaped
42147
+ * `parameters` object. Malformed tools fail fast with AiConfigError,
42148
+ * never reaching the wire.
42149
+ */
42150
+ static validateTools(tools) {
42151
+ if (!Array.isArray(tools) || tools.length === 0) {
42152
+ throw new AiConfigError("AI tools must be a non-empty list of {name, description, parameters}");
42153
+ }
42154
+ for (const tool of tools) {
42155
+ if (!tool || typeof tool !== "object" || Array.isArray(tool)) {
42156
+ throw new AiConfigError("AI tool must be an object with name, description, parameters");
42157
+ }
42158
+ const record = tool;
42159
+ if (typeof record.name !== "string" || record.name.length === 0) {
42160
+ throw new AiConfigError("AI tool requires a non-empty string 'name'");
42161
+ }
42162
+ if (typeof record.description !== "string") {
42163
+ throw new AiConfigError("AI tool requires a string 'description'");
42164
+ }
42165
+ if (!record.parameters || typeof record.parameters !== "object" || Array.isArray(record.parameters)) {
42166
+ throw new AiConfigError("AI tool requires a JSON-Schema object 'parameters'");
42167
+ }
42168
+ }
42169
+ }
42170
+ /**
42171
+ * Validate the outbound tool_choice value (ADR-0061). The four accepted
42172
+ * shapes are 'auto', 'none', 'required', and {name: 'x'}.
42173
+ */
42174
+ static validateToolChoice(choice) {
42175
+ if (typeof choice === "string") {
42176
+ if (choice !== "auto" && choice !== "none" && choice !== "required") {
42177
+ throw new AiConfigError("AI toolChoice string must be 'auto', 'none', or 'required'");
42178
+ }
42179
+ return;
42180
+ }
42181
+ if (choice && typeof choice === "object" && !Array.isArray(choice)) {
42182
+ const record = choice;
42183
+ if (typeof record.name !== "string" || record.name.length === 0) {
42184
+ throw new AiConfigError("AI toolChoice object requires a non-empty string 'name'");
42185
+ }
42186
+ return;
42187
+ }
42188
+ throw new AiConfigError("AI toolChoice must be 'auto'|'none'|'required' or {name: string}");
42189
+ }
42119
42190
  static number(name, fallback, minimum) {
42120
42191
  const value = process.env[name] === void 0 ? fallback : Number(process.env[name]);
42121
42192
  if (!Number.isFinite(value) || value < minimum) throw new AiConfigError(`${name} must be numeric and at least ${minimum}`);
@@ -42164,25 +42235,100 @@ var init_aiClient = __esm({
42164
42235
  }
42165
42236
  /**
42166
42237
  * 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', ...}}
42238
+ * list plus optional tool declarations (ADR-0060 + ADR-0061).
42239
+ *
42240
+ * Content parts translate per provider:
42241
+ * - OpenAI/local: image → {type:'image_url', image_url:{url}}
42242
+ * - Anthropic: image → {type:'image', source:{type:'base64'|'url', ...}}
42170
42243
  * String content is preserved verbatim in the OpenAI/local shape and
42171
42244
  * likewise for Anthropic (both accept a bare string).
42245
+ *
42246
+ * Tool-result turns are normalised to the current provider's expected
42247
+ * shape (either the OpenAI `{role:"tool", tool_call_id, content}` turn or
42248
+ * the Anthropic `{role:"user", content:[{type:"tool_result", ...}]}`
42249
+ * turn), so an agent-loop written against Tina4 never has to fork on
42250
+ * TINA4_AI_PROVIDER (ADR-0061 wire translation).
42172
42251
  */
42173
42252
  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 };
42253
+ const normalized = this.normalizeMessagesForProvider(messages, config.provider);
42254
+ const body = { model: config.model, messages: normalized, stream: options.stream ?? false };
42176
42255
  if (options.temperature !== void 0) body.temperature = options.temperature;
42177
42256
  if (options.maxTokens !== void 0) body.max_tokens = options.maxTokens;
42178
42257
  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"));
42258
+ const systemParts = [];
42259
+ for (const message of messages) {
42260
+ if (message.role !== "system") continue;
42261
+ const content = message.content;
42262
+ systemParts.push(typeof content === "string" ? content : this.contentToPlainText(content));
42263
+ }
42264
+ body.messages = normalized.filter((message) => message.role !== "system");
42181
42265
  body.max_tokens = options.maxTokens ?? 1024;
42182
42266
  if (systemParts.length) body.system = systemParts.join("\n\n");
42183
42267
  }
42268
+ this.applyTools(body, config.provider, options);
42184
42269
  return body;
42185
42270
  }
42271
+ /**
42272
+ * Normalise the Tina4-shaped messages into the provider's on-wire shape.
42273
+ * The `tool` role and the `tool_result` content part are translated
42274
+ * between the OpenAI and Anthropic forms so either input works against
42275
+ * either provider (ADR-0061 return-path table).
42276
+ */
42277
+ static normalizeMessagesForProvider(messages, provider) {
42278
+ const out = [];
42279
+ for (const message of messages) {
42280
+ if (message.role === "tool") {
42281
+ if (provider === "anthropic") {
42282
+ out.push({
42283
+ role: "user",
42284
+ content: [{ type: "tool_result", tool_use_id: message.tool_call_id, content: message.content }]
42285
+ });
42286
+ } else {
42287
+ out.push({ role: "tool", tool_call_id: message.tool_call_id, content: message.content });
42288
+ }
42289
+ continue;
42290
+ }
42291
+ if (Array.isArray(message.content) && message.content.some((part) => part.type === "tool_result")) {
42292
+ if (provider === "anthropic") {
42293
+ out.push({ role: message.role, content: this.translateContent(message.content, provider) });
42294
+ } else {
42295
+ for (const part of message.content) {
42296
+ if (part.type === "tool_result") {
42297
+ out.push({ role: "tool", tool_call_id: part.tool_use_id, content: part.content });
42298
+ }
42299
+ }
42300
+ }
42301
+ continue;
42302
+ }
42303
+ out.push({ role: message.role, content: this.translateContent(message.content, provider) });
42304
+ }
42305
+ return out;
42306
+ }
42307
+ /**
42308
+ * Attach the outbound `tools` and `tool_choice` (ADR-0061 outbound
42309
+ * translation tables) to the body in place. When toolChoice is 'none'
42310
+ * on Anthropic (Anthropic has no "none" mode) the tools list is omitted
42311
+ * entirely — the model cannot call what it cannot see.
42312
+ */
42313
+ static applyTools(body, provider, options) {
42314
+ const choice = options.toolChoice;
42315
+ const suppressToolsForAnthropic = provider === "anthropic" && choice === "none";
42316
+ if (options.tools !== void 0 && !suppressToolsForAnthropic) {
42317
+ body.tools = options.tools.map(
42318
+ (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 } }
42319
+ );
42320
+ }
42321
+ if (choice === void 0) return;
42322
+ if (provider === "anthropic") {
42323
+ if (choice === "none") return;
42324
+ if (choice === "auto") body.tool_choice = { type: "auto" };
42325
+ else if (choice === "required") body.tool_choice = { type: "any" };
42326
+ else body.tool_choice = { type: "tool", name: choice.name };
42327
+ } else {
42328
+ if (typeof choice === "string") body.tool_choice = choice;
42329
+ else body.tool_choice = { type: "function", function: { name: choice.name } };
42330
+ }
42331
+ }
42186
42332
  /**
42187
42333
  * Translate one message content value into the provider's on-wire shape.
42188
42334
  * A plain string is passed through (both providers accept a string
@@ -42193,6 +42339,7 @@ var init_aiClient = __esm({
42193
42339
  if (provider === "anthropic") {
42194
42340
  return content.map((part) => {
42195
42341
  if (part.type === "text") return { type: "text", text: part.text };
42342
+ if (part.type === "tool_result") return { type: "tool_result", tool_use_id: part.tool_use_id, content: part.content };
42196
42343
  if (part.source.startsWith("data:")) {
42197
42344
  const parsed = this.parseDataUri(part.source);
42198
42345
  return { type: "image", source: { type: "base64", media_type: parsed.mediaType, data: parsed.data } };
@@ -42202,6 +42349,9 @@ var init_aiClient = __esm({
42202
42349
  }
42203
42350
  return content.map((part) => {
42204
42351
  if (part.type === "text") return { type: "text", text: part.text };
42352
+ if (part.type === "tool_result") {
42353
+ return { type: "text", text: part.content };
42354
+ }
42205
42355
  return { type: "image_url", image_url: { url: part.source } };
42206
42356
  });
42207
42357
  }
@@ -42004,6 +42004,8 @@ var init_aiClient = __esm({
42004
42004
  Ai = class {
42005
42005
  static chat(messages, options = {}) {
42006
42006
  this.validateMessages(messages);
42007
+ if (options.tools !== void 0) this.validateTools(options.tools);
42008
+ if (options.toolChoice !== void 0) this.validateToolChoice(options.toolChoice);
42007
42009
  const config = this.config("chat", options);
42008
42010
  const body = this.chatBody(config, messages, options);
42009
42011
  const headers = this.headers(config);
@@ -42033,15 +42035,31 @@ var init_aiClient = __esm({
42033
42035
  }
42034
42036
  /**
42035
42037
  * 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.
42038
+ * list of {type:'text'|'image'|'tool_result', ...} parts (ADR-0060 +
42039
+ * ADR-0061). The `tool` role is the OpenAI-style tool-result turn
42040
+ * (ADR-0061). Malformed parts fail fast with AiConfigError, never
42041
+ * reaching the wire.
42038
42042
  */
42039
42043
  static validateMessages(messages) {
42040
42044
  if (!Array.isArray(messages) || messages.length === 0) {
42041
42045
  throw new AiConfigError("AI messages must contain supported roles and string content");
42042
42046
  }
42043
- for (const message of messages) {
42044
- if (!message || !["system", "user", "assistant"].includes(message.role)) {
42047
+ for (const raw of messages) {
42048
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
42049
+ throw new AiConfigError("AI messages must contain supported roles and string content");
42050
+ }
42051
+ const message = raw;
42052
+ const role = message.role;
42053
+ if (role === "tool") {
42054
+ if (typeof message.tool_call_id !== "string" || message.tool_call_id.length === 0) {
42055
+ throw new AiConfigError("AI tool message requires a non-empty string 'tool_call_id'");
42056
+ }
42057
+ if (typeof message.content !== "string") {
42058
+ throw new AiConfigError("AI tool message requires a string 'content'");
42059
+ }
42060
+ continue;
42061
+ }
42062
+ if (role !== "system" && role !== "user" && role !== "assistant") {
42045
42063
  throw new AiConfigError("AI messages must contain supported roles and string content");
42046
42064
  }
42047
42065
  this.validateContent(message.content);
@@ -42072,11 +42090,64 @@ var init_aiClient = __esm({
42072
42090
  if (record.source.startsWith("data:") && !/^data:[^;,\s]+;base64,[A-Za-z0-9+/=]+$/.test(record.source)) {
42073
42091
  throw new AiConfigError("AI image data URI must be data:<media_type>;base64,<payload>");
42074
42092
  }
42093
+ } else if (partType === "tool_result") {
42094
+ if (typeof record.tool_use_id !== "string" || record.tool_use_id.length === 0) {
42095
+ throw new AiConfigError("AI tool_result part requires a non-empty string 'tool_use_id'");
42096
+ }
42097
+ if (typeof record.content !== "string") {
42098
+ throw new AiConfigError("AI tool_result part requires a string 'content'");
42099
+ }
42075
42100
  } else {
42076
42101
  throw new AiConfigError(`AI content part has unknown type '${String(partType)}'`);
42077
42102
  }
42078
42103
  }
42079
42104
  }
42105
+ /**
42106
+ * Validate the outbound tool declarations (ADR-0061). Each tool needs a
42107
+ * non-empty `name`, a string `description`, and a JSON-Schema-shaped
42108
+ * `parameters` object. Malformed tools fail fast with AiConfigError,
42109
+ * never reaching the wire.
42110
+ */
42111
+ static validateTools(tools) {
42112
+ if (!Array.isArray(tools) || tools.length === 0) {
42113
+ throw new AiConfigError("AI tools must be a non-empty list of {name, description, parameters}");
42114
+ }
42115
+ for (const tool of tools) {
42116
+ if (!tool || typeof tool !== "object" || Array.isArray(tool)) {
42117
+ throw new AiConfigError("AI tool must be an object with name, description, parameters");
42118
+ }
42119
+ const record = tool;
42120
+ if (typeof record.name !== "string" || record.name.length === 0) {
42121
+ throw new AiConfigError("AI tool requires a non-empty string 'name'");
42122
+ }
42123
+ if (typeof record.description !== "string") {
42124
+ throw new AiConfigError("AI tool requires a string 'description'");
42125
+ }
42126
+ if (!record.parameters || typeof record.parameters !== "object" || Array.isArray(record.parameters)) {
42127
+ throw new AiConfigError("AI tool requires a JSON-Schema object 'parameters'");
42128
+ }
42129
+ }
42130
+ }
42131
+ /**
42132
+ * Validate the outbound tool_choice value (ADR-0061). The four accepted
42133
+ * shapes are 'auto', 'none', 'required', and {name: 'x'}.
42134
+ */
42135
+ static validateToolChoice(choice) {
42136
+ if (typeof choice === "string") {
42137
+ if (choice !== "auto" && choice !== "none" && choice !== "required") {
42138
+ throw new AiConfigError("AI toolChoice string must be 'auto', 'none', or 'required'");
42139
+ }
42140
+ return;
42141
+ }
42142
+ if (choice && typeof choice === "object" && !Array.isArray(choice)) {
42143
+ const record = choice;
42144
+ if (typeof record.name !== "string" || record.name.length === 0) {
42145
+ throw new AiConfigError("AI toolChoice object requires a non-empty string 'name'");
42146
+ }
42147
+ return;
42148
+ }
42149
+ throw new AiConfigError("AI toolChoice must be 'auto'|'none'|'required' or {name: string}");
42150
+ }
42080
42151
  static number(name, fallback, minimum) {
42081
42152
  const value = process.env[name] === void 0 ? fallback : Number(process.env[name]);
42082
42153
  if (!Number.isFinite(value) || value < minimum) throw new AiConfigError(`${name} must be numeric and at least ${minimum}`);
@@ -42125,25 +42196,100 @@ var init_aiClient = __esm({
42125
42196
  }
42126
42197
  /**
42127
42198
  * 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', ...}}
42199
+ * list plus optional tool declarations (ADR-0060 + ADR-0061).
42200
+ *
42201
+ * Content parts translate per provider:
42202
+ * - OpenAI/local: image → {type:'image_url', image_url:{url}}
42203
+ * - Anthropic: image → {type:'image', source:{type:'base64'|'url', ...}}
42131
42204
  * String content is preserved verbatim in the OpenAI/local shape and
42132
42205
  * likewise for Anthropic (both accept a bare string).
42206
+ *
42207
+ * Tool-result turns are normalised to the current provider's expected
42208
+ * shape (either the OpenAI `{role:"tool", tool_call_id, content}` turn or
42209
+ * the Anthropic `{role:"user", content:[{type:"tool_result", ...}]}`
42210
+ * turn), so an agent-loop written against Tina4 never has to fork on
42211
+ * TINA4_AI_PROVIDER (ADR-0061 wire translation).
42133
42212
  */
42134
42213
  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 };
42214
+ const normalized = this.normalizeMessagesForProvider(messages, config.provider);
42215
+ const body = { model: config.model, messages: normalized, stream: options.stream ?? false };
42137
42216
  if (options.temperature !== void 0) body.temperature = options.temperature;
42138
42217
  if (options.maxTokens !== void 0) body.max_tokens = options.maxTokens;
42139
42218
  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"));
42219
+ const systemParts = [];
42220
+ for (const message of messages) {
42221
+ if (message.role !== "system") continue;
42222
+ const content = message.content;
42223
+ systemParts.push(typeof content === "string" ? content : this.contentToPlainText(content));
42224
+ }
42225
+ body.messages = normalized.filter((message) => message.role !== "system");
42142
42226
  body.max_tokens = options.maxTokens ?? 1024;
42143
42227
  if (systemParts.length) body.system = systemParts.join("\n\n");
42144
42228
  }
42229
+ this.applyTools(body, config.provider, options);
42145
42230
  return body;
42146
42231
  }
42232
+ /**
42233
+ * Normalise the Tina4-shaped messages into the provider's on-wire shape.
42234
+ * The `tool` role and the `tool_result` content part are translated
42235
+ * between the OpenAI and Anthropic forms so either input works against
42236
+ * either provider (ADR-0061 return-path table).
42237
+ */
42238
+ static normalizeMessagesForProvider(messages, provider) {
42239
+ const out = [];
42240
+ for (const message of messages) {
42241
+ if (message.role === "tool") {
42242
+ if (provider === "anthropic") {
42243
+ out.push({
42244
+ role: "user",
42245
+ content: [{ type: "tool_result", tool_use_id: message.tool_call_id, content: message.content }]
42246
+ });
42247
+ } else {
42248
+ out.push({ role: "tool", tool_call_id: message.tool_call_id, content: message.content });
42249
+ }
42250
+ continue;
42251
+ }
42252
+ if (Array.isArray(message.content) && message.content.some((part) => part.type === "tool_result")) {
42253
+ if (provider === "anthropic") {
42254
+ out.push({ role: message.role, content: this.translateContent(message.content, provider) });
42255
+ } else {
42256
+ for (const part of message.content) {
42257
+ if (part.type === "tool_result") {
42258
+ out.push({ role: "tool", tool_call_id: part.tool_use_id, content: part.content });
42259
+ }
42260
+ }
42261
+ }
42262
+ continue;
42263
+ }
42264
+ out.push({ role: message.role, content: this.translateContent(message.content, provider) });
42265
+ }
42266
+ return out;
42267
+ }
42268
+ /**
42269
+ * Attach the outbound `tools` and `tool_choice` (ADR-0061 outbound
42270
+ * translation tables) to the body in place. When toolChoice is 'none'
42271
+ * on Anthropic (Anthropic has no "none" mode) the tools list is omitted
42272
+ * entirely — the model cannot call what it cannot see.
42273
+ */
42274
+ static applyTools(body, provider, options) {
42275
+ const choice = options.toolChoice;
42276
+ const suppressToolsForAnthropic = provider === "anthropic" && choice === "none";
42277
+ if (options.tools !== void 0 && !suppressToolsForAnthropic) {
42278
+ body.tools = options.tools.map(
42279
+ (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 } }
42280
+ );
42281
+ }
42282
+ if (choice === void 0) return;
42283
+ if (provider === "anthropic") {
42284
+ if (choice === "none") return;
42285
+ if (choice === "auto") body.tool_choice = { type: "auto" };
42286
+ else if (choice === "required") body.tool_choice = { type: "any" };
42287
+ else body.tool_choice = { type: "tool", name: choice.name };
42288
+ } else {
42289
+ if (typeof choice === "string") body.tool_choice = choice;
42290
+ else body.tool_choice = { type: "function", function: { name: choice.name } };
42291
+ }
42292
+ }
42147
42293
  /**
42148
42294
  * Translate one message content value into the provider's on-wire shape.
42149
42295
  * A plain string is passed through (both providers accept a string
@@ -42154,6 +42300,7 @@ var init_aiClient = __esm({
42154
42300
  if (provider === "anthropic") {
42155
42301
  return content.map((part) => {
42156
42302
  if (part.type === "text") return { type: "text", text: part.text };
42303
+ if (part.type === "tool_result") return { type: "tool_result", tool_use_id: part.tool_use_id, content: part.content };
42157
42304
  if (part.source.startsWith("data:")) {
42158
42305
  const parsed = this.parseDataUri(part.source);
42159
42306
  return { type: "image", source: { type: "base64", media_type: parsed.mediaType, data: parsed.data } };
@@ -42163,6 +42310,9 @@ var init_aiClient = __esm({
42163
42310
  }
42164
42311
  return content.map((part) => {
42165
42312
  if (part.type === "text") return { type: "text", text: part.text };
42313
+ if (part.type === "tool_result") {
42314
+ return { type: "text", text: part.content };
42315
+ }
42166
42316
  return { type: "image_url", image_url: { url: part.source } };
42167
42317
  });
42168
42318
  }
@@ -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";
@@ -30831,6 +30831,8 @@ var init_aiClient = __esm({
30831
30831
  Ai = class {
30832
30832
  static chat(messages, options = {}) {
30833
30833
  this.validateMessages(messages);
30834
+ if (options.tools !== void 0) this.validateTools(options.tools);
30835
+ if (options.toolChoice !== void 0) this.validateToolChoice(options.toolChoice);
30834
30836
  const config = this.config("chat", options);
30835
30837
  const body = this.chatBody(config, messages, options);
30836
30838
  const headers = this.headers(config);
@@ -30860,15 +30862,31 @@ var init_aiClient = __esm({
30860
30862
  }
30861
30863
  /**
30862
30864
  * 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.
30865
+ * list of {type:'text'|'image'|'tool_result', ...} parts (ADR-0060 +
30866
+ * ADR-0061). The `tool` role is the OpenAI-style tool-result turn
30867
+ * (ADR-0061). Malformed parts fail fast with AiConfigError, never
30868
+ * reaching the wire.
30865
30869
  */
30866
30870
  static validateMessages(messages) {
30867
30871
  if (!Array.isArray(messages) || messages.length === 0) {
30868
30872
  throw new AiConfigError("AI messages must contain supported roles and string content");
30869
30873
  }
30870
- for (const message of messages) {
30871
- if (!message || !["system", "user", "assistant"].includes(message.role)) {
30874
+ for (const raw of messages) {
30875
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
30876
+ throw new AiConfigError("AI messages must contain supported roles and string content");
30877
+ }
30878
+ const message = raw;
30879
+ const role = message.role;
30880
+ if (role === "tool") {
30881
+ if (typeof message.tool_call_id !== "string" || message.tool_call_id.length === 0) {
30882
+ throw new AiConfigError("AI tool message requires a non-empty string 'tool_call_id'");
30883
+ }
30884
+ if (typeof message.content !== "string") {
30885
+ throw new AiConfigError("AI tool message requires a string 'content'");
30886
+ }
30887
+ continue;
30888
+ }
30889
+ if (role !== "system" && role !== "user" && role !== "assistant") {
30872
30890
  throw new AiConfigError("AI messages must contain supported roles and string content");
30873
30891
  }
30874
30892
  this.validateContent(message.content);
@@ -30899,11 +30917,64 @@ var init_aiClient = __esm({
30899
30917
  if (record.source.startsWith("data:") && !/^data:[^;,\s]+;base64,[A-Za-z0-9+/=]+$/.test(record.source)) {
30900
30918
  throw new AiConfigError("AI image data URI must be data:<media_type>;base64,<payload>");
30901
30919
  }
30920
+ } else if (partType === "tool_result") {
30921
+ if (typeof record.tool_use_id !== "string" || record.tool_use_id.length === 0) {
30922
+ throw new AiConfigError("AI tool_result part requires a non-empty string 'tool_use_id'");
30923
+ }
30924
+ if (typeof record.content !== "string") {
30925
+ throw new AiConfigError("AI tool_result part requires a string 'content'");
30926
+ }
30902
30927
  } else {
30903
30928
  throw new AiConfigError(`AI content part has unknown type '${String(partType)}'`);
30904
30929
  }
30905
30930
  }
30906
30931
  }
30932
+ /**
30933
+ * Validate the outbound tool declarations (ADR-0061). Each tool needs a
30934
+ * non-empty `name`, a string `description`, and a JSON-Schema-shaped
30935
+ * `parameters` object. Malformed tools fail fast with AiConfigError,
30936
+ * never reaching the wire.
30937
+ */
30938
+ static validateTools(tools) {
30939
+ if (!Array.isArray(tools) || tools.length === 0) {
30940
+ throw new AiConfigError("AI tools must be a non-empty list of {name, description, parameters}");
30941
+ }
30942
+ for (const tool of tools) {
30943
+ if (!tool || typeof tool !== "object" || Array.isArray(tool)) {
30944
+ throw new AiConfigError("AI tool must be an object with name, description, parameters");
30945
+ }
30946
+ const record = tool;
30947
+ if (typeof record.name !== "string" || record.name.length === 0) {
30948
+ throw new AiConfigError("AI tool requires a non-empty string 'name'");
30949
+ }
30950
+ if (typeof record.description !== "string") {
30951
+ throw new AiConfigError("AI tool requires a string 'description'");
30952
+ }
30953
+ if (!record.parameters || typeof record.parameters !== "object" || Array.isArray(record.parameters)) {
30954
+ throw new AiConfigError("AI tool requires a JSON-Schema object 'parameters'");
30955
+ }
30956
+ }
30957
+ }
30958
+ /**
30959
+ * Validate the outbound tool_choice value (ADR-0061). The four accepted
30960
+ * shapes are 'auto', 'none', 'required', and {name: 'x'}.
30961
+ */
30962
+ static validateToolChoice(choice) {
30963
+ if (typeof choice === "string") {
30964
+ if (choice !== "auto" && choice !== "none" && choice !== "required") {
30965
+ throw new AiConfigError("AI toolChoice string must be 'auto', 'none', or 'required'");
30966
+ }
30967
+ return;
30968
+ }
30969
+ if (choice && typeof choice === "object" && !Array.isArray(choice)) {
30970
+ const record = choice;
30971
+ if (typeof record.name !== "string" || record.name.length === 0) {
30972
+ throw new AiConfigError("AI toolChoice object requires a non-empty string 'name'");
30973
+ }
30974
+ return;
30975
+ }
30976
+ throw new AiConfigError("AI toolChoice must be 'auto'|'none'|'required' or {name: string}");
30977
+ }
30907
30978
  static number(name, fallback, minimum) {
30908
30979
  const value = process.env[name] === void 0 ? fallback : Number(process.env[name]);
30909
30980
  if (!Number.isFinite(value) || value < minimum) throw new AiConfigError(`${name} must be numeric and at least ${minimum}`);
@@ -30952,25 +31023,100 @@ var init_aiClient = __esm({
30952
31023
  }
30953
31024
  /**
30954
31025
  * 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', ...}}
31026
+ * list plus optional tool declarations (ADR-0060 + ADR-0061).
31027
+ *
31028
+ * Content parts translate per provider:
31029
+ * - OpenAI/local: image → {type:'image_url', image_url:{url}}
31030
+ * - Anthropic: image → {type:'image', source:{type:'base64'|'url', ...}}
30958
31031
  * String content is preserved verbatim in the OpenAI/local shape and
30959
31032
  * likewise for Anthropic (both accept a bare string).
31033
+ *
31034
+ * Tool-result turns are normalised to the current provider's expected
31035
+ * shape (either the OpenAI `{role:"tool", tool_call_id, content}` turn or
31036
+ * the Anthropic `{role:"user", content:[{type:"tool_result", ...}]}`
31037
+ * turn), so an agent-loop written against Tina4 never has to fork on
31038
+ * TINA4_AI_PROVIDER (ADR-0061 wire translation).
30960
31039
  */
30961
31040
  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 };
31041
+ const normalized = this.normalizeMessagesForProvider(messages, config.provider);
31042
+ const body = { model: config.model, messages: normalized, stream: options.stream ?? false };
30964
31043
  if (options.temperature !== void 0) body.temperature = options.temperature;
30965
31044
  if (options.maxTokens !== void 0) body.max_tokens = options.maxTokens;
30966
31045
  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"));
31046
+ const systemParts = [];
31047
+ for (const message of messages) {
31048
+ if (message.role !== "system") continue;
31049
+ const content = message.content;
31050
+ systemParts.push(typeof content === "string" ? content : this.contentToPlainText(content));
31051
+ }
31052
+ body.messages = normalized.filter((message) => message.role !== "system");
30969
31053
  body.max_tokens = options.maxTokens ?? 1024;
30970
31054
  if (systemParts.length) body.system = systemParts.join("\n\n");
30971
31055
  }
31056
+ this.applyTools(body, config.provider, options);
30972
31057
  return body;
30973
31058
  }
31059
+ /**
31060
+ * Normalise the Tina4-shaped messages into the provider's on-wire shape.
31061
+ * The `tool` role and the `tool_result` content part are translated
31062
+ * between the OpenAI and Anthropic forms so either input works against
31063
+ * either provider (ADR-0061 return-path table).
31064
+ */
31065
+ static normalizeMessagesForProvider(messages, provider) {
31066
+ const out = [];
31067
+ for (const message of messages) {
31068
+ if (message.role === "tool") {
31069
+ if (provider === "anthropic") {
31070
+ out.push({
31071
+ role: "user",
31072
+ content: [{ type: "tool_result", tool_use_id: message.tool_call_id, content: message.content }]
31073
+ });
31074
+ } else {
31075
+ out.push({ role: "tool", tool_call_id: message.tool_call_id, content: message.content });
31076
+ }
31077
+ continue;
31078
+ }
31079
+ if (Array.isArray(message.content) && message.content.some((part) => part.type === "tool_result")) {
31080
+ if (provider === "anthropic") {
31081
+ out.push({ role: message.role, content: this.translateContent(message.content, provider) });
31082
+ } else {
31083
+ for (const part of message.content) {
31084
+ if (part.type === "tool_result") {
31085
+ out.push({ role: "tool", tool_call_id: part.tool_use_id, content: part.content });
31086
+ }
31087
+ }
31088
+ }
31089
+ continue;
31090
+ }
31091
+ out.push({ role: message.role, content: this.translateContent(message.content, provider) });
31092
+ }
31093
+ return out;
31094
+ }
31095
+ /**
31096
+ * Attach the outbound `tools` and `tool_choice` (ADR-0061 outbound
31097
+ * translation tables) to the body in place. When toolChoice is 'none'
31098
+ * on Anthropic (Anthropic has no "none" mode) the tools list is omitted
31099
+ * entirely — the model cannot call what it cannot see.
31100
+ */
31101
+ static applyTools(body, provider, options) {
31102
+ const choice = options.toolChoice;
31103
+ const suppressToolsForAnthropic = provider === "anthropic" && choice === "none";
31104
+ if (options.tools !== void 0 && !suppressToolsForAnthropic) {
31105
+ body.tools = options.tools.map(
31106
+ (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 } }
31107
+ );
31108
+ }
31109
+ if (choice === void 0) return;
31110
+ if (provider === "anthropic") {
31111
+ if (choice === "none") return;
31112
+ if (choice === "auto") body.tool_choice = { type: "auto" };
31113
+ else if (choice === "required") body.tool_choice = { type: "any" };
31114
+ else body.tool_choice = { type: "tool", name: choice.name };
31115
+ } else {
31116
+ if (typeof choice === "string") body.tool_choice = choice;
31117
+ else body.tool_choice = { type: "function", function: { name: choice.name } };
31118
+ }
31119
+ }
30974
31120
  /**
30975
31121
  * Translate one message content value into the provider's on-wire shape.
30976
31122
  * A plain string is passed through (both providers accept a string
@@ -30981,6 +31127,7 @@ var init_aiClient = __esm({
30981
31127
  if (provider === "anthropic") {
30982
31128
  return content.map((part) => {
30983
31129
  if (part.type === "text") return { type: "text", text: part.text };
31130
+ if (part.type === "tool_result") return { type: "tool_result", tool_use_id: part.tool_use_id, content: part.content };
30984
31131
  if (part.source.startsWith("data:")) {
30985
31132
  const parsed = this.parseDataUri(part.source);
30986
31133
  return { type: "image", source: { type: "base64", media_type: parsed.mediaType, data: parsed.data } };
@@ -30990,6 +31137,9 @@ var init_aiClient = __esm({
30990
31137
  }
30991
31138
  return content.map((part) => {
30992
31139
  if (part.type === "text") return { type: "text", text: part.text };
31140
+ if (part.type === "tool_result") {
31141
+ return { type: "text", text: part.content };
31142
+ }
30993
31143
  return { type: "image_url", image_url: { url: part.source } };
30994
31144
  });
30995
31145
  }
@@ -24,7 +24,10 @@ export interface ChatResponse {
24
24
  /**
25
25
  * A multimodal content part. `text` carries plain UTF-8 prose; `image`
26
26
  * carries a `data:<media_type>;base64,<payload>` URI or an https:// URL
27
- * (the client translates to each provider's shape). ADR-0060.
27
+ * (the client translates to each provider's shape, ADR-0060). `tool_result`
28
+ * carries the Anthropic-style return of a locally-executed tool call
29
+ * (ADR-0061); the client translates it to OpenAI's `{role: "tool", ...}`
30
+ * turn on non-Anthropic providers.
28
31
  */
29
32
  export type ContentPart = {
30
33
  type: "text";
@@ -32,13 +35,50 @@ export type ContentPart = {
32
35
  } | {
33
36
  type: "image";
34
37
  source: string;
38
+ } | {
39
+ type: "tool_result";
40
+ tool_use_id: string;
41
+ content: string;
35
42
  };
36
43
  /** The value a caller may pass for `message.content`. ADR-0060. */
37
44
  export type AiMessageContent = string | ContentPart[];
38
- export interface AiMessage {
45
+ /**
46
+ * One conversation turn. The three "chat" roles carry a string OR a
47
+ * content-parts array (ADR-0060). The `tool` role is the OpenAI-style
48
+ * return of a tool call (ADR-0061); the client translates it to the
49
+ * Anthropic user-turn form when the current provider is Anthropic.
50
+ */
51
+ export type AiMessage = {
39
52
  role: "system" | "user" | "assistant";
40
53
  content: AiMessageContent;
54
+ } | {
55
+ role: "tool";
56
+ tool_call_id: string;
57
+ content: string;
58
+ };
59
+ /**
60
+ * A tool declaration the model may call (named `AiToolDeclaration` to
61
+ * stay out of the way of {@link ./ai.ts}'s existing `AiTool` interface
62
+ * for AI-coding-tool context installation). `parameters` is a JSON
63
+ * Schema object; it is passed to the provider unchanged (ADR-0061
64
+ * `parameters-passthrough`).
65
+ */
66
+ export interface AiToolDeclaration {
67
+ name: string;
68
+ description: string;
69
+ parameters: Record<string, unknown>;
41
70
  }
71
+ /**
72
+ * How the model picks a tool. Four Tina4 values that span the useful cases
73
+ * across providers (ADR-0061 wire-translation table):
74
+ * 'auto' — model may call any tool or answer with text
75
+ * 'none' — model must not call a tool (Anthropic omits `tools`)
76
+ * 'required' — model must call some tool
77
+ * {name: 'x'} — model must call tool 'x'
78
+ */
79
+ export type AiToolChoice = "auto" | "none" | "required" | {
80
+ name: string;
81
+ };
42
82
  /**
43
83
  * One event yielded by `Ai.chat(stream: true)`. The four variants
44
84
  * discriminated by `type`. Text deltas arrive per chunk (typewriter UX);
@@ -74,6 +114,14 @@ export interface AiChatOptions {
74
114
  stream?: boolean;
75
115
  timeout?: number;
76
116
  provider?: "local" | "openai" | "anthropic";
117
+ /** Tools the model may call. ADR-0061 — translated per provider. */
118
+ tools?: AiToolDeclaration[];
119
+ /**
120
+ * How the model picks a tool. ADR-0061 — translated per provider. If
121
+ * `'none'` on Anthropic (which has no "none" mode), `tools` is omitted
122
+ * from the outbound body entirely.
123
+ */
124
+ toolChoice?: AiToolChoice;
77
125
  }
78
126
  export interface AiEmbedOptions {
79
127
  model?: string;
@@ -91,24 +139,60 @@ export declare class Ai {
91
139
  static embed(textOrTexts: string | string[], options?: AiEmbedOptions): Promise<number[] | number[][]>;
92
140
  /**
93
141
  * Validate role + content shape. Content may be a string OR a non-empty
94
- * list of {type:'text'|'image', ...} parts (ADR-0060). Malformed parts
95
- * fail fast with AiConfigError, never reaching the wire.
142
+ * list of {type:'text'|'image'|'tool_result', ...} parts (ADR-0060 +
143
+ * ADR-0061). The `tool` role is the OpenAI-style tool-result turn
144
+ * (ADR-0061). Malformed parts fail fast with AiConfigError, never
145
+ * reaching the wire.
96
146
  */
97
147
  private static validateMessages;
98
148
  private static validateContent;
149
+ /**
150
+ * Validate the outbound tool declarations (ADR-0061). Each tool needs a
151
+ * non-empty `name`, a string `description`, and a JSON-Schema-shaped
152
+ * `parameters` object. Malformed tools fail fast with AiConfigError,
153
+ * never reaching the wire.
154
+ */
155
+ private static validateTools;
156
+ /**
157
+ * Validate the outbound tool_choice value (ADR-0061). The four accepted
158
+ * shapes are 'auto', 'none', 'required', and {name: 'x'}.
159
+ */
160
+ private static validateToolChoice;
99
161
  private static number;
100
162
  private static config;
101
163
  private static endpoint;
102
164
  private static headers;
103
165
  /**
104
166
  * Build the provider-specific request body from a Tina4-shaped message
105
- * list. Multimodal parts are translated per provider (ADR-0060):
106
- * - OpenAI/local: {type:'image_url', image_url:{url}}
107
- * - Anthropic: {type:'image', source:{type:'base64'|'url', ...}}
167
+ * list plus optional tool declarations (ADR-0060 + ADR-0061).
168
+ *
169
+ * Content parts translate per provider:
170
+ * - OpenAI/local: image → {type:'image_url', image_url:{url}}
171
+ * - Anthropic: image → {type:'image', source:{type:'base64'|'url', ...}}
108
172
  * String content is preserved verbatim in the OpenAI/local shape and
109
173
  * likewise for Anthropic (both accept a bare string).
174
+ *
175
+ * Tool-result turns are normalised to the current provider's expected
176
+ * shape (either the OpenAI `{role:"tool", tool_call_id, content}` turn or
177
+ * the Anthropic `{role:"user", content:[{type:"tool_result", ...}]}`
178
+ * turn), so an agent-loop written against Tina4 never has to fork on
179
+ * TINA4_AI_PROVIDER (ADR-0061 wire translation).
110
180
  */
111
181
  private static chatBody;
182
+ /**
183
+ * Normalise the Tina4-shaped messages into the provider's on-wire shape.
184
+ * The `tool` role and the `tool_result` content part are translated
185
+ * between the OpenAI and Anthropic forms so either input works against
186
+ * either provider (ADR-0061 return-path table).
187
+ */
188
+ private static normalizeMessagesForProvider;
189
+ /**
190
+ * Attach the outbound `tools` and `tool_choice` (ADR-0061 outbound
191
+ * translation tables) to the body in place. When toolChoice is 'none'
192
+ * on Anthropic (Anthropic has no "none" mode) the tools list is omitted
193
+ * entirely — the model cannot call what it cannot see.
194
+ */
195
+ private static applyTools;
112
196
  /**
113
197
  * Translate one message content value into the provider's on-wire shape.
114
198
  * A plain string is passed through (both providers accept a string
@@ -58,7 +58,7 @@ export type { AiTool } from "./ai.js";
58
58
  export { Sso, SSO, SsoError } from "./sso.js";
59
59
  export type { SsoOptions } from "./sso.js";
60
60
  export { Ai, AiError, AiConfigError, AiHTTPError, AiTimeoutError, AiParseError } from "./aiClient.js";
61
- export type { ChatResponse, AiMessage, AiChatOptions, AiEmbedOptions, AiEvent, ContentPart, AiMessageContent } from "./aiClient.js";
61
+ export type { ChatResponse, AiMessage, AiChatOptions, AiEmbedOptions, AiEvent, ContentPart, AiMessageContent, AiToolDeclaration, AiToolChoice } from "./aiClient.js";
62
62
  export type { ImapMessage, ImapFullMessage, ImapAttachment } from "./messenger.js";
63
63
  export { LiteBackend } from "./queueBackends/liteBackend.js";
64
64
  export { RabbitMQBackend, parseAmqpUrl } from "./queueBackends/rabbitmqBackend.js";