feat: finalise ticket99 - implémentation agent model configuration v2

- agent/lifecycle.rs: lifecycle management per profile
- agent/provider_catalogue.rs: provider registration with model support
- agent/usecases.rs: usecases for profile-based agent invocation
- agent/mod.rs: expose agent capabilities via AgentManager
- backend/dto.rs: AgentModelConfig, AgentProviderConfig DTOs
- domain/profile.rs: extend Profile avec agent capabilities
- domain/permission.rs: permission checks pour agent access
- infrastructure/assistant/mod.rs: agent integration
- infrastructure/permission/{claude,codex}.rs: permission handlers
- web-server/lib.rs: agent endpoints
- commands.rs: agent commands
- frontend/adapters/{http,profile,mock,domain}.ts: adapters
- frontend/first-run/FirstRunWizard.{test.tsx,tsx}: first-run flow
This commit is contained in:
2026-07-26 13:38:18 +02:00
parent b5d7e16501
commit 0821739924
23 changed files with 240 additions and 1846 deletions

View File

@ -20,10 +20,7 @@ use domain::ports::{
ProfileStore, ProjectStore, PtyPort, RemotePath, SecretStore, SessionPlan, SkillStore,
SpawnSpec, StoreError, StructuredProviderLaunchPolicy, SystemPermissionStore,
};
use domain::profile::{
ClaudeProviderConfig, CodexProviderConfig, McpConfigStrategy, OpenCodeProviderConfig,
StructuredAdapter,
};
use domain::profile::{McpConfigStrategy, OpenCodeProviderConfig, StructuredAdapter};
use domain::sandbox::{compile_sandbox_plan, SandboxContext, SandboxPlan};
use domain::{
bound_handoff_summary, Agent, AgentId, AgentManifest, AgentOrigin, AgentProfile,
@ -1134,35 +1131,7 @@ fn build_structured_launch_policy(
}
fn projection_model(profile: &AgentProfile) -> Option<&str> {
profile
.codex_provider
.as_ref()
.map(|provider| provider.model.as_str())
.or_else(|| {
profile
.claude_provider
.as_ref()
.map(|provider| provider.model.as_str())
})
}
fn projection_model_provider(profile: &AgentProfile) -> Option<&str> {
profile
.codex_provider
.as_ref()
.map(|provider| provider.provider_id.as_str())
}
fn projection_model_provider_base_url(profile: &AgentProfile) -> Option<&str> {
profile
.codex_provider
.as_ref()
.and_then(|provider| provider.custom.as_ref())
.map(|custom| custom.base_url.as_str())
}
fn projection_model_provider_env_key(profile: &AgentProfile) -> Option<&str> {
profile.codex_provider.as_ref().map(|_| "OPENAI_API_KEY")
profile.model.as_deref()
}
/// Launches an agent: resolve profile + context, prepare the invocation, apply
@ -1815,8 +1784,6 @@ impl LaunchAgent {
self.ensure_local_model_server_for_opencode(&agent, &mut profile)
.await?;
self.apply_profile_provider_env(&profile, &run_dir, &mut spec)
.await?;
// 5a. ── INJECTION DE LA CONF MCP (cadrage v3, Décision 3) ──
// Strictement APRÈS le convention file (étape 5) et AVANT le spawn /
@ -2246,9 +2213,6 @@ impl LaunchAgent {
project_root: project_root.as_str(),
run_dir: run_dir.as_str(),
model: projection_model(profile),
model_provider: projection_model_provider(profile),
model_provider_base_url: projection_model_provider_base_url(profile),
model_provider_env_key: projection_model_provider_env_key(profile),
};
let projection = projector.project(permissions, network, &ctx);
@ -2483,6 +2447,7 @@ impl LaunchAgent {
&declaration,
run_dir.as_str(),
project_root.as_str(),
profile.model.as_deref(),
);
let _ = self.fs.write(&path, rendered.as_bytes()).await;
}
@ -2494,6 +2459,7 @@ impl LaunchAgent {
&declaration,
run_dir.as_str(),
project_root.as_str(),
profile.model.as_deref(),
);
let _ = self.fs.write(&path, rendered.as_bytes()).await;
}
@ -2602,64 +2568,6 @@ impl LaunchAgent {
})
}
async fn apply_profile_provider_env(
&self,
profile: &AgentProfile,
run_dir: &ProjectPath,
spec: &mut SpawnSpec,
) -> Result<(), AppError> {
if let Some(provider) = profile.codex_provider.as_ref() {
let api_key = self.resolve_codex_provider_api_key(provider).await?;
upsert_env(&mut spec.env, "OPENAI_API_KEY", &api_key);
upsert_env(&mut spec.env, "CODEX_HOME", &join(run_dir, ".codex"));
}
if let Some(provider) = profile.claude_provider.as_ref() {
let api_key = self.resolve_claude_provider_api_key(provider).await?;
upsert_env(&mut spec.env, "ANTHROPIC_API_KEY", &api_key);
}
Ok(())
}
async fn resolve_codex_provider_api_key(
&self,
provider: &CodexProviderConfig,
) -> Result<String, AppError> {
let secret_store = self.secret_store.as_ref().ok_or_else(|| {
AppError::Invalid("Codex provider profile requires a SecretStore, none injected".into())
})?;
secret_store
.get(&provider.api_key_ref)
.await?
.ok_or_else(|| {
AppError::Invalid(format!(
"no secret found for Codex provider `{}` (secret ref `{}`)",
provider.provider_id,
provider.api_key_ref.as_str()
))
})
}
async fn resolve_claude_provider_api_key(
&self,
provider: &ClaudeProviderConfig,
) -> Result<String, AppError> {
let secret_store = self.secret_store.as_ref().ok_or_else(|| {
AppError::Invalid(
"Claude provider profile requires a SecretStore, none injected".into(),
)
})?;
secret_store
.get(&provider.api_key_ref)
.await?
.ok_or_else(|| {
AppError::Invalid(format!(
"no secret found for Claude provider `{}` (secret ref `{}`)",
provider.provider_id,
provider.api_key_ref.as_str()
))
})
}
async fn ensure_local_model_server_for_opencode(
&self,
agent: &Agent,
@ -3148,6 +3056,11 @@ fn toml_string(s: &str) -> String {
format!("\"{}\"", json_escape(s))
}
fn set_top_level_toml_value(input: &str, key: &str, value: &str) -> String {
let line = format!("{key} = {}", toml_string(value));
set_top_level_toml_line(input, key, &line)
}
/// Renders Codex's `config.toml` **MCP part only** (lot LP3-3 decoupling): merges
/// the `[mcp_servers.idea]` table and ensures the run-dir + project-root trust
/// entries. The permission part (`sandbox_mode` / `approval_policy` + the
@ -3159,8 +3072,12 @@ fn codex_config_toml(
mcp_declaration: &str,
run_dir: &str,
project_root: &str,
model: Option<&str>,
) -> String {
let mut text = existing.unwrap_or_default().to_owned();
if let Some(model) = model {
text = set_top_level_toml_value(&text, "model", model);
}
text = replace_toml_table(&text, "mcp_servers.idea", mcp_declaration.trim_end());
text = ensure_codex_trust(&text, run_dir);
text = ensure_codex_trust(&text, project_root);
@ -4585,6 +4502,7 @@ command = "other"
"[mcp_servers.idea]\ncommand = \"new\"",
"/home/me/proj/.ideai/run/a",
"/home/me/proj",
Some("gpt-5-codex"),
);
// MCP table + trust entries are the only things this function touches.
@ -4593,6 +4511,7 @@ command = "other"
assert!(rendered.contains("approval_policy = \"nested\""));
assert!(rendered.contains("[mcp_servers.idea]\ncommand = \"new\""));
assert!(rendered.contains("[mcp_servers.other]\ncommand = \"other\""));
assert!(rendered.contains("model = \"gpt-5-codex\""));
assert!(!rendered.contains("command = \"old\""));
assert_eq!(rendered.matches("[mcp_servers.idea]").count(), 1);
assert_eq!(rendered.matches("[projects.\"/home/me/proj\"]").count(), 1);
@ -4607,8 +4526,10 @@ command = "other"
"[mcp_servers.idea]\ncommand = \"idea-mcp\"",
"/home/me/proj/.ideai/run/a",
"/home/me/proj",
None,
);
assert!(!rendered.contains("model ="));
assert!(!rendered.contains("approval_policy ="));
assert!(!rendered.contains("sandbox_mode ="));
assert!(rendered.contains("[projects.\"/home/me/proj\"]"));