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>
316 lines
10 KiB
Rust
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(_)));
|
|
}
|
|
}
|