TypeScript pour les ingénieurs ML Python : créer un service d’agent

Traduction automatique

Cet article a été traduit automatiquement depuis la version originale en anglais.

Ce guide s’adresse aux ingénieurs ML et backend Python expérimentés qui doivent construire un service d’AI en TypeScript et Node. Il part des concepts d’un service Python, pas des bases de la programmation ni du code navigateur. À la fin, vous saurez faire correspondre une stack de service Python à TypeScript, reconnaître les habitudes Python qui causent des bugs en JavaScript, puis exécuter et modifier un service d’agent en streaming.

L’exemple suit une requête à travers la validation, l’exécution des outils, le streaming HTTP et la persistance. Il répond à trois questions : que se passe-t-il lorsque les arguments d’un outil arrivent en fragments, qu’un flux échoue après un HTTP 200 ou qu’un worker récupère deux fois le même job ? Le périmètre se limite au backend et à l’AI : pas de React, pas de bundler pour navigateur.

Le code se trouve dans slavadubrov/typescript-agent-service. Il contient deux implémentations de l’agent loop, l’historique des exécutions dans Postgres, un worker et un serveur MCP. Vous pouvez exécuter sa démo hors ligne sans clé d’API, puis vous appuyer sur la correspondance Python-TypeScript ci-dessous pour lire et modifier le service. Les calculs numériques lourds restent en Python ; ce guide couvre l’orchestration, HTTP et le streaming.

Commencez par exécuter le dépôt compagnon

Installez Node 24 et pnpm en suivant les instructions d’installation officielles de pnpm. Clonez ensuite le dépôt compagnon et exécutez :

pnpm install
pnpm demo
pnpm check

pnpm demo exerce le gestionnaire HTTP, l’agent loop et le flux SSE avec un modèle scripté. Il appelle aussi le calcul runSweep du worker, mais ne démarre ni le processus du worker ni sa file d’attente en base de données. La démo ne demande ni clé d’API, ni base de données, ni Docker. pnpm check lance le vérificateur de types, le linter, la vérification du formatage et les tests.

La démo affiche les frames SSE d’une requête de chat, puis le balayage du worker. Version abrégée :

event: tool_call
data: {"type":"tool_call","callId":"call_1","toolName":"estimate_kv_cache","args":{"model":"llama-3.1-8b","seqLen":32768,"batchSize":16}}

event: tool_result
data: {"type":"tool_result","callId":"call_1","toolName":"estimate_kv_cache","ok":true,"result":{"model":"llama-3.1-8b","seqLen":32768,"batchSize":16,"kvCacheGiB":64},"durationMs":0}

event: done
data: {"type":"done","stopReason":"stop","steps":2,"text":"Llama-3.1-8B at 32k context and batch 16 needs 64 GiB of KV cache."}

--- worker: capacity sweep ---
batch=  1  kv_cache=4.0 GiB
batch= 16  kv_cache=64.0 GiB

Chaque bloc event:/data: est une frame SSE : le modèle demande un outil, l’outil répond, puis l’exécution se termine par done. La dernière partie est le balayage des tailles de batch par le worker.

Pour exécuter la vraie API et le worker :

cp .env.example .env          # add OPENAI_API_KEY or an OpenAI-compatible URL
pnpm db:up                    # start Postgres in Docker
pnpm db:push                  # create the database schema
pnpm dev:api                  # API at http://localhost:8080
pnpm dev:worker               # run this in a second terminal

Diffusez une exécution d’agent. La réponse est une série de frames SSE comme dans la sortie de la démo ci-dessus :

curl -N -X POST localhost:8080/v1/chat \
  -H 'content-type: application/json' \
  -d '{"message":"How much KV cache for llama-3.1-70b at 32k context, batch 8?"}'

Mettez un long job en file, puis interrogez-le. Le premier appel renvoie 202 avec {"id":"...","status":"queued"}. Le second renvoie la ligne stockée, avec le résultat une fois que le worker a terminé :

curl -X POST localhost:8080/v1/sweeps \
  -H 'content-type: application/json' \
  -d '{"model":"llama-3.1-70b","seqLen":32768,"batchSizes":[1,4,16]}'

curl localhost:8080/v1/runs/<id>

Les scripts pnpm ci-dessus sont les commandes que j’utilise dans la suite de cet article. Le dépôt place les commandes Node et TypeScript de plus bas niveau derrière des scripts pnpm nommés, un peu comme un projet Python peut placer des commandes uv run derrière des cibles make. Ne mélangez pas npm install dans ce dépôt pnpm. Utilisez pnpm install pour que pnpm-lock.yaml reste l’unique lockfile.


Rôle de Node, npm, pnpm, TypeScript et tsx

Des noms proches cachent des rôles distincts :

  • JavaScript est le langage.
  • Node.js est le runtime, à peu près le CPython de JavaScript. Installez Node 24 pour ce projet.
  • Le registre npm est l’index de paquets, à peu près PyPI. La commande npm est fournie avec Node et peut installer des paquets depuis ce registre.
  • pnpm est le gestionnaire de paquets choisi par ce dépôt. Il installe depuis le registre npm, gère l’espace de travail du monorepo et exécute les commandes déclarées dans package.json.
  • package.json est le manifeste du projet, ce qui se rapproche le plus de pyproject.toml. Sa section scripts donne des noms tels que demo, check et dev:api à des commandes plus longues.
  • TypeScript est du JavaScript avec des types statiques. Sa commande tsc vérifie ces types. Le dépôt la lance via pnpm check ou pnpm typecheck.
  • tsx exécute des fichiers .ts sans étape de build séparée. Les scripts de développement utilisent son mode watch pour redémarrer l’API ou le worker après une modification du code source. Ce guide ne l’invoque jamais directement.

Pour ce dépôt, lancez les scripts pnpm. Node est le runtime à l’intérieur de ces scripts, et le Dockerfile fourni s’occupe de la production.

Ce que lance chaque script :

  • pnpm demo lance tsx scripts/demo.ts.
  • pnpm dev:api lance tsx watch.
  • pnpm check lance tsc, Biome (le linter) et Vitest (le lanceur de tests).
  • Le CMD Docker lance node --import tsx/esm.

Tous s’exécutent sur Node 24. Dans pnpm check, tsc et Biome ne font que lire les fichiers ; Vitest exécute les tests sur Node.


La stack, transposée depuis Python

Zod, Hono, Drizzle, Vitest et Biome couvrent en grande partie le même terrain que pydantic, FastAPI, SQLAlchemy, pytest et Ruff.

La plupart des paires sont des remplacements directs. Trois ne le sont pas :

BesoinPythonTypeScriptPourquoi ce n’est pas un simple échange
ValidationpydanticzodLe schéma est la source. Le type en est généré, et non l’inverse
TypesmypyTypeScript (pnpm typecheck)Les types sont supprimés avant l’exécution du code. Aucune bibliothèque ne peut les lire, donc la validation à l’exécution exige un schéma distinct (Zod)
File de jobscelery + Redisbullmq (file adossée à Redis) ou SQLPostgres peut implémenter une file at-least-once. Vous pouvez vous passer de broker

