diff --git a/CHANGELOG.md b/CHANGELOG.md index cf1a081..4f43388 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,14 @@ incompatibles. Chaque ligne renvoie à un commit dédié (un artefact = un commi ## [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) ### Added - Squelette orchestrateur TS (Node 22, ESM strict, Fastify) : `config.ts` diff --git a/orchestrator/src/agent/loop.ts b/orchestrator/src/agent/loop.ts new file mode 100644 index 0000000..762b5a3 --- /dev/null +++ b/orchestrator/src/agent/loop.ts @@ -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 { + 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, + }; +} diff --git a/orchestrator/src/agent/types.ts b/orchestrator/src/agent/types.ts new file mode 100644 index 0000000..28c1fa8 --- /dev/null +++ b/orchestrator/src/agent/types.ts @@ -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; + /** 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): Promise; +} + +/** + * 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 }; +} diff --git a/orchestrator/src/llm/ollama.ts b/orchestrator/src/llm/ollama.ts new file mode 100644 index 0000000..7d102aa --- /dev/null +++ b/orchestrator/src/llm/ollama.ts @@ -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 }; +} + +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; // 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 { + 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); + } + } +}