@orxataguy/tyr 1.0.30 → 1.0.31

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/README.md CHANGED
@@ -252,6 +252,8 @@ tyr deploy --debug
252
252
  │ │ ├── Kernel.ts # Command router and execution engine
253
253
  │ │ ├── Container.ts # Dependency injection container
254
254
  │ │ ├── TyrError.ts # Structured error type
255
+ │ │ ├── util
256
+ │ │ │ └── getenv.ts # Helper: get environment variables
255
257
  │ │ └── sys/
256
258
  │ │ ├── gen.ts # Built-in: scaffold a command
257
259
  │ │ ├── rem.ts # Built-in: remove a command
@@ -259,16 +261,23 @@ tyr deploy --debug
259
261
  │ ├── commands/
260
262
  │ │ └── *.tyr.ts # Your custom commands go here
261
263
  │ └── lib/
262
- ├── ShellManager.ts
263
- ├── FileSystemManager.ts
264
- ├── PackageManager.ts
265
- ├── DockerManager.ts
266
- ├── GitManager.ts
267
- ├── SystemManager.ts
268
- ├── SQLManager.ts
269
- └── WebManager.ts
270
- ├── config/
271
- └── map.yml # Command registry
264
+ ├── AIContextManager.ts
265
+ ├── AIVendorManager.ts
266
+ ├── DockerManager.ts
267
+ ├── FileSystemManager.ts
268
+ ├── GitManager.ts
269
+ ├── JiraManager.ts
270
+ ├── MongoManager.ts
271
+ │ ├── PackageManager.ts
272
+ │ │ ├── PromptTemplateManager.ts
273
+ │ ├── SetupManager.ts
274
+ │ │ ├── ShellManager.ts
275
+ │ │ ├── SQLManager.ts
276
+ │ │ ├── SystemManager.ts
277
+ │ │ ├── TokenManager.ts
278
+ │ │ ├── WebManager.ts
279
+ │ │ └── WorkspaceManager.ts
280
+ | └── index.ts # Exports context
272
281
  └── tests/
273
282
  ├── setup.ts # Mock context factory
274
283
  └── test-runner.ts # Smoke test runner
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@orxataguy/tyr",
3
- "version": "1.0.30",
3
+ "version": "1.0.31",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "tyr": "./bin/tyr.js"
@@ -8,9 +8,33 @@ import {getEnvString, getEnvInt, getEnvDouble} from '../core/util/getenv.js';
8
8
  export type AIVendor = 'anthropic' | 'openai' | 'gemini';
9
9
  export type AIRole = 'system' | 'user' | 'assistant';
10
10
 
11
+ /**
12
+ * Representación normalizada (independiente del vendor) del contenido de un mensaje.
13
+ *
14
+ * - 'text': texto plano.
15
+ * - 'tool_use': el modelo pide ejecutar una herramienta. `id` es opaco para el llamador: hay
16
+ * que devolverlo tal cual en el `tool_result` correspondiente, sin asumir nada sobre su
17
+ * formato (en Gemini, por ejemplo, es un id sintético generado por este manager, ya que la
18
+ * API de Gemini no da ids reales para las function calls).
19
+ * - 'tool_result': resultado de haber ejecutado una herramienta, para devolvérselo al modelo.
20
+ */
21
+ export type AIContentBlock =
22
+ | { type: 'text'; text: string }
23
+ | { type: 'tool_use'; id: string; name: string; input: any }
24
+ | { type: 'tool_result'; tool_use_id: string; content: string; is_error?: boolean };
25
+
11
26
  export interface AIMessage {
12
27
  role: AIRole;
13
- content: string;
28
+ /** Texto plano, o una lista de bloques cuando el mensaje incluye tool_use / tool_result. */
29
+ content: string | AIContentBlock[];
30
+ }
31
+
32
+ /** Definición de herramienta en formato JSON-schema al estilo Anthropic; se traduce internamente
33
+ * al formato de cada vendor (function-calling de OpenAI, functionDeclarations de Gemini). */
34
+ export interface AITool {
35
+ name: string;
36
+ description: string;
37
+ input_schema: any;
14
38
  }
15
39
 