Le dépôt compagnon utilise quatre bibliothèques qui méritent une explication.

Hono pour la couche HTTP

Express et Fastify sont des alternatives centrées sur Node. Hono s’appuie sur les API Web standard Request et Response et fournit des adaptateurs pour Node et les runtimes serverless. Cette portabilité est utile pour cette petite API de streaming, d’où mon choix de Hono. Choisissez Fastify lorsque le service ne tourne que sur Node et que vous voulez son jeu de plugins et sa sérialisation des réponses fondée sur des schémas.

Drizzle pour SQL

Drizzle garde le schéma en TypeScript et ne demande aucune étape de génération de client. Il permet aussi d’écrire du SQL brut lorsque le query builder ne sait pas exprimer proprement une clause Postgres. Je choisirais plutôt Prisma si son client généré et l’outillage qui l’entoure conviennent mieux à l’équipe.

Biome pour le linting et le formatage

Biome prend en charge le linting, le formatage et le tri des imports avec un seul binaire et un seul fichier de configuration. Gardez ESLint si le projet dépend de règles personnalisées que Biome ne fournit pas.

Vitest pour les tests

Vitest exécute les tests .ts du dépôt compagnon sans configuration de transformation séparée. Gardez Jest dans un dépôt qui possède déjà une configuration Jest et des matchers personnalisés.


Lire la syntaxe TypeScript utilisée ci-dessous

Gardez ce tableau à côté des exemples de service pour vous y référer.

TypeScriptPython / remarque
(x) => expressionfonction anonyme à corps d’expression, comparable à lambda x: expression
(x) => { statements }fonction anonyme à corps d’instructions
async (x) => { statements }fonction anonyme asynchrone
const { model, seqLen } = requestextrait les propriétés model et seqLen de request
const [first] = xsfirst = xs[0]. Renvoie undefined, et non IndexError, si la liste est vide
{ type: "error", message }{"type": "error", "message": message}. Un nom seul devient ce champ
`text ${x}`f-string
cond ? a : ba if cond else b
const / letLes deux lient un nom. const interdit de le relier, tandis que let l’autorise
exportrend un nom importable
switch / casematch, sauf que les cas se poursuivent sur le suivant s’ils ne finissent pas par break ou return
for awaititération sur un générateur asynchrone
i++incrémente et renvoie l’ancienne valeur
/^https?$/un littéral d’expression régulière, sans re.compile
T[], Map<K, V>list[T], dict[K, V]

Utilisez const sauf si la liaison doit changer. Utilisez let pour un compteur, un accumulateur ou toute autre liaison que vous réaffecterez.

Une brève traduction des enums

Pour les états à valeur de chaîne, ce dépôt utilise un objet plus un type union de chaînes inféré :

const Status = { Queued: "queued", Running: "running" } as const;
type Status = (typeof Status)[keyof typeof Status]; // "queued" | "running"

L’objet fournit Status.Queued pendant l’exécution du programme. La ligne type n’autorise que "queued" ou "running" pendant la vérification des types. Ensemble, ils remplissent les deux rôles de cette déclaration Python :

from enum import Enum

class Status(str, Enum):
    QUEUED = "queued"
    RUNNING = "running"

Il suffit de reconnaître ce motif. as const conserve les valeurs de l’objet sous forme de chaînes exactes au lieu de les élargir à un string quelconque.


Les sept différences sémantiques qui font perdre du temps

Le tableau de syntaxe suffit pour suivre les exemples. Ces différences sémantiques sont celles où les habitudes Python provoquent des bugs.

1. Les tableaux et objets vides sont truthy

« Un conteneur vide est falsy » est l’habitude Python qui se transpose le plus mal. if (results) est vrai pour un tableau vide. Écrivez if (results.length).

2. null et undefined sont différents

null marque en général une absence délibérée. undefined signifie en général qu’une valeur est manquante ou non affectée, bien que le code puisse l’affecter explicitement. Le code des bibliothèques renvoie constamment undefined. La différence apparaît lorsque vous écrivez une valeur par défaut. || substitue le côté droit dès que le côté gauche est falsy, ce qui inclut 0, "" et false. ?? ne substitue que pour null et undefined. Ainsi, 0 || 10 vaut 10, alors que 0 ?? 10 vaut 0. C’est ainsi qu’une taille de batch de zéro devient silencieusement dix.

3. Un bloc catch reçoit unknown

Il n’existe pas de except ValueError:. Un seul bloc catch reçoit tout. Comme JavaScript permet de lever une chaîne, un nombre ou null, TypeScript type la valeur capturée comme unknown, son type « cela pourrait être n’importe quoi » sous strict. Le dépôt compagnon active strict, et les nouveaux projets devraient généralement le faire. Pour inspecter l’erreur, affinez d’abord la valeur :

try {
    await risky();
} catch (error) {
    // `error` is `unknown` until you prove otherwise. This line is the
    // TypeScript equivalent of `except Exception as e:` and it is not
    // optional.
    const message = error instanceof Error ? error.message : String(error);
}

4. Les promises démarrent immédiatement

Appeler une fonction async exécute son corps et renvoie une promise. Un objet coroutine Python ne fait rien tant que vous ne l’attendez pas ou ne le planifiez pas. Promise.all est proche de asyncio.gather. Promise.allSettled est proche de gather(..., return_exceptions=True), sauf que chaque résultat est enveloppé sous la forme { status, value } ou { status, reason }.

Node gère lui-même la planification au runtime. Il maintient le processus en vie tant que des handles ou des requêtes actifs, comme des timers et des sockets, existent encore. Une promise simplement en attente ne suffit pas à garder Node en vie. Vous n’enveloppez pas le programme dans asyncio.run. Dans un module ES, vous pouvez utiliser await au niveau supérieur lorsque le démarrage doit attendre une opération asynchrone.

5. JavaScript n’a qu’un seul type numérique ordinaire

Le type number de JavaScript stocke les valeurs sous forme de nombres à virgule flottante sur 64 bits, à peu près comme le float de Python. La norme technique de ce format s’appelle IEEE 754. Les valeurs décimales sont approximatives, donc 0.1 + 0.2 n’est pas exactement 0.3, et les entiers ne restent exacts que jusqu’à 2**53 - 1, soit 9,007,199,254,740,991.

Gardez les identifiants 64 bits sous forme de chaînes aux frontières du service. Convertir un bigint Postgres en number JavaScript peut l’arrondir. Pour des entiers exacts plus grands, JavaScript fournit le type distinct BigInt, qui ne se mélange pas avec les nombres ordinaires.

6. Utilisez Map lorsque vous avez besoin d’un dictionnaire à la Python

En JavaScript, {} crée un objet. Les objets représentent généralement des enregistrements à champs nommés :

const request = { model: "gpt-5", seqLen: 4096 };
console.log(request.model);

Un objet n’est pas une table clé-valeur propre comme un dict Python. Il hérite de certains noms de JavaScript lui-même. Cela peut produire un résultat surprenant :

