Files
IdeA/crates/infrastructure/src/session/factory.rs
Blomios dcc7a6f216 fix: project model and reasoning_effort into Codex CLI args, stop rewriting .codex/config.toml
- Add model_reasoning_effort field to AgentProfile (domain layer)
- Remove model parameter from codex_config_toml, stop rewriting model in TOML
- Pass model and model_reasoning_effort via Codex CLI -c overrides on every exec
- Update CodexExecSession with new_with_policy_and_overrides factory
- Cover new conversation and resume flows with tests
2026-08-02 17:19:26 +02:00

352 lines
13 KiB
Rust

//! [`StructuredSessionFactory`] — la fabrique [`AgentSessionFactory`] qui **route un
//! profil vers le bon adapter** structuré (ARCHITECTURE §17.2) selon
//! `profile.structured_adapter` (§17.3). Agrège Claude + Codex derrière une seule
//! surface ; aucun type concret ne franchit la frontière domaine (seuls
//! `Arc<dyn AgentSession>` sortent).
//!
//! Open/Closed : ajouter un moteur structuré = ajouter un adapter + une variante
//! [`StructuredAdapter`] + un bras de `match` ici. Le cœur ne bouge pas.
use std::sync::Arc;
use async_trait::async_trait;
use serde_json::Value;
use domain::ports::{
AgentSession, AgentSessionError, AgentSessionFactory, PreparedContext, SessionPlan,
StructuredProviderLaunchPolicy, ToolInvocationError, ToolInvoker, ToolSpec,
};
use domain::profile::{AgentProfile, StructuredAdapter};
use domain::project::ProjectPath;
use domain::sandbox::{SandboxEnforcer, SandboxPlan};
use domain::SessionId;
use super::claude::ClaudeSdkSession;
use super::codex::CodexExecSession;
use super::openai_compat::OpenAiCompatibleSession;
use super::opencode::OpenCodeSession;
const PROJECT_ROOT_ARG: &str = "__ideaProjectRoot";
const REQUESTER_ARG: &str = "__ideaRequester";
struct ProjectScopedToolInvoker {
inner: Arc<dyn ToolInvoker>,
project_root: String,
requester: String,
}
#[async_trait]
impl ToolInvoker for ProjectScopedToolInvoker {
fn tools(&self) -> Vec<ToolSpec> {
self.inner.tools()
}
async fn tools_for_bound_context(&self) -> Result<Vec<ToolSpec>, ToolInvocationError> {
self.inner
.tools_for_context(&self.project_root, &self.requester)
.await
}
async fn call(&self, name: &str, args_json: &str) -> Result<String, ToolInvocationError> {
let mut value: Value = serde_json::from_str(args_json)
.map_err(|e| ToolInvocationError::InvalidArguments(format!("JSON invalide: {e}")))?;
match &mut value {
Value::Object(map) => {
map.insert(
PROJECT_ROOT_ARG.to_owned(),
Value::String(self.project_root.clone()),
);
map.insert(
REQUESTER_ARG.to_owned(),
Value::String(self.requester.clone()),
);
}
_ => {
return Err(ToolInvocationError::InvalidArguments(
"les arguments d'outil doivent être un objet JSON".to_owned(),
));
}
}
self.inner.call(name, &value.to_string()).await
}
}
/// Fabrique infra des sessions structurées, sélectionnée par le profil.
///
/// Quasi sans état : elle instancie l'adapter au vol depuis le profil (le binaire à
/// lancer = `profile.command`), de sorte qu'un seul exemplaire injecté au
/// composition root sert tous les agents (jumeau de `CliAgentRuntime`). Le seul état
/// porté est l'**enforcer de sandbox OS** optionnel (lot LP4-4), injecté **par
/// instance** au composition root (jumeau de `PortablePtyAdapter::with_sandbox_enforcer`)
/// et apparié au plan **par lancement** dans [`start`](AgentSessionFactory::start).
#[derive(Clone, Default)]
pub struct StructuredSessionFactory {
/// Enforcer OS optionnel passé aux adapters structurés. `None` ⇒ aucun
/// sandboxing (chemin natif inchangé, zéro régression).
sandbox_enforcer: Option<Arc<dyn SandboxEnforcer>>,
/// Invoker outil optionnel pour les adapters sans client MCP natif.
tool_invoker: Option<Arc<dyn ToolInvoker>>,
}
impl StructuredSessionFactory {
/// Construit la fabrique (sans enforcer : chemin natif).
#[must_use]
pub fn new() -> Self {
Self {
sandbox_enforcer: None,
tool_invoker: None,
}
}
/// Builder additif : câble un [`SandboxEnforcer`] OS (lot LP4-4). Jumeau exact de
/// [`crate::PortablePtyAdapter::with_sandbox_enforcer`]. Avec lui, tout lancement
/// structuré dont le plan (`SpawnSpec.sandbox`) est `Some` voit ce plan appliqué
/// sur l'enfant. Sans lui (défaut), aucun tour n'est sandboxé.
#[must_use]
pub fn with_sandbox_enforcer(mut self, enforcer: Arc<dyn SandboxEnforcer>) -> Self {
self.sandbox_enforcer = Some(enforcer);
self
}
/// Builder additif : câble l'invocation d'outils `idea_*` pour les moteurs HTTP
/// sans client MCP natif. `None` (défaut) ⇒ chat nu.
#[must_use]
pub fn with_tool_invoker(mut self, invoker: Arc<dyn ToolInvoker>) -> Self {
self.tool_invoker = Some(invoker);
self
}
}
/// Dérive l'[`SessionPlan`] le `seed` de reprise : seul [`SessionPlan::Resume`]
/// amorce l'adapter avec un id de conversation existant ; `Assign`/`None` partent
/// d'une conversation neuve (l'id sera capté au premier tour).
fn seed_conversation_id(session: &SessionPlan) -> Option<String> {
match session {
SessionPlan::Resume { conversation_id } => Some(conversation_id.clone()),
SessionPlan::None | SessionPlan::Assign { .. } => None,
}
}
#[async_trait]
impl AgentSessionFactory for StructuredSessionFactory {
fn supports(&self, profile: &AgentProfile) -> bool {
// Source unique de vérité (§17.3, D7) : sélectionnabilité = pilotable en
// structuré. `is_selectable` et `supports` partagent ainsi le **même**
// prédicat de domaine, ils ne peuvent pas diverger.
profile.is_selectable()
}
async fn start(
&self,
profile: &AgentProfile,
ctx: &PreparedContext,
cwd: &ProjectPath,
session: &SessionPlan,
requester: Option<&str>,
env: &[(String, String)],
sandbox: Option<&SandboxPlan>,
structured_policy: Option<&StructuredProviderLaunchPolicy>,
) -> Result<Arc<dyn AgentSession>, AgentSessionError> {
let adapter = profile.structured_adapter.ok_or_else(|| {
AgentSessionError::Start(format!(
"le profil « {} » n'a pas d'adapter structuré",
profile.name
))
})?;
let id = SessionId::new_random();
let command = profile.command.clone();
let cwd = cwd.as_str().to_owned();
let seed = seed_conversation_id(session);
let requester = requester
.map(str::to_owned)
.unwrap_or_else(|| fallback_requester_from_cwd(&cwd));
// Appariement (lot LP4-4) : plan **par lancement** (param) + enforcer **par
// instance** (champ). Tous deux sont relayés à l'adapter, qui remplira
// `SpawnLine.sandbox` et passera l'enforcer à `run_turn`. `plan == None` ⇒
// l'adapter reste sur le drain async natif.
let plan = sandbox.cloned();
let enforcer = self.sandbox_enforcer.clone();
// NOTE : le contexte (`_ctx`) est injecté par `LaunchAgent` (D3) via le
// convention file dans le run dir *avant* l'appel à la factory (le `.md` est
// déjà écrit) ; l'adapter n'a donc qu'à lancer la CLI dans ce cwd. Aucune
// injection supplémentaire n'incombe ici en mode structuré (la CLI lit son
// fichier conventionnel — CLAUDE.md / AGENTS.md — depuis le cwd).
let session: Arc<dyn AgentSession> = match adapter {
StructuredAdapter::Claude => Arc::new(ClaudeSdkSession::new(
id, command, cwd, seed, plan, enforcer,
)),
StructuredAdapter::Codex => {
let policy = match structured_policy {
Some(StructuredProviderLaunchPolicy::Codex {
sandbox_mode,
writable_roots,
network_access,
}) => (
sandbox_mode.clone(),
writable_roots.clone(),
Some(*network_access),
),
None => (
"workspace-write".to_owned(),
vec![ctx.project_root.clone()],
None,
),
};
Arc::new(CodexExecSession::new_with_policy_and_overrides(
id,
command,
cwd,
seed,
policy.0,
policy.1,
policy.2,
profile.model.clone(),
profile.model_reasoning_effort.clone(),
env.to_vec(),
plan,
enforcer,
))
}
StructuredAdapter::OpenCode => Arc::new(OpenCodeSession::new(
id,
profile.command.clone(),
profile.args.clone(),
cwd,
seed,
env.to_vec(),
plan,
enforcer,
)?),
StructuredAdapter::OpenAiCompatible => {
let config = profile.chat_http.clone().ok_or_else(|| {
AgentSessionError::Start(format!(
"le profil « {} » n'a pas de configuration chatHttp",
profile.name
))
})?;
Arc::new(OpenAiCompatibleSession::new(
id,
config,
&cwd,
ctx.content.as_str(),
self.tool_invoker.clone().map(|inner| {
Arc::new(ProjectScopedToolInvoker {
inner,
project_root: ctx.project_root.clone(),
requester: requester.clone(),
}) as Arc<dyn ToolInvoker>
}),
)?)
}
};
Ok(session)
}
}
fn fallback_requester_from_cwd(cwd: &str) -> String {
std::path::Path::new(cwd)
.file_name()
.and_then(|name| name.to_str())
.unwrap_or_default()
.to_owned()
}
#[cfg(test)]
mod tests {
use std::sync::Mutex;
use serde_json::json;
use super::*;
#[derive(Default)]
struct RecordingToolInvoker {
call: Mutex<Option<(String, String)>>,
tools_context: Mutex<Option<(String, String)>>,
}
#[async_trait]
impl ToolInvoker for RecordingToolInvoker {
fn tools(&self) -> Vec<ToolSpec> {
Vec::new()
}
async fn tools_for_context(
&self,
project_root: &str,
requester: &str,
) -> Result<Vec<ToolSpec>, ToolInvocationError> {
*self.tools_context.lock().unwrap() =
Some((project_root.to_owned(), requester.to_owned()));
Ok(vec![ToolSpec {
name: "idea_memory_read".to_owned(),
description: "Read memory".to_owned(),
input_schema: json!({"type":"object"}),
}])
}
async fn call(&self, name: &str, args_json: &str) -> Result<String, ToolInvocationError> {
*self.call.lock().unwrap() = Some((name.to_owned(), args_json.to_owned()));
Ok("ok".to_owned())
}
}
#[tokio::test]
async fn project_scoped_tool_invoker_injects_explicit_requester_identity() {
let recorder = Arc::new(RecordingToolInvoker::default());
let invoker = ProjectScopedToolInvoker {
inner: recorder.clone(),
project_root: "/project/root".to_owned(),
requester: "ticket-assistant:project:7".to_owned(),
};
let out = invoker
.call("idea_ticket_update", r##"{"ref":"#7"}"##)
.await
.expect("tool call ok");
assert_eq!(out, "ok");
let (name, args_json) = recorder.call.lock().unwrap().clone().expect("recorded");
assert_eq!(name, "idea_ticket_update");
let args: Value = serde_json::from_str(&args_json).unwrap();
assert_eq!(
args,
json!({
"ref": "#7",
PROJECT_ROOT_ARG: "/project/root",
REQUESTER_ARG: "ticket-assistant:project:7",
})
);
}
#[tokio::test]
async fn project_scoped_tool_invoker_filters_tools_with_bound_context() {
let recorder = Arc::new(RecordingToolInvoker::default());
let invoker = ProjectScopedToolInvoker {
inner: recorder.clone(),
project_root: "/project/root".to_owned(),
requester: "agent-1".to_owned(),
};
let tools = invoker
.tools_for_bound_context()
.await
.expect("tools resolve");
assert_eq!(tools.len(), 1);
assert_eq!(tools[0].name, "idea_memory_read");
assert_eq!(
recorder.tools_context.lock().unwrap().clone(),
Some(("/project/root".to_owned(), "agent-1".to_owned()))
);
}
#[test]
fn fallback_requester_uses_cwd_file_name_only_when_explicit_identity_absent() {
assert_eq!(fallback_requester_from_cwd("/tmp/run/7"), "7");
}
}