fr2026-05-13

Construire un agent de coding en Zig : bash et grep

Hypercode bash+grep illustration

À la fin du sixième article, Hypercode pouvait lire, écrire et modifier des fichiers. Suffisant pour toucher au code, insuffisant pour valider qu'on n'a rien cassé.

Cet article ajoute les deux outils qui ferment la boucle : bash (lancer une commande shell, donc zig build, npm test, pytest, etc.) et grep (chercher une chaîne récursivement dans la base de code).

Avec ces cinq outils — read, write, edit, bash, grep — on a un agent qui peut observer, éditer, et vérifier. C'est la triade qui définit un assistant de coding utilisable.

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

L'outil bash

L'enjeu de bash n'est pas la complexité du code — std.process.run fait 90 % du travail — c'est la discipline des limites. Une commande shell peut :

  • Tourner indéfiniment (un tail -f ou une boucle infinie).
  • Cracher un volume gigantesque sur stdout (un cat /dev/urandom).
  • Cracher la même chose sur stderr.

Pour chacun, il nous faut une borne. Toutes vivent dans constants.zig :

src/constants.zig
pub const bash_default_timeout_ms: u32 = 30_000;
pub const bash_timeout_ms_max: u32 = 120_000;
pub const bash_stdout_bytes_max: u32 = 64 * KiB;
pub const bash_stderr_bytes_max: u32 = 16 * KiB;

Le modèle peut demander un timeout plus court que le défaut, jamais plus long. C'est la règle « tout a une limite » de TigerStyle, appliquée au temps d'exécution.

src/tools/bash.zig
const Args = struct {
    command: []const u8,
    timeout_ms: ?u32 = null,
};

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 args = parsed.value;

    const requested = args.timeout_ms orelse constants.bash_default_timeout_ms;
    const timeout_ms = @min(requested, constants.bash_timeout_ms_max);

    const result = try std.process.run(gpa, io, .{
        .argv = &.{ "sh", "-c", args.command },
        .stdout_limit = .limited(constants.bash_stdout_bytes_max),
        .stderr_limit = .limited(constants.bash_stderr_bytes_max),
        .timeout = .{ .duration = .{
            .raw = .fromMilliseconds(@intCast(timeout_ms)),
            .clock = .awake,
        } },
    });
    defer gpa.free(result.stdout);
    defer gpa.free(result.stderr);

    const exit_code: i32 = switch (result.term) {
        .exited => |c| @intCast(c),
        .signal => |s| -@as(i32, @intCast(@intFromEnum(s))),
        .stopped, .unknown => -1,
    };

    return std.fmt.allocPrint(
        gpa,
        "exit: {d}\n--- stdout ---\n{s}\n--- stderr ---\n{s}",
        .{ exit_code, result.stdout, result.stderr },
    );
}

Quatre détails :

DétailPourquoi
sh -c <command>On confie l'interprétation au shell. Le modèle peut écrire des pipelines, des &&, des redirections — on n'a pas à parser.
.clock = .awakeHorloge monotone qui exclut le temps de suspension système. Pour un timeout, c'est ce qu'on veut.
result.term est une union étiquetéeexited / signal / stopped / unknown — quatre vraies issues d'un processus Unix. On les mappe vers un i32 signé (négatif = tué par signal).
Réponse formatée exit/stdout/stderrLe format est volontairement uniforme pour que le modèle apprenne la convention.

Note de sécurité

sh -c <command> exécute exactement ce que le modèle demande. Si le modèle décide d'écrire rm -rf ~/, il le fait. Pour Hypercode dans son état actuel, le contrat avec l'utilisateur est : tu fais confiance au modèle. Un futur article ajoutera une sandbox (probablement bwrap sur Linux, sandbox-exec sur macOS). Pas dans le scope de celui-ci.

L'outil grep

C'est la deuxième moitié du rituel : avant d'éditer, on cherche.

Le nom est trompeur — on ne fait pas de regex, juste une recherche de sous-chaîne. C'est ce que 90 % des modèles utilisent en pratique, et c'est bien plus rapide qu'un moteur regex à charger en mémoire.

Le format de sortie copie celui de ripgrep / git grep — path:line:content — pour que le modèle le reconnaisse :

