Files
clube67_newwhats.local/newwhats.clube67.com/newwhats.local/plugins/secretaria/brain.ts
T
VPS 4 Deploy Agent 2f8c04a0a7
continuous-integration/webhook Falha no deploy de clube67_newwhats.local (VPS 4)
chore(ops): restore all source files in newwhats.clube67.com
2026-05-18 03:28:29 +02:00

870 lines
34 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* ProtocolEngine — Cérebro Stateful da Secretária IA
*
* Princípios de economia de tokens:
* 1. Lê o ESTADO atual do protocolo (summary), não o histórico completo
* 2. Carrega apenas as últimas N mensagens (context_window)
* 3. Sumarização ativa a cada 10 trocas para manter o resumo atualizado
* 4. Nós do cérebro são compostos apenas com os ativos
*/
import { Knex } from 'knex'
import type { PluginConfigStore } from '../../backend/src/core/plugin-config'
import type { HookBus } from '../../backend/src/core/hook-bus'
import { type ToolDef, type ToolContext, resolveTools, ALL_TOOL_NAMES } from './tools'
export class ProtocolEngine {
constructor(
private readonly db: Knex,
private readonly config: PluginConfigStore,
) {}
// ── Chat ─────────────────────────────────────────────────────────────────
async chat(
conversationId: string,
userMessage: string,
opts?: {
contextData?: Record<string, unknown>
systemExtra?: string
tools?: string[] // nomes das tools a habilitar (padrão: todas)
hooks?: HookBus
tenantId?: string
},
): Promise<string> {
const conversation = await this.db('sec_conversations').where({ id: conversationId }).first()
if (!conversation) throw new Error('Conversa não encontrada')
const agent = await this.db('sec_agents').where({ id: conversation.agent_id }).first()
if (!agent) throw new Error('Agente não encontrado')
// Monta system prompt a partir dos nós ativos + contexto externo
const systemPrompt = await this.buildSystemPrompt(agent, conversation, opts)
// Carrega apenas as últimas N mensagens (não o histórico completo)
const contextWindow: number = agent.context_window ?? 8
const recentMessages = await this.db('sec_messages')
.where({ conversation_id: conversationId })
.orderBy('created_at', 'desc')
.limit(contextWindow)
.then((rows: any[]) => rows.reverse())
// Salva a mensagem do usuário
await this.db('sec_messages').insert({
id: this.uuid(),
conversation_id: conversationId,
role: 'user',
content: userMessage,
created_at: new Date(),
})
// Chama a IA — usa node_model do nó persona se definido (sobrepõe o agente)
const personaNode = await this.db('sec_brain_nodes')
.where({ agent_id: conversation.agent_id, type: 'persona', active: true })
.orderBy('sort_order')
.first()
const agentOverride = personaNode?.node_model
? { ...agent, model: personaNode.node_model }
: agent
const messages = [
...recentMessages.map((m: any) => ({ role: m.role, content: m.content })),
{ role: 'user', content: userMessage },
]
// Resolve tools: usa lista passada por opts, ou todas as builtins por padrão
const toolNames = opts?.tools ?? ALL_TOOL_NAMES
const toolDefs = resolveTools(toolNames)
const toolCtx: ToolContext = {
db: this.db,
conversationId,
extChatId: conversation.ext_chat_id ?? undefined,
tenantId: opts?.tenantId,
hooks: opts?.hooks,
}
let response: string
let usageInfo: any = null
let providerUsed: string | null = null
let modelUsed: string | null = null
try {
if (toolDefs.length > 0) {
response = await this.callAIWithTools(agentOverride, systemPrompt, messages, toolDefs, toolCtx)
// Telemetria escrita pelos tool loops via side channel (toolCtx._telemetry)
if (toolCtx._telemetry) {
usageInfo = toolCtx._telemetry.usage
providerUsed = toolCtx._telemetry.provider
modelUsed = toolCtx._telemetry.model
}
} else {
const result = await this.callAI(agentOverride, systemPrompt, messages)
response = result.text
usageInfo = result.usage
providerUsed = result.provider
modelUsed = result.model
}
} catch (err: any) {
response = `[Erro ao chamar IA: ${err.message}. Verifique a API Key nas configurações do plugin.]`
}
// Salva resposta da IA com telemetria de tokens
await this.db('sec_messages').insert({
id: this.uuid(),
conversation_id: conversationId,
role: 'assistant',
content: response,
usage_tokens: usageInfo ? JSON.stringify(usageInfo) : null,
provider_used: providerUsed,
model_used: modelUsed,
created_at: new Date(),
})
// Atualiza conversa + sumariza a cada 10 trocas
const totalMsgs = await this.db('sec_messages')
.where({ conversation_id: conversationId })
.count('id as c')
.first()
.then((r: any) => Number(r?.c ?? 0))
let summary = conversation.summary
if (totalMsgs > 0 && (totalMsgs % 10 === 0 || totalMsgs === 5)) {
summary = await this.summarize(agent, recentMessages, userMessage, response)
}
await this.db('sec_conversations')
.where({ id: conversationId })
.update({ updated_at: new Date(), summary })
return response
}
// ── Protocol Number ───────────────────────────────────────────────────────
static generateProtocolNumber(): string {
const now = new Date()
const p = (n: number, d = 2) => String(n).padStart(d, '0')
return `${p(now.getDate())}${p(now.getMonth() + 1)}${String(now.getFullYear()).slice(-2)}${p(now.getHours())}${p(now.getMinutes())}${p(now.getSeconds())}`
}
// ── System Prompt Builder ─────────────────────────────────────────────────
private async buildSystemPrompt(
agent: any,
conversation: any,
opts?: { contextData?: Record<string, unknown>; systemExtra?: string },
): Promise<string> {
const nodes = await this.db('sec_brain_nodes')
.where({ agent_id: agent.id, active: true })
.orderBy('sort_order')
let prompt = ''
// Data/hora real — impede o modelo de alucinar a data
const nowReal = new Date()
const dtStr = nowReal.toLocaleString('pt-BR', {
timeZone: 'America/Sao_Paulo',
weekday: 'long', day: '2-digit', month: 'long', year: 'numeric',
hour: '2-digit', minute: '2-digit',
})
prompt += `=== DATA E HORA ATUAL ===\n${dtStr} (horário de Brasília)\n\n`
// Cabeçalho do protocolo — sempre presente, leve (3 linhas)
const protocolHeader = [
`=== PROTOCOLO ATIVO ===`,
`Número: ${conversation.protocol_number || '—'}`,
`Contato: ${conversation.contact_name}`,
`Status: ${conversation.status}`,
``,
].join('\n')
prompt += protocolHeader
for (const node of nodes as any[]) {
switch (node.type) {
case 'persona':
prompt += `${node.content}\n\n`
break
case 'knowledge':
prompt += `=== BASE DE CONHECIMENTO ===\n${node.content}\n\n`
break
case 'rules':
prompt += `=== REGRAS ===\n${node.content}\n\n`
break
case 'calendar': {
const calCtx = await this.getCalendarContext()
prompt += `=== AGENDA DISPONÍVEL (próximos 7 dias) ===\n${calCtx}\n\nInstruções: ${node.content}\n\n`
break
}
case 'escalation':
prompt += `=== REGRAS DE ESCALADA ===\n${node.content}\n\n`
break
default:
prompt += `${node.content}\n\n`
}
}
// Injeta contexto local do projeto (enviado pelo plugin satélite)
if (opts?.contextData && Object.keys(opts.contextData).length > 0) {
const ctx = JSON.stringify(opts.contextData, null, 2)
prompt += `=== CONTEXTO DO CLIENTE (dados reais do projeto) ===\n${ctx}\n\n`
}
// Prompt extra do plugin (instruções específicas da chamada)
if (opts?.systemExtra?.trim()) {
prompt += `=== INSTRUÇÕES ADICIONAIS ===\n${opts.systemExtra.trim()}\n\n`
}
// Injeta resumo do estado atual (economia de tokens — evita reler o histórico)
if (conversation.summary) {
prompt += `=== ESTADO ATUAL DA CONVERSA ===\n${conversation.summary}\n\n`
}
return prompt.trim()
}
// ── Finalize Protocol ─────────────────────────────────────────────────────
async finalizeProtocol(conversationId: string): Promise<{ summary: string; protocol_number: string }> {
const conversation = await this.db('sec_conversations').where({ id: conversationId }).first()
if (!conversation) throw new Error('Conversa não encontrada')
if (conversation.status === 'closed') {
return { summary: conversation.summary ?? '', protocol_number: conversation.protocol_number }
}
const agent = await this.db('sec_agents').where({ id: conversation.agent_id }).first()
if (!agent) throw new Error('Agente não encontrado')
// Carrega todas as mensagens para gerar resumo completo
const messages = await this.db('sec_messages')
.where({ conversation_id: conversationId })
.orderBy('created_at')
let summary = conversation.summary ?? ''
if (messages.length > 0) {
const transcript = (messages as any[])
.map((m) => `${m.role === 'user' ? 'Cliente' : 'Ana'}: ${m.content}`)
.join('\n')
const summaryPrompt = `Gere um resumo estruturado desta conversa de atendimento para uso futuro como contexto rápido.\nInclua: motivo do contato, o que foi resolvido, próximos passos pendentes (se houver).\nMáximo 5 linhas. Seja objetivo.\n\nProtocolo: ${conversation.protocol_number}\nContato: ${conversation.contact_name}\n\n${transcript}`
const cheapModel: Record<string, string> = {
openai: 'gpt-4o-mini', anthropic: 'claude-3-5-haiku-20241022',
gemini: 'gemini-2.0-flash', ollama: agent.model ?? 'llama3',
}
const finalAgent = {
...agent, temperature: 0.2, max_tokens: 200,
model: cheapModel[agent.provider as string] ?? agent.model,
}
try {
const result = await this.callAI(finalAgent, '', [{ role: 'user', content: summaryPrompt }])
summary = result.text
} catch {
summary = conversation.summary ?? `Protocolo ${conversation.protocol_number} encerrado.`
}
}
// Apaga mensagens — contexto comprimido no resumo (economia de tokens)
await this.db('sec_messages').where({ conversation_id: conversationId }).delete()
// Fecha o protocolo com resumo persistido
await this.db('sec_conversations')
.where({ id: conversationId })
.update({ status: 'closed', summary, updated_at: new Date() })
return { summary, protocol_number: conversation.protocol_number }
}
// ── AI Call ───────────────────────────────────────────────────────────────
private buildFallbackChain(agent: any, cfg: any): { provider: string; model: string }[] {
const chainStr: string = (cfg.fallback_chain as string | undefined) ?? 'openai,gemini,anthropic,ollama'
const order = chainStr.split(',').map((s: string) => s.trim()).filter(Boolean)
const defaults: Record<string, string> = {
openai: 'gpt-4o-mini',
anthropic: 'claude-3-5-haiku-20241022',
gemini: 'gemini-2.0-flash',
ollama: 'llama3',
}
const hasKey = (p: string): boolean => {
if (p === 'openai') return !!(cfg.openai_key as string | undefined)
if (p === 'anthropic') return !!(cfg.anthropic_key as string | undefined)
if (p === 'gemini') return !!(cfg.gemini_key as string | undefined)
if (p === 'ollama') return true // local, sempre disponível
return false
}
const agentProvider: string = agent.provider ?? 'openai'
const agentModel: string = agent.model ?? defaults[agentProvider] ?? 'gpt-4o-mini'
const chain: { provider: string; model: string }[] = [{ provider: agentProvider, model: agentModel }]
for (const p of order) {
if (p === agentProvider) continue
if (!hasKey(p)) continue
chain.push({ provider: p, model: defaults[p] ?? p })
}
return chain
}
private isRecoverableError(err: Error): boolean {
const msg = err.message.toLowerCase()
return (
msg.includes('quota') ||
msg.includes('rate limit') ||
msg.includes('limite da api') ||
msg.includes('exceeded') ||
msg.includes('billing') ||
msg.includes('insufficient') ||
msg.includes('invalid_api_key') ||
msg.includes('econnrefused') ||
msg.includes('enotfound') ||
msg.includes('não configurada')
)
}
private async callAI(
agent: any, systemPrompt: string, messages: any[],
): Promise<{ text: string; usage: any; provider: string; model: string }> {
const cfg = await this.config.get('secretaria')
const chain = this.buildFallbackChain(agent, cfg)
let lastError: Error = new Error('Nenhum provider disponível')
for (const entry of chain) {
try {
return await this.callProvider(entry.provider, entry.model, agent, cfg, systemPrompt, messages)
} catch (err: any) {
lastError = err
if (this.isRecoverableError(err)) continue
throw err
}
}
throw new Error(`Todos os providers falharam. Último erro: ${lastError.message}`)
}
/**
* Chama o provider e retorna { text, usage, provider, model }.
* usage: { input, output, cached?, total } — chars/tokens consumidos.
* Reads agent.max_tokens (default 250 — adequado a WhatsApp).
*/
private async callProvider(
provider: string, model: string, agent: any, cfg: any, systemPrompt: string, messages: any[],
): Promise<{ text: string; usage: any; provider: string; model: string }> {
const maxTokens = agent.max_tokens ?? 250
const temperature = agent.temperature ?? 0.7
// ── OpenAI ────────────────────────────────────────────────────────────────
if (provider === 'openai') {
const apiKey = (cfg.openai_key as string | undefined) ?? ''
if (!apiKey) throw new Error('OpenAI API Key não configurada. Acesse Admin → Plugins → Secretária IA.')
const res = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
signal: AbortSignal.timeout(25_000),
body: JSON.stringify({
model,
temperature,
max_tokens: maxTokens,
messages: [{ role: 'system', content: systemPrompt }, ...messages],
}),
})
const data = (await res.json()) as any
if (!res.ok) throw new Error(data.error?.message ?? `OpenAI ${res.status}`)
const text = data.choices[0].message.content as string
const usage = {
input: data.usage?.prompt_tokens ?? 0,
output: data.usage?.completion_tokens ?? 0,
cached: data.usage?.prompt_tokens_details?.cached_tokens ?? 0,
total: data.usage?.total_tokens ?? 0,
}
return { text, usage, provider, model }
}
// ── Anthropic (com prompt caching ephemeral no system) ───────────────────
if (provider === 'anthropic') {
const apiKey = (cfg.anthropic_key as string | undefined) ?? ''
if (!apiKey) throw new Error('Anthropic API Key não configurada. Acesse Admin → Plugins → Secretária IA.')
const res = await fetch('https://api.anthropic.com/v1/messages', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': apiKey,
'anthropic-version': '2023-06-01',
// Header necessário até GA do prompt caching
'anthropic-beta': 'prompt-caching-2024-07-31',
},
signal: AbortSignal.timeout(25_000),
body: JSON.stringify({
model,
max_tokens: maxTokens,
// System como array com cache_control: trecho fica em cache 5min
// Próximas chamadas com mesmo systemPrompt pagam ~10% pelo trecho cacheado.
system: [
{ type: 'text', text: systemPrompt, cache_control: { type: 'ephemeral' } },
],
messages,
}),
})
const data = (await res.json()) as any
if (!res.ok) throw new Error(data.error?.message ?? `Anthropic ${res.status}`)
const text = data.content[0].text as string
const usage = {
input: data.usage?.input_tokens ?? 0,
output: data.usage?.output_tokens ?? 0,
cache_create: data.usage?.cache_creation_input_tokens ?? 0,
cache_read: data.usage?.cache_read_input_tokens ?? 0,
total: (data.usage?.input_tokens ?? 0) + (data.usage?.output_tokens ?? 0),
}
return { text, usage, provider, model }
}
// ── Google Gemini ─────────────────────────────────────────────────────────
if (provider === 'gemini') {
const apiKey = (cfg.gemini_key as string | undefined) ?? ''
if (!apiKey) throw new Error('Google Gemini API Key não configurada. Acesse Admin → Plugins → Secretária IA.')
const geminiModel = model.startsWith('gemini') ? model : 'gemini-2.0-flash'
const geminiMessages = messages.map((m) => ({
role: m.role === 'assistant' ? 'model' : 'user',
parts: [{ text: m.content }],
}))
const geminiBody = JSON.stringify({
systemInstruction: { parts: [{ text: systemPrompt }] },
contents: geminiMessages,
generationConfig: { temperature, maxOutputTokens: maxTokens },
})
const geminiUrl = `https://generativelanguage.googleapis.com/v1beta/models/${geminiModel}:generateContent?key=${apiKey}`
const doGeminiCall = async () => fetch(geminiUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
signal: AbortSignal.timeout(25_000),
body: geminiBody,
})
let res = await doGeminiCall()
let data = (await res.json()) as any
if (!res.ok && (res.status === 429 || String(data.error?.message ?? '').toLowerCase().includes('quota'))) {
const msg: string = data.error?.message ?? ''
const match = msg.match(/retry in ([\d.]+)s/i)
// Espera no máximo 8s (era 30s) e mínimo 1.5s (era 5s) — limita impacto de quota no tempo total
const waitMs = match ? Math.min(Math.ceil(parseFloat(match[1])) * 1000, 8_000) : 1_500
await new Promise((r) => setTimeout(r, waitMs))
res = await doGeminiCall()
data = (await res.json()) as any
}
if (!res.ok) {
const errMsg: string = data.error?.message ?? `Gemini ${res.status}`
if (errMsg.toLowerCase().includes('quota') || res.status === 429) {
throw new Error('Limite da API Gemini atingido. Aguarde alguns instantes e tente novamente.')
}
throw new Error(errMsg)
}
const text = data.candidates[0].content.parts[0].text as string
const usage = {
input: data.usageMetadata?.promptTokenCount ?? 0,
output: data.usageMetadata?.candidatesTokenCount ?? 0,
total: data.usageMetadata?.totalTokenCount ?? 0,
}
return { text, usage, provider, model: geminiModel }
}
// ── Ollama (local) ────────────────────────────────────────────────────────
if (provider === 'ollama') {
const baseUrl = (cfg.ollama_url as string | undefined) ?? 'http://localhost:11434'
const ollamaModel = model || 'llama3'
const res = await fetch(`${baseUrl}/api/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
signal: AbortSignal.timeout(25_000),
body: JSON.stringify({
model: ollamaModel,
stream: false,
messages: [{ role: 'system', content: systemPrompt }, ...messages],
options: { temperature, num_predict: maxTokens },
}),
})
const data = (await res.json()) as any
if (!res.ok) throw new Error(data.error ?? `Ollama ${res.status}`)
const text = data.message.content as string
const usage = {
input: data.prompt_eval_count ?? 0,
output: data.eval_count ?? 0,
total: (data.prompt_eval_count ?? 0) + (data.eval_count ?? 0),
}
return { text, usage, provider, model: ollamaModel }
}
throw new Error(`Provider "${provider}" não suportado. Use: openai, anthropic, gemini, ollama`)
}
// ── Calendar Context ──────────────────────────────────────────────────────
private async getCalendarContext(): Promise<string> {
const today = new Date().toISOString().split('T')[0]
const nextWeek = new Date()
nextWeek.setDate(nextWeek.getDate() + 7)
const nextWeekStr = nextWeek.toISOString().split('T')[0]
const slots = await this.db('sec_calendar')
.whereIn('status', ['available', 'booked'])
.whereBetween('date', [today, nextWeekStr])
.orderBy('date')
.orderBy('time_start')
.limit(30)
if (slots.length === 0) return 'Nenhum horário nos próximos 7 dias.'
const lines = (slots as any[]).map((s) => {
const time = `${s.date} ${s.time_start.slice(0, 5)}${s.time_end.slice(0, 5)}`
if (s.status === 'booked') {
const who = s.attendee_name ? ` | Paciente: ${s.attendee_name}` : ''
const phone = s.attendee_phone ? ` (${s.attendee_phone})` : ''
return `• [AGENDADO] ${time}: ${s.title}${who}${phone}`
}
return `• [DISPONÍVEL] ${time}: ${s.title}`
})
return lines.join('\n')
}
// ── Summarization (token economy) ────────────────────────────────────────
/**
* Sumarização com modelo barato (M1.5).
* Força modelo "mini/haiku/flash" mesmo que o agente principal use modelo caro.
* Sumário é tarefa simples — não precisa do modelo de produção.
*/
private async summarize(
agent: any,
recentMsgs: any[],
lastUser: string,
lastAssistant: string,
): Promise<string> {
const excerpt = [
...recentMsgs.slice(-6).map((m: any) => `${m.role}: ${m.content}`),
`user: ${lastUser}`,
`assistant: ${lastAssistant}`,
].join('\n')
const prompt = `Resuma em no máximo 2 frases curtas o estado atual desta conversa de atendimento, focando no tema e próximo passo:\n\n${excerpt}`
// Modelo barato por provider
const cheapModel: Record<string, string> = {
openai: 'gpt-4o-mini',
anthropic: 'claude-3-5-haiku-20241022',
gemini: 'gemini-2.0-flash',
ollama: agent.model ?? 'llama3',
}
const summaryAgent = {
...agent,
temperature: 0.3,
max_tokens: 120,
model: cheapModel[agent.provider as string] ?? agent.model,
}
try {
const result = await this.callAI(summaryAgent, '', [{ role: 'user', content: prompt }])
return result.text
} catch {
return ''
}
}
// ── Tool Calling ──────────────────────────────────────────────────────────
private async callAIWithTools(
agent: any,
systemPrompt: string,
inputMessages: any[],
tools: ToolDef[],
toolCtx: ToolContext,
): Promise<string> {
const cfg = await this.config.get('secretaria')
const chain = this.buildFallbackChain(agent, cfg)
// Prefere provider com suporte a tool calling; Ollama cai em modo texto
const TOOL_PROVIDERS = ['openai', 'anthropic', 'gemini']
const entry = chain.find(e => TOOL_PROVIDERS.includes(e.provider))
if (!entry) {
// Nenhum provider com tool calling disponível — usa modo texto normal
return (await this.callAI(agent, systemPrompt, inputMessages)).text
}
try {
switch (entry.provider) {
case 'openai':
return await this.openAIToolLoop(entry.model, agent, cfg, systemPrompt, inputMessages, tools, toolCtx)
case 'anthropic':
return await this.anthropicToolLoop(entry.model, agent, cfg, systemPrompt, inputMessages, tools, toolCtx)
case 'gemini':
return await this.geminiToolLoop(entry.model, agent, cfg, systemPrompt, inputMessages, tools, toolCtx)
default:
return (await this.callAI(agent, systemPrompt, inputMessages)).text
}
} catch (err: any) {
if (this.isRecoverableError(err)) {
// Provider com tools falhou — tenta sem tools no próximo da chain
return (await this.callAI(agent, systemPrompt, inputMessages)).text
}
throw err
}
}
private async executeTool(
name: string, rawArgs: string | Record<string, any>, tools: ToolDef[], toolCtx: ToolContext,
): Promise<any> {
const tool = tools.find(t => t.name === name)
if (!tool) return { error: `Tool "${name}" não encontrada.` }
const args = typeof rawArgs === 'string' ? JSON.parse(rawArgs || '{}') : rawArgs
try {
return await tool.execute(args, toolCtx)
} catch (e: any) {
return { error: e.message }
}
}
// ── Telemetria helper para tool loops ─────────────────────────────────────
private accumTelemetry(
toolCtx: ToolContext, provider: string, model: string,
incremental: { input: number; output: number; cache_read?: number; cached?: number },
): void {
if (!toolCtx._telemetry) {
toolCtx._telemetry = {
usage: { input: 0, output: 0, total: 0, cache_read: 0, cached: 0 },
provider, model, iterations: 0,
}
}
toolCtx._telemetry.iterations += 1
toolCtx._telemetry.usage.input += incremental.input
toolCtx._telemetry.usage.output += incremental.output
toolCtx._telemetry.usage.total += incremental.input + incremental.output
if (incremental.cache_read) toolCtx._telemetry.usage.cache_read = (toolCtx._telemetry.usage.cache_read ?? 0) + incremental.cache_read
if (incremental.cached) toolCtx._telemetry.usage.cached = (toolCtx._telemetry.usage.cached ?? 0) + incremental.cached
}
// ── OpenAI tool loop ───────────────────────────────────────────────────────
private async openAIToolLoop(
model: string, agent: any, cfg: any,
systemPrompt: string, inputMessages: any[],
tools: ToolDef[], toolCtx: ToolContext,
): Promise<string> {
const apiKey = (cfg.openai_key as string | undefined) ?? ''
if (!apiKey) throw new Error('OpenAI API Key não configurada')
const oaiTools = tools.map(t => ({
type: 'function',
function: { name: t.name, description: t.description, parameters: t.parameters },
}))
let msgs = [...inputMessages]
const MAX_ITER = 5
for (let i = 0; i < MAX_ITER; i++) {
const res = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
signal: AbortSignal.timeout(25_000),
body: JSON.stringify({
model,
temperature: agent.temperature ?? 0.7,
max_tokens: agent.max_tokens ?? 250,
messages: [{ role: 'system', content: systemPrompt }, ...msgs],
tools: oaiTools,
tool_choice: 'auto',
}),
})
const data = (await res.json()) as any
if (!res.ok) throw new Error(data.error?.message ?? `OpenAI ${res.status}`)
// Telemetria (M1.4)
this.accumTelemetry(toolCtx, 'openai', model, {
input: data.usage?.prompt_tokens ?? 0,
output: data.usage?.completion_tokens ?? 0,
cached: data.usage?.prompt_tokens_details?.cached_tokens ?? 0,
})
const choice = data.choices[0]
const assistantMsg = choice.message
if (choice.finish_reason !== 'tool_calls' || !assistantMsg.tool_calls?.length) {
return (assistantMsg.content ?? '') as string
}
// Execute tools in parallel
msgs.push(assistantMsg)
const toolResults = await Promise.all(
(assistantMsg.tool_calls as any[]).map(async (tc) => {
const result = await this.executeTool(tc.function.name, tc.function.arguments, tools, toolCtx)
return { role: 'tool', tool_call_id: tc.id, content: JSON.stringify(result) }
}),
)
msgs.push(...toolResults)
}
throw new Error('Tool calling: limite de iterações atingido')
}
// ── Anthropic tool loop ────────────────────────────────────────────────────
private async anthropicToolLoop(
model: string, agent: any, cfg: any,
systemPrompt: string, inputMessages: any[],
tools: ToolDef[], toolCtx: ToolContext,
): Promise<string> {
const apiKey = (cfg.anthropic_key as string | undefined) ?? ''
if (!apiKey) throw new Error('Anthropic API Key não configurada')
const anthropicTools = tools.map(t => ({
name: t.name, description: t.description, input_schema: t.parameters,
}))
let msgs = [...inputMessages]
const MAX_ITER = 5
for (let i = 0; i < MAX_ITER; i++) {
const res = await fetch('https://api.anthropic.com/v1/messages', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': apiKey,
'anthropic-version': '2023-06-01',
},
signal: AbortSignal.timeout(25_000),
body: JSON.stringify({
model, max_tokens: agent.max_tokens ?? 250, system: systemPrompt,
messages: msgs, tools: anthropicTools,
}),
})
const data = (await res.json()) as any
if (!res.ok) throw new Error(data.error?.message ?? `Anthropic ${res.status}`)
// Telemetria (M1.4)
this.accumTelemetry(toolCtx, 'anthropic', model, {
input: data.usage?.input_tokens ?? 0,
output: data.usage?.output_tokens ?? 0,
cache_read: data.usage?.cache_read_input_tokens ?? 0,
})
// Texto puro
if (data.stop_reason !== 'tool_use') {
const textBlock = (data.content as any[]).find(b => b.type === 'text')
return (textBlock?.text ?? '') as string
}
// Tool calls
msgs.push({ role: 'assistant', content: data.content })
const toolResults = await Promise.all(
(data.content as any[])
.filter(b => b.type === 'tool_use')
.map(async (b) => {
const result = await this.executeTool(b.name, b.input, tools, toolCtx)
return { type: 'tool_result', tool_use_id: b.id, content: JSON.stringify(result) }
}),
)
msgs.push({ role: 'user', content: toolResults })
}
throw new Error('Tool calling (Anthropic): limite de iterações atingido')
}
// ── Gemini tool loop ───────────────────────────────────────────────────────
private async geminiToolLoop(
model: string, agent: any, cfg: any,
systemPrompt: string, inputMessages: any[],
tools: ToolDef[], toolCtx: ToolContext,
): Promise<string> {
const apiKey = (cfg.gemini_key as string | undefined) ?? ''
if (!apiKey) throw new Error('Gemini API Key não configurada')
const geminiModel = model.startsWith('gemini') ? model : 'gemini-2.0-flash'
const url = `https://generativelanguage.googleapis.com/v1beta/models/${geminiModel}:generateContent?key=${apiKey}`
const geminiTools = [{
functionDeclarations: tools.map(t => ({
name: t.name, description: t.description, parameters: t.parameters,
})),
}]
// Converte msgs para formato Gemini
let contents: any[] = inputMessages.map(m => ({
role: m.role === 'assistant' ? 'model' : 'user',
parts: [{ text: m.content as string }],
}))
const MAX_ITER = 5
for (let i = 0; i < MAX_ITER; i++) {
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
signal: AbortSignal.timeout(25_000),
body: JSON.stringify({
systemInstruction: { parts: [{ text: systemPrompt }] },
contents,
tools: geminiTools,
generationConfig: { temperature: agent.temperature ?? 0.7, maxOutputTokens: agent.max_tokens ?? 250 },
}),
})
const data = (await res.json()) as any
if (!res.ok) throw new Error(data.error?.message ?? `Gemini ${res.status}`)
// Telemetria (M1.4)
this.accumTelemetry(toolCtx, 'gemini', geminiModel, {
input: data.usageMetadata?.promptTokenCount ?? 0,
output: data.usageMetadata?.candidatesTokenCount ?? 0,
})
const candidate = data.candidates?.[0]
const parts: any[] = candidate?.content?.parts ?? []
// Verifica se há function calls
const fnCalls = parts.filter(p => p.functionCall)
if (!fnCalls.length) {
const textPart = parts.find(p => p.text)
return (textPart?.text ?? '') as string
}
// Adiciona resposta do modelo ao histórico
contents.push({ role: 'model', parts })
// Executa tools e injeta resultados
const resultParts = await Promise.all(
fnCalls.map(async (p) => {
const result = await this.executeTool(p.functionCall.name, p.functionCall.args ?? {}, tools, toolCtx)
return { functionResponse: { name: p.functionCall.name, response: result } }
}),
)
contents.push({ role: 'user', parts: resultParts })
}
throw new Error('Tool calling (Gemini): limite de iterações atingido')
}
// ── Utils ─────────────────────────────────────────────────────────────────
private uuid(): string {
// Node 14.17+ tem crypto.randomUUID globalmente; fallback para Date-based
try {
return (crypto as any).randomUUID()
} catch {
return `${Date.now()}-${Math.random().toString(36).slice(2)}`
}
}
}