16
40
  export interface AICompletionOptions {
@@ -19,14 +43,26 @@ export interface AICompletionOptions {
19
43
  temperature?: number;
20
44
  maxTokens?: number;
21
45
  maxRetries?: number;
46
+ /** Herramientas disponibles para que el modelo las invoque. Solo soportado en complete(),
47
+ * no en stream() — ver el guard al inicio de stream(). */
48
+ tools?: AITool[];
22
49
  }
23
50
 
24
51
  export interface AICompletionResult {
52
+ /** Texto plano concatenado de todos los bloques de tipo 'text' (conveniencia; equivalente al
53
+ * comportamiento anterior de esta clase, antes de soportar tools). */
25
54
  content: string;
55
+ /** Contenido completo y normalizado, incluyendo bloques tool_use si el modelo pidió alguno.
56
+ * Necesario para reconstruir el mensaje de assistant en el siguiente turno de un bucle
57
+ * agente. */
58
+ blocks: AIContentBlock[];
26
59
  vendor: AIVendor;
27
60
  model: string;
28
61
  promptTokens?: number;
29
62
  completionTokens?: number;
63
+ /** Motivo de parada normalizado tal como lo reporta cada vendor (stop_reason / finish_reason
64
+ * / finishReason), sin normalizar entre vendors — úsalo solo informativamente. */
65
+ stopReason?: string;
30
66
  }
31
67
 
32
68
  interface VendorConfig {
@@ -68,6 +104,12 @@ const RETRY_BASE_DELAY_MS = 500;
68
104
  * environment variables / Tyr configuration, retries transient failures with exponential
69
105
  * backoff, and supports both blocking and streaming responses.
70
106
  *
107
+ * Tool use (function calling) is supported in `complete()` for all three vendors, behind a
108
+ * vendor-agnostic representation (`AITool` / `AIContentBlock`). Each vendor's wire format is
109
+ * different — this class does the translation both ways (request and response) so callers never
110
+ * need to know which vendor is active. `stream()` does not support tools yet (see the guard at
111
+ * the top of that method).
112
+ *
71
113
  * Environment variables:
72
114
  * AI_VENDOR – 'anthropic' | 'openai' | 'gemini' (default: 'anthropic')
73
115
  * ANTHROPIC_API_KEY, OPENAI_API_KEY, GEMINI_API_KEY – API key for the selected vendor
@@ -116,13 +158,154 @@ export class AIVendorManager {
116
158
  }
117
159
 
118
160
  private splitSystem(messages: AIMessage[]): { system: string; turns: AIMessage[] } {
119
- const system = messages.filter(m => m.role === 'system').map(m => m.content).join('\n\n');
161
+ const system = messages
162
+ .filter(m => m.role === 'system')
163
+ .map(m => (typeof m.content === 'string' ? m.content : m.content.map(b => (b.type === 'text' ? b.text : '')).join('')))
164
+ .join('\n\n');
120
165
  const turns = messages.filter(m => m.role !== 'system');
121
166
  return { system, turns };
122
167
  }
123
168
 
124
- private buildRequest(config: VendorConfig, messages: AIMessage[], stream: boolean): VendorRequest {
169
+ // --- Traducción de herramientas por vendor -------------------------------------------------
170
+
171
+ private buildVendorTools(config: VendorConfig, tools?: AITool[]): any {
172
+ if (!tools || tools.length === 0) return undefined;
173
+
174
+ switch (config.vendor) {
175
+ case 'anthropic':
176
+ return tools.map(t => ({ name: t.name, description: t.description, input_schema: t.input_schema }));
177
+ case 'openai':
178
+ return tools.map(t => ({
179
+ type: 'function',
180
+ function: { name: t.name, description: t.description, parameters: t.input_schema },
181
+ }));
182
+ case 'gemini':
183
+ return [{ functionDeclarations: tools.map(t => ({ name: t.name, description: t.description, parameters: t.input_schema })) }];
184
+ }
185
+ }
186
+
187
+ // --- Traducción de mensajes por vendor ------------------------------------------------------
188
+
189
+ private toAnthropicContent(content: string | AIContentBlock[]): any {
190
+ if (typeof content === 'string') return content;
191
+ return content.map(block => {
192
+ if (block.type === 'text') return { type: 'text', text: block.text };
193
+ if (block.type === 'tool_use') return { type: 'tool_use', id: block.id, name: block.name, input: block.input };
194
+ return { type: 'tool_result', tool_use_id: block.tool_use_id, content: block.content, ...(block.is_error ? { is_error: true } : {}) };
195
+ });
196
+ }
197
+
198
+ private buildAnthropicMessages(turns: AIMessage[]): any[] {
199
+ return turns.map(m => ({ role: m.role, content: this.toAnthropicContent(m.content) }));
200
+ }
201
+
202
+ /**
203
+ * OpenAI no tiene un bloque "tool_result" dentro de un mensaje de usuario: cada resultado de
204
+ * herramienta tiene que ir en su propio mensaje con role: 'tool'. Por eso, a diferencia de
205
+ * Anthropic, un único AIMessage de entrada puede expandirse a varios mensajes de salida.
206
+ */
207
+ private buildOpenAIMessages(turns: AIMessage[]): any[] {
208
+ const result: any[] = [];
209
+
210
+ for (const m of turns) {
211
+ if (typeof m.content === 'string') {
212
+ result.push({ role: m.role, content: m.content });
213
+ continue;
214
+ }
215
+
216
+ if (m.role === 'assistant') {
217
+ const text = m.content.filter(b => b.type === 'text').map(b => (b as any).text).join('');
218
+ const toolUses = m.content.filter(b => b.type === 'tool_use') as Array<Extract<AIContentBlock, { type: 'tool_use' }>>;
219
+
220
+ const msg: any = { role: 'assistant', content: text || null };
221
+ if (toolUses.length > 0) {
222
+ msg.tool_calls = toolUses.map(t => ({
223
+ id: t.id,
224
+ type: 'function',
225
+ function: { name: t.name, arguments: JSON.stringify(t.input ?? {}) },
226
+ }));
227
+ }
228
+ result.push(msg);
229
+ continue;
230
+ }
231
+
232
+ const toolResults = m.content.filter(b => b.type === 'tool_result') as Array<Extract<AIContentBlock, { type: 'tool_result' }>>;
233
+ const text = m.content.filter(b => b.type === 'text').map(b => (b as any).text).join('');
234
+
235
+ for (const tr of toolResults) {
236
+ result.push({ role: 'tool', tool_call_id: tr.tool_use_id, content: tr.content });
237
+ }
238
+ if (text) {
239
+ result.push({ role: m.role, content: text });
240
+ }
241
+ }
242
+
243
+ return result;
244
+ }
245
+
246
+ /** Busca hacia atrás en TODO el historial (no solo en el turno actual) el nombre de la
247
+ * función asociada a un tool_use_id, porque Gemini necesita el `name` en el functionResponse
248
+ * y nosotros solo tenemos el id opaco que generamos al parsear la respuesta anterior. */
249
+ private findToolUseName(allMessages: AIMessage[], toolUseId: string): string | undefined {
250
+ for (const m of allMessages) {
251
+ if (!Array.isArray(m.content)) continue;
252
+ const match = m.content.find(b => b.type === 'tool_use' && b.id === toolUseId) as
253
+ | Extract<AIContentBlock, { type: 'tool_use' }>
254
+ | undefined;
255
+ if (match) return match.name;
256
+ }
257
+ return undefined;
258
+ }
259
+
260
+ /**
261
+ * NOTA: la API pública de Gemini espera los resultados de función en un content con
262
+ * role: 'function' (parts: [{ functionResponse: { name, response } }]). Esto puede variar
263
+ * entre versiones de la API — si Google cambia el contrato, este es el único sitio a tocar.
264
+ */
265
+ private buildGeminiContents(turns: AIMessage[], allMessages: AIMessage[]): any[] {
266
+ const result: any[] = [];
267
+
268
+ for (const m of turns) {
269
+ if (typeof m.content === 'string') {
270
+ result.push({ role: m.role === 'assistant' ? 'model' : 'user', parts: [{ text: m.content }] });
271
+ continue;
272
+ }
273
+
274
+ if (m.role === 'assistant') {
275
+ const parts: any[] = [];
276
+ for (const b of m.content) {
277
+ if (b.type === 'text' && b.text) parts.push({ text: b.text });
278
+ if (b.type === 'tool_use') parts.push({ functionCall: { name: b.name, args: b.input ?? {} } });
279
+ }
280
+ result.push({ role: 'model', parts });
281
+ continue;
282
+ }
283
+
284
+ const toolResults = m.content.filter(b => b.type === 'tool_result') as Array<Extract<AIContentBlock, { type: 'tool_result' }>>;
285
+ const textBlocks = m.content.filter(b => b.type === 'text') as Array<Extract<AIContentBlock, { type: 'text' }>>;
286
+
287
+ if (toolResults.length > 0) {
288
+ result.push({
289
+ role: 'function',
290
+ parts: toolResults.map(tr => ({
291
+ functionResponse: {
292
+ name: this.findToolUseName(allMessages, tr.tool_use_id) ?? 'unknown_function',
293
+ response: { content: tr.content },
294
+ },
295
+ })),
296
+ });
297
+ }
298
+ if (textBlocks.length > 0) {
299
+ result.push({ role: 'user', parts: textBlocks.map(b => ({ text: b.text })) });
300
+ }
301
+ }
302
+
303
+ return result;
304
+ }
305
+
306
+ private buildRequest(config: VendorConfig, messages: AIMessage[], stream: boolean, tools?: AITool[]): VendorRequest {
125
307
  const { system, turns } = this.splitSystem(messages);
308
+ const vendorTools = this.buildVendorTools(config, tools);
126
309
 
127
310
  switch (config.vendor) {
128
311
  case 'anthropic':
@@ -136,10 +319,11 @@ export class AIVendorManager {
136
319
  body: {
137
320
  model: config.model,
138
321
  system,
139
- messages: turns.map(m => ({ role: m.role, content: m.content })),
322
+ messages: this.buildAnthropicMessages(turns),
140
323
  temperature: config.temperature,
141
324
  max_tokens: config.maxTokens,
142
325
  stream,
326
+ ...(vendorTools ? { tools: vendorTools } : {}),
143
327
  },
144
328
  };
145
329
 
@@ -154,12 +338,13 @@ export class AIVendorManager {
154
338
  model: config.model,
155
339
  messages: [
156
340
  ...(system ? [{ role: 'system', content: system }] : []),
157
- ...turns.map(m => ({ role: m.role, content: m.content })),
341
+ ...this.buildOpenAIMessages(turns),
158
342
  ],
159
343
  temperature: config.temperature,
160
344
  max_tokens: config.maxTokens,
161
345
  stream,
162
346
  ...(stream ? { stream_options: { include_usage: true } } : {}),
347
+ ...(vendorTools ? { tools: vendorTools } : {}),
163
348
  },
164
349
  };
165
350
 
@@ -170,14 +355,12 @@ export class AIVendorManager {
170
355
  headers: { 'content-type': 'application/json' },
171
356
  body: {
172
357
  ...(system ? { systemInstruction: { parts: [{ text: system }] } } : {}),
173
- contents: turns.map(m => ({
174
- role: m.role === 'assistant' ? 'model' : 'user',
175
- parts: [{ text: m.content }],
176
- })),
358
+ contents: this.buildGeminiContents(turns, messages),
177
359
  generationConfig: {
178
360
  temperature: config.temperature,
179
361
  maxOutputTokens: config.maxTokens,
180
362
  },
363
+ ...(vendorTools ? { tools: vendorTools } : {}),
181
364
  },
182
365
  };
183
366
  }
@@ -214,47 +397,109 @@ export class AIVendorManager {
214
397
 
215
398
  private parseCompletion(config: VendorConfig, data: any): AICompletionResult {
216
399
  switch (config.vendor) {
217
- case 'anthropic':
400
+ case 'anthropic': {
401
+ const blocks: AIContentBlock[] = (data.content ?? []).map((b: any) => {
402
+ if (b.type === 'tool_use') return { type: 'tool_use', id: b.id, name: b.name, input: b.input };
403
+ return { type: 'text', text: b.text ?? '' };
404
+ });
218
405
  return {
219
- content: (data.content ?? []).map((b: any) => b.text ?? '').join(''),
406
+ content: blocks.filter(b => b.type === 'text').map((b: any) => b.text).join(''),
407
+ blocks,
220
408
  vendor: config.vendor,
221
409
  model: config.model,
222
410
  promptTokens: data.usage?.input_tokens,
223
411
  completionTokens: data.usage?.output_tokens,
412
+ stopReason: data.stop_reason,
224
413
  };
225
- case 'openai':
414
+ }
415
+
416
+ case 'openai': {
417
+ const message = data.choices?.[0]?.message ?? {};
418
+ const blocks: AIContentBlock[] = [];
419
+
420
+ if (message.content) blocks.push({ type: 'text', text: message.content });
421
+
422
+ for (const call of message.tool_calls ?? []) {
423
+ let input: any = {};
424
+ try {
425
+ input = JSON.parse(call.function?.arguments || '{}');
426
+ } catch {
427
+ input = {};
428
+ }
429
+ blocks.push({ type: 'tool_use', id: call.id, name: call.function?.name, input });
430
+ }
431
+
226
432
  return {
227
- content: data.choices?.[0]?.message?.content ?? '',
433
+ content: message.content ?? '',
434
+ blocks,
228
435
  vendor: config.vendor,
229
436
  model: config.model,
230
437
  promptTokens: data.usage?.prompt_tokens,
231
438
  completionTokens: data.usage?.completion_tokens,
439
+ stopReason: data.choices?.[0]?.finish_reason,
232
440
  };
233
- case 'gemini':
441
+ }
442
+
443
+ case 'gemini': {
444
+ const parts = data.candidates?.[0]?.content?.parts ?? [];
445
+ const blocks: AIContentBlock[] = [];
446
+ let callIndex = 0;
447
+
448
+ for (const p of parts) {
449
+ if (p.text) blocks.push({ type: 'text', text: p.text });
450
+ if (p.functionCall) {
451
+ // Gemini no da ids: generamos uno sintético que solo usamos internamente
452
+ // para poder emparejar el tool_result correspondiente más adelante.
453
+ blocks.push({
454
+ type: 'tool_use',
455
+ id: `gemini-call-${callIndex++}-${p.functionCall.name}`,
456
+ name: p.functionCall.name,
457
+ input: p.functionCall.args ?? {},
458
+ });
459
+ }
460
+ }
461
+
234
462
  return {
235
- content: (data.candidates?.[0]?.content?.parts ?? []).map((p: any) => p.text ?? '').join(''),
463
+ content: blocks.filter(b => b.type === 'text').map((b: any) => b.text).join(''),
464
+ blocks,
236
465
  vendor: config.vendor,
237
466
  model: config.model,
238
467
  promptTokens: data.usageMetadata?.promptTokenCount,
239
468
  completionTokens: data.usageMetadata?.candidatesTokenCount,
469
+ stopReason: data.candidates?.[0]?.finishReason,
240
470
  };
471
+ }
241
472
  }
242
473
  }
243
474
 
244
475
  /**
245
476
  * @method complete
246
477
  * @description Sends a chat-completion request and returns the full response once ready.
247
- * Retries automatically on rate limiting (429) or server errors (5xx).
478
+ * Retries automatically on rate limiting (429) or server errors (5xx). When `options.tools`
479
+ * is provided, the model may respond with one or more `tool_use` blocks in `result.blocks`
480
+ * instead of (or in addition to) text — the caller is responsible for executing those tools
481
+ * and feeding the results back as a follow-up message with 'tool_result' blocks.
248
482
  * @param {AIMessage[]} messages - Conversation messages ('system', 'user', 'assistant').
249
- * @param {AICompletionOptions} options - Optional overrides (vendor, model, temperature, maxTokens).
483
+ * @param {AICompletionOptions} options - Optional overrides (vendor, model, temperature, maxTokens, tools).
250
484
  * @returns {Promise<AICompletionResult>} The generated content plus vendor/model/usage metadata.
251
485
  * @example
252
486
  * const result = await ai.complete([{ role: 'user', content: 'Explain this bug...' }]);
253
487
  * console.log(result.content);
488
+ * @example
489
+ * // Tool use loop
490
+ * let result = await ai.complete(messages, { tools });
491
+ * while (result.blocks.some(b => b.type === 'tool_use')) {
492
+ * messages.push({ role: 'assistant', content: result.blocks });
493
+ * const toolResults = result.blocks
494
+ * .filter(b => b.type === 'tool_use')
495
+ * .map(b => ({ type: 'tool_result' as const, tool_use_id: b.id, content: runTool(b) }));
496
+ * messages.push({ role: 'user', content: toolResults });
497
+ * result = await ai.complete(messages, { tools });
498
+ * }
254
499
  */
255
500
  public async complete(messages: AIMessage[], options?: AICompletionOptions): Promise<AICompletionResult> {
256
501
  const config = this.resolveConfig(options);
257
- const { url, headers, body } = this.buildRequest(config, messages, false);
502
+ const { url, headers, body } = this.buildRequest(config, messages, false, options?.tools);
258
503
 
259
504
  try {
260
505
  const response = await this.withRetries(
@@ -278,6 +523,11 @@ export class AIVendorManager {
278
523
  * @description Sends a chat-completion request and streams the response as it is generated.
279
524
  * Retries are only applied before any data has been received; once streaming has started,
280
525
  * failures are surfaced immediately to avoid emitting duplicated content.
526
+ *
527
+ * Tool use is NOT supported here: streaming tool calls arrive as incremental JSON fragments
528
+ * (Anthropic's `input_json_delta`, OpenAI's indexed `tool_calls` deltas, Gemini's partial
529
+ * function-call parts) and this method does not accumulate them. Passing `options.tools`
530
+ * throws immediately rather than silently dropping tool calls the model might make mid-stream.
281
531
  * @param {AIMessage[]} messages - Conversation messages ('system', 'user', 'assistant').
282
532
  * @param {(chunk: string) => void} onChunk - Called with each incremental text fragment.
283
533
  * @param {AICompletionOptions} options - Optional overrides (vendor, model, temperature, maxTokens).
@@ -290,6 +540,14 @@ export class AIVendorManager {
290
540
  onChunk: (chunk: string) => void,
291
541
  options?: AICompletionOptions
292
542
  ): Promise<AICompletionResult> {
543
+ if (options?.tools && options.tools.length > 0) {
544
+ throw new TyrError(
545
+ 'Tool use is not supported in streaming mode yet',
546
+ null,
547
+ 'Use complete() instead of stream() when passing tools.'
548
+ );
549
+ }
550
+
293
551
  const config = this.resolveConfig(options);
294
552
  const { url, headers, body } = this.buildRequest(config, messages, true);
295
553
 
@@ -381,8 +639,8 @@ export class AIVendorManager {
381
639
  }
382
640
  }
383
641
 
384
- return { content, vendor: config.vendor, model: config.model, promptTokens, completionTokens };
642
+ return { content, blocks: [{ type: 'text', text: content }], vendor: config.vendor, model: config.model, promptTokens, completionTokens };
385
643
  }
386
644
  }
387
645
 
388
- export const AIVendorManagerTests = {};
646
+ export const AIVendorManagerTests = {};
@@ -23,6 +23,13 @@ const DEFAULT_TEMPLATES: Record<string, PromptTemplate> = {
23
23
  'JSDoc comments on public methods. Output only the TypeScript code for the file, no commentary.',
24
24
  user: 'Command name: {{name}}\nRequested behaviour:\n{{description}}',
25
25
  },
26
+ 'generate-code': {
27
+ system:
28
+ 'You are a senior engineer. Follow the ' +
29
+ 'existing conventions exactly: KISS, SOLID, DRY. Only make simple comments on the functions to give context if is necessary ' +
30
+ 'Output only the code for the file, no commentary.',
31
+ user: 'Command name: {{name}}\nRequested behaviour:\n{{description}}',
32
+ },
26
33
  'explain-code': {
27
34
  system:
28
35
  'You are a senior software engineer. Explain what the given code does clearly and ' +