Construire un harness agent-first avec les custom tools OpenCode

Deep Dive · 6 min de lecture
🇬🇧 Cet article est aussi disponible en English

L’agent lit le code source. Il comprend le bug. Il propose un fix. Il commit. Le bug est toujours là.

Ce n’est pas un problème de modèle — c’est un problème d’outillage. Sans accès au runtime, l’agent raisonne comme un dev enfermé dans son éditeur : il peut corriger ce qu’il voit dans les fichiers. Pas ce qui se passe dans le navigateur.

Sur un blog Astro, ça se traduit par des situations très concrètes : une erreur Mermaid qui n’apparaît qu’au runtime dans le browser, un composant MDX qui casse le rendu d’une page spécifique, une régression de performance invisible sans mesure. L’agent peut inspecter le MDX source et ne rien trouver. La page est cassée quand même.

La solution n’est pas de mieux expliquer le problème dans le prompt. C’est de donner à l’agent les outils pour le constater lui-même.

Le problème : un agent sans yeux ni mains

Un agent en boucle de correction typique ressemble à ça : lire les fichiers, modifier le code, commit, attendre le feedback humain. Le feedback humain, c’est le seul signal qu’il a sur l’effet réel de ses modifications. Tout le reste est de l’inférence depuis le code source.

Ce modèle a deux défauts structurels.

Le premier : l’agent ne peut pas distinguer “le code est correct” de “l’application fonctionne”. Un composant peut être syntaxiquement valide, type-safe, sans aucune erreur au build — et produire une page blanche à cause d’un {} non escapé dans un bloc Mermaid qui déclenche une erreur acorn au runtime. Le build ne le voit pas. L’agent non plus, s’il n’a que le build.

Le second : la boucle de feedback est humaine. L’agent corrige, l’humain vérifie, l’humain rapporte. Pour chaque cycle. Sur un blog avec douze articles et cinq composants custom, ça représente beaucoup de cycles.

Ce qu’on veut, c’est une boucle où l’agent peut lui-même vérifier que ses corrections fonctionnent. Pas en lisant le code — en exécutant l’application, en naviguant les pages, en capturant les erreurs. Un feedback loop autonome.

Architecture du harness : .opencode/tools/

OpenCode supporte les custom tools via un répertoire .opencode/tools/. Chaque fichier TypeScript dans ce répertoire expose des tools que l’agent peut appeler directement. La convention de nommage est <fichier>_<export> : un outil start dans blog_dev.ts devient blog_dev_start dans la liste des tools disponibles.

Trois fichiers, quinze tools, un agent. C’est l’architecture du harness :

graph TD
    subgraph project[Projet Astro]
        subgraph tools[".opencode/tools/"]
            DEV["blog_dev.ts<br/>start · stop · status<br/>logs · http_get"]
            BUILD["blog_build.ts<br/>run · lint"]
            BROWSER["blog_browser.ts<br/>screenshot · perf · js_errors"]
        end
        subgraph agents[".opencode/agents/"]
            VALIDATOR["blog-validator.md"]
        end
    end

    VALIDATOR -->|invoque| DEV
    VALIDATOR -->|invoque| BUILD
    VALIDATOR -->|invoque| BROWSER

    DEV -->|spawn| ASTRO["astro dev<br/>(port 4321)"]
    BUILD -->|execSync| ASTROBUILD["astro build"]
    BROWSER -->|Playwright| CHROME["Chromium headless"]

    ASTRO -.->|HTTP| DEV
    CHROME -.->|navigue| ASTRO

Le SDK utilisé est @opencode-ai/plugin. La fonction tool() agit comme une fonction identité enrichie de métadonnées : une description textuelle que l’agent utilise pour savoir quand appeler ce tool, un schéma Zod pour les arguments, et la fonction execute. Ce qui n’est pas exporté reste invisible pour l’agent — les helpers internes (comme httpGet ou waitForServer) restent dans le fichier sans jamais apparaître dans la liste des tools.