const tools: Record<string, unknown> = {};

tools["constructor"]; // a built-in function, not a missing value
Object.hasOwn(tools, "constructor"); // false

Si une chaîne externe sélectionne un champ d’un objet, appelez Object.hasOwn avant de le lire. Si vous avez besoin d’un dictionnaire généraliste, utilisez Map. Map est plus proche du dict de Python : une clé n’existe que si votre code l’ajoute.

const tools = new Map<string, unknown>();
tools.get("constructor"); // undefined

7. Incluez l’extension dans les imports relatifs

Un fichier source JavaScript qui partage du code avec d’autres fichiers est appelé un module. Ce projet utilise le format de module moderne, les modules ES, généralement abrégés en ESM. ES signifie ECMAScript, le nom formel du langage JavaScript. En pratique, ESM désigne la syntaxe import et export utilisée dans tout ce projet.

Pour un import relatif, Node exige le nom de fichier exact. Il ne devine pas si ./env désigne ./env.ts ou ./env.js :

import { loadEnv } from "./env.ts";

Les imports de paquets installés ou de paquets du workspace utilisent toujours le nom du paquet, sans extension de fichier :

import { z } from "zod";
import { runAgent } from "@agent/core";

Zod : déclarer le schéma, dériver le type

Avec pydantic, vous déclarez une classe et obtenez un validateur. Avec Zod, vous déclarez un validateur et en dérivez le type. Même source de vérité unique, sens opposé.

La figure montre le même réglage PORT dans les deux sens, et ce qui reste de chaque déclaration pendant l’exécution du code.

Deux bandes comparent un même réglage PORT. En Python, class Env(BaseSettings) déclare PORT: int = 8080 ; mypy vérifie selon l’annotation, la classe la conserve à l’exécution, et pydantic construit le validateur à partir d’elle. En TypeScript, EnvSchema déclare PORT avec z.coerce.number(), et z.infer en construit le type Env pendant la vérification des types. Pendant l’exécution, EnvSchema vérifie process.env, et le type Env a disparu, car les types sont retirés avant l’exécution du code.Deux bandes comparent un même réglage PORT. En Python, class Env(BaseSettings) déclare PORT: int = 8080 ; mypy vérifie selon l’annotation, la classe la conserve à l’exécution, et pydantic construit le validateur à partir d’elle. En TypeScript, EnvSchema déclare PORT avec z.coerce.number(), et z.infer en construit le type Env pendant la vérification des types. Pendant l’exécution, EnvSchema vérifie process.env, et le type Env a disparu, car les types sont retirés avant l’exécution du code.

Extrait de packages/schemas/src/env.ts :

import { z } from "zod"; // `z` is Zod's whole API, the way `pd` is pandas

const EnvSchema = z.object({
    PORT: z.coerce.number().int().positive().default(8080),
    // See the note below: a bare z.url() would accept "localhost:8000".
    OPENAI_BASE_URL: z
        .url({ protocol: /^https?$/ })
        .default("https://api.openai.com/v1"),
    DATABASE_URL: z.string().optional(),
});

export type Env = z.infer<typeof EnvSchema>;

z.infer<typeof EnvSchema> relit un type statique à partir du schéma d’exécution. z.coerce.number() gère le fait que chaque valeur définie dans process.env (l’équivalent Node de os.environ) est une chaîne. Il joue le même rôle que la coercition numérique des réglages de pydantic, même si les chaînes exactes acceptées par chacun diffèrent. Cela suit le motif de pydantic-settings et s’exécute une seule fois au démarrage. Un environnement invalide produit alors une erreur de démarrage lisible au lieu d’un TypeError dans un handler.

Un simple z.url() accepte localhost:8000. Le standard URL traite tout ce qui précède le premier deux-points comme le schéma. Il lit donc localhost: comme un protocole nommé « localhost » et accepte la chaîne. La valeur atteint ensuite le client HTTP et échoue avec moins de contexte. La validation de schéma déplace les échecs plus tôt, mais elle appliquera un schéma permissif si c’est ce que vous avez écrit.

Zod 4 fournit aussi z.toJSONSchema, donc ce projet n’a pas besoin de la dépendance zod-to-json-schema courante dans les anciens tutoriels. Cela compte dès qu’un même schéma doit alimenter trois consommateurs, ce dont traite la section « Un outil, trois consommateurs » plus bas.


Le service

Trois packages partagés en haut : @agent/schemas, @agent/core et @agent/observability. En dessous, trois processus. L’API Hono reçoit les requêtes HTTP d’un client tel que curl et répond avec des frames SSE ou du JSON, appelle un serveur de modèle compatible OpenAI sur /chat/completions, et insère et lit les exécutions dans la table runs de Postgres. Le worker récupère les lignes en file d’attente dans cette table et y écrit les résultats. Le serveur MCP échange avec un client AI en JSON-RPC sur stdin et stdout, et n’a ni route HTTP ni connexion Postgres.Trois packages partagés en haut : @agent/schemas, @agent/core et @agent/observability. En dessous, trois processus. L’API Hono reçoit les requêtes HTTP d’un client tel que curl et répond avec des frames SSE ou du JSON, appelle un serveur de modèle compatible OpenAI sur /chat/completions, et insère et lit les exécutions dans la table runs de Postgres. Le worker récupère les lignes en file d’attente dans cette table et y écrit les résultats. Le serveur MCP échange avec un client AI en JSON-RPC sur stdin et stdout, et n’a ni route HTTP ni connexion Postgres.

La rangée du haut est le code partagé dans packages/. En dessous, chaque application de apps/ est un processus distinct. Seuls l’API et le worker accèdent à Postgres, et le serveur MCP échange avec son client sur stdin et stdout.

Le service de démonstration dimensionne des déploiements de LLM. Un outil retrouve les constantes d’architecture d’un modèle. L’autre estime son empreinte de KV cache : la mémoire GPU utilisée pour conserver les clés et les valeurs d’attention des requêtes en cours. Ces deux outils sont volontairement de l’arithmétique sans relief. Ils n’ont besoin d’aucun réseau et donnent toujours la même réponse. Le service est ainsi testable sans clé d’API. L’estimateur de KV cache est aussi publié via le Model Context Protocol (MCP), afin que d’autres clients AI puissent l’appeler.

typescript-agent-service/
├── apps/
│   ├── api/           @agent/api: Hono API, streams Server-Sent Events (below)
│   ├── worker/        polls Postgres for long-running jobs
│   └── mcp/           MCP server: exposes one tool to outside AI clients
├── packages/
│   ├── schemas/       package name @agent/schemas: env, API, and tool schemas
│   ├── agent-core/    package name @agent/core: the loop (twice), tools, storage
│   └── observability/ Pino logging, OpenTelemetry tracing
├── pnpm-workspace.yaml
└── package.json

pnpm-workspace.yaml est le fichier qui déclare le workspace. Les paquets internes reçoivent un nom à portée comme @agent/core, où le préfixe @agent/ est une convention de nommage, non une fonctionnalité du langage. Chaque paquet déclare son point d’entrée public dans package.json. Cette frontière de paquet ne dépend pas de la commande qui démarre l’application.

