/opt/mawid/apps/api/src/agent
Edit: /opt/mawid/apps/api/src/agent/agent.service.ts (8163B)
import { Inject, Injectable, Logger, Optional } from '@nestjs/common';
import type OpenAI from 'openai';
import { t, type Language } from '@mawid/shared';
import { PrismaService } from '../prisma/prisma.service';
import {
AGENT_MODEL,
INTENT_MODEL,
LLM_CLIENT,
minReasoningEffort,
type AgentLlmClient,
} from './openai.client';
import { BookingToolsService, type ToolContext } from './booking-tools.service';
import { buildSystemPrompt } from './system-prompt';
import { AGENT_TOOLS } from './tool-definitions';
export interface InboundContext {
clinicId: string;
patientId: string;
conversationId: string;
text: string;
}
interface ConversationState {
handedOff?: boolean;
handoffReason?: string;
agentTurnsMs?: number[];
[key: string]: unknown;
}
const MAX_TURNS_PER_HOUR = 15;
const MAX_TOOL_ITERATIONS = 8;
const HISTORY_LIMIT = 20;
const OPENAI_TOOLS: OpenAI.Chat.Completions.ChatCompletionTool[] = AGENT_TOOLS.map((tool) => ({
type: 'function',
function: {
name: tool.name,
description: tool.description,
parameters: tool.input_schema,
},
}));
@Injectable()
export class AgentService {
private readonly logger = new Logger(AgentService.name);
constructor(
private readonly prisma: PrismaService,
private readonly tools: BookingToolsService,
@Optional() @Inject(LLM_CLIENT) private readonly llm: AgentLlmClient | null,
) {}
get enabled(): boolean {
return this.llm !== null;
}
/** Runs one agent turn for an inbound patient message. Returns the reply text or null. */
async handleInbound(ctx: InboundContext): Promise
{
if (!this.llm) return null;
const conversation = await this.prisma.conversation.findFirst({
where: { id: ctx.conversationId, clinicId: ctx.clinicId },
include: { patient: true, clinic: true },
});
if (!conversation) return null;
const state = (conversation.state ?? {}) as ConversationState;
if (state.handedOff) return null;
const lang = this.patientLang(conversation.patient.language);
// Guardrail: max 15 agent turns per conversation per hour.
const now = Date.now();
const turns = (state.agentTurnsMs ?? []).filter((ts) => now - ts < 3600_000);
if (turns.length >= MAX_TURNS_PER_HOUR) {
await this.saveState(conversation.id, {
...state,
handedOff: true,
handoffReason: 'rate_limit',
agentTurnsMs: turns,
});
await this.audit(ctx, 'agent:rate_limited', {});
return t('handoffNotice', lang);
}
await this.saveState(conversation.id, { ...state, agentTurnsMs: [...turns, now] });
try {
const intent = await this.classifyIntent(ctx.text);
const model = intent === 'booking' ? AGENT_MODEL : INTENT_MODEL;
return await this.runToolLoop(ctx, conversation, model);
} catch (err) {
// PII discipline: log the error, never the message body.
this.logger.error(`agent turn failed for conversation ${ctx.conversationId}: ${String(err)}`);
return t('agentError', lang);
}
}
private async classifyIntent(text: string): Promise<'booking' | 'faq' | 'other'> {
const res = await this.llm!.chat.completions.create({
model: INTENT_MODEL,
// Generous cap: reasoning tokens count toward the completion limit.
max_completion_tokens: 2048,
reasoning_effort: minReasoningEffort(INTENT_MODEL),
messages: [
{
role: 'system',
content:
'Classify the patient WhatsApp message for a clinic. Reply with exactly one word: "booking" (anything about appointments: book, move, cancel, availability, waitlist, confirm), "faq" (questions about the clinic, services, prices, hours, directions), or "other".',
},
{ role: 'user', content: text.slice(0, 1000) },
],
});
const word = res.choices[0]?.message.content?.trim().toLowerCase() ?? '';
return word === 'booking' || word === 'faq' ? word : 'other';
}
private async runToolLoop(
ctx: InboundContext,
conversation: {
id: string;
patient: { name: string | null; language: string };
clinic: { name: string; timezone: string };
},
model: string,
): Promise {
const system = buildSystemPrompt({
clinicName: conversation.clinic.name,
timezone: conversation.clinic.timezone,
patientName: conversation.patient.name,
patientLanguage: conversation.patient.language,
now: new Date(),
});
const messages: OpenAI.Chat.Completions.ChatCompletionMessageParam[] = [
{ role: 'system', content: system },
...(await this.loadHistory(conversation.id)),
];
const toolCtx: ToolContext = {
clinicId: ctx.clinicId,
patientId: ctx.patientId,
conversationId: ctx.conversationId,
};
for (let i = 0; i < MAX_TOOL_ITERATIONS; i++) {
const response = await this.llm!.chat.completions.create({
model,
max_completion_tokens: 4096,
// GPT-5.6 chat completions reject tools + reasoning_effort != 'none'.
reasoning_effort: minReasoningEffort(model),
messages,
tools: OPENAI_TOOLS,
});
const message = response.choices[0]?.message;
if (!message) return null;
const toolCalls = (message.tool_calls ?? []).filter(
(call): call is OpenAI.Chat.Completions.ChatCompletionMessageToolCall & { type: 'function' } =>
call.type === 'function',
);
const text = (message.content ?? '').trim();
if (toolCalls.length === 0) return text || null;
messages.push({
role: 'assistant',
content: message.content ?? null,
tool_calls: message.tool_calls,
});
for (const call of toolCalls) {
let input: Record = {};
try {
input = JSON.parse(call.function.arguments || '{}') as Record;
} catch {
// malformed arguments → run the tool with empty input; it will report the error back
}
const result = await this.tools.execute(toolCtx, call.function.name, input);
messages.push({
role: 'tool',
tool_call_id: call.id,
content: JSON.stringify(result),
});
}
// The handoff tool ends the agent's participation; any text it produced
// alongside is still delivered.
if (toolCalls.some((call) => call.function.name === 'handoff_to_human')) {
return text || null;
}
}
this.logger.warn(`tool loop hit iteration cap for conversation ${ctx.conversationId}`);
return null;
}
/** Recent transcript (from Message) as alternating chat turns. */
private async loadHistory(
conversationId: string,
): Promise {
const rows = await this.prisma.message.findMany({
where: { conversationId, body: { not: null } },
orderBy: { createdAt: 'desc' },
take: HISTORY_LIMIT,
});
rows.reverse();
const merged: Array<{ role: 'user' | 'assistant'; content: string }> = [];
for (const row of rows) {
const role = row.direction === 'inbound' ? 'user' : 'assistant';
const last = merged[merged.length - 1];
if (last && last.role === role) {
last.content = `${last.content}\n${row.body}`;
} else {
merged.push({ role, content: row.body! });
}
}
return merged;
}
private patientLang(language: string): Language {
return (['ar', 'tr', 'en'] as const).includes(language as Language)
? (language as Language)
: 'en';
}
private async saveState(conversationId: string, state: ConversationState): Promise {
await this.prisma.conversation.update({
where: { id: conversationId },
data: { state: state as object },
});
}
private async audit(ctx: InboundContext, action: string, meta: object): Promise {
await this.prisma.auditLog.create({
data: {
clinicId: ctx.clinicId,
actor: 'agent',
action,
meta: { conversationId: ctx.conversationId, ...meta } as object,
},
});
}
}