Capitalise les notes durables produites pendant le sprint UI rework et les tickets livrés depuis. Commit séparé du code : `.ideai/memory/` est le store durable versionné, il ne doit jamais être mélangé aux commits de feature. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
9.5 KiB
name, description, metadata
| name | description | metadata | ||
|---|---|---|---|---|
| ticket14-local-lan-openai-adapter-scoping | memory note ticket14-local-lan-openai-adapter-scoping |
|
Ticket #14 — cadrage figé : adapter IA local/LAN OpenAI-compatible canonique
Périmètre figé (review agent-user faite) : canal = adapter HTTP natif parlant une API compatible OpenAI/Ollama ; usage transparent vs Claude/Codex (mêmes cellules, conversation headless canonique, historique, reprise, délégation, vivacité) ; PUREMENT ADDITIF (zéro régression Claude Code / Codex CLI) ; parité tool-calling/MCP dès ce ticket avec dégradation propre si le modèle n'expose pas les outils.
0. Asymétrie structurelle (commande TOUT le design)
| Claude/Codex (existant) | Modèle local HTTP (#14) | |
|---|---|---|
| Nature | binaire CLI spawné | serveur HTTP appelé |
| Boucle agentique | conduite par la CLI | conduite par IdeA |
| Rôle MCP d'IdeA | serveur ; CLI = client | pas de client ⇒ IdeA = orchestrateur d'outils |
| Contexte .md | la CLI lit son convention file | injecté en system message par l'adapter |
| Conversation | id moteur (session_id/thread_id) | API stateless ⇒ transcript possédé par IdeA |
Conséquence : le nouvel adapter n'utilise PAS session/process.rs (run_turn/spawn/drain). Il ne partage avec claude.rs/codex.rs que les types de port (AgentSession, ReplyEvent). Cette absence de code partagé mutable EST la garantie d'isolation. |
1. Cartographie hexagonale
Domaine (crates/domain)
- Variante
StructuredAdapter::OpenAiCompatible(profile.rs:244).provider_key()⇒"openai-compatible"(contrat persistance providers.json). Sérialise camelCase"openAiCompatible". - Nouveau VO validé (parse-don't-validate)
HttpChatConfig { endpoint:String (http/https non vide), model:String (non vide), api_key_env:Option<String> (nom de var env valide, JAMAIS la clé), request_timeout_ms:Option<u32>, connect_timeout_ms:Option<u32>, max_tool_iterations:Option<u16> (garde-fou boucle, défaut ~16) }. Le domaine ne résout jamais la clé (pas d'I/O env), il porte le nom de variable. - Rattachement
AgentProfile.chat_http: Option<HttpChatConfig>avec#[serde(default, skip_serializing_if="Option::is_none")](miroir exact demcp/liveness/rate_limit_pattern, profile.rs:570-604) ⇒ zéro régression de sérialisation (profils Claude/Codex bit-identiques). Test round-trip obligatoire (miroir profile_without_mcp_round_trips_identically).
Nouveau port tool-calling (le seam clé — le pont MCP .mcp.json/config.toml + bridge idea mcp-server NE s'applique pas, pas de client CLI)
pub struct ToolSpec { name:String, description:String, input_schema:serde_json::Value }
#[async_trait] pub trait ToolInvoker: Send+Sync {
fn tools(&self) -> Vec<ToolSpec>;
async fn call(&self, name:&str, args_json:&str) -> Result<String, ToolInvocationError>;
}
Implémenté en app-tauri en déléguant à la même OrchestratorService::dispatch (orchestrator/service.rs:1082) que le endpoint MCP ⇒ parité délégation/rendez-vous (idea_ask_agent/idea_reply), gating permissions/sandbox conservé. Injecté dans la factory via with_tool_invoker(Arc<dyn ToolInvoker>) (jumeau de with_sandbox_enforcer). None ⇒ tool-calling désactivé proprement (chat nu).
Infra : nouveau fichier crates/infrastructure/src/session/openai_compat.rs (jumeau structurel de codex.rs SANS process). Client HTTP reqwest + rustls-tls (jamais native-tls — spike AppImage §13-2, confiné au crate infra). Parsing isolé pur parse_chat_delta/parse_completion (miroir de parse_event) — seul endroit qui connaît le schéma OpenAI (choices[].message, tool_calls[], SSE data:).
Routing — seul contact avec l'existant : StructuredSessionFactory::start (factory.rs:111) = UN bras de match ajouté ; bras Claude/Codex INTOUCHÉS ; supports() reste profile.is_selectable() (=structured_adapter.is_some(), profile.rs:857) ⇒ sélectionnable sans changer le prédicat.
2. Contrat de session headless
send(prompt) : 1) transcript.push(user) ; 2) émettre Heartbeat ; 3) POST {endpoint}/chat/completions {model, messages:transcript, tools?:invoker.tools(), stream:true} ; 4) si tool_calls ⇒ pour chaque: ToolActivity{label=name} + result=invoker.call(name,args) + push(assistant tool_call)+push(tool result), reboucler (borné max_tool_iterations) ; 5) sinon streamer chunks ⇒ TextDelta (+tap→Announcement, cf claude.rs:337) ; 6) réponse complète ⇒ push(assistant) + Final{content} ; 7) persister transcript run dir. Contrat de flux universel respecté (un seul Final terminal) ⇒ send_blocking/drain_with_readiness marchent tels quels.
Erreurs (jamais de corps HTTP brut propagé) : endpoint injoignable 1er contact/probe ⇒ AgentSessionError::Start (UI erreur, IdeA non bloqué) ; réseau/coupure en tour ⇒ Io ; JSON illisible/schéma ⇒ Decode (diagnostic court) ; timeout ⇒ Timeout via send_blocking, session non tuée ; modèle absent (404/400) ⇒ Start dédié.
Reprise sans mélange d'ids (invariant du ticket) : /chat/completions stateless ⇒ AUCUN id provider ⇒ conversation_id() retourne None (rien à mélanger). État = transcript provider-shaped (roles+tool_calls) possédé par IdeA, persisté dans le run dir stable .ideai/run/<agent-id>/chat-transcript.json (clé = agent, jamais id provider). Reprise = ré-instanciation factory ⇒ rechargement transcript ; seed_conversation_id (factory.rs:63) ignoré par cet adapter (documenter). DISTINCT du conversation-log LS6 (log.jsonl, dérivé ReplyEvent, surface humaine, inchangé) — deux artefacts, aucun mélange.
Contexte : start reçoit ctx:&PreparedContext ; ctx.content (ports.rs:120) = Markdown rendu ⇒ injecté comme premier message system (le serveur HTTP ne lit pas de convention file).
3. Seam tool-calling / dégradation
Parité : ToolInvoker câblé ⇒ modèle local voit idea_* via la même OrchestratorService (délégation/rendez-vous/contexte/mémoire/tickets identiques, gating conservé). Dégradation 3 niveaux : (1) ToolInvoker None ⇒ chat nu ; (2) profil opte sans outils ⇒ idem ; (3) endpoint rejette param tools ⇒ détecter, retirer tools, rejouer chat nu, 1 diagnostic — l'agent converse toujours, sans déléguer. Garde-fou max_tool_iterations (modèles locaux moins fiables) ⇒ au plafond, Final + note.
4. Lots B/F
Backend : B1 domaine (variante+provider_key, VO HttpChatConfig, champ chat_http, port ToolInvoker/ToolSpec/ToolInvocationError ; tests sérialisation zéro-régression, round-trip, invariants ; zéro I/O). B2 adapter infra openai_compat (reqwest/rustls, parse_* purs, mapping ReplyEvent+erreurs, résolution clé env ; tests fixtures OpenAI/Ollama, erreurs via wiremock, pas de fuite payload). B3 boucle outils bornée + persistance/rechargement transcript run-dir (reprise) + dégradation tools non supporté (tests ToolInvoker fake, reprise=rechargement, plafond). B4 routing (bras match) + composition root (with_tool_invoker, impl ToolInvoker→OrchestratorService) + profil de référence "Ollama / OpenAI-compatible local model" éditable (tests factory route, supports true, probe indispo→Start). Frontend : F1 types TS HttpChatConfig + champ chatHttp? + adapter "openAiCompatible". F2 wizard/settings : sélectionnable comme Claude/Codex, form endpoint/model/apiKeyEnv/timeouts, validation miroir backend (first-run/profile.ts isValidEnvVar, URL, non-vide), apiKeyEnv = nom de var jamais clé. F3 cellule = chemin chat structuré existant (dérivé de is_selectable, "gratuit" si DTO respecté) + erreur "endpoint indisponible" propre sans bloquer UI (tests RTL gateways mock). Frontière B↔F : DTO AgentProfile (déjà le pont IPC) porte structuredAdapter:"openAiCompatible" + chatHttp{endpoint,model,apiKeyEnv?,requestTimeoutMs?,connectTimeoutMs?,maxToolIterations?}. Sélectionnabilité + rendu chat dérivés du DTO existant, pas de nouveau canal ni commande Tauri au cœur (le seed du profil de référence peut passer par le CRUD profils existant — à confirmer B4/F3).
5. Vigilance régression / invariants
- Zéro régression sérialisation (skip_serializing_if=Option::is_none ; round-trip Claude/Codex bit-identique — BLOQUANT). 2. Isolation code : nouvel adapter = fichier neuf ; INTERDIT de toucher claude.rs/codex.rs/process.rs ; seul diff existant = 1 bras match factory.rs + champs optionnels domaine. 3. Contrat flux : exactement un Final terminal ; Heartbeat/ToolActivity/TextDelta/RateLimited non terminaux (conformance.rs doit couvrir le nouvel adapter). 4. Frontière domaine : reqwest UNIQUEMENT infra ; aucun type HTTP en domaine/application ; ToolInvoker port pur. 5. Nouvelle dépendance AppImage : reqwest rustls-tls (pas OpenSSL, spike §13-2). 6. Non-mélange ids : conversation_id()=None ; transcript clé-agent ; distinct de LS6. 7. Sandbox : pas de process spawné ⇒ SandboxEnforcer OS = no-op pour le tour (pas une régression) mais les outils via ToolInvoker gardent le gating orchestrateur.
6. Décisions produit remontées à Main (NON tranchées par Architect)
- Surface d'outils v1 : parité totale immédiate vs sous-ensemble curaté (fiabilité tool-calling des modèles locaux). Reco : parité + garde-fou max_tool_iterations.
- Streaming SSE dès v1 (reco, parité observabilité live) vs non-streaming (repli si endpoint ne stream pas).
- Nom canonique variante :
OpenAiCompatible(reco, décrit le protocole ; provider_key figé) vsLocalChat(formulation ticket).