Files
IdeaSDK/crates/infrastructure/src/session/opencode.rs
Blomios 4e70631c40 feat(opencode): remplace le provider Ollama par llama.cpp
Le tool-calling local ne fonctionnait jamais via Ollama. Refonte du
support local d'OpenCode autour de llama.cpp: profil, catalogue,
matérialisation de la config OpenCode et surface first-run alignés sur
llama-server (backend + frontend).

QA vert (commandes réelles): domain 244, application 81+64, infra 263
(10 échecs = bind-port sandbox identiques sur develop, non-régression),
frontend 574, tsc propre. Réserve E2E live non bloquante faute de
llama-server joignable.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 00:41:19 +02:00

316 lines
10 KiB
Rust

//! [`OpenCodeSession`] — adapter structuré OpenCode + llama.cpp.
//!
//! OpenCode est piloté comme host process local : IdeA génère un `opencode.json`
//! isolé dans le run dir, OpenCode lance le bridge MCP `idea`, puis chaque tour est
//! un `opencode run --format json <prompt>`. L'adapter ne connaît que le contrat
//! JSONL minimal observé/cadré : `step_start`, `text`, `step_finish`.
use std::process::Stdio;
use std::sync::Arc;
use async_trait::async_trait;
use serde_json::Value;
use tokio::io::{AsyncBufReadExt, AsyncReadExt, BufReader};
use tokio::process::Command;
use domain::ports::{AgentSession, AgentSessionError, ReplyEvent, ReplyStream};
use domain::sandbox::{SandboxEnforcer, SandboxPlan};
use domain::SessionId;
/// Un événement OpenCode parsé depuis stdout JSONL.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ParsedEvent {
/// Début d'étape OpenCode.
StepStart,
/// Fragment texte assistant.
Text(String),
/// Fin d'étape OpenCode.
StepFinish,
/// Evénement JSON valide mais hors contrat minimal.
Ignored,
}
/// Parse une ligne JSONL OpenCode.
///
/// # Errors
/// [`AgentSessionError::Decode`] si la ligne non vide n'est pas du JSON valide.
pub fn parse_jsonl_event(line: &str) -> Result<ParsedEvent, AgentSessionError> {
let trimmed = line.trim();
if trimmed.is_empty() {
return Ok(ParsedEvent::Ignored);
}
let value: Value = serde_json::from_str(trimmed)
.map_err(|e| AgentSessionError::Decode(format!("ligne JSON OpenCode illisible: {e}")))?;
match value.get("type").and_then(Value::as_str) {
Some("step_start") | Some("step.start") => Ok(ParsedEvent::StepStart),
Some("step_finish") | Some("step.finish") => Ok(ParsedEvent::StepFinish),
Some("text") => Ok(ParsedEvent::Text(
value
.get("text")
.or_else(|| value.get("content"))
.and_then(Value::as_str)
.unwrap_or_default()
.to_owned(),
)),
_ => Ok(ParsedEvent::Ignored),
}
}
/// Convertit des lignes JSONL OpenCode en événements domaine.
///
/// Le `Final` est la concaténation ordonnée des événements `text`. Un stdout JSONL
/// valide mais sans texte est une erreur typée : une délégation ne peut pas être
/// considérée comme réussie sans réponse finale capturable.
pub fn parse_jsonl_turn(lines: &[String]) -> Result<Vec<ReplyEvent>, AgentSessionError> {
let mut events = Vec::new();
let mut final_text = String::new();
for line in lines {
match parse_jsonl_event(line)? {
ParsedEvent::StepStart | ParsedEvent::StepFinish => {
events.push(ReplyEvent::Heartbeat);
}
ParsedEvent::Text(text) => {
if !text.is_empty() {
final_text.push_str(&text);
events.push(ReplyEvent::TextDelta { text });
}
}
ParsedEvent::Ignored => {}
}
}
if final_text.trim().is_empty() {
return Err(AgentSessionError::Decode(
"OpenCode n'a produit aucun final textuel".to_owned(),
));
}
events.push(ReplyEvent::Final {
content: final_text,
});
Ok(events)
}
/// Découpe une commande utilisateur en argv sans shell implicite.
///
/// Supporte les guillemets simples/doubles et les antislashs. Les expansions shell,
/// pipes et substitutions ne sont pas interprétés.
pub fn split_command_prefix(raw: &str) -> Result<Vec<String>, AgentSessionError> {
let mut out = Vec::new();
let mut cur = String::new();
let mut chars = raw.chars().peekable();
let mut quote: Option<char> = None;
while let Some(ch) = chars.next() {
match (quote, ch) {
(Some(q), c) if c == q => quote = None,
(None, '\'' | '"') => quote = Some(ch),
(_, '\\') => {
if let Some(next) = chars.next() {
cur.push(next);
} else {
cur.push('\\');
}
}
(None, c) if c.is_whitespace() => {
if !cur.is_empty() {
out.push(std::mem::take(&mut cur));
}
}
(_, c) => cur.push(c),
}
}
if quote.is_some() {
return Err(AgentSessionError::Start(
"commande OpenCode invalide: guillemet non fermé".to_owned(),
));
}
if !cur.is_empty() {
out.push(cur);
}
if out.is_empty() {
return Err(AgentSessionError::Start(
"commande OpenCode vide".to_owned(),
));
}
Ok(out)
}
/// Adapter OpenCode process-backed.
pub struct OpenCodeSession {
id: SessionId,
command: String,
prefix_args: Vec<String>,
cwd: String,
env: Vec<(String, String)>,
sandbox: Option<SandboxPlan>,
sandbox_enforcer: Option<Arc<dyn SandboxEnforcer>>,
}
impl OpenCodeSession {
/// Construit l'adapter. `command_prefix` peut être `opencode`, un chemin absolu,
/// ou un wrapper avec arguments; IdeA ajoute ensuite `run --format json`.
pub fn new(
id: SessionId,
command_prefix: impl Into<String>,
profile_args: Vec<String>,
cwd: impl Into<String>,
env: Vec<(String, String)>,
sandbox: Option<SandboxPlan>,
sandbox_enforcer: Option<Arc<dyn SandboxEnforcer>>,
) -> Result<Self, AgentSessionError> {
let mut prefix = split_command_prefix(&command_prefix.into())?;
let command = prefix.remove(0);
prefix.extend(profile_args);
Ok(Self {
id,
command,
prefix_args: prefix,
cwd: cwd.into(),
env,
sandbox,
sandbox_enforcer,
})
}
fn build_args(&self, prompt: &str) -> Vec<String> {
let mut args = self.prefix_args.clone();
args.extend([
"run".to_owned(),
"--format".to_owned(),
"json".to_owned(),
prompt.to_owned(),
]);
args
}
}
#[async_trait]
impl AgentSession for OpenCodeSession {
fn id(&self) -> SessionId {
self.id
}
fn conversation_id(&self) -> Option<String> {
None
}
async fn send(&self, prompt: &str) -> Result<ReplyStream, AgentSessionError> {
let mut cmd = Command::new(&self.command);
cmd.args(self.build_args(prompt))
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
if !self.cwd.is_empty() && self.cwd != "/" {
cmd.current_dir(&self.cwd);
}
for (key, value) in &self.env {
cmd.env(key, value);
}
// OpenCode supporte déjà son propre confinement logique; le plan Landlock
// structuré reste réservé aux chemins process génériques existants.
let _ = (&self.sandbox, &self.sandbox_enforcer);
let mut child = cmd
.spawn()
.map_err(|e| AgentSessionError::Start(format!("{}: {e}", self.command)))?;
let stdout = child
.stdout
.take()
.ok_or_else(|| AgentSessionError::Io("stdout pipe indisponible".to_owned()))?;
let mut stderr_pipe = child
.stderr
.take()
.ok_or_else(|| AgentSessionError::Io("stderr pipe indisponible".to_owned()))?;
let mut lines = BufReader::new(stdout).lines();
let mut collected = Vec::new();
while let Some(line) = lines
.next_line()
.await
.map_err(|e| AgentSessionError::Io(e.to_string()))?
{
collected.push(line);
}
let mut stderr_bytes = Vec::new();
stderr_pipe
.read_to_end(&mut stderr_bytes)
.await
.map_err(|e| AgentSessionError::Io(e.to_string()))?;
let status = child
.wait()
.await
.map_err(|e| AgentSessionError::Io(e.to_string()))?;
let stderr = String::from_utf8_lossy(&stderr_bytes);
if stderr.contains("server unavailable") && stderr.contains("key=idea") {
return Err(AgentSessionError::Start(
"serveur MCP OpenCode `idea` indisponible".to_owned(),
));
}
if !status.success() {
return Err(AgentSessionError::Io(format!(
"OpenCode a quitté avec le statut {}: {}",
status,
stderr.trim()
)));
}
let events = parse_jsonl_turn(&collected)?;
Ok(Box::new(events.into_iter()))
}
async fn shutdown(&self) -> Result<(), AgentSessionError> {
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn split_command_prefix_handles_quotes_without_shell() {
assert_eq!(
split_command_prefix(r#""/tmp/my opencode" --flag "two words" 'three words'"#).unwrap(),
vec!["/tmp/my opencode", "--flag", "two words", "three words"]
);
}
#[test]
fn parse_jsonl_turn_concatenates_text_and_adds_final() {
let events = parse_jsonl_turn(&[
r#"{"type":"step_start"}"#.to_owned(),
r#"{"type":"text","text":"hel"}"#.to_owned(),
r#"{"type":"text","text":"lo"}"#.to_owned(),
r#"{"type":"step_finish"}"#.to_owned(),
])
.unwrap();
assert_eq!(
events,
vec![
ReplyEvent::Heartbeat,
ReplyEvent::TextDelta {
text: "hel".to_owned()
},
ReplyEvent::TextDelta {
text: "lo".to_owned()
},
ReplyEvent::Heartbeat,
ReplyEvent::Final {
content: "hello".to_owned()
}
]
);
}
#[test]
fn parse_jsonl_turn_rejects_empty_final() {
let err = parse_jsonl_turn(&[r#"{"type":"step_start"}"#.to_owned()]).unwrap_err();
assert!(matches!(err, AgentSessionError::Decode(_)));
}
#[test]
fn parse_jsonl_event_rejects_invalid_json() {
let err = parse_jsonl_event("{nope").unwrap_err();
assert!(matches!(err, AgentSessionError::Decode(_)));
}
}