
Dans le cinquième article, on a donné à Hypercode son premier outil : read. Avec un seul outil, l'agent pouvait inspecter mais pas agir. Ce qui suit lève la deuxième restriction : write (créer ou écraser un fichier) et edit (modifier précisément un endroit d'un fichier existant).
À la fin, on aura un agent capable de réaliser une tâche complète : « crée ce fichier, puis modifie celui-là ». C'est 80 % de ce qu'un assistant de coding doit savoir faire.
Le code reste sur github.com/alexisbchz/hypercode.
@embedFileAvant d'ajouter de nouveaux outils, on rapatrie une amélioration du Post 05. Le schéma JSON de read vivait en chaîne multi-ligne dans read.zig :
pub const parameters_json =
\\{"type":"object","properties":{"path":{"type":"string"}},"required":["path"]}
;
Lisible à 10 caractères près. À 100, illisible. Zig a @embedFile qui inline le contenu d'un fichier à la compilation. On déplace le schéma dans read.schema.json à côté du .zig :
{
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Path to the file, relative to the working directory."
}
},
"required": ["path"]
}
Et le .zig devient :
pub const parameters_json = @embedFile("read.schema.json");
Les octets du fichier sont incorporés au binaire à la compilation — zéro coût à l'exécution, syntax-highlighting JSON dans l'éditeur, validation par tout outil JSON externe. Convention pour tous les outils à venir : <nom>.zig + <nom>.schema.json côte à côte.
writepub const name = "write";
pub const description = "Write contents to a file, creating or overwriting it.";
pub const parameters_json = @embedFile("write.schema.json");
const Args = struct {
path: []const u8,
content: []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 args = parsed.value;
if (args.content.len > constants.tool_file_bytes_max) return error.FileTooLarge;
try std.Io.Dir.cwd().writeFile(io, .{ .sub_path = args.path, .data = args.content });
return std.fmt.allocPrint(
gpa,
"Wrote {d} bytes to {s}.",
.{ args.content.len, args.path },
);
}
C'est court parce que la stdlib fait le travail : Dir.writeFile crée le fichier (ou l'écrase) et écrit le contenu. La seule défense propre : borner la taille à tool_file_bytes_max (256 KiB), partagée avec read — un agent qui aurait besoin d'écrire un fichier de 10 MiB est probablement en train de faire n'importe quoi.
Le schéma :
{
"type": "object",
"properties": {
"path": { "type": "string", "description": "Path to the file." },
"content": { "type": "string", "description": "Bytes to write." }
},
"required": ["path", "content"]
}
editC'est le plus important des trois. Il prend trois arguments : path, old_string, new_string. Il lit le fichier, vérifie que old_string y apparaît exactement une fois, et le remplace par new_string.
L'invariant exactement une fois mérite d'être expliqué. Considérons un agent qui veut renommer une variable count en total dans un fichier où count apparaît cinquante fois. Un replace_all aveugle est dangereux — certains count sont des substrings (countdown, account) qu'il ne faut pas toucher. La solution : forcer le modèle à fournir assez de contexte pour identifier UN site précis. Pas deux. Une.
C'est le pattern qu'utilisent Claude Code, Codex, Cursor. Il oblige le modèle à raisonner sur le contexte avant d'éditer, et à itérer (lire, identifier le contexte unique, éditer) au lieu de bombarder le fichier.
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.old_string.len == 0) return error.OldStringEmpty;
std.debug.assert(args.old_string.len > 0);
const cwd = std.Io.Dir.cwd();
const buf = try gpa.alloc(u8, constants.tool_file_bytes_max);
defer gpa.free(buf);
const contents = try cwd.readFile(io, args.path, buf);
const first = std.mem.indexOf(u8, contents, args.old_string) orelse return error.OldStringNotFound;
if (std.mem.indexOfPos(u8, contents, first + 1, args.old_string) != null) {
return error.OldStringNotUnique;
}
const new_len = contents.len - args.old_string.len + args.new_string.len;
if (new_len > constants.tool_file_bytes_max) return error.FileTooLarge;
const out = try gpa.alloc(u8, new_len);
defer gpa.free(out);
@memcpy(out[0..first], contents[0..first]);
@memcpy(out[first..][0..args.new_string.len], args.new_string);
@memcpy(out[first + args.new_string.len ..], contents[first + args.old_string.len ..]);
try cwd.writeFile(io, .{ .sub_path = args.path, .data = out });
return std.fmt.allocPrint(
gpa,
"Edited {s} ({d} → {d} bytes).",
.{ args.path, contents.len, new_len },
);
}
Quatre paires d'assertions et de validations :
| Vérification | Pourquoi |
|---|---|
args.old_string.len == 0 → OldStringEmpty | Une chaîne vide matchrait à la position 0, et insérerait new_string au début. Refus net. |
indexOf → OldStringNotFound | Si le modèle a mal copié le texte cible (espace en trop, casse incorrecte), on lui rend la main avec une erreur explicite. |
indexOfPos(first+1) → OldStringNotUnique | L'invariant d'unicité. Si la chaîne apparaît deux fois, on refuse. |
new_len > tool_file_bytes_max → FileTooLarge | Pas d'expansion incontrôlée. |
L'écriture elle-même est un @memcpy triple : préfixe, nouvelle chaîne, suffixe. Pas de réallocation incrémentale, pas d'ArrayList. Une fonction qui tient sur un écran.
Le schéma :
{
"type": "object",
"properties": {
"path": { "type": "string" },
"old_string": {
"type": "string",
"description": "Exact text to replace. Must occur exactly once in the file."
},
"new_string": { "type": "string", "description": "Replacement text." }
},
"required": ["path", "old_string", "new_string"]
}
La description du champ old_string est cruciale : c'est ce que voit le modèle quand il décide comment formater son appel.
tools.zig gagne deux entrées et deux branches. Cinq lignes :
pub const definitions: []const Definition = &.{
.{ .name = read.name, .description = read.description, .parameters_json = read.parameters_json },
.{ .name = write.name, .description = write.description, .parameters_json = write.parameters_json },
.{ .name = edit.name, .description = edit.description, .parameters_json = edit.parameters_json },
};
pub fn run(...) ![]u8 {
if (std.mem.eql(u8, name, read.name)) return read.run(gpa, io, args_json);
if (std.mem.eql(u8, name, write.name)) return write.run(gpa, io, args_json);
if (std.mem.eql(u8, name, edit.name)) return edit.run(gpa, io, args_json);
return error.UnknownTool;
}
À six outils on aura probablement envie d'une table-driven dispatch (for (registry) |t|), mais à trois c'est encore plus clair en branches explicites.
./zig-out/bin/hypercode "Create a file at /tmp/hc-test.txt with the content 'hello, world.'"
→ write({"path": "/tmp/hc-test.txt", "content": "hello, world.\n"})
→ read({"path": "/tmp/hc-test.txt"})
I've created the file `/tmp/hc-test.txt` with the content `hello, world.`
(including the newline at the end). The file has been written successfully
and verified.
Le modèle a écrit, puis relu pour vérifier — comportement émergent intéressant : quand l'écriture a un outil de validation (read) à portée, l'agent l'utilise.
./zig-out/bin/hypercode "Edit /tmp/hc-test.txt and change 'world' to 'hypercode'."
→ read({"path": "/tmp/hc-test.txt"})
→ edit({"new_string": "hello, hypercode.", "old_string": "hello, world.", "path": "/tmp/hc-test.txt"})
→ read({"path": "/tmp/hc-test.txt"})
Done! I've edited `/tmp/hc-test.txt` and changed "world" to "hypercode".
Notez qu'au lieu de old_string: "world", le modèle a choisi "hello, world." — un anchor plus long. Sans qu'on le lui dise, il a compris que l'unicité demandait du contexte. C'est le pattern qui marche : le modèle apprend à donner assez de précision pour que l'outil accepte.
Erreur claire :
./zig-out/bin/hypercode "Edit /tmp/hc-test.txt: replace 'goodbye' with 'farewell'."
→ read({"path": "/tmp/hc-test.txt"})
The file doesn't contain the word "goodbye", so there's nothing to replace.
Le modèle a relu, a vu que la chaîne cible était absente, et a répondu intelligemment plutôt que de tenter l'édition.
--help reflète l'état du mondeUne ligne par outil :
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.
f37c8fc docs(main): list write and edit in --help
1653150 feat(tools): edit — unique-anchor in-place file edit
257063d feat(tools): write — create or overwrite a file
0fa7d4d refactor(constants): generalize tool_file_bytes_max
dfd8f12 refactor(tools): embed read parameters from read.schema.json
Cinq commits, cinq étapes lisibles. La constante a été généralisée avant d'introduire le deuxième utilisateur. C'est ce qu'on aime dans un git log propre : chaque évolution se lit en isolation.
L'agent peut maintenant toucher au système de fichiers : lire, écrire, modifier. Avec ces trois primitives, il sait déjà accomplir de vraies tâches — refactorer un module, créer un fichier de test, mettre à jour un README. Pas du tout impressionnant techniquement, mais c'est tout ce qu'un IDE-de-poche a besoin de savoir.
Dans le prochain article, on ajoute deux outils essentiels pour qu'Hypercode soit agentique au sens fort : bash (exécuter une commande dans le shell — avec un timeout strict) et grep (chercher dans la base de code). À ce moment-là, l'agent pourra observer son propre travail, vérifier que les tests passent, et corriger.
Bloqué ou envie de partager vos notes ? Rejoins le serveur Discord.