node-ai-agent-framework
node-ai-agent-framework
openapi:fetch avvia ogni servizio in locale per leggerne /api-docs.json, e questo all'avvio si connette al database ed esegue le proprie migrazioni. Le rotte sono documentate a mano nella sezione API REST, che è anche l'unico posto in cui i due prefissi (/ e /api) sono elencati insieme.In sintesi
Esegue agenti AI conversazionali per conto degli altri moduli del portale: riceve un prompt, lo manda al modello del provider configurato (Anthropic, OpenAI, Google, DeepSeek, Bedrock, Vertex, Ollama), lascia che l'agente usi i tool a cui ha diritto e restituisce la risposta — sincrona o in streaming SSE. Tutto è persistito su PostgreSQL: progetti, sessioni, messaggi e le loro parti, così una conversazione sopravvive al riavvio del pod e può essere ripresa.
È anche una libreria npm (main: dist/index.js), il che spiega la forma del codice: il microservizio è uno dei consumatori del framework, non l'unico.
Attivo solo sul piano enterprise del provisioning (node-provisioning-orchestrator, MICROSERVIZI_ENTERPRISE): sugli altri piani l'ingress /ai-agent non esiste e un modulo che lo chiama deve degradare, non fallire.
Funzionalità principali
- Chat sincrona e in streaming:
POST /sessions/{id}/chatrestituisce la risposta completa con costo e token;GET /sessions/{id}/streamla trasmette in SSE viaEventSource(parametri in query string, perchéEventSourcenon ha body) - Agenti: cinque built-in seminati dalle migrazioni (
orchestrator,planner,react,reflector,tree-thinker) più gli agenti applicativi e quelli generati da una descrizione libera (POST /agents/generate-and-save) - Progetti e sessioni persistite, con advisory lock PostgreSQL su ogni mutazione concorrente: una seconda chat sulla stessa sessione riceve
423, non una risposta corrotta - RAG su pgvector: indicizzazione asincrona (
202+jobId, stato daGET /memory/index/{jobId}), ricerca semantica, istruzioni per progetto. Embeddingtext-embedding-3-smalla 1536 dimensioni, senza fallback - Delega OCR: un PDF senza layer testo non viene indicizzato vuoto — è mandato al task
extract-textdinode-ocrattraverso la facciata MCP dell'orchestrator, e oltre 700 KB depositato prima sunode-storage - Due famiglie di tool: quelli di motore (
task,todowrite/todoread,memory_search,create_agent,webfetch) sono in-process e sempre presenti; quelli di piattaforma (stampa, storage, notifiche, OCR) arrivano esclusivamente dalla facciata MCP dell'orchestrator. Se l'orchestrator è irraggiungibile spariscono i secondi e restano i primi - Permessi con conferma umana: un tool con permesso
asksospende l'esecuzione; si legge la richiesta pendente daGET /permissions/{sessionID}e si risponde cononce/always/reject - Catalogo modelli sincronizzato da models.dev (
/api/models*), validato voce per voce: una voce malformata viene scartata, non invalida l'intero catalogo - Modulo di
node-orchestrator: espone i taskchatestructuredvia NATS request-reply, richiamabili da un workflow - Configurazione a runtime nella tabella
configurazione: API key dei provider, modello di default, limiti. Si cambiano per tenant senza redeploy
Architettura
Stack: Fastify v5 · Clean Architecture + DDD su quattro layer · DI con container custom (DIContainer, risoluzione lazy e async) · PostgreSQL con driver pg e Repository Pattern (zero SQL fuori da infrastructure/database/repositories/) · pgvector su cluster separato · @pzeta/fastify-utils (securityPlugin, loggingPlugin, healthcheckPlugin, metricsPlugin, errorHandlerPlugin, openapiPlugin) · @pzeta/orchestrator-node · SDK MCP (transport stdio e HTTP/SSE) · Zod · ESM puro · Biome al posto di ESLint+Prettier.
Layout DDD (src/):
| Layer | Contenuto |
|---|---|
domain/ | Agent, Session, Message/Part (11 tipi di part), Permission, Tool, Provider, value object e porte. Nessuna dipendenza esterna |
application/ | Casi d'uso per agent, chat, sessioni, memoria (RAG e procedurale), permessi, configurazione, provider, MCP. PromptOrchestrator esegue il ciclo agente come AsyncGenerator di part |
infrastructure/ | Repository PostgreSQL, provider AI, embedding, parser documenti, chunking, client MCP, adapter orchestrator, container DI, migration runner, VectorSchemaInitializer |
presentation/ | Server Fastify, controller, route plugin, schemi Zod con metadati OpenAPI |
Autenticazione: il servizio non registra authPlugin. L'identità è quella che l'ingress ha già stabilito e propaga negli header X-User-*; è per questo che l'auth_request nginx davanti a /ai-agent non è un dettaglio di configurazione ma l'unica autenticazione che esiste su queste rotte.
Mastra (Strangler Fig): con USE_MASTRA=true l'esecutore diventa MastraAdapter al posto di PromptOrchestrator, stesso contratto IOrchestrator. Le tabelle mastra_* sono additive; il default resta l'esecutore storico.
Migrazioni all'avvio: il servizio applica le proprie migrazioni prima di servire traffico, e un fallimento non viene mascherato — il pod non parte, invece di restare in piedi con lo schema disallineato. Le probe hanno per questo initialDelaySeconds di 120/130 s.
Casi d'uso
- Redazione assistita di un annuncio (
vue-hr-reclutamento): dal dialog «nuova posizione» il modulo apre una sessione sul progettoportalee chiama l'agenteagente-reclutamento, che restituisce titolo, descrizione e requisiti come JSON da precompilare nel form - Chat conversazionale in un MFE: la sessione dura quanto la conversazione dell'utente e la risposta arriva in streaming SSE, così il testo compare mentre viene generato
- Domande su documenti caricati: i documenti del progetto vengono indicizzati (
POST /memory/index, esito daGET /memory/index/{jobId}) e l'agente li consulta con il toolmemory_searchdurante la chat - Passo AI dentro un workflow: un workflow di
node-orchestratorinvoca il taskchatdi questo modulo per riassumere o classificare, e usa l'output nei passi successivi - Agente su misura senza deploy:
POST /agents/generate-and-savegenera un agente da una descrizione in linguaggio naturale e lo persiste — con permessi conservativi forzati (bash: deny,edit: deny,webfetch: ask)
Identità & esposizione
| Campo | Valore |
|---|---|
| Categoria | ai |
| Versione cluster | 1.2.3 |
| Image | gitea.pzetatouch.it/pzeta_touch/node-ai-agent-framework:1.2.3 |
| URL pubblico | https://ditta.pzeta.it/ai-agent |
| Path regex ingress | `/ai-agent(/ |
| Rewrite a backend | /$2 |
| DNS interno | node-ai-agent-framework-ditta.ditta.svc.cluster.local:3000 |
| Auth nginx | auth_request → node-user-auth |
| Repository | node-ai-agent-framework |
Endpoint operazionali
Endpoint convenzionali esposti da tutti i microservizi PZeta basati su @pzeta/fastify-utils:
| Path pubblico | Scopo |
|---|---|
https://ditta.pzeta.it/ai-agent/health | liveness probe |
https://ditta.pzeta.it/ai-agent/ready | readiness probe |
https://ditta.pzeta.it/ai-agent/metrics | metriche Prometheus |
API REST
Due prefissi nello stesso servizio
Le rotte stanno alla radice, tranne il catalogo modelli che sta sotto /api:
POST /sessions POST /api/models/sync
POST /sessions/{id}/chat GET /api/models
GET /projects GET /api/models/status
GET /agents GET /api/models/{provider}/{modelId}
POST /memory/search
GET /models risponde 404 ROUTE_NOT_FOUND, e il corpo di quel 404 non dice che il catalogo è vuoto: dice che quella rotta non esiste.
README.md e diversi commenti JSDoc del servizio citano /api/sessions/… e /api/memory/…. Sono inesatti: registerRoutes monta tutti i plugin con prefix: '/' e solo modelRoutes scrive /api/ nel path. La fonte autorevole è src/presentation/routes/.Sopra ci sta il prefisso dell'ingress: dall'esterno ogni path va chiesto come https://ditta.pzeta.it/ai-agent<path>, che nginx riscrive in <path> prima di inoltrarlo. La chiave API_PREFIX: "ai-agent" presente nel ConfigMap del tenant non è letta da nessuna riga del servizio: non aggiunge né toglie prefissi.
Le rotte
| Gruppo | Rotte |
|---|---|
| Progetti | GET /projects · POST /projects · POST /projects/init · GET /projects/{id} · PATCH /projects/{id} · DELETE /projects/{id} |
| Sessioni | GET /sessions · POST /sessions · GET /sessions/{id} · PATCH /sessions/{id} · DELETE /sessions/{id} · POST /sessions/{id}/clear · POST /sessions/{id}/summarize · GET /sessions/{id}/context-usage · DELETE /sessions/{sessionId}/messages/{messageId} |
| Chat | POST /sessions/{sessionId}/chat · GET /sessions/{sessionId}/stream (SSE) |
| Agenti | GET /agents · POST /agents · POST /agents/generate · POST /agents/generate-and-save · GET /agents/{name} · DELETE /agents/{name} |
| Memoria / RAG | POST /memory/index · POST /memory/index-directory · GET /memory/index/{jobId} · POST /memory/search · GET e POST /memory/instructions · GET, PUT e DELETE /memory/instructions/{id} · GET /memory/instructions/{projectId}/merged |
| Memoria procedurale | GET e POST /procedural-memory · GET, PATCH e DELETE /procedural-memory/{name} · POST /procedural-memory/execute · GET /procedural-memory/execute-in-chat (SSE) |
| Permessi | GET /permissions/{sessionID} · POST /permissions/{sessionID}/{permissionID} |
| Configurazione | GET /config · GET /config/type/{tipo} · GET /config/{chiave} · POST /config · PATCH /config/{chiave} · DELETE /config/{chiave} · POST /config/cache/invalidate |
| MCP | GET /mcp/status · POST /mcp/reload · POST /config/invalidate-cache |
| Modelli | POST /api/models/sync · GET /api/models · GET /api/models/status · GET /api/models/{provider}/{modelId} |
L'involucro delle risposte, e i nomi dei campi
Ogni risposta è avvolta in { success, data, message, requestId, timestamp }. Da un client che a sua volta restituisce il body dentro data — come quello di httpFor — il payload si legge quindi in risposta.data.data.
I nomi dei campi non sono quelli delle colonne: GET /projects restituisce id, non idprogetto (che è il nome della colonna, e compare nella risposta di POST /sessions come riferimento al progetto). Un client scritto sul nome della colonna passa i test contro un mock costruito sulla stessa assunzione, e fallisce alla prima chiamata vera.
Gli errori usano { success: false, error: { code, message, details? }, requestId, timestamp }. Sulla chat: 404 solo per sessione o agente inesistenti — il riconoscimento è per tipo di errore, non per sottostringa del messaggio —, 422 per body non valido, 423 per sessione già occupata da un'altra richiesta, 500 per il resto, modello assente dal catalogo compreso.
Il flusso: progetto → sessione → chat
Non esiste una chiamata «one-shot». Per una sola domanda servono tre passi, perché il framework è pensato per conversazioni che durano.
POST /sessions esige projectId (z.number().int().positive(), obbligatorio, nessun default). L'id cambia da un ambiente all'altro, quindi va risolto per nome: la migrazione 012 semina il progetto portale, condiviso da tutti i moduli del portale che aprono sessioni.
BASE=https://ditta.pzeta.it/ai-agent
# 1. id del progetto, risolto per nome
curl -s -b cookie.txt "$BASE/projects" \
| jq '.data[] | select(.nome == "portale") | .id' # -> 1
# 2. sessione (una per conversazione)
curl -s -b cookie.txt -X POST "$BASE/sessions" \
-H 'Content-Type: application/json' \
-d '{"projectId": 1, "titolo": "Redazione annuncio"}' \
| jq '.data.id' # -> 42
# 3. chat
curl -s -b cookie.txt -X POST "$BASE/sessions/42/chat" \
-H 'Content-Type: application/json' \
-d '{"content": "Riassumi in tre righe.", "agentName": "orchestrator"}' \
| jq '.data.content'
La risposta della chat è { messageId, sessionId, content, stats: { cost, tokens } }: content è il testo completo, già ripulito dai blocchi <thinking>; stats riporta il costo in USD e i token (input, output, reasoning, cache).
Chi vuole il testo mentre viene generato usa lo stream, che è una GET con i parametri in query string — EventSource non può inviare un body:
GET /sessions/42/stream?content=Riassumi%20in%20tre%20righe.&agentName=orchestrator
Agenti
GET /agents chiama listCustomAgents(): elenca solo gli agenti con builtin = false. I cinque built-in — orchestrator, planner, react, reflector, tree-thinker — non compaiono in quella lista pur esistendo, e orchestrator è per giunta il valore di default di agentName nello schema di chat. Una lista vuota non significa quindi «nessun agente disponibile»: per verificarne uno si interroga direttamente GET /agents/orchestrator, che risponde 200.
Gli agenti generati (POST /agents/generate-and-save, o il tool create_agent) nascono con permessi conservativi forzati: bash: deny, edit: deny, webfetch: ask, nessun accesso al filesystem.
Prerequisiti operativi
Il pod può essere Running, /health verde e GET /agents popolato, e nessuna chat funzionare lo stesso. Prima di cercare il guasto nel proprio codice conviene verificare questi quattro punti, tutti da fuori:
| Cosa | Come si verifica | Sintomo quando manca |
|---|---|---|
| Credenziale del provider | GET /config/provider.anthropic — la chiave esiste se la risposta la trova; il valore è redatto, ed è corretto così | ogni chat fallisce sul provider |
| Catalogo modelli | GET /api/models/status: cacheAge: -1 significa che la cache non è mai stata scritta. Si popola con POST /api/models/sync | «Model X not found for provider Y» su qualunque agente |
| Progetto | GET /projects contiene portale (migrazione 012) | POST /sessions non ha un projectId valido da usare |
| RAG (solo per memoria e ricerca) | POST /memory/search restituisce risultati | ricerche sempre vuote e indicizzazione in errore: manca VECTOR_DATABASE_PASSWORD nel Secret del tenant. Il resto del servizio funziona |
Le API key dei provider non stanno in un Secret Kubernetes: si risolvono dalla tabella configurazione con precedenza DB → variabile d'ambiente, sotto la chiave provider.<nome> con valore { "api_key": "…", "default_model": "…" }. Si inseriscono per tenant a runtime, senza redeploy.
Un quinto prerequisito riguarda solo la delega OCR dei PDF scansionati, che è attiva di default: il gateway deve inoltrare X-User-ID a questo servizio, e l'IP del servizio deve comparire in TRUSTED_PROXY_IPS sull'orchestrator — la rotta /mcp dell'orchestrator non è autenticata e fuori da quel confine di fiducia scarta gli header X-User-*, facendo rifiutare il job come anonimo. Oggi quella variabile non è valorizzata nel repo Flux: un PDF scansionato fallisce esplicitamente, senza costo OCR e senza mai finire vuoto nell'indice.
Integrazione da un MFE
Il servizio è nel registro della shell (ServiceRegistry) con prefisso /ai-agent e senza chiaveEnv: non esiste alcun env.aiAgentUrl da leggere, e context.httpFor('ai-agent') è l'unica via per raggiungerlo — un client undefined significa che quella shell non implementa il canale.
Il client esiste però anche dove il servizio non è deployato, perché il registro è statico: a dire se risponde è il probe della shell, in context.env.services['ai-agent'], con l'evento shell:services-changed che ne segnala i cambi mentre la pagina è aperta. Sui tenant che non sono di piano enterprise il verdetto è false e la funzione va spenta, non ritentata.
const http = context?.httpFor?.('ai-agent')
if (!http) return // servizio non instradato: funzione non disponibile
// i path sono relativi, il prefisso dell'ingress lo mette il client
const progetti = await http.get<{ data?: Array<{ id?: number; nome?: string }> }>('/projects')
const idProgetto = progetti.data?.data?.find((p) => p.nome === 'portale')?.id
const sessione = await http.post<{ data?: { id?: number } }>('/sessions', {
projectId: idProgetto,
titolo: 'Redazione annuncio'
})
const esito = await http.post<{ data?: { content?: string } }>(
`/sessions/${sessione.data?.data?.id}/chat`,
{ content: descrizione, agentName: 'agente-reclutamento' }
)
Due avvertenze che costano un giro di debug ciascuna: il payload sta in risposta.data.data (l'involucro del servizio dentro quello del client), e una chat può durare decine di secondi — va dato un timeout esplicito e un AbortSignal, non lasciata al default.
Configurazione
Le variabili d'ambiente portano solo il bootstrap; tutto il resto vive nella tabella configurazione ed è modificabile a runtime.
| Variabile | Ruolo |
|---|---|
DATABASE_HOST / _PORT / _NAME / _USER / _PASSWORD | Obbligatorie. Connessione PostgreSQL; compongono anche DATABASE_URL e POSTGRES_CONNECTION_STRING |
DB_POOL_MAX | Dimensione del pool pg (default 20) |
ANTHROPIC_API_KEY / OPENAI_API_KEY / GOOGLE_GENERATIVE_AI_API_KEY / DEEPSEEK_API_KEY | Fallback delle credenziali provider quando la tabella configurazione non le ha. OPENAI_API_KEY serve anche agli embedding del RAG |
VECTOR_DATABASE_HOST / _PORT / _NAME / _USER / _PASSWORD / _SCHEMA | Cluster pgvector condiviso; lo schema isola il tenant. Senza password il RAG resta spento e il servizio parte comunque |
ORCHESTRATOR_ENABLED / ORCHESTRATOR_URL / ORCHESTRATOR_MODULE_ID / ORCHESTRATOR_TASK_TIMEOUT_MS | Registrazione come modulo di node-orchestrator e accesso alla sua facciata MCP, da cui arrivano i tool di piattaforma. Iniettate dal provisioning |
RAG_OCR_DELEGA_ENABLED e le tarature RAG_OCR_* | Delega dei PDF scansionati a node-ocr; attiva di default, si spegne solo per contenere il costo per pagina dell'OCR |
RAG_INDEX_POLLING_MS / RAG_INDEX_STALLO_MS / RAG_INDEX_HEARTBEAT_MS / RAG_INDEX_WORKER_ENABLED | Worker della coda di indicizzazione |
USE_MASTRA | Sostituisce PromptOrchestrator con MastraAdapter |
MAX_STEPS / LOCK_TIMEOUT_MS | Bootstrap dei limiti, poi sovrascrivibili da configurazione.performance.limits |
RATE_LIMIT_MAX / RATE_LIMIT_WINDOW / CORS_ORIGIN / LOG_LEVEL | Plugin @pzeta/fastify-utils |
Chiavi notevoli della tabella configurazione: provider.<nome> (credenziale e modello di default del provider), runtime.defaults (modello di default globale, allineato a claude-sonnet-4 dalla migrazione 013), performance.limits, security.settings, security.vectorCredentials (credenziali del ruolo vettoriale dedicato al tenant, create al primo avvio).
I valori che contengono password, api_key o token sono redatti dalle risposte di GET /config*: la lettura in chiaro esiste solo dentro il servizio.
Note eventing NATS
Il servizio non pubblica né consuma subject applicativi propri: il suo unico uso di NATS passa dall'SDK di node-orchestrator.
Con ORCHESTRATOR_ENABLED=true e ORCHESTRATOR_URL valorizzati si registra come modulo (moduleId di default node-ai-agent-framework) ed espone due task in request-reply sul subject workflow.execution.{moduleId}.{taskId}:
chat— manda un prompt a un agente e restituisce{ text, sessionId, userMessageId, partCount }. Crea una sessione nuova, o prosegue quella indicatastructured— esegue una memoria procedurale registrata sostituendo le variabili del template; restituisce testo oppure un oggetto strutturato, secondo lo schema della memoria
Entrambi sono esclusi dal catalogo MCP (mcp: { exclude: true }): il framework è insieme modulo registrato e consumatore del catalogo, quindi senza esclusione un agente vedrebbe chat fra i propri tool e potrebbe invocare sé stesso. Restano invocabili da un workflow, che è il loro scopo.
NATS consegna at-least-once, e rieseguire un task AI non è solo inefficiente: costa token e produce un output diverso da quello che il workflow ha già ricevuto. ExecutionDeduplicator memorizza quindi il risultato per X-Execution-Id + taskId; essendo in-process, con più repliche una riconsegna instradata altrove viene comunque rieseguita.
La registrazione è fail-safe: se l'hub non risponde il servizio parte lo stesso e resta pienamente usabile via REST — spariscono soltanto i tool di piattaforma, che da quella stessa facciata MCP arrivano.
Dipendenze e dipendenti
Dipende da (servizi che questo servizio chiama):
Consumato da (chi chiama questo servizio):
frontend Vuenode-orchestrator
Infrastruttura (PostgreSQL, NATS, Redis, MinIO) non è elencata qui — vedi sezione Architettura del singolo servizio.