2026-10-19 23:45:23 +00:00
import { countContextTokens } from "./context" ;
2026-10-26 22:17:20 +00:00
import { runToolCallsDetailed , 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 ;
2026-10-12 07:16:26 +00:00
/** 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 ,
2026-10-12 07:16:26 +00:00
pendingContext : [ ] ,
2026-10-11 00:11:20 +00:00
ui : opts.ui ,
cwd : opts.cwd ,
} ;
}
2026-10-20 22:47:36 +00:00
/** Nombre d'itérations par défaut avant de s'arrêter (ou de demander s'il faut continuer). */
export const MAX_ITERATIONS = 50 ;
2026-10-11 00:11:20 +00:00
2026-10-20 00:06:51 +00:00
/** 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). */
2026-10-26 22:17:20 +00:00
/** done : réponse finale · max_iterations : limite atteinte · stopped : l'utilisateur a répondu « stop » (rien n'est annulé). */
export type TurnEnd = "done" | "max_iterations" | "stopped" ;
2026-10-20 00:06:51 +00:00
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 ,
2026-10-20 22:47:36 +00:00
opts : {
maxIterations? : number ;
/ * *
* Appelée quand la limite d ' itérations est atteinte sans réponse finale : true → continuer pour ` maxIterations `
* itérations de plus , false → s 'arrêter. Absente : arrêt avec un message d' erreur .
* /
onLimit ? : ( iterations : number ) = > Promise < boolean > ;
} = { } ,
2026-10-20 00:06:51 +00:00
) : Promise < TurnEnd > {
const maxIterations = opts . maxIterations ? ? MAX_ITERATIONS ;
2026-10-11 00:11:20 +00:00
const { ui } = session ;
2026-10-12 07:16:26 +00:00
// 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 ;
2026-10-20 22:47:36 +00:00
// Après un arrêt à la limite d'itérations, le dernier message est déjà un message « utilisateur » (résultats d'outils) :
// la nouvelle demande s'y ajoute (l'alternance user/assistant est exigée par certains serveurs)
const last = session . messages . at ( - 1 ) ;
if ( last ? . role === "user" ) {
session . messages [ session . messages . length - 1 ] = { . . . last , content : [ . . . last . content , { type : "text" , text } ] } ;
} else {
session . messages . push ( { role : "user" , content : [ { type : "text" , text } ] } ) ;
}
2026-10-20 22:00:52 +00:00
ui . onContextUsage ? . ( sessionContextTokens ( session ) ) ; // la demande (et le contexte joint) occupe déjà des tokens
2026-10-11 00:11:20 +00:00
2026-10-20 22:47:36 +00:00
let iterations = 0 ;
let limit = maxIterations ;
for ( ; ; ) {
if ( iterations >= limit ) {
if ( opts . onLimit && ( await opts . onLimit ( iterations ) ) ) limit += maxIterations ;
else break ;
}
iterations ++ ;
2026-10-11 00:11:20 +00:00
const assistant : ContentBlock [ ] = [ ] ;
let text = "" ;
2026-10-19 23:45:23 +00:00
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" ) {
2026-10-19 23:45:23 +00:00
reportedOutput = ev . outputTokens ;
} else if ( ev . type === "done" && ev . stopReason === "max_tokens" ) {
truncated = true ;
2026-10-11 00:11:20 +00:00
}
2026-10-19 23:45:23 +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 } ) ;
2026-10-19 23:45:23 +00:00
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" ) ;
2026-10-20 00:06:51 +00:00
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
2026-10-26 22:17:20 +00:00
const { results , stopped } = await runToolCallsDetailed ( session , calls , signal ) ;
2026-10-11 00:11:20 +00:00
session . messages . push ( { role : "user" , content : results } ) ;
2026-10-20 22:00:52 +00:00
ui . onContextUsage ? . ( sessionContextTokens ( session ) ) ; // les résultats d'outils pèsent avant l'itération suivante
2026-10-26 22:17:20 +00:00
// « stop » : le contexte est conservé tel quel ; le prochain message de l'utilisateur s'ajoute à ces résultats
if ( stopped ) return "stopped" ;
2026-10-11 00:11:20 +00:00
}
2026-10-20 22:47:36 +00:00
session . ui . onToolResult ( "agent" , ` Arrêt : plus de ${ iterations } itérations ` , true ) ;
2026-10-20 00:06:51 +00:00
return "max_iterations" ;
2026-10-11 00:11:20 +00:00
}
2026-10-18 23:02:44 +00:00
/ * *
2026-10-19 23:45:23 +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 (
2026-10-19 23:45:23 +00:00
session : Pick < AgentSession , " system " | " messages " | " tools " | " pendingContext " > ,
2026-10-18 23:02:44 +00:00
) : number {
2026-10-19 23:45:23 +00:00
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
}