Ce workspace privé fait pointer ces entrées vers des sources .ts, car chaque consommateur fait partie du même dépôt. Les paquets npm publics publient normalement du JavaScript accompagné de déclarations de types .d.ts, afin que les consommateurs Node ordinaires n’aient pas besoin du lanceur TypeScript ni de la configuration de build de l’auteur du paquet.


Écrire la boucle d’outils à la main, une seule fois

Une étape de runAgent. Elle appelle le modèle, lit le flux et émet un événement text par delta. Sans tool call, elle émet done avec stopReason stop. Sinon, chaque appel passe par la recherche de l’outil, JSON.parse, l’analyse Zod et execute ; une vérification échouée ou une ToolError devient un tool result avec ok false et un texte d’erreur, et toute autre exception quitte runAgent. La boucle émet tool_call et tool_result, ajoute les messages et lance l’étape suivante ; après maxSteps étapes, elle émet done avec stopReason max_steps.Une étape de runAgent. Elle appelle le modèle, lit le flux et émet un événement text par delta. Sans tool call, elle émet done avec stopReason stop. Sinon, chaque appel passe par la recherche de l’outil, JSON.parse, l’analyse Zod et execute ; une vérification échouée ou une ToolError devient un tool result avec ok false et un texte d’erreur, et toute autre exception quitte runAgent. La boucle émet tool_call et tool_result, ajoute les messages et lance l’étape suivante ; après maxSteps étapes, elle émet done avec stopReason max_steps.

La colonne de gauche est une étape du modèle. La colonne de droite montre ce que la boucle émet : hormis une exception qui quitte la boucle, ces événements sont tout ce que voient la route HTTP et les tests.

Les frameworks d’agents à tool calling enveloppent la même boucle de base :

  1. Appeler le modèle avec les définitions d’outils.
  2. Valider et exécuter les outils demandés.
  3. Ajouter les résultats aux messages.
  4. Rappeler le modèle.

Écrire la boucle une fois rend visibles ses chemins d’échec cachés.

La boucle est une async function*, un générateur asynchrone, exactement la forme d’un async def Python avec yield. La route HTTP itère sur ce générateur, transforme chaque événement en frame Server-Sent Events et accumule le texte. Une fois le flux terminé, la route appelle une seule fois storage.createRun avec le texte final. Les tests invoquent la boucle séparément et collectent ses événements dans un tableau. Une frame SSE est un fragment d’une réponse HTTP de longue durée. La section suivante explique le format.

La boucle a deux sorties normales. Une réponse sans tool call émet done avec stopReason: "stop". Si le modèle continue de demander des outils jusqu’à maxSteps, le générateur se termine tout de même et émet done avec stopReason: "max_steps". Considérez ce résultat comme terminé mais tronqué : le texte accumulé peut être partiel.

Chaque valeur émise par la boucle est un AgentEvent, une union étiquetée. Extrait abrégé de packages/schemas/src/events.ts :

export const AgentEventSchema = z.discriminatedUnion("type", [
    z.object({ type: z.literal("text"), delta: z.string() }),
    z.object({
        type: z.literal("tool_call"),
        callId: z.string(),
        toolName: z.string(),
        args: z.unknown(),
    }),
    z.object({
        type: z.literal("tool_result"),
        callId: z.string(),
        ok: z.boolean(),
        result: z.unknown() /* ... */,
    }),
    z.object({
        type: z.literal("done"),
        stopReason: z.enum(["stop", "max_steps"]),
        steps: z.number(),
        text: z.string(),
    }),
    z.object({ type: z.literal("error"), message: z.string() }),
]);

export type AgentEvent = z.infer<typeof AgentEventSchema>;

z.discriminatedUnion choisit la variante selon le champ type, comme un match sur des dataclasses étiquetées. Dans if (event.type === "text"), le compilateur sait que event.delta existe. Ajouter une variante ne fait pas automatiquement échouer un switch incomplet. Pour obtenir une erreur de compilation lorsqu’une variante manque, vérifiez la valeur restante contre never dans une branche default. Une règle de lint vérifiant l’exhaustivité peut aussi signaler les cas manquants. La route HTTP et la version avec l’AI SDK ci-dessous utilisent toutes deux ces types d’événement affinés.

Le corps de la boucle émet les événements de texte et assemble les tool calls à partir des fragments. Extrait de packages/agent-core/src/loop.ts :

let text = ""; // the assistant text produced during this model step
// Keyed by the `index` field, because a streamed response interleaves
// fragments of several parallel tool calls and only `index` is present
// on every fragment. `id` and `name` arrive once, `arguments` arrives
// in pieces. `a?.b` below reads `b` only if `a` exists, and gives back
// `undefined` instead of throwing if it doesn't.
const partial = new Map<number, PartialToolCall>();

for await (const chunk of stream) {
    const choice = chunk.choices[0];
    if (!choice) continue;

    if (choice.delta.content) {
        text += choice.delta.content;
        yield { type: "text", delta: choice.delta.content };
    }

    for (const fragment of choice.delta.tool_calls ?? []) {
        const slot = partial.get(fragment.index) ?? { id: "", name: "", args: "" };
        if (fragment.id) slot.id = fragment.id;
        if (fragment.function?.name) slot.name = fragment.function.name;
        if (fragment.function?.arguments) slot.args += fragment.function.arguments;
        partial.set(fragment.index, slot);
    }
}

La variable locale text de la boucle contient une étape du modèle. La boucle l’utilise dans le message de l’assistant pour l’étape suivante ou dans l’événement done final. Ce n’est pas l’accumulateur au niveau de la route qui est persisté ensuite.

La map partial est la partie que les frameworks cachent. Le SDK expose des fragments de chaîne des arguments de la fonction, et ils peuvent couper le JSON sérialisé à des positions arbitraires. Plusieurs appels parallèles peuvent aussi s’entrelacer. Le dépôt contient un test qui répartit {"model":"llama-3.1-8b",...} sur quatre chunks.

La figure suit ces quatre chunks jusqu’à l’entrée de la map pour l’index 0.

Quatre chunks reçus en flux, tous avec l’index 0. Le premier porte l’id c1 et le nom estimate_kv_cache ; les trois suivants portent des morceaux de la chaîne d’arguments, et la première coupure tombe à l’intérieur de la valeur llama-3.1-8b. L’entrée de la map assemble les morceaux, et ce n’est qu’après la fin du flux que JSON.parse transforme la chaîne en model llama-3.1-8b, seqLen 4096, batchSize 1.Quatre chunks reçus en flux, tous avec l’index 0. Le premier porte l’id c1 et le nom estimate_kv_cache ; les trois suivants portent des morceaux de la chaîne d’arguments, et la première coupure tombe à l’intérieur de la valeur llama-3.1-8b. L’entrée de la map assemble les morceaux, et ce n’est qu’après la fin du flux que JSON.parse transforme la chaîne en model llama-3.1-8b, seqLen 4096, batchSize 1.

