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:
@@ -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`
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user