@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 +19 -10
- package/package.json +1 -1
- package/src/lib/AIVendorManager.ts +278 -20
- package/src/lib/PromptTemplateManager.ts +7 -0
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
|
-
│
|
|
263
|
-
│
|
|
264
|
-
│
|
|
265
|
-
│
|
|
266
|
-
│
|
|
267
|
-
│
|
|
268
|
-
│
|
|
269
|
-
│
|
|
270
|
-
├──
|
|
271
|
-
│
|
|
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
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
-
...
|
|
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:
|
|
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: (
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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: (
|
|
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 ' +
|