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:
@@ -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