fr2026-05-13

Construire un agent de coding en Zig : la conversation

Hypercode conversation illustration

Dans le troisième article, on a fait parler Hypercode. Une question, une réponse. Pas de mémoire entre les deux. Cet article ajoute la couche qui transforme un coup unique en conversation : un Session qui retient l'historique, et une boucle REPL qui enchaîne les tours.

Le code reste sur github.com/alexisbchz/hypercode.

Pourquoi le multi-turn ?

Tous les agents de coding que vous utilisez fonctionnent au tour par tour. Vous demandez "fix this", l'agent corrige ; vous dites "no, the previous version was better", l'agent comprend "previous" en référence à ce qui vient d'être discuté. Sans mémoire, "previous" ne veut rien dire.

Techniquement, c'est trivial : on garde la liste des messages échangés et on l'envoie en entier à chaque appel. OpenAI, Anthropic, OpenRouter — tous attendent un tableau messages, pas un prompt unique. On a déjà cette forme dans openrouter.zig, on n'utilise qu'une seule entrée.

La forme : Session

Un nouveau fichier, src/session.zig :

src/session.zig
const std = @import("std");

pub const Role = enum { user, assistant };

pub const Message = struct {
    role: Role,
    content: []const u8,
};

pub const Session = struct {
    gpa: std.mem.Allocator,
    messages: std.ArrayList(Message),

    pub fn init(gpa: std.mem.Allocator) Session {
        return .{ .gpa = gpa, .messages = .empty };
    }

    pub fn deinit(self: *Session) void {
        for (self.messages.items) |m| self.gpa.free(m.content);
        self.messages.deinit(self.gpa);
    }

    pub fn append(self: *Session, role: Role, content: []const u8) !void {
        const owned = try self.gpa.dupe(u8, content);
        errdefer self.gpa.free(owned);
        try self.messages.append(self.gpa, .{ .role = role, .content = owned });
    }
};

Quatre décisions méritent un mot.

DécisionRaison
Role est un enum, pas un []const u8Le compilateur empêche .{ .role = "useer" }. Erreur impossible.
append copie le contenu via gpa.dupeLe caller peut libérer sa source. Pas de durée de vie partagée.
errdefer après le dupeSi l'append à l'ArrayList échoue (OOM), on libère la copie. Pas de fuite.
std.ArrayList(Message) plutôt qu'un buffer statiquePour l'instant. On migrera vers une allocation statique quand on saura combien de messages au max — pour le moment, on prototype.

Refactoriser openrouter.call

Avant, call prenait un seul prompt. Maintenant, un tableau de messages. La structure interne Message devient pub.

src/openrouter.zig
pub const Message = struct {
    role: []const u8,
    content: []const u8,
};

pub fn call(
    gpa: std.mem.Allocator,
    io: std.Io,
    api_key: []const u8,
    model: []const u8,
    messages: []const Message,
) !Result {
    const req = Request{ .model = model, .messages = messages };
    // ... reste identique ...
}

