fr2026-05-13

Construire un agent de coding en Zig : les outils

Hypercode tools illustration

À la fin du quatrième article, on a écrit : « l'agent a maintenant de la mémoire ; mais il ne sait toujours rien faire d'autre que parler ». Cet article corrige ça. On lui donne sa première vraie capacité : lire un fichier.

À la fin, Hypercode pourra recevoir un prompt comme « lis src/main.zig et résume-le », déclencher un appel à l'outil read, recevoir le contenu, et formuler une réponse. C'est le moment où l'assistant devient un agent.

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

Le protocole tool-call

OpenRouter (et l'API OpenAI-compatible derrière) fonctionne en trois temps quand un modèle veut appeler un outil.

1. La requête — on déclare les outils disponibles dans le champ tools :

{
  "model": "...",
  "messages": [{ "role": "user", "content": "lis src/main.zig" }],
  "tools": [
    {
      "type": "function",
      "function": {
        "name": "read",
        "description": "Read the contents of a UTF-8 text file.",
        "parameters": {
          "type": "object",
          "properties": { "path": { "type": "string" } },
          "required": ["path"]
        }
      }
    }
  ]
}

2. La réponse — au lieu d'un content, le modèle renvoie des tool_calls :

{
  "choices": [{
    "message": {
      "tool_calls": [{
        "id": "call_abc",
        "type": "function",
        "function": {
          "name": "read",
          "arguments": "{\"path\":\"src/main.zig\"}"
        }
      }]
    }
  }]
}

arguments est une chaîne contenant du JSON encodé — pas un objet imbriqué. Un détail qui surprend la première fois.

3. Le tour suivant — on exécute l'outil et on renvoie le résultat sous une nouvelle forme de message :

{ "role": "tool", "tool_call_id": "call_abc", "content": "<contenu du fichier>" }

Le modèle voit le résultat, et soit il rappelle un outil, soit il produit une réponse finale en texte. On boucle jusqu'à ce qu'il produise du texte.

Repenser Message

L'ancienne structure de message (cf. Post 04) était :

pub const Message = struct {
    role: Role,
    content: ?[]const u8 = null,
    tool_calls: []const ToolCall = &.{},
    tool_call_id: ?[]const u8 = null,
};

Quatre champs, trois optionnels. Le compilateur n'empêche pas d'écrire .{ .role = .assistant, .content = "hi", .tool_calls = some_calls } — un message assistant à la fois texte et tool-call. Le wire format dit que c'est impossible, mais le type Zig ne le sait pas.

Au lieu de juggler des optionnels, on utilise une union étiquetée. Chaque variante a la forme correcte pour son rôle :

src/session.zig
pub const Message = union(enum) {
    user: []const u8,
    assistant_text: []const u8,
    assistant_tool_calls: []const ToolCall,
    tool_result: ToolResult,
};

pub const ToolResult = struct {
    tool_call_id: []const u8,
    content: []const u8,
};

pub const ToolCall = struct {
    id: []const u8,
    name: []const u8,
    arguments_json: []const u8,
};

Quatre variantes, l'étiquette de l'union est le rôle. Le compilateur force désormais à traiter chaque cas dans tous les switch — si on rajoute system un jour, on ne peut pas oublier de l'écrire.

L'enum Role est supprimé. Il faisait doublon avec la variante de l'union.

Une convention de propriété pour append_*

Avant, append faisait gpa.dupe du contenu en interne. Pratique, mais ça nous coûtait une double allocation quand les tool_calls arrivaient déjà alloués depuis openrouter.call.

Nouvelle règle : les méthodes append_* prennent possession des octets qu'on leur passe. Le caller pré-alloue ; la session libère sur deinit.

pub fn append_user(self: *Session, owned_content: []const u8) !void {
    errdefer self.gpa.free(owned_content);
    try self.messages.append(self.gpa, .{ .user = owned_content });
}

C'est cohérent pour toutes les variantes. Conséquence côté caller, quand on lit une ligne de stdin :

try session.append_user(try gpa.dupe(u8, trimmed));

Une seule allocation, un seul free. La friction est minime.

La table d'outils

Pour un seul outil, on n'a pas besoin d'un vtable, ni d'un registre fancy. Une table de déclarations + un switch pour le dispatch :

src/tools.zig
pub const Definition = struct {
    name: []const u8,
    description: []const u8,
    parameters_json: []const u8,
};

pub const definitions: []const Definition = &.{
    .{ .name = read.name, .description = read.description, .parameters_json = read.parameters_json },
};

pub fn run(gpa: std.mem.Allocator, io: std.Io, name: []const u8, args_json: []const u8) ![]u8 {
    if (std.mem.eql(u8, name, read.name)) return read.run(gpa, io, args_json);
    return error.UnknownTool;
}

Ajouter un outil = ajouter une entrée à definitions + une branche au switch. Quand on en a six, on refactorisera vers un dispatch table-driven. Pour l'instant, c'est verbeux mais lisible.

L'outil read

src/tools/read.zig
const std = @import("std");
const constants = @import("../constants.zig");

pub const name = "read";
pub const description = "Read the contents of a UTF-8 text file from the working directory.";

pub const parameters_json =
    \\{"type":"object","properties":{"path":{"type":"string"}},"required":["path"]}
;

const Args = struct { path: []const u8 };

pub fn run(gpa: std.mem.Allocator, io: std.Io, args_json: []const u8) ![]u8 {
    const parsed = try std.json.parseFromSlice(Args, gpa, args_json, .{ .ignore_unknown_fields = true });
    defer parsed.deinit();

    const buf = try gpa.alloc(u8, constants.read_tool_bytes_max);
    defer gpa.free(buf);

    const contents = try std.Io.Dir.cwd().readFile(io, parsed.value.path, buf);
    return gpa.dupe(u8, contents);
}

Trois choses méritent un mot.

ÉlémentRôle
parameters_json comme chaîneStocker le schéma JSON validé comme texte permet à openrouter.zig de l'embarquer brut dans la requête, sans le parser puis le re-stringifier.
constants.read_tool_bytes_maxBorne stricte de 256 KiB. Un fichier plus gros sera tronqué — l'API d'std.Io.Dir.readFile remplit le buffer fourni, point.
gpa.dupe(u8, contents)contents pointe dans buf qu'on libère ; on en fait une copie possédée par le caller.

Tous les bornages vivent dans src/constants.zig, en un seul endroit auditable.

Écrire la requête JSON à la main

Avant le tool-call, on avait :

const payload = try std.json.Stringify.valueAlloc(gpa, req, .{});

Pratique, mais pour les outils il faut écrire le parameters du schéma comme du JSON brut (pas un struct Zig). Donc on construit le body explicitement avec l'API streaming de std.json.Stringify :

src/openrouter.zig
fn write_request_body(
    writer: *std.Io.Writer,
    model: []const u8,
    messages: []const session.Message,
    tool_defs: []const tools.Definition,
) !void {
    var s: std.json.Stringify = .{ .writer = writer };

    try s.beginObject();
    try s.objectField("model");
    try s.write(model);

    try s.objectField("messages");
    try s.beginArray();
    for (messages) |m| try write_message(&s, m);
    try s.endArray();

    if (tool_defs.len > 0) {
        try s.objectField("tools");
        try s.beginArray();
        for (tool_defs) |d| try write_tool_def(&s, d);
        try s.endArray();
    }

    try s.endObject();
}

Pour chaque outil, on appelle beginWriteRaw autour du schéma — ce qui dit à Stringify : « ce qui suit est déjà du JSON valide, n'échappe rien ». Sans ça, le schéma serait stringifié et finirait dans le payload comme une chaîne.

fn write_tool_def(s: *std.json.Stringify, d: tools.Definition) !void {
    try s.beginObject();
    try s.objectField("type");        try s.write("function");
    try s.objectField("function");
    try s.beginObject();
    try s.objectField("name");        try s.write(d.name);
    try s.objectField("description"); try s.write(d.description);
    try s.objectField("parameters");
    try s.beginWriteRaw();
    try s.writer.writeAll(d.parameters_json);
    s.endWriteRaw();
    try s.endObject();
    try s.endObject();
}

write_message fait un switch exhaustif sur l'union Message — quatre cas, quatre formes JSON différentes. Le compilateur force la complétude.

Décoder la réponse

Les types miroirs du wire OpenRouter sont nommés explicitement (plutôt qu'imbriqués anonymement comme avant) :

const WireFunctionCall = struct {
    name: []const u8,
    arguments: []const u8,
};

const WireToolCall = struct {
    id: []const u8,
    type: []const u8 = "function",
    function: WireFunctionCall,
};

const WireMessage = struct {
    content: ?[]const u8 = null,
    tool_calls: ?[]const WireToolCall = null,
};

const Choice = struct { message: WireMessage };
const Response = struct { choices: []const Choice };

Un type, un rôle. Si OpenRouter ajoute un champ à message, on l'ajoute dans WireMessage et nulle part ailleurs.

Le Result que la fonction openrouter.call retourne devient une union plus riche :

pub const Result = union(enum) {
    text: []const u8,                  // réponse finale
    tool_calls: []const ToolCall,      // le modèle veut un outil
    network_error,
    http_status: u16,
    bad_response,
};

La boucle de l'agent

C'est ici qu'Hypercode passe du chat au agent. La fonction answer boucle : on appelle le modèle, si on reçoit .text on a fini, si on reçoit .tool_calls on les exécute et on rappelle.

src/main.zig
fn answer(...) !void {
    var i: u8 = 0;
    while (i < constants.tool_iterations_max) : (i += 1) {
        const result = try openrouter.call(gpa, io, cfg.api_key, cfg.model, session.messages.items);
        switch (result) {
            .text => |text| {
                try stdout.writeAll(text);
                try stdout.writeAll("\n");
                try stdout.flush();
                try session.append_assistant_text(text);
                return;
            },
            .tool_calls => |calls| {
                try session.append_assistant_tool_calls(calls);
                for (calls) |c| try run_one_tool(gpa, io, session, stderr, c);
            },
            .network_error => fail(stderr, "could not reach {s}", .{openrouter.endpoint}),
            .http_status => |code| fail(stderr, "{s} returned HTTP {d}", .{ openrouter.endpoint, code }),
            .bad_response => fail(stderr, "unexpected response shape from {s}", .{openrouter.endpoint}),
        }
    }
    fail(stderr, "agent loop exceeded {d} iterations", .{constants.tool_iterations_max});
}

La borne tool_iterations_max = 16 (dans constants.zig) tue les boucles infinies — si le modèle décide pour une raison quelconque d'appeler read indéfiniment, on l'arrête. C'est la règle « tout a une limite » de TigerStyle.

L'exécution d'un appel se fait dans run_one_tool :

fn run_one_tool(
    gpa: std.mem.Allocator,
    io: std.Io,
    session: *session_mod.Session,
    stderr: *Io.Writer,
    c: session_mod.ToolCall,
) !void {
    try stderr.print("→ {s}({s})\n", .{ c.name, c.arguments_json });
    try stderr.flush();

    const id = try gpa.dupe(u8, c.id);
    if (tools.run(gpa, io, c.name, c.arguments_json)) |out| {
        try session.append_tool_result(id, out);
    } else |err| {
        try stderr.print("× {s} failed: {s}\n", .{ c.name, @errorName(err) });
        try stderr.flush();
        const msg = try std.fmt.allocPrint(gpa, "tool '{s}' failed: {s}", .{ c.name, @errorName(err) });
        try session.append_tool_result(id, msg);
    }
}

Diagnostics sur stderr

Volontairement, les annonces → read(...) et × ... failed: ... vont sur stderr, pas stdout. Conséquence :

hypercode "lis src/main.zig et résume-le" > answer.txt

answer.txt contient uniquement la réponse du modèle. Les diagnostics restent dans le terminal. Quand on veut tout dans le fichier : ... > answer.txt 2>&1.

C'est la convention POSIX et c'est ce qui rend les outils composables.

Démonstration

./zig-out/bin/hypercode "Read the file src/main.zig and tell me in one sentence what its main function does."
→ read({"path": "src/main.zig"})
The `main` function initializes I/O buffers, parses CLI arguments, resolves
the configuration, and then either runs a single-shot answer loop or an
interactive REPL session that queries an LLM via OpenRouter, allowing it
to call tools like file reading in a loop until completion.

Le tour de magie : le modèle a reconnu la demande (« read the file »), a généré un tool_call avec les bons arguments JSON, on l'a exécuté, on lui a renvoyé le contenu, et il a synthétisé une réponse — tout ça dans un seul appel utilisateur, deux allers-retours réseau.

Erreur :

./zig-out/bin/hypercode "Read the file does-not-exist.zig"
→ read({"path": "does-not-exist.zig"})
× read failed: FileNotFound
The file does-not-exist.zig does not exist in the current working directory.

L'erreur est visible sur stderr ; on l'envoie aussi au modèle, qui en informe l'utilisateur proprement au lieu de planter.

Les commits

Deux commits, deux couches :

ba615ac feat(agent): tool-call protocol end-to-end
406af97 feat(tools): central limits + Read tool with simple dispatch

Le premier ajoute constants.zig, tools.zig, et tools/read.zig — du code isolé qui ne casse rien. Le second fait basculer session.zig, openrouter.zig et main.zig ensemble pour parler le protocole tool-call. C'est un commit plus gros parce que les changements sont atomiques — séparer ne ferait que créer des états intermédiaires qui ne compilent pas.

Conclusion

Hypercode est désormais un agent. Pas très puissant, parce qu'il n'a qu'un outil — mais le mécanisme est en place : tous les outils suivants ne sont qu'une entrée dans tools.zig plus un <nom>.zig à côté.

Dans le prochain article, on ajoute deux outils essentiels : write (créer un fichier) et edit (modifier précisément une portion d'un fichier existant). À deux outils près, on a déjà 80 % d'un assistant de coding fonctionnel.

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