node-ai-agent-framework

Agenti AI multi-provider con sessioni persistenti, RAG e tool di piattaforma via MCP.
ai
/ai-agentauth: nginx

node-ai-agent-framework

Lo spec OpenAPI non è snapshottato: 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}/chat restituisce la risposta completa con costo e token; GET /sessions/{id}/stream la trasmette in SSE via EventSource (parametri in query string, perché EventSource non 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 da GET /memory/index/{jobId}), ricerca semantica, istruzioni per progetto. Embedding text-embedding-3-small a 1536 dimensioni, senza fallback
  • Delega OCR: un PDF senza layer testo non viene indicizzato vuoto — è mandato al task extract-text di node-ocr attraverso la facciata MCP dell'orchestrator, e oltre 700 KB depositato prima su node-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 ask sospende l'esecuzione; si legge la richiesta pendente da GET /permissions/{sessionID} e si risponde con once / 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 task chat e structured via 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/):

LayerContenuto
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 progetto portale e chiama l'agente agente-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 da GET /memory/index/{jobId}) e l'agente li consulta con il tool memory_search durante la chat
  • Passo AI dentro un workflow: un workflow di node-orchestrator invoca il task chat di questo modulo per riassumere o classificare, e usa l'output nei passi successivi
  • Agente su misura senza deploy: POST /agents/generate-and-save genera un agente da una descrizione in linguaggio naturale e lo persiste — con permessi conservativi forzati (bash: deny, edit: deny, webfetch: ask)

Identità & esposizione

CampoValore
Categoriaai
Versione cluster1.2.3
Imagegitea.pzetatouch.it/pzeta_touch/node-ai-agent-framework:1.2.3
URL pubblicohttps://ditta.pzeta.it/ai-agent
Path regex ingress`/ai-agent(/
Rewrite a backend/$2
DNS internonode-ai-agent-framework-ditta.ditta.svc.cluster.local:3000
Auth nginxauth_requestnode-user-auth
Repositorynode-ai-agent-framework

Endpoint operazionali

Endpoint convenzionali esposti da tutti i microservizi PZeta basati su @pzeta/fastify-utils:

Path pubblicoScopo
https://ditta.pzeta.it/ai-agent/healthliveness probe
https://ditta.pzeta.it/ai-agent/readyreadiness probe
https://ditta.pzeta.it/ai-agent/metricsmetriche 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.

Il 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

GruppoRotte
ProgettiGET /projects · POST /projects · POST /projects/init · GET /projects/{id} · PATCH /projects/{id} · DELETE /projects/{id}
SessioniGET /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}
ChatPOST /sessions/{sessionId}/chat · GET /sessions/{sessionId}/stream (SSE)
AgentiGET /agents · POST /agents · POST /agents/generate · POST /agents/generate-and-save · GET /agents/{name} · DELETE /agents/{name}
Memoria / RAGPOST /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 proceduraleGET e POST /procedural-memory · GET, PATCH e DELETE /procedural-memory/{name} · POST /procedural-memory/execute · GET /procedural-memory/execute-in-chat (SSE)
PermessiGET /permissions/{sessionID} · POST /permissions/{sessionID}/{permissionID}
ConfigurazioneGET /config · GET /config/type/{tipo} · GET /config/{chiave} · POST /config · PATCH /config/{chiave} · DELETE /config/{chiave} · POST /config/cache/invalidate
MCPGET /mcp/status · POST /mcp/reload · POST /config/invalidate-cache
ModelliPOST /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:

CosaCome si verificaSintomo quando manca
Credenziale del providerGET /config/provider.anthropic — la chiave esiste se la risposta la trova; il valore è redatto, ed è corretto cosìogni chat fallisce sul provider
Catalogo modelliGET /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
ProgettoGET /projects contiene portale (migrazione 012)POST /sessions non ha un projectId valido da usare
RAG (solo per memoria e ricerca)POST /memory/search restituisce risultatiricerche 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.

VariabileRuolo
DATABASE_HOST / _PORT / _NAME / _USER / _PASSWORDObbligatorie. Connessione PostgreSQL; compongono anche DATABASE_URL e POSTGRES_CONNECTION_STRING
DB_POOL_MAXDimensione del pool pg (default 20)
ANTHROPIC_API_KEY / OPENAI_API_KEY / GOOGLE_GENERATIVE_AI_API_KEY / DEEPSEEK_API_KEYFallback 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 / _SCHEMACluster 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_MSRegistrazione 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_ENABLEDWorker della coda di indicizzazione
USE_MASTRASostituisce PromptOrchestrator con MastraAdapter
MAX_STEPS / LOCK_TIMEOUT_MSBootstrap dei limiti, poi sovrascrivibili da configurazione.performance.limits
RATE_LIMIT_MAX / RATE_LIMIT_WINDOW / CORS_ORIGIN / LOG_LEVELPlugin @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 indicata
  • structured — 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):

Infrastruttura (PostgreSQL, NATS, Redis, MinIO) non è elencata qui — vedi sezione Architettura del singolo servizio.

Loading OpenAPI…
Loading NATS contracts…