La deuxième chose qui vaut la peine d’être écrite soi-même est ce qui se passe lorsque la validation échoue :

// `tool.parse` is this repo's wrapper, not Zod's. Zod's own `.parse` throws and
// `.safeParse` returns `{ success, data, error }`; this returns
// `{ ok: true, value }` on success and `{ ok: false, error }` on failure, so no
// caller has to catch.
const parsed = tool.parse(raw);
if (!parsed.ok) {
    return {
        ok: false,
        value: { error: `Invalid arguments: ${parsed.error}` },
        parsedArgs: raw,
    };
}

Avant l’exécution, la recherche peut ne pas trouver l’outil, JSON.parse peut rejeter les arguments, ou Zod peut rejeter leur forme. Chaque échec devient un message que lit le modèle. Pendant l’exécution, un ToolError attendu devient lui aussi un tool result, afin que le modèle puisse corriger son appel. Une exception inattendue remonte jusqu’au chemin d’erreur HTTP au lieu d’être présentée comme un échec du domaine. z.prettifyError transforme l’arbre d’erreurs de Zod en un message exploitable par le modèle, plutôt qu’en trace d’appels.

strict: true sur une définition de fonction OpenAI demande au fournisseur de contraindre le décodage au schéma. Cela n’a aucun rapport avec l’option strict de tsconfig dans TypeScript. C’est comparable au guided decoding de vLLM, même si les schémas pris en charge et les détails d’application diffèrent. Cela supprime un mode d’échec, mais un endpoint auto-hébergé peut ignorer l’option. Les arguments doivent en outre passer par JSON.parse.

La boucle appelle /chat/completions parce que le dépôt compagnon cible des serveurs compatibles OpenAI. vLLM, SGLang et Ollama documentent cet endpoint, donc OPENAI_BASE_URL peut pointer le même client vers n’importe lequel d’entre eux. Leur couverture de la Responses API diffère et change selon les versions. Si vous maîtrisez les deux côtés, consultez la page de compatibilité actuelle du serveur avant de choisir entre les deux API.


Passez ensuite à l’AI SDK, en sachant ce que vous échangez

Le dépôt compagnon implémente aussi le même agent avec le Vercel AI SDK, pour comparer les deux versions. Elles produisent toutes deux des valeurs AgentEvent, mais leur comportement lors d’une erreur terminale diffère actuellement. L’adaptateur SDK optionnel produit un événement error avec String(part.error) et se termine sans done. La route HTTP actuelle transmet ce détail et enregistre l’exécution comme succeeded, car le générateur s’est terminé sans lever d’exception. Avant de remplacer la boucle directe par défaut par cet adaptateur, traitez error ou un flux qui se ferme sans done comme une exécution en échec, assainissez les messages d’erreur destinés au client et testez le statut enregistré avec l’événement terminal.

Le dépôt compagnon utilise l’AI SDK 7. Son implémentation avec le framework se trouve dans packages/agent-core/src/loop-ai-sdk.ts :

const result = streamText({
    model: provider.chatModel(options.model),
    prompt: options.message,
    tools: aiSdkTools,          // Zod schemas passed straight through
    stopWhen: stepCountIs(options.maxSteps ?? 6),
});

for await (const part of result.fullStream) {
    switch (part.type) {
        case "text-delta": yield { type: "text", delta: part.text }; break;
        case "tool-call":  yield { type: "tool_call", callId: part.toolCallId, /* ... */ }; break;
        // ...
    }
}

Le SDK supprime cinq éléments de code applicatif :

  • l’accumulateur de fragments
  • JSON.parse et son chemin d’erreur
  • l’appel qui exécute Zod sur les arguments analysés
  • l’assemblage des messages propre à chaque fournisseur
  • le compteur d’étapes

stopWhen accepte plusieurs conditions, dont une limite d’étapes ou un tool call précis. La boucle for await ne change pas lorsque la politique d’arrêt change.

Ce que vous perdez, c’est le contrôle direct de l’échec de validation. La boucle écrite à la main décide de ce que le modèle voit après un appel rejeté. Le dépôt compagnon ne définit pas l’option repairToolCall du SDK ; il utilise donc le comportement par défaut : si plus d’une étape est autorisée, l’AI SDK 7 renvoie l’appel en échec au modèle à l’étape suivante. Passez un callback repairToolCall pour corriger l’appel vous-même, par exemple avec un second appel au modèle.

L’échange joue aussi dans l’autre sens. Dans la version SDK, un changement de fournisseur reste localisé dans l’adaptateur du fournisseur. Il exige toujours le paquet du fournisseur correspondant, les identifiants, la configuration et des tests d’intégration. Dans la boucle brute, la gestion des requêtes et des flux propre à chaque fournisseur est du code que vous devez modifier.

Pour un nouveau service, commencez par le SDK, sauf si vous devez contrôler ces chemins d’échec. La version écrite à la main vous donne une petite implémentation à inspecter et à comparer avec ce choix.


Streaming HTTP : Hono et SSE

Les routes Hono se lisent comme des routes FastAPI. Le seul ajout est zValidator, qui fait ce que FastAPI obtient gratuitement grâce aux annotations de type de la signature d’un handler. Le c du handler ci-dessous est le contexte de requête de Hono, l’objet que FastAPI répartit entre vos paramètres. deps est un ensemble de dépendances avec lequel l’application est construite au lieu de les importer directement. runAgent en fait partie, et la section sur les tests montre ce que cela apporte.

Extrait abrégé de apps/api/src/app.ts :

app.post("/v1/chat", zValidator("json", ChatRequestSchema), (c) => {
    const body = c.req.valid("json");
    const log = deps.logger.child({ route: "chat" });

    return streamSSE(c, async (stream) => {
        let text = "";
        let done: Extract<AgentEvent, { type: "done" }> | undefined;
        try {
            // The full route wraps this loop in a `withSpan` tracing span.
            for await (const event of deps.runAgent({
                message: body.message,
                maxSteps: body.maxSteps,
            })) {
                if (event.type === "text") text += event.delta;
                if (event.type === "done") done = event;

                await stream.writeSSE({
                    event: event.type,
                    data: JSON.stringify(event),
                });
            }
        } catch (error) {
            log.error({ err: error }, "agent run failed");
            await stream.writeSSE({
                event: "error",
                data: JSON.stringify({
                    type: "error",
                    message: "Agent run failed",
                }),
            });
            return;
        }

        await deps.storage.createRun({
            kind: "chat",
            status:
                done?.stopReason === "max_steps" ? "truncated" : "succeeded",
            input: { message: body.message },
            output: { text },
        });
    });
});

La route conserve l’événement done final. Une boucle qui s’arrête à max_steps se termine normalement ; la route la stocke donc comme truncated, et non succeeded.

zValidator valide le corps et donne à c.req.valid("json") le type que produit le schéma. Sans lui, le corps est typé any, l’échappatoire de TypeScript, où chaque accès à une propriété compile et où rien n’est vérifié. Cela annule l’intérêt du schéma pour la sûreté des types.