Notez que openrouter.Message.role est un []const u8 (parce que c'est ce que JSON attend sur le câble), tandis que session.Message.role est un Role enum. C'est volontaire : la couche réseau utilise les chaînes du protocole, la couche métier utilise des types Zig sûrs. La traduction se fait au point de transition, dans main.

Le flag -i / --interactive

src/cli.zig
pub const Args = struct {
    help: bool = false,
    version: bool = false,
    interactive: bool = false,
    model: ?[:0]const u8 = null,
    api_key: ?[:0]const u8 = null,
    prompt: ?[:0]const u8 = null,
};

Et la branche dans le parseur :

} else if (std.mem.eql(u8, arg, "-i") or std.mem.eql(u8, arg, "--interactive")) {
    out.interactive = true;

Dans config.resolve, le prompt n'est plus obligatoire si --interactive :

src/config.zig
if (!args.interactive and args.prompt == null) return .no_prompt;

En mode interactif, le premier message viendra de stdin. Le prompt positionnel reste accepté — il devient le premier message si fourni.

La boucle REPL dans main

C'est le morceau qui fait basculer Hypercode d'un binaire de coup unique vers un agent en mode conversation.

src/main.zig
const gpa = init.gpa;
var session = session_mod.Session.init(gpa);
defer session.deinit();

if (cfg.prompt) |p| try session.append(.user, p);

if (cfg.interactive) {
    try repl(gpa, io, cfg, &session, stdout, stderr);
} else {
    try one_turn(gpa, io, cfg, &session, stdout, stderr);
}

one_turn factorise ce qu'on faisait dans Post 03 — un seul appel modèle. La nouveauté : il append la réponse au session.

fn one_turn(...) !void {
    const wire = try to_wire(gpa, session.messages.items);
    defer gpa.free(wire);

    const result = try openrouter.call(gpa, io, cfg.api_key, cfg.model, wire);
    switch (result) {
        .ok => |text| {
            defer gpa.free(text);
            try stdout.writeAll(text);
            try stdout.writeAll("\n");
            try stdout.flush();
            try session.append(.assistant, text);
        },
        // ... erreurs comme avant ...
    }
}

to_wire traduit []session_mod.Message (avec enum Role) en []openrouter.Message (avec strings) :

fn to_wire(
    gpa: std.mem.Allocator,
    messages: []const session_mod.Message,
) ![]const openrouter.Message {
    const wire = try gpa.alloc(openrouter.Message, messages.len);
    for (messages, 0..) |m, i| {
        wire[i] = .{ .role = @tagName(m.role), .content = m.content };
    }
    return wire;
}

@tagName(m.role) donne "user" ou "assistant". Trois lignes pour relier les deux mondes.

La boucle elle-même

fn repl(...) !void {
    // Si un prompt seed est passé en CLI, on y répond d'abord.
    if (session.messages.items.len > 0) try one_turn(gpa, io, cfg, session, stdout, stderr);

    var stdin_buffer: [4096]u8 = undefined;
    var stdin_reader: Io.File.Reader = .init(.stdin(), io, &stdin_buffer);
    const stdin = &stdin_reader.interface;

    while (true) {
        try stdout.writeAll("> ");
        try stdout.flush();

        const raw = stdin.takeDelimiterInclusive('\n') catch |err| switch (err) {
            error.EndOfStream => {
                try stdout.writeAll("\n");
                try stdout.flush();
                return;
            },
            else => return err,
        };
        const trimmed = std.mem.trim(u8, raw, " \t\r\n");
        if (trimmed.len == 0) continue;
        if (std.mem.eql(u8, trimmed, "/quit") or std.mem.eql(u8, trimmed, "/exit")) return;

        try session.append(.user, trimmed);
        try one_turn(gpa, io, cfg, session, stdout, stderr);
    }
}

Une boucle de cinq lignes utiles, le reste c'est de l'ergonomie :

  • > comme prompt visuel
  • /quit ou /exit ou Ctrl-D pour sortir
  • les lignes vides sont ignorées
  • les espaces/CR/LF sont rognés

Un piège qui m'a coûté trente minutes

J'ai d'abord utilisé takeDelimiterExclusive('\n'). Le binaire est entré dans une boucle infinie après le premier tour, imprimant > à 98 % de CPU.

En lisant le source de la stdlib (zig/lib/std/Io/Reader.zig), le bug devient évident : takeDelimiterExclusive ne consomme pas le délimiteur. Il retourne le contenu jusqu'au \n, puis toss(result.len) — mais result.len n'inclut pas le \n. Le \n reste dans le stream. Au prochain appel, on lit "" devant le \n qui n'a jamais été consommé, et on boucle.

La doc dit "advancing the seek position past the delimiter". Le code dit l'inverse. La doc ment.

Le fix : takeDelimiterInclusive('\n') puis std.mem.trim pour retirer le \n final. C'est ce qu'on fait ici.

C'est le genre de truc que la zéro dépendance nous oblige à comprendre — pas de wrapper qui masque le problème. La stdlib est l'autorité ; quand elle se trompe, on plonge dans le code source.

Pull les tests transverses

main.zig ajoute session.zig à la liste d'imports de tests :

test {
    _ = @import("cli.zig");
    _ = @import("config.zig");
    _ = @import("openrouter.zig");
    _ = @import("session.zig");
}
./zig/zig build test --summary all
Build Summary: 3/3 steps succeeded; 11/11 tests passed

La démo

./zig-out/bin/hypercode -i
> My favorite color is blue.
That's a great choice! Blue is such a versatile color — it can be calm and
soothing like a clear sky, or deep and mysterious like the ocean. Do you have
a particular shade of blue you like best?

> What color did I just say?
You said your favorite color is blue! I remember because you mentioned it in
your first message, and it's a wonderful choice — blue is such a calming and
refreshing color.

> /quit

Le modèle se souvient. Pas par magie : à chaque tour, on lui envoie l'historique complet. Le coût en tokens monte à chaque échange — c'est la première vraie limite qu'on devra adresser plus tard avec une stratégie de compression ou de fenêtre glissante.

Le mode coup unique fonctionne toujours :

./zig-out/bin/hypercode "say hi in 3 words"
Hi there! 😊

Les commits

Quatre commits, quatre couches :

9097f69 feat(main): REPL loop wires session + openrouter across turns
165d9c7 feat(cli): -i/--interactive makes prompt optional
c817d53 refactor(openrouter): call takes a message slice, not a single prompt
62e6fab feat(session): conversation state with role+content messages

git show 62e6fab montre uniquement la structure de session. git show c817d53 montre la refacto du protocole. La séparation reste lisible.

Conclusion

L'agent a maintenant de la mémoire. On peut lui parler comme à un collègue qui suit le fil de la discussion. Mais il ne sait toujours rien faire d'autre que parler — il ne peut pas lire un fichier, lancer un test, modifier du code.

Dans le prochain article, on lui donne sa première vraie capacité : un outil. On définit le protocole tool-call d'OpenRouter, on construit un dispatcher, et on lui branche un outil Read qui lui permet de lire les fichiers de l'utilisateur. À partir de là, Hypercode devient un agent.

Bloqué ou envie de partager vos notes ? Rejoins le serveur Discord.