src/tools/read.zig:13:pub fn run(gpa: std.mem.Allocator, io: std.Io, args_json: []const u8) ![]u8 {
src/tools/write.zig:16:pub fn run(gpa: std.mem.Allocator, io: std.Io, args_json: []const u8) ![]u8 {

Schéma simple :

src/tools/grep.schema.json
{
  "type": "object",
  "properties": {
    "pattern": { "type": "string", "description": "Literal substring to search for. No regex." },
    "path":    { "type": "string", "description": "Directory to search. Defaults to working directory." }
  },
  "required": ["pattern"]
}

L'implémentation utilise std.Io.Dir.Walker :

src/tools/grep.zig
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 args = parsed.value;

    if (args.pattern.len == 0) return error.PatternEmpty;

    var base = if (args.path) |p|
        try std.Io.Dir.cwd().openDir(io, p, .{ .iterate = true })
    else
        try std.Io.Dir.cwd().openDir(io, ".", .{ .iterate = true });
    defer base.close(io);

    var walker = try base.walk(gpa);
    defer walker.deinit();

    var out: std.Io.Writer.Allocating = .init(gpa);
    defer out.deinit();

    const file_buf = try gpa.alloc(u8, constants.tool_file_bytes_max);
    defer gpa.free(file_buf);

    var matches: u32 = 0;
    var files_scanned: u32 = 0;

    while (try walker.next(io)) |entry| {
        if (entry.kind != .file) continue;
        if (files_scanned >= constants.grep_files_scanned_max) break;
        files_scanned += 1;

        const contents = entry.dir.readFile(io, entry.basename, file_buf) catch continue;
        var line_num: u32 = 1;
        var cursor: usize = 0;
        while (cursor < contents.len) {
            const line_end = std.mem.indexOfScalarPos(u8, contents, cursor, '\n') orelse contents.len;
            const line = contents[cursor..line_end];
            if (std.mem.indexOf(u8, line, args.pattern)) |_| {
                try out.writer.print("{s}:{d}:{s}\n", .{ entry.path, line_num, line });
                matches += 1;
                if (matches >= constants.grep_matches_max) break;
            }
            cursor = line_end + 1;
            line_num += 1;
        }
        if (matches >= constants.grep_matches_max) break;
    }

    if (matches == 0) {
        return std.fmt.allocPrint(gpa, "no matches for '{s}' ({d} files scanned)", .{ args.pattern, files_scanned });
    }
    return gpa.dupe(u8, out.writer.buffer[0..out.writer.end]);
}

Deux bornes essentielles :

  • grep_files_scanned_max = 5000 — n'importe quelle base de code raisonnable est sous ce seuil. node_modules ne l'est pas, mais c'est le problème du node_modules.
  • grep_matches_max = 200 — au-delà, le modèle est noyé. On préfère lui dire « 200 matchs, raffine » plutôt que lui envoyer 5000 lignes.

entry.dir.readFile(io, entry.basename, file_buf) catch continue : si on tombe sur un fichier illisible (binaire qui dépasse 256 KiB, permissions denied), on saute. Pas d'erreur fatale.

Pas de .gitignore, pas de regex

Une implémentation propre suivrait .gitignore, skiprait .git/, node_modules/, target/, zig-out/. Ça vient peut-être plus tard. Pour cet article, on choisit la simplicité — l'utilisateur saura que dans un projet avec un node_modules lourd, il vaut mieux lui passer path: "src".

Test à chaud — la boucle code-bash-vérifier

Avec bash et grep, on peut maintenant demander à Hypercode quelque chose qui ressemble à du vrai travail.

./zig-out/bin/hypercode "Use grep to find where the constant tool_file_bytes_max is used, then run zig build to make sure everything compiles."
→ grep({"pattern": "tool_file_bytes_max"})
→ bash({"command": "./zig/zig build"})

`tool_file_bytes_max` is used in 4 places:
- src/constants.zig:5 (definition)
- src/tools/read.zig:18 (read tool buffer cap)
- src/tools/edit.zig:33 (edit tool read buffer)
- src/tools/edit.zig:39 (edit tool write check)
- src/tools/grep.zig:39 (grep file buffer cap)

The build succeeded (exit code 0). Everything compiles cleanly.

C'est l'instant où Hypercode passe de jouet à outil. Il sait localiser une référence, vérifier que ça compile, et raconter ce qui se passe.

Erreur shell :

./zig-out/bin/hypercode "Run 'false' via bash and tell me what happened."
→ bash({"command": "false"})

The command `false` returned a non-zero exit code (1) — by convention, this
indicates an error. There was no output to stdout or stderr. The command
`false` is designed specifically to always exit with a failure status,
typically used in shell scripting for conditional logic.

Le code retour est correctement remonté ; le modèle le comprend.

Le --help reflète l'état du monde

Cinq outils, cinq lignes :

Tools (always available to the model):
  read    Read the contents of a UTF-8 text file.
  write   Create or overwrite a file.
  edit    Replace one exact occurrence of a string in a file.
  bash    Run a shell command with a hard timeout.
  grep    Recursively search for a literal substring.

Les commits

2516735 feat(tools): grep — bounded recursive substring search
4222421 feat(tools): bash with hard timeout and output caps

Deux commits, deux outils. Aucun outil n'a besoin de comprendre l'autre — la dispatch table dans tools.zig les unit, c'est tout.

Conclusion

Cinq outils. Hypercode peut maintenant écrire du code, le chercher, et vérifier qu'il marche. On a quitté la catégorie « assistant qui parle » pour entrer dans la catégorie « agent qui agit ».

Mais l'expérience reste rugueuse : chaque tour utilisateur attend la fin du turn modèle complet, sans rien afficher entre temps. On voit → read({...}) puis silence pendant 5 secondes, puis la réponse arrive d'un bloc. C'est dommage parce que les modèles modernes streamment leur réponse token par token.

Dans le prochain article, on s'attaque enfin au streaming SSE. Le modèle parle, on imprime au fur et à mesure. C'est moins fondamental que les tools, mais c'est ce qui fait passer Hypercode du statut « demo » à « on a envie de l'utiliser ».

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