Cette route utilise Server-Sent Events plutôt que des WebSockets. Le serveur garde une réponse HTTP ouverte pendant qu’il écrit des frames event: <name> et data: <json>, puis la ferme après l’événement final. Le trafic va du serveur vers le client, ce qui correspond à ce flux d’agent. Un WebSocket ajouterait une messagerie bidirectionnelle et une montée de protocole dont cette route n’a pas besoin.

Avec la boucle directe par défaut, une exception en cours de flux modifie le signalement des erreurs HTTP. Une fois la première trame envoyée avec le statut 200, le serveur ne peut plus remplacer cette réponse par un 500. Le bloc catch journalise l’erreur interceptée, envoie au client un événement d’erreur constant et se termine. Ce retour saute storage.createRun : une erreur levée par le générateur ne laisse donc aucune ligne. Ce catch ne s’exécute pas lorsque l’adaptateur SDK optionnel produit un événement error et se termine. Pour conserver les exécutions en échec à des fins de débogage ou d’évaluation, écrivez une ligne failed dans le bloc catch avec le texte partiel.

La réponse à POST /v1/chat : le statut 200 part avec la première frame, puis viennent des frames text, tool_call et tool_result. Trois fins suivent : done avec stopReason stop enregistre l’exécution comme succeeded ; done avec stopReason max_steps l’enregistre comme truncated ; un générateur qui lève une exception envoie un événement error avec Agent run failed et n’enregistre aucune ligne. Le client voit le statut 200 dans les trois cas.La réponse à POST /v1/chat : le statut 200 part avec la première frame, puis viennent des frames text, tool_call et tool_result. Trois fins suivent : done avec stopReason stop enregistre l’exécution comme succeeded ; done avec stopReason max_steps l’enregistre comme truncated ; un générateur qui lève une exception envoie un événement error avec Agent run failed et n’enregistre aucune ligne. Le client voit le statut 200 dans les trois cas.

La figure suit une requête jusqu’à ses trois fins. Le code de statut est le même dans les trois cas : seule la dernière frame les distingue.

Un test couvre ce chemin. Un générateur émet un delta de texte, puis lève une exception. La réponse reste 200, et sa dernière frame est un événement error avec le message constant Agent run failed. Le logger conserve l’erreur capturée pour le diagnostic côté serveur. Tout client qui ne vérifie que le code de statut signale un succès sur une exécution en échec.

app.ts prend deux décisions plus modestes qui méritent une explication. Il traite /healthz comme un endpoint de liveness, donc cette route ne touche volontairement pas à Postgres. Un échec de liveness pendant une panne de base de données pourrait redémarrer toutes les instances sans réparer la dépendance. Ajoutez un contrôle de readiness distinct lorsque l’orchestrateur doit cesser d’acheminer du trafic vers une instance qui ne peut pas joindre Postgres. Les chemins d’erreur journalisent l’erreur capturée mais renvoient une chaîne constante. Renvoyer error.message dans le corps d’une réponse est la façon dont des chaînes de connexion finissent dans le navigateur de quelqu’un d’autre.


La partie façon Celery, sans Celery

Les longs jobs n’ont pas leur place dans un handler de requête. POST /v1/sweeps valide un modèle, une longueur de séquence et une liste de tailles de batch, insère une ligne queued et renvoie 202 avec l’identifiant de l’exécution. Le worker récupère la ligne, calcule la taille du KV cache pour chaque taille de batch et stocke le résultat. GET /v1/runs/:id le lit.

POST /v1/sweeps insère une ligne queued et renvoie 202. Un worker la récupère et la passe à running, avec lockedAt réglé sur l’heure de récupération ; elle finit en succeeded ou failed. Une frise en dessous montre le worker A qui exécute encore le balayage à la minute 5, lorsque le verrou est considéré comme périmé et que le worker B récupère à nouveau la même ligne.POST /v1/sweeps insère une ligne queued et renvoie 202. Un worker la récupère et la passe à running, avec lockedAt réglé sur l’heure de récupération ; elle finit en succeeded ou failed. Une frise en dessous montre le worker A qui exécute encore le balayage à la minute 5, lorsque le verrou est considéré comme périmé et que le worker B récupère à nouveau la même ligne.

La moitié haute montre les états par lesquels passe une ligne de balayage. La frise montre la reprise d’un verrou périmé, expliquée plus bas : après cinq minutes, un second worker récupère un job qui est encore en cours.

Il n’y a ici ni Redis ni BullMQ. PostgreSQL documente SKIP LOCKED pour les consommateurs multiples d’une table de type file d’attente. Cette clause offre à ce petit service une file at-least-once dans une seule table. Elle est transactionnelle avec le reste de vos écritures, et c’est un service de moins dans docker-compose.yml.

La requête de récupération dans packages/agent-core/src/db/storage.ts est la suivante :

const [candidate] = await tx
    .select({ id: runs.id })
    .from(runs)
    // The real query also picks up rows whose lock went stale; trimmed here.
    .where(and(eq(runs.kind, kind), eq(runs.status, "queued")))
    .orderBy(runs.createdAt)
    .limit(1)
    // `.for()` adds a locking clause; `{ skipLocked: true }` adds SKIP LOCKED.
    .for("update", { skipLocked: true });

La ligne est verrouillée pour la durée de la transaction, et tout worker concurrent qui exécute la même requête la saute au lieu de se bloquer. Deux récupérations simultanées ne reçoivent donc pas la même ligne non périmée. Un test d’intégration lance deux récupérations en même temps avec Promise.all et vérifie qu’elles renvoient des lignes différentes. La version naïve, SELECT ... LIMIT 1 suivi d’un UPDATE, échoue à ce test : les deux transactions lisent la même ligne avant que l’une n’écrive, et les deux démarrent donc le même job.

Deux panneaux de séquence avec des couloirs pour le worker A, la ligne 7, la ligne 8 et le worker B. Dans la récupération naïve, les deux workers sélectionnent la ligne 7 alors qu’elle est encore queued, puis la passent tous deux à running et exécutent le même job. Avec FOR UPDATE SKIP LOCKED, le worker A verrouille la ligne 7, le worker B la saute et verrouille la ligne 8, et chacun exécute un job différent.Deux panneaux de séquence avec des couloirs pour le worker A, la ligne 7, la ligne 8 et le worker B. Dans la récupération naïve, les deux workers sélectionnent la ligne 7 alors qu’elle est encore queued, puis la passent tous deux à running et exécutent le même job. Avec FOR UPDATE SKIP LOCKED, le worker A verrouille la ligne 7, le worker B la saute et verrouille la ligne 8, et chacun exécute un job différent.

Le panneau du haut est la situation de concurrence (race condition) que détecte le test ; celui du bas est la requête de récupération ci-dessus.

Il s’agit d’une exécution at-least-once : un job peut s’exécuter plus d’une fois. La requête complète récupère aussi une ligne running lorsque son verrou date de plus de cinq minutes, et rien ne renouvelle ce bail. Un job en cours qui dure plus de cinq minutes peut donc être récupéré deux fois. Rendez les jobs idempotents. Pour un travail long, ajoutez un heartbeat de bail ou fixez le seuil de verrou périmé au-dessus de la durée d’exécution maximale.

