
À 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.
bashL'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 :
tail -f ou une boucle infinie).cat /dev/urandom).Pour chacun, il nous faut une borne. Toutes vivent dans 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.
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étail | Pourquoi |
|---|---|
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 = .awake | Horloge monotone qui exclut le temps de suspension système. Pour un timeout, c'est ce qu'on veut. |
result.term est une union étiquetée | exited / 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/stderr | Le format est volontairement uniforme pour que le modèle apprenne la convention. |
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.
grepC'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 :
{
"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 :
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.
.gitignore, pas de regexUne 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".
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.
--help reflète l'état du mondeCinq 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.
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.
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.