Aller au contenu principal
Version: 1.0.0

Importer un tool d’un autre module local

Réutiliser un contrat de tool typé sans acquérir de permissions par import. L’implémentation lit réellement le catalogue HTTP Données Québec ; elle n’invente pas de titre de jeu de données. Prérequis : Touba, Node.js 24, npm et Internet. Aucun modèle payant ni clé API n’est nécessaire.

Créer le projet et les modules​

Terminal
mkdir -p imported-tool-demo
cd imported-tool-demo
mkdir -p src/examples handlers/examples/data_tools vendor
npm init -y
node --input-type=module -e 'import {execFileSync} from "node:child_process"; import {copyFileSync} from "node:fs"; const v=JSON.parse(execFileSync("touba",["version","--json"],{encoding:"utf8"})); copyFileSync(v.distribution.sdk_archive,"vendor/touba-sdk.tgz");'
npm install --offline --ignore-scripts --save-exact ./vendor/touba-sdk.tgz
src/examples/data_tools.tb
module examples.data_tools

type DatasetSearchRequest
query is Text
rows is Int

type DatasetSearchResult
count is Int
first_title is Text

tool SearchDatasets
description "Search the live Données Québec catalogue and return its match count and first title."
takes request as DatasetSearchRequest
gives DatasetSearchResult
requires network
src/examples/research_app.tb
module examples.research_app

import type DatasetSearchRequest from examples.data_tools
import type DatasetSearchResult from examples.data_tools
import tool SearchDatasets from examples.data_tools

skill BuildDatasetBrief
description "Read the public catalogue through the imported tool contract."
takes request as DatasetSearchRequest
gives DatasetSearchResult
uses SearchDatasets
do
SearchDatasets
with request as request
into result
give result

strategy RunDatasetSearch
takes request as DatasetSearchRequest
gives DatasetSearchResult
uses BuildDatasetBrief
do
BuildDatasetBrief
with request as request
into result
give result

Implémenter et autoriser le tool déclarant​

Handler et manifest utilisent la référence du module déclarant, tool:examples.data_tools/SearchDatasets, pas celle du module importeur. L’hôte autorise explicitement le réseau et choisit un contrat sans écriture externe. Le manifest de refus retire ce grant sans changer le source.

handlers/examples/data_tools/search_datasets.handler.mjs
import { defineTool, ToubaHandlerError } from "@touba/sdk";
await defineTool({ toolRef: "tool:examples.data_tools/SearchDatasets",
inputSignature: [{ name: "request", type_ref: "type:examples.data_tools/DatasetSearchRequest" }],
outputType: "type:examples.data_tools/DatasetSearchResult", requiredGrants: ["network"],
run: async ({ request }) => {
const url = new URL("https://www.donneesquebec.ca/recherche/api/3/action/package_search");
url.searchParams.set("q", request.query); url.searchParams.set("rows", String(request.rows));
const response = await fetch(url, { signal: AbortSignal.timeout(15000) });
if (!response.ok) throw new ToubaHandlerError("CatalogueHttpError", "The public catalogue request failed.");
const data = await response.json();
if (data.success !== true || !Number.isSafeInteger(data.result?.count) || !Array.isArray(data.result?.results))
throw new ToubaHandlerError("CatalogueResponseInvalid", "The public catalogue returned an invalid response.");
const first = data.result.results[0];
const title = first?.title ?? first?.name ?? "No matching dataset";
if (typeof title !== "string") throw new ToubaHandlerError("CatalogueTitleInvalid", "The dataset title must be text.");
return { count: data.result.count, first_title: title };
},
});
configure.mjs
import { writeFileSync } from "node:fs";
const manifest = { manifest_version: "touba-manifest-v1", bindings: {
"tool:examples.data_tools/SearchDatasets": {
handler: { kind: "process", command: process.execPath,
args: ["handlers/examples/data_tools/search_datasets.handler.mjs"], cwd: "." },
grants: ["network"], effect_contract: { kind: "none" }, timeout_ms: 20000, max_payload_bytes: 65536,
payload_constraints: { input: { "request.query": { non_empty: true, max_length: 120 }, "request.rows": { min: 1, max: 20 } } },
},
} };
writeFileSync("touba.manifest.json", JSON.stringify(manifest, null, 2));
manifest.bindings["tool:examples.data_tools/SearchDatasets"].grants = [];
writeFileSync("denied.manifest.json", JSON.stringify(manifest, null, 2));

Préparer, exécuter et vérifier​

La préparation parcourt les sources, résout les deux modules et préserve le handler complété. Le script vérifie ses octets, l’identité canonique du tool, une réponse HTTP réelle et le refus avant autorisation du handler sans grant réseau. Le catalogue peut évoluer ; une panne réseau reste une erreur, pas un résultat de substitution.

exercise.mjs
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { spawnSync } from "node:child_process";
const path = "handlers/examples/data_tools/search_datasets.handler.mjs";
const before = readFileSync(path, "utf8");
const prepared = spawnSync("touba", ["prepare", "--profile", "dev", "--manifest", "touba.manifest.json",
"--entry", "strategy:examples.research_app/RunDatasetSearch", "--mode", "write", "--json"], { encoding: "utf8" });
assert.equal(prepared.status, 0, prepared.stderr || prepared.stdout);
assert.equal(readFileSync(path, "utf8"), before);
const air = ".touba/build/dev/air/examples/research_app.air.json";
const document = JSON.parse(readFileSync(air, "utf8"));
assert.ok(document.definitions.tools.some(tool => tool.id === "tool:examples.data_tools/SearchDatasets"));
for (const [manifest, expected] of [["touba.manifest.json", 0], ["denied.manifest.json", 1]]) {
const child = spawnSync("touba", ["run", air, "--entry", "strategy:examples.research_app/RunDatasetSearch",
"--manifest", manifest, "--inputs-json", JSON.stringify({ request: { query: "pompier", rows: 1 } }), "--output", "json"], { encoding: "utf8" });
assert.equal(child.status, expected, child.stderr || child.stdout);
const result = JSON.parse(child.stdout);
if (expected === 0) {
assert.equal(result.status, "success");
assert.ok(Number.isSafeInteger(result.result.count) && result.result.count >= 0);
assert.equal(typeof result.result.first_title, "string");
console.log(JSON.stringify(result.result));
} else {
assert.equal(result.reason, "ToolCapabilityNotGranted");
assert.ok(!result.trace.events.some(event => event.kind === "tool_binding_authorized"));
}
}
console.log("IMPORTED_TOOL_LIVE_READ_AND_AUTHORITY_REFUSAL_OK");
Terminal
node configure.mjs
node exercise.mjs
touba inspect .touba/build/dev/air/examples/research_app.air.json

Marqueur attendu : IMPORTED_TOOL_LIVE_READ_AND_AUTHORITY_REFUSAL_OK. Un grant n’est pas une sandbox OS. Le tutoriel supply chain montre comment verrouiller l’identité du handler et ses dépendances déclarées.