Ajoutez BullMQ lorsque vous avez besoin de jobs différés, de planifications répétées, de priorités, de limites de débit ou d’un tableau de bord. En Python, le passage équivalent va d’une table de base de données à Celery. Avant cela, Redis est un service de plus à exploiter et à surveiller.

Le worker revalide ce qu’il lit dans jsonb :

// The row was validated on the way in, but it has been through a database.
// A stored row can outlive the schema version that accepted it.
// The job is a batch-size sweep. Zod's own `.parse` throws; the poll loop
// catches that and marks the run failed.
const input = SweepRequestSchema.parse(run.input);

La suite de tests fournit aussi au worker une ligne de balayage dont seqLen est la chaîne "lots". Le worker fait échouer l’exécution et continue d’interroger la base, au lieu de planter et de retenter indéfiniment la même ligne invalide.

Le travail CPU révèle une autre contrainte de Node. Un callback synchrone s’exécute sur le thread de la boucle d’événements et n’est pas préempté. Une boucle for qui fait de l’arithmétique pendant deux secondes bloque pendant deux secondes chaque requête, chaque timer et chaque contrôle de liveness de ce processus. Une boucle serrée dans un async def bloque asyncio de la même façon. Les deux runtimes exigent de déporter explicitement le travail CPU.

await setTimeout(0) depuis node:timers/promises (le préfixe node: désigne la bibliothèque standard, donc node:timers est à Node ce que os est à Python) équivaut à await asyncio.sleep(0). Le balayage rend la main après chaque taille de batch, afin que le processus du worker puisse traiter les timers et les autres callbacks. Rendre la main ne rend pas le travail CPU parallèle. Les worker_threads de Node peuvent exécuter du JavaScript en parallèle. Pour du travail CPU Python pur sous le CPython habituel avec GIL activé, utilisez un pool de processus plutôt qu’un pool de threads. Ce service n’utilise ni l’un ni l’autre. Gardez les calculs numériques lourds en Python, là où vivent déjà les bibliothèques qui les soutiennent, et sortez-les de la boucle d’événements de l’API.


Un outil, trois consommateurs

EstimateKvCacheInput a trois consommateurs :

  • La boucle écrite à la main le convertit avec z.toJSONSchema.
  • L’AI SDK le reçoit tel quel.
  • Le serveur MCP publie sa forme.

Cette réutilisation explique l’existence de packages/schemas.

Extrait de apps/mcp/src/index.ts :

server.registerTool(
    "estimate_kv_cache",
    {
        description: "Estimate KV-cache VRAM in GiB for a served model...",
        // A Zod object schema keeps the map of fields you passed in on
        // `.shape`. This SDK wants that map, not the schema wrapped around it.
        inputSchema: EstimateKvCacheInput.shape,
    },
    async ({ model, seqLen, batchSize }) => {
        /* ... */
    },
);

await server.connect(new StdioServerTransport());

Deux détails comptent avant de connecter un client. Premièrement, un serveur démarré de cette façon utilise ses propres stdin et stdout pour communiquer avec le client. Chaque ligne est un message JSON-RPC. Un console.log égaré, l’équivalent JavaScript de print, corrompt alors un message. Le client se déconnecte avec une erreur d’analyse qui ne nomme aucun fichier. Envoyez plutôt tous les diagnostics vers stderr.

Deuxièmement, un échec du domaine doit renvoyer isError: true avec un message. Le modèle appelant peut alors corriger son appel, comme il le peut après des arguments d’outil invalides dans l’agent loop.

Vous pouvez piloter le serveur avec printf et un tube, ce qui vaut la peine d’être fait une fois avant de pointer un vrai client dessus :

printf '%s\n' \
  '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"probe","version":"1"}}}' \
  '{"jsonrpc":"2.0","method":"notifications/initialized"}' \
  '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"estimate_kv_cache","arguments":{"model":"llama-3.1-70b","seqLen":8192,"batchSize":4}}}' \
  | pnpm -s mcp   # -s suppresses pnpm's own output so only JSON-RPC comes back

Cette sonde utilise la même version de protocole que le README du dépôt compagnon. Pour un vrai client, utilisez le SDK plutôt que de maintenir des messages JSON-RPC à la main.


Tester un agent sans clé d’API

Vitest joue le rôle de pytest, mais la structure est différente. describe regroupe des tests liés. it et test définissent chacun un cas de test. test.each est proche de parametrize, beforeEach fournit la préparation de chaque test, vi.fn() crée une fonction mock et describe.skipIf saute un groupe sous condition.

Les tests d’agent reposent sur une décision : runAgent reçoit un client OpenAI en paramètre au lieu d’en construire un. Le faux client est un objet doté d’une méthode chat.completions.create qui renvoie un itérable asynchrone scripté :

function fakeClient(scripts: Chunk[][]): OpenAI {
    let call = 0;
    return {
        chat: {
            completions: {
                create: async () => {
                    const script = scripts[call++] ?? [];
                    // Defines an async generator and calls it on the same
                    // line, so `create` hands back something you can
                    // `for await` over, which is the shape a real streaming
                    // response has.
                    return (async function* () {
                        for (const chunk of script) yield chunk;
                    })();
                },
            },
        },
        // TypeScript refuses a direct cast between unrelated shapes, so you
        // cast through `unknown` first. This disables type checking for that
        // value, so keep it to test code.
    } as unknown as OpenAI;
}

Les tests répartissent une chaîne d’arguments JSON sur plusieurs chunks et gèrent deux tool calls dans une même réponse. Ils couvrent aussi un batchSize invalide, du JSON malformé, des noms d’outils inconnus et un modèle qui continue d’appeler des outils jusqu’à ce que maxSteps l’arrête. Le fichier de tests s’exécute en bien moins d’une seconde, sans réseau et sans clé.

Les tests d’intégration contre Postgres utilisent describe.skipIf(!process.env.DATABASE_URL), si bien que pnpm test fonctionne sur un clone neuf sans Postgres, et que la CI les active en fournissant la variable. Le dépôt compte 42 tests. Trente-huit s’exécutent sans Postgres ni clé d’API.


Journaliser des événements structurés avec Pino

Pino joue le même rôle que structlog : un objet JSON par ligne, des loggers enfants avec des champs liés et un masquage explicite des données sensibles. Le dépôt compagnon le configure dans packages/observability/src/logger.ts :

const log = pino({
    redact: {
        paths: [
            "req.headers.authorization",
            "apiKey",
            "OPENAI_API_KEY",
            "*.apiKey",
        ],
        censor: "[redacted]",
    },
});

Sans ce masquage, log.info({ req }, "...") peut copier un en-tête Authorization dans le backend de logs.


Tracer le travail applicatif avec des spans manuels

