MagicCode/packages/core/src/agent.ts

167 lines
6.2 KiB
TypeScript
Raw Normal View History

import { countContextTokens } from "./context";
2026-10-18 23:02:44 +00:00
import { runToolCalls, type ToolUseBlock } from "./tool-runner";
2026-10-11 00:11:20 +00:00
import type {
AgentSkill,
AgentUI,
ContentBlock,
LLMProvider,
Message,
ModeInitResult,
PermissionPolicy,
2026-10-15 21:49:58 +00:00
PermissionSource,
2026-10-11 00:11:20 +00:00
SlashCommand,
Tool,
} from "./types";
export interface AgentSession {
provider: LLMProvider;
system: string;
tools: Map<string, Tool>;
commands: SlashCommand[];
skills: AgentSkill[];
permissions?: PermissionPolicy;
2026-10-15 21:49:58 +00:00
/** Fichiers dont viennent les règles de permissions (affichage avec /permissions). */
permissionSources: PermissionSource[];
2026-10-11 00:11:20 +00:00
env?: Record<string, string>;
messages: Message[];
2026-10-18 23:02:44 +00:00
/** Résumé compacté qui sert de contexte de départ (présent dans `system`) ; absent : contexte MAIA.md / CLAUDE.md. */
startContext?: string;
/** Contexte ajouté par l'utilisateur hors conversation (ex. résultat d'une commande "!"), joint à sa prochaine demande. */
pendingContext: string[];
2026-10-11 00:11:20 +00:00
ui: AgentUI;
cwd: string;
}
export function createSession(opts: {
provider: LLMProvider;
init: ModeInitResult;
ui: AgentUI;
cwd: string;
2026-10-18 23:02:44 +00:00
startContext?: string;
2026-10-11 00:11:20 +00:00
}): AgentSession {
return {
provider: opts.provider,
system: opts.init.systemPrompt,
tools: new Map(opts.init.tools.map((t) => [t.spec.name, t])),
commands: opts.init.commands ?? [],
skills: opts.init.skills ?? [],
permissions: opts.init.permissions,
2026-10-15 22:54:08 +00:00
permissionSources: opts.init.permissionSources ?? [],
2026-10-11 00:11:20 +00:00
env: opts.init.env,
messages: [],
2026-10-18 23:02:44 +00:00
startContext: opts.startContext,
pendingContext: [],
2026-10-11 00:11:20 +00:00
ui: opts.ui,
cwd: opts.cwd,
};
}
const MAX_ITERATIONS = 25;
/** Fin d'un tour : réponse finale du modèle, ou arrêt faute d'itérations (le dernier message est alors un résultat d'outil). */
export type TurnEnd = "done" | "max_iterations";
2026-10-11 00:11:20 +00:00
/** Un tour complet : message utilisateur → (LLM → outils)* → réponse finale. */
export async function runTurn(
session: AgentSession,
userText: string,
signal?: AbortSignal,
opts: { maxIterations?: number } = {},
): Promise<TurnEnd> {
const maxIterations = opts.maxIterations ?? MAX_ITERATIONS;
2026-10-11 00:11:20 +00:00
const { ui } = session;
// Le contexte en attente est joint à la demande (un seul message : certains providers exigent l'alternance user/assistant)
const context = session.pendingContext.splice(0);
const text = context.length > 0 ? `${context.join("\n\n")}\n\n${userText}` : userText;
session.messages.push({ role: "user", content: [{ type: "text", text }] });
2026-10-11 00:11:20 +00:00
for (let i = 0; i < maxIterations; i++) {
2026-10-11 00:11:20 +00:00
const assistant: ContentBlock[] = [];
let text = "";
let truncated = false; // réponse coupée par la limite de tokens de sortie du modèle
let reportedOutput = 0; // tokens de sortie annoncés par le serveur
2026-10-11 00:11:20 +00:00
ui.onThinking(true);
let first = true;
try {
for await (const ev of session.provider.chat(
{
system: session.system,
messages: session.messages,
tools: [...session.tools.values()].map((t) => t.spec),
},
signal,
)) {
if (first) {
ui.onThinking(false);
first = false;
}
if (ev.type === "text_delta") {
text += ev.text;
ui.onText(ev.text);
} else if (ev.type === "tool_use") {
assistant.push({ type: "tool_use", id: ev.id, name: ev.name, input: ev.input });
2026-10-15 22:54:08 +00:00
} else if (ev.type === "usage") {
reportedOutput = ev.outputTokens;
} else if (ev.type === "done" && ev.stopReason === "max_tokens") {
truncated = true;
2026-10-11 00:11:20 +00:00
}
// La consommation du serveur ne sert pas à la taille du contexte (comptée localement), seulement à repérer une coupure.
// "done" : la fin du flux suffit, les appels d'outils décident de la suite.
2026-10-11 00:11:20 +00:00
}
} finally {
if (first) ui.onThinking(false);
}
if (text) {
ui.onTextEnd();
assistant.unshift({ type: "text", text });
}
session.messages.push({ role: "assistant", content: assistant });
ui.onContextUsage?.(sessionContextTokens(session));
// Certains serveurs (LiteLLM derrière Bedrock/Anthropic…) « réparent » un appel d'outil coupé par la limite de sortie et
// annoncent « tool_calls » au lieu de « length » : une sortie qui tombe pile sur une limite usuelle (4096…) est suspecte.
if (!truncated && assistant.some((b) => b.type === "tool_use") && isUsualOutputLimit(reportedOutput)) truncated = true;
if (truncated) {
ui.onToolResult(
"agent",
"Réponse probablement coupée par la limite de tokens de sortie du modèle : un texte ou un appel d'outil (ex. le contenu d'un fichier à écrire) peut être incomplet. " +
"Demandez un contenu plus court ou, si la limite du serveur est basse, fixez « MaxOutputTokens » dans ~/.maia/settings.json.",
true,
);
}
2026-10-11 00:11:20 +00:00
const calls = assistant.filter((b): b is ToolUseBlock => b.type === "tool_use");
if (calls.length === 0) return "done";
2026-10-11 00:11:20 +00:00
2026-10-18 23:02:44 +00:00
// 1 appel : premier plan ; 2 appels ou plus : une tâche de fond par outil, on attend toutes les fins
const results = await runToolCalls(session, calls, signal);
2026-10-11 00:11:20 +00:00
session.messages.push({ role: "user", content: results });
}
session.ui.onToolResult("agent", `Arrêt : plus de ${maxIterations} itérations`, true);
return "max_iterations";
2026-10-11 00:11:20 +00:00
}
2026-10-18 23:02:44 +00:00
/**
* Taille du contexte de la session en cours, comptée avec js-tiktoken : prompt système, outils, messages, plus le
* contexte en attente (résultats de commandes « ! », fichiers cités) qui sera joint à la prochaine demande.
2026-10-18 23:02:44 +00:00
*/
export function sessionContextTokens(
session: Pick<AgentSession, "system" | "messages" | "tools" | "pendingContext">,
2026-10-18 23:02:44 +00:00
): number {
return countContextTokens({
system: session.system,
messages: session.messages,
tools: [...session.tools.values()].map((t) => t.spec),
pending: session.pendingContext,
});
}
const USUAL_OUTPUT_LIMITS = new Set([1024, 2048, 4096, 8192, 16384, 32768, 65536]);
/** Vrai si le nombre de tokens de sortie est exactement une limite usuelle (réponse très probablement coupée). */
export function isUsualOutputLimit(outputTokens: number): boolean {
return USUAL_OUTPUT_LIMITS.has(outputTokens);
2026-10-15 22:54:08 +00:00
}