feat: client Ollama + boucle agent tool-calling (v0.6.0)

Client /api/chat (modèles cloud via proxy, auth Bearer, timeout, erreurs
sans fuite). Boucle comprendre→outil→observer→répondre avec garde-fou
anti-boucle, audit par appel et Guard injecté (abstraction prête pour le
gatekeeper Phase 2). ToolHandle découplé de MCP.

Palier de risque : reversible.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Kantin-Petit
2026-06-23 01:10:56 +02:00
parent 5fcb3ef18d
commit be1ad76966
4 changed files with 284 additions and 0 deletions
+8
View File
@@ -6,6 +6,14 @@ incompatibles. Chaque ligne renvoie à un commit dédié (un artefact = un commi
## [Unreleased] ## [Unreleased]
## [0.6.0] — 2026-06-23
### Added
- Client Ollama `/api/chat` (`src/llm/ollama.ts`) : modèles cloud via proxy,
auth Bearer, timeout, tool-calling, erreurs sans fuite de secret.
- Boucle agent (`src/agent/loop.ts` + `types.ts`) : comprendre → outil → observer
→ répondre, garde-fou anti-boucle, audit par appel, `Guard` injecté
(abstraction prête pour le gatekeeper Phase 2). `ToolHandle` indépendant de MCP.
## [0.5.0] — 2026-06-23 — début Phase 1 (cerveau lecture seule) ## [0.5.0] — 2026-06-23 — début Phase 1 (cerveau lecture seule)
### Added ### Added
- Squelette orchestrateur TS (Node 22, ESM strict, Fastify) : `config.ts` - Squelette orchestrateur TS (Node 22, ESM strict, Fastify) : `config.ts`
+141
View File
@@ -0,0 +1,141 @@
import type { Logger } from "pino";
import { OllamaClient, type OllamaMessage, type OllamaTool } from "../llm/ollama.js";
import { auditToolExecution } from "../audit/log.js";
import type { ToolHandle, Guard } from "./types.js";
/**
* Boucle agent : comprendre → choisir un outil → agir → observer → répondre.
*
* Le LLM (Ollama cloud) reçoit la liste d'outils ; tant qu'il demande des
* appels, on les exécute (sous contrôle du Guard + audit) et on lui renvoie les
* observations, jusqu'à une réponse finale ou la limite de pas.
*/
export interface AgentTurnInput {
client: OllamaClient;
system: string;
userText: string;
tools: ToolHandle[];
/** Identité de l'appelant (ex. id utilisateur Telegram). */
actor: string;
guard: Guard;
logger: Logger;
/** Garde-fou anti-boucle infinie. */
maxSteps?: number;
}
export interface AgentTurnResult {
reply: string;
steps: number;
}
function toOllamaTools(tools: ToolHandle[]): OllamaTool[] {
return tools.map((t) => ({
type: "function",
function: {
name: t.spec.name,
description: t.spec.description,
parameters: t.spec.parameters,
},
}));
}
export async function runAgentTurn(
input: AgentTurnInput,
): Promise<AgentTurnResult> {
const { client, tools, actor, guard, logger } = input;
const maxSteps = input.maxSteps ?? 8;
const byName = new Map(tools.map((t) => [t.spec.name, t]));
const toolDefs = toOllamaTools(tools);
const messages: OllamaMessage[] = [
{ role: "system", content: input.system },
{ role: "user", content: input.userText },
];
for (let step = 1; step <= maxSteps; step++) {
const assistant = await client.chat({ messages, tools: toolDefs });
messages.push(assistant);
const calls = assistant.tool_calls ?? [];
if (calls.length === 0) {
return { reply: assistant.content, steps: step };
}
// Exécute chaque outil demandé, sous contrôle du Guard + audit.
for (const call of calls) {
const handle = byName.get(call.function.name);
if (!handle) {
messages.push({
role: "tool",
tool_name: call.function.name,
content: `Erreur : outil inconnu "${call.function.name}".`,
});
continue;
}
const verdict = guard.authorize(handle.spec);
if (!verdict.allowed) {
auditToolExecution(logger, {
server: handle.spec.server,
tool: handle.spec.name,
riskTier: handle.spec.riskTier,
readOnly: handle.spec.readOnly,
actor,
outcome: "blocked",
...(verdict.reason ? { error: verdict.reason } : {}),
});
messages.push({
role: "tool",
tool_name: handle.spec.name,
content: `Refusé : ${verdict.reason ?? "non autorisé"}.`,
});
continue;
}
const started = Date.now();
try {
const result = await handle.execute(call.function.arguments);
auditToolExecution(logger, {
server: handle.spec.server,
tool: handle.spec.name,
riskTier: handle.spec.riskTier,
readOnly: handle.spec.readOnly,
actor,
outcome: "ok",
durationMs: Date.now() - started,
});
messages.push({
role: "tool",
tool_name: handle.spec.name,
content: result,
});
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
auditToolExecution(logger, {
server: handle.spec.server,
tool: handle.spec.name,
riskTier: handle.spec.riskTier,
readOnly: handle.spec.readOnly,
actor,
outcome: "error",
durationMs: Date.now() - started,
error: msg,
});
messages.push({
role: "tool",
tool_name: handle.spec.name,
content: `Erreur d'exécution : ${msg}`,
});
}
}
}
// Limite atteinte : on s'arrête proprement plutôt que de boucler.
return {
reply:
"Je n'ai pas pu finaliser dans la limite d'étapes autorisée. " +
"Reformule ou découpe la demande.",
steps: maxSteps,
};
}
+35
View File
@@ -0,0 +1,35 @@
import type { RiskTier } from "../audit/log.js";
/**
* Contrat d'un outil exposé à l'agent. Indépendant de MCP : le registry MCP
* (v0.7.0) produit des ToolHandle, et la Phase 2 pourra en produire d'autres
* (outils auto-créés) sans changer la boucle.
*/
export interface ToolSpec {
/** Nom unique exposé au LLM (préfixé par serveur, ex. "n8n.list_workflows"). */
name: string;
description: string;
/** JSON Schema des arguments. */
parameters: Record<string, unknown>;
/** Serveur d'origine (ex. "n8n", "portainer"). */
server: string;
/** Outil sans effet de bord (MCP readOnlyHint). */
readOnly: boolean;
/** Palier de risque résolu (voir docs/risk-tiers.md). */
riskTier: RiskTier;
}
export interface ToolHandle {
spec: ToolSpec;
/** Exécute l'outil. Retourne un texte (sérialisé) destiné au LLM. */
execute(args: Record<string, unknown>): Promise<string>;
}
/**
* Gardien d'exécution. Phase 1 : autorise la lecture seule, refuse le reste.
* Phase 2 remplacera l'implémentation par le vrai gatekeeper (statut d'asset,
* need-review) sans toucher à la boucle.
*/
export interface Guard {
authorize(spec: ToolSpec): { allowed: boolean; reason?: string };
}
+100
View File
@@ -0,0 +1,100 @@
/**
* Client Ollama (`/api/chat`) — modèles cloud via le proxy local.
*
* Le conteneur Ollama proxifie vers ollama.com (modèles `:cloud`). Transparent
* pour nous : on parle à `OLLAMA_BASE_URL` comme à du Ollama local. La clé cloud
* (`OLLAMA_API_KEY`) part en en-tête Authorization — jamais loggée.
*/
export interface OllamaToolCall {
function: { name: string; arguments: Record<string, unknown> };
}
export interface OllamaMessage {
role: "system" | "user" | "assistant" | "tool";
content: string;
/** Présent sur les réponses assistant qui appellent des outils. */
tool_calls?: OllamaToolCall[];
/** Sur un message role:"tool", nomme l'outil dont c'est le résultat. */
tool_name?: string;
}
/** Déclaration d'outil au format Ollama (compatible function-calling). */
export interface OllamaTool {
type: "function";
function: {
name: string;
description: string;
parameters: Record<string, unknown>; // JSON Schema
};
}
export interface ChatRequest {
messages: OllamaMessage[];
tools?: OllamaTool[];
}
export interface OllamaClientConfig {
baseUrl: string;
apiKey: string;
model: string;
/** Timeout par requête (ms). */
timeoutMs?: number;
}
interface ChatResponseBody {
message?: OllamaMessage;
done?: boolean;
}
export class OllamaError extends Error {}
export class OllamaClient {
constructor(private readonly cfg: OllamaClientConfig) {}
/** Un tour de chat. Retourne le message assistant (avec tool_calls éventuels). */
async chat(req: ChatRequest): Promise<OllamaMessage> {
const controller = new AbortController();
const timeout = setTimeout(
() => controller.abort(),
this.cfg.timeoutMs ?? 120_000,
);
try {
const res = await fetch(`${this.cfg.baseUrl}/api/chat`, {
method: "POST",
headers: {
"content-type": "application/json",
authorization: `Bearer ${this.cfg.apiKey}`,
},
body: JSON.stringify({
model: this.cfg.model,
messages: req.messages,
tools: req.tools ?? [],
stream: false,
}),
signal: controller.signal,
});
if (!res.ok) {
// On ne logge pas le corps brut (peut contenir l'écho de la requête).
throw new OllamaError(`Ollama HTTP ${res.status}`);
}
const body = (await res.json()) as ChatResponseBody;
if (!body.message) {
throw new OllamaError("Réponse Ollama sans message");
}
return body.message;
} catch (err) {
if (err instanceof OllamaError) throw err;
if (err instanceof Error && err.name === "AbortError") {
throw new OllamaError("Ollama: timeout");
}
throw new OllamaError(
`Ollama injoignable: ${err instanceof Error ? err.message : String(err)}`,
);
} finally {
clearTimeout(timeout);
}
}
}