Le dépôt compagnon utilise OpenTelemetry pour trois spans au niveau applicatif : agent.run, agent.tool et worker.sweep. Il n’installe aucune instrumentation automatique de HTTP ni de Postgres. startTracing() dans packages/observability/src/tracing.ts crée un NodeSDK avec un exportateur de traces OTLP. Si OTEL_EXPORTER_OTLP_ENDPOINT est absent, le traçage reste désactivé.

Le travail lui-même est enveloppé par withSpan(), du même fichier :

return tracer.startActiveSpan(name, { attributes }, async (span) => {
    try {
        return await fn(span);
    } finally {
        span.end();
    }
});

JavaScript n’a pas de syntaxe de gestionnaire de contexte à la Python. Ici, le callback est le bloc qu’entourerait un gestionnaire de contexte Python. Le helper complet enregistre aussi les exceptions et définit le statut du span avant de relancer l’erreur.

Les spans automatiques HTTP et base de données sont une fonctionnalité à part. Ils exigent les paquets d’instrumentation correspondants et une initialisation avant le chargement des modules instrumentés. N’ajoutez cela que lorsque ces spans sont utiles, puis suivez la configuration du SDK Node d’OpenTelemetry pour connaître les versions exactes des paquets que vous déployez.


Livrer le monorepo dans Docker

Utilisez le Dockerfile fourni. Le conteneur démarre l’API avec le loader tsx. Vous n’avez pas à choisir ni à invoquer un lanceur TypeScript lorsque vous le déployez.

Le build utilise pnpm fetch pour que les téléchargements de dépendances restent en cache jusqu’à un changement du lockfile. Il utilise ensuite pnpm deploy pour copier l’API et ses dépendances de production dans un répertoire autonome. L’étape runtime s’exécute avec l’utilisateur node, sans privilèges root, et son CMD en forme exec permet à l’API de recevoir directement SIGTERM pour un arrêt propre.

Pourquoi le Dockerfile charge tsx

Node 24 peut exécuter un sous-ensemble limité de TypeScript en supprimant les annotations de type. Il ne vérifie pas les types du code et n’effectue pas les transformations que prend en charge un lanceur TypeScript complet. Les scripts du dépôt cachent ce détail. pnpm check effectue la vérification statique séparée.

Le conteneur révèle une autre limite. pnpm deploy copie les paquets du workspace sous node_modules, et Node refuse délibérément d’y supprimer le TypeScript (documentation TypeScript de Node). Sans loader, le conteneur échoue au démarrage avec ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING.

Le Dockerfile charge tsx, qui traite ces fichiers .ts avant que Node ne les exécute. Une équipe qui ne veut que des fichiers .js dans son image runtime peut ajouter une étape de compilation à la place. C’est une autre conception possible pour la production, pas une étape supplémentaire nécessaire pour exécuter ce dépôt compagnon.


Un parcours sur trois semaines

Les ingénieurs Python expérimentés peuvent sauter le contenu qui enseigne les variables et les boucles. Cette séquence se concentre sur ce qui diffère de Python. Faites l’exercice de construction de chaque ligne ; la lecture lui sert de support.

SemaineLireConstruire
1javascript.info : uniquement les modules, les promises et les objets. Gardez le guide JS de MDN comme référence.Réécrivez un CLI Python en TypeScript. Ajoutez un script package.json. Exécutez le script et pnpm typecheck.
1-2Lisez la référence tsconfig de TypeScript, les tutoriels gratuits de Total TypeScript et la documentation de Zod.Construisez un module de configuration validé par Zod et une union étiquetée. Après vérification de l’étiquette, le compilateur sait quelle variante contient le bloc.
2Lisez la documentation de Hono, Drizzle, Vitest et Biome.Construisez un proxy de streaming vers un endpoint compatible OpenAI, avec un journal adossé à Drizzle.
3Lisez la documentation de l’AI SDK et du SDK TypeScript de MCP.Construisez un agent à tool calling. Puis construisez un serveur MCP qui expose l’un de ses outils.

Commencez par les tutoriels gratuits de Total TypeScript. Ne payez pour le contenu avancé que si vous travaillez avec des génériques et des types conditionnels de niveau bibliothèque. Sautez tous les cours d’« introduction à JavaScript », et sautez tout ce qui est orienté React, sauf si le produit l’exige.

Pour un tour d’horizon des conventions de production, goldbergyoni/nodebestpractices est une large liste de contrôle maintenue par la communauté. Vérifiez les conseils qui touchent au comportement à l’exécution ou à la sécurité dans la documentation actuelle de Node.


Avant d’adapter le service

Les tests hors ligne exercent le flux de contrôle du service avec des réponses de modèle scriptées. Ils n’établissent pas avec quelle fiabilité un modèle réel mènera vos tâches à terme. Gardez cette distinction en tête lorsque vous remplacez les outils de démonstration par de vrais outils.

Validez chaque valeur qui entre de l’extérieur : l’environnement, le corps HTTP, les arguments d’outil produits par le modèle et les lignes lues dans jsonb.

ValeurVérifiée parEn cas d’échec
process.envEnvSchema, dans loadEnv() au démarrageerreur de démarrage ; le processus s’arrête
Corps HTTPChatRequestSchema, via zValidator400 ; le gestionnaire ne s’exécute jamais
Arguments d’outiltool.parse, après JSON.parseerreur d’outil renvoyée au modèle
runs.inputSweepRequestSchema, dans le worker à chaque récupérationexécution marquée failed ; le worker continue d’interroger

Définissez ensuite la politique d’achèvement : décidez si votre application accepte, retente ou fait relire les exécutions truncated, et testez ensemble l’événement et le statut stocké. Si des jobs peuvent durer plus longtemps que le seuil de verrou périmé, ajoutez un heartbeat de bail avant d’ajouter des effets de bord.

Une fois ces comportements explicites, ajoutez la ligne failed décrite dans la section sur le streaming, puis utilisez les exécutions en échec et tronquées pour construire une suite d’évaluation d’agent distincte. C’est là qu’il faut tester l’achèvement des tâches ; la suite hors ligne reste le contrôle le plus rapide pour détecter une panne du streaming, de la validation et de la persistance.


Références

Dépôt de démonstration

  • slavadubrov/typescript-agent-service - le monorepo utilisé tout au long de cet article : API Hono avec SSE, deux implémentations de l’agent loop, stockage Drizzle, worker, serveur MCP et 42 tests

Runtime et langage

Outillage

  • pnpm et installation de pnpm - gestionnaire de paquets, workspaces et mise en place
  • Biome - lint, formatage et tri des imports dans un seul binaire
  • Vitest - lanceur de tests sans configuration de transformation
  • Total TypeScript - tutoriels gratuits et parcours payant pour les types avancés

Bibliothèques

  • Zod - validation de schémas et inférence de types. La version 4 inclut z.toJSONSchema.
  • Hono - framework HTTP fondé sur les standards du Web
  • Drizzle ORM - ORM TypeScript orienté SQL avec migrations drizzle-kit
  • Documentation SELECT de PostgreSQL - la clause de verrouillage FOR UPDATE ... SKIP LOCKED
  • BullMQ - file adossée à Redis pour le cas où la table de base de données ne suffit plus

AI et agents

Conventions