import { tool } from "@opencode-ai/plugin";

export const my_tool = tool({
  description: "Ce que l'agent doit comprendre pour décider d'appeler ce tool.",
  args: {
    path: tool.schema.string().describe("Le chemin à inspecter"),
    port: tool.schema.number().optional().default(4321),
  },
  async execute(args, context) {
    // context.worktree = chemin absolu vers la racine du projet
    return JSON.stringify({ result: "..." });
  },
});

context.worktree est la clé : c’est le chemin absolu vers le répertoire du projet, injecté automatiquement par OpenCode. Tous les chemins de fichiers, les cwd de spawn, les require() dynamiques — tout part de là.

Les outils de dev server (blog_dev.ts)

Cinq tools couvrent le cycle de vie du serveur de développement : start, stop, status, logs, http_get.

Le plus intéressant à comprendre est start. Le serveur Astro doit tourner en arrière-plan pendant que l’agent travaille — il ne peut pas bloquer l’exécution. La solution : spawn avec detached: true, stdout/stderr redirigés vers un fichier log, child.unref() pour que le processus survive au parent.

export const start = tool({
  description:
    "Démarre le serveur de développement Astro en arrière-plan. " +
    "Capture stdout/stderr dans .opencode/dev-server.log. Retourne le PID et le port.",
  args: {
    port: tool.schema.number().optional().default(4321),
  },
  async execute(args, context) {
    const port = args.port ?? 4321;
    const opencodeDir = path.join(context.worktree, ".opencode");
    const pidFile = path.join(opencodeDir, "dev-server.pid");
    const logFile = path.join(opencodeDir, "dev-server.log");

    // Remet le log à zéro
    fs.writeFileSync(logFile, "", "utf-8");

    const logFd = fs.openSync(logFile, "a");
    const child = spawn("npm", ["run", "dev", "--", "--port", String(port)], {
      cwd: context.worktree,
      detached: true,
      stdio: ["ignore", logFd, logFd],
    });
    fs.closeSync(logFd);

    const pid = child.pid!;
    fs.writeFileSync(pidFile, String(pid), "utf-8");
    child.unref();

    const reached = await waitForServer(port);
    return JSON.stringify({ pid, port, status: reached ? "started" : "started_unreachable" });
  },
});

Le fichier PID dans .opencode/dev-server.pid permet aux autres tools (stop, status) de retrouver le processus. waitForServer fait du polling HTTP sur localhost:${port}/ avec backoff exponentiel — il attend jusqu’à 15 secondes que le serveur soit réellement prêt à répondre avant de rendre la main.

http_get est le tool le plus utilisé en pratique. Il permet à l’agent de vérifier qu’une route renvoie 200, de s’assurer qu’une page n’est pas vide, de détecter les redirections inattendues :

export const http_get = tool({
  description: "Fait une requête HTTP GET vers le serveur local.",
  args: {
    path: tool.schema.string(),
    port: tool.schema.number().optional(),
  },
  async execute(args, context) {
    const portFile = path.join(context.worktree, ".opencode", "dev-server.port");
    const port = args.port ?? (fs.existsSync(portFile)
      ? parseInt(fs.readFileSync(portFile, "utf-8").trim(), 10)
      : 4321);

    const result = await httpGet(`http://localhost:${port}${args.path}`);
    const bodyExcerpt = result.body.slice(0, 500);
    return JSON.stringify({
      status: result.status,
      contentType: result.contentType,
      bodyLength: result.body.length,
      bodyExcerpt,
    });
  },
});

Le port est lu depuis le fichier .opencode/dev-server.port si non fourni — l’agent n’a pas à se souvenir sur quel port le serveur a démarré.

Les outils de build & lint (blog_build.ts)

Deux tools : run pour le build Astro complet, lint pour les linters de contenu.

run utilise execSync plutôt que spawn. C’est délibéré : le build est une opération synchrone par nature, qui doit se terminer avant que l’agent puisse interpréter les résultats. Le timeout est fixé à 120 secondes — largement suffisant pour un blog Astro, jamais atteint en pratique.

export const run = tool({
  description:
    "Lance le build Astro complet et retourne les métriques : " +
    "durée, pages générées, taille du dist/, erreurs et warnings.",
  args: {},
  async execute(_args, context) {
    const startTime = Date.now();
    let success = false;
    let output = "";

    try {
      output = execSync("npm run build", {
        cwd: context.worktree,
        env: {
          ...process.env,
          PATH: `${process.env.PATH ?? ""}:/usr/local/bin:/opt/homebrew/bin`,
        },
        encoding: "utf8",
        timeout: 120000,
      });
      success = true;
    } catch (e: any) {
      output = (e.stdout ?? "") + (e.stderr ?? e.message ?? String(e));
      success = false;
    }

    const duration = ((Date.now() - startTime) / 1000).toFixed(1);
    // ...extraction des métriques depuis output
    return JSON.stringify({ success, duration, pageCount, distSize, errors, warnings });
  },
});

Le tool retourne des métriques structurées : durée, nombre de pages générées, taille du répertoire dist/, et les dernières lignes de l’output pour que l’agent puisse lire les erreurs Astro directement. Pas besoin de chercher dans les logs — tout est dans la réponse.

Conseil

Le build Astro fait échouer la validation Zod si un tag ou un type d’article inconnu est utilisé dans le frontmatter. C’est le seul endroit où ces erreurs remontent de façon exploitable — pas au dev server, uniquement au build.

Les outils browser (blog_browser.ts)

Trois tools Playwright : screenshot, perf, js_errors. C’est là que le harness devient vraiment intéressant.

Playwright est chargé dynamiquement depuis les node_modules du projet, pas depuis un package global :

function loadPlaywright(worktree: string) {
  try {
    return require(path.join(worktree, "node_modules", "@playwright/test"));
  } catch (e) {
    throw new Error(
      `Playwright non installé. Lance: npm install -D @playwright/test && npx playwright install chromium\n${e}`
    );
  }
}

Pourquoi cette approche ? Parce qu’OpenCode s’exécute dans son propre contexte Node, qui n’a pas accès aux node_modules locaux via require() standard. En construisant le chemin absolu vers node_modules/@playwright/test, on s’assure d’utiliser exactement la version installée dans le projet, avec les binaires Chromium correspondants.

js_errors est le tool le plus utile pour l’autonomie de l’agent :

export const js_errors = tool({
  description:
    "Capture les erreurs JS runtime et messages console d'une page. " +
    "Utile pour détecter les erreurs acorn/MDX, les ressources 404, les warnings Astro.",
  args: {
    path: tool.schema.string(),
    port: tool.schema.number().optional().default(4321),
    includeWarnings: tool.schema.boolean().optional().default(false),
  },
  async execute(args, context) {
    const { chromium } = loadPlaywright(context.worktree);
    const browser = await chromium.launch({ headless: true });
    const page = await browser.newPage();

    const errors: string[] = [];
    const warnings: string[] = [];
    const failedRequests: string[] = [];

    page.on("console", (msg) => {
      if (msg.type() === "error") errors.push(msg.text());
      if (msg.type() === "warning" && args.includeWarnings) warnings.push(msg.text());
    });
    page.on("pageerror", (err) => errors.push(err.message));
    page.on("requestfailed", (req) =>
      failedRequests.push(`${req.method()} ${req.url()} — ${req.failure()?.errorText}`)
    );

    const url = `http://localhost:${args.port}${args.path}`;
    await page.goto(url, { waitUntil: "networkidle" });
    await browser.close();

    return JSON.stringify({
      url,
      errors,
      warnings,
      failedRequests,
      summary: errors.length === 0 ? "no errors" : `${errors.length} error(s) detected`,
    });
  },
});

Les trois listeners couvrent les trois types d’erreurs qui intéressent l’agent :

  • console error — erreurs JavaScript runtime, avertissements de frameworks
  • pageerror — exceptions non catchées, erreurs de parsing
  • requestfailed — ressources 404, CSS/JS non chargés, images manquantes

C’est pageerror qui attrape les erreurs acorn générées par les {} non escapés dans les blocs Mermaid — celles que ni le build ni le dev server ne remontent, mais qui cassent silencieusement le rendu côté client.

perf mesure les métriques de navigation via l’API PerformanceNavigationTiming : TTFB, domInteractive, domContentLoaded, load time. Utile pour détecter une régression après un changement de layout ou d’import de font.

screenshot sauvegarde les captures dans .opencode/screenshots/ avec un nom de fichier basé sur le path et un timestamp. L’agent peut ainsi voir visuellement ce qu’une page rend — utile pour valider qu’un composant s’affiche correctement, pas seulement qu’il ne lève pas d’erreur.

Assembler : le blog-validator agent

Les tools isolément, c’est de l’infrastructure. Ce qui les rend utiles, c’est l’agent qui les orchestre.

sequenceDiagram
    participant H as Humain / CI
    participant V as blog-validator
    participant DEV as blog_dev_*
    participant BUILD as blog_build_*
    participant BR as blog_browser_*

    H->>V: Valide le blog après modifications
    V->>DEV: blog_dev_status
    DEV-->>V: { running: false }
    V->>DEV: blog_dev_start
    DEV-->>V: { pid: 12345, port: 4321, status: "started" }
    V->>BUILD: blog_build_lint
    BUILD-->>V: { content: { success: true }, mermaid: { success: true } }
    V->>DEV: blog_dev_http_get("/")
    DEV-->>V: { status: 200, bodyLength: 112000 }
    V->>DEV: blog_dev_http_get("/en/")
    DEV-->>V: { status: 200, bodyLength: 112000 }
    V->>BR: blog_browser_js_errors("/")
    BR-->>V: { errors: [], summary: "no errors" }
    V->>BR: blog_browser_perf("/articles/mon-article")
    BR-->>V: { ttfb: 42, load: 180 }
    V-->>H: Verdict PASS / WARN / FAIL

Le workflow standard du blog-validator :

  1. blog_dev_status — serveur en cours ? Sinon, blog_dev_start
  2. blog_build_lint — linters de contenu avant de tester les routes
  3. blog_dev_http_get sur les routes critiques (/, /en/, /articles/, /en/articles/)
  4. blog_browser_js_errors sur la home FR — attrape les erreurs runtime invisibles au build
  5. Verdict PASS / WARN / FAIL avec détails actionnables

Ce que ça change concrètement : l’agent peut maintenant corriger une erreur Mermaid, vérifier lui-même que l’erreur a disparu avec js_errors, valider que la page charge toujours en moins de 200ms avec perf, et committer. Sans intervention humaine dans la boucle.

La boucle complète — modifier, exécuter, observer, corriger — tourne en autonomie. L’humain n’intervient que pour les verdicts FAIL qui nécessitent une décision architecturale, pas pour chaque vérification de routine.

Note

Le blog-validator tourne en mode subagent dans OpenCode — il est invocable depuis d’autres agents ou depuis le chat principal. On peut le lancer manuellement après une session d’édition, ou l’intégrer dans un hook post-commit pour validation systématique.


Ce harness n’a rien de magique. C’est quinze tools, quelques centaines de lignes de TypeScript, et un agent avec un workflow clair. Ce qui le rend efficace, c’est la complétude de la couverture : exécution, observation HTTP, analyse de logs, rendu browser, métriques de performance. L’agent n’a plus besoin de supposer — il peut constater.

← Retour aux articles