//! AI runtime profile and its context-injection strategy. //! //! A profile is *declarative configuration* (see CONTEXT.md §9): adding an AI //! means adding data, not code (Open/Closed). The [`crate::ports::AgentRuntime`] //! port is parameterised by an [`AgentProfile`]. use serde::{Deserialize, Serialize}; use crate::error::DomainError; use crate::ids::ProfileId; /// Strategy for injecting an agent's `.md` context into the launched CLI. /// /// Invariants: /// - `ConventionFile.target` is a relative file name without `..` and not absolute, /// - `Env.var` is a valid environment-variable identifier, /// - `Flag.flag` is non-empty. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase", tag = "strategy")] pub enum ContextInjection { /// Write/symlink the `.md` to a conventional file (e.g. `CLAUDE.md`). ConventionFile { /// Relative target file name. target: String, }, /// Pass the context file path through a CLI flag. Flag { /// The flag template (e.g. `--context-file {path}` or `-f`). flag: String, }, /// Pipe the Markdown content on stdin. Stdin, /// Pass the context via an environment variable. Env { /// Environment variable name. var: String, }, } impl ContextInjection { /// Validated `ConventionFile` constructor. /// /// # Errors /// Returns [`DomainError::PathNotRelativeSafe`] if `target` is absolute or /// contains `..`. pub fn convention_file(target: impl Into) -> Result { let target = target.into(); crate::validation::relative_safe(&target)?; Ok(Self::ConventionFile { target }) } /// Validated `Flag` constructor. /// /// # Errors /// Returns [`DomainError::EmptyField`] if `flag` is empty. pub fn flag(flag: impl Into) -> Result { let flag = flag.into(); crate::validation::non_empty(&flag, "contextInjection.flag")?; Ok(Self::Flag { flag }) } /// `Stdin` constructor (no validation needed). #[must_use] pub const fn stdin() -> Self { Self::Stdin } /// Validated `Env` constructor. /// /// # Errors /// Returns [`DomainError::InvalidEnvVar`] if `var` is not a valid identifier. pub fn env(var: impl Into) -> Result { let var = var.into(); crate::validation::valid_env_var(&var)?; Ok(Self::Env { var }) } } /// Declares how IdeA assigns and resumes a CLI conversation, without parsing /// the model's output (universal, declarative — see CONTEXT.md §9). /// /// Optional on a profile: a profile without a `session` block behaves as today /// (no resume). /// /// Invariants: /// - `resume_flag` is non-empty, /// - if `assign_flag` is `Some`, it is non-empty. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SessionStrategy { /// Flag passed on the FIRST launch with a UUID generated by IdeA /// (e.g. `"--session-id"`). `None` => degraded mode (resume without id). #[serde(default, skip_serializing_if = "Option::is_none")] pub assign_flag: Option, /// Resume flag (e.g. `"--resume"` or `"--continue"`). pub resume_flag: String, } impl SessionStrategy { /// Builds a validated session strategy. /// /// # Errors /// Returns [`DomainError::EmptyField`] if `resume_flag` is empty, or if /// `assign_flag` is `Some` but empty. pub fn new( assign_flag: Option, resume_flag: impl Into, ) -> Result { let resume_flag = resume_flag.into(); crate::validation::non_empty(&resume_flag, "session.resumeFlag")?; if let Some(flag) = &assign_flag { crate::validation::non_empty(flag, "session.assignFlag")?; } Ok(Self { assign_flag, resume_flag, }) } } /// Declarative runtime configuration for one AI CLI. /// /// Invariants: /// - `name` and `command` non-empty, /// - `context_injection` is itself valid (guaranteed by its constructors), /// - `session` is itself valid (guaranteed by its constructor). #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct AgentProfile { /// Stable identifier. pub id: ProfileId, /// Display name. pub name: String, /// Launch command (e.g. `claude`, `codex`, `gemini`, `aider`). pub command: String, /// Static arguments. pub args: Vec, /// Context-injection strategy. pub context_injection: ContextInjection, /// Optional detection command (e.g. `claude --version`). pub detect: Option, /// Working-directory template. Always `"{agentRunDir}"` (ARCHITECTURE §14.1): /// an agent's PTY cwd is its isolated `.ideai/run//` directory, /// **never** the project root, so that N agents of the same profile never /// collide on a single conventional file (`CLAUDE.md`, …) at the root. pub cwd_template: String, /// Optional conversation assign/resume strategy. Absent (legacy / no resume) /// keeps today's behaviour. #[serde(default, skip_serializing_if = "Option::is_none")] pub session: Option, } /// Embedding strategy of an [`EmbedderProfile`] (LOT C, étage 2 vectoriel). /// /// Declarative, Open/Closed: a new engine family is a new variant *only* if it /// changes the adapter's dispatch; otherwise it is pure data on the profile /// (`model`, `endpoint`, …). The default product posture is [`None`](Self::None): /// no embedding ⇒ no heavy dependency, recall stays the naïve étage 1. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum EmbedderStrategy { /// A local ONNX model run in-process (real integration is a follow-up). LocalOnnx, /// A local embedding server reached over HTTP (real integration is a follow-up). LocalServer, /// A remote embedding API (real integration is a follow-up). Api, /// No embedding engine: recall stays the dependency-free naïve étage 1. None, } /// Declarative configuration for one embedding engine (LOT C, étage 2). /// /// Stored in the global IDE store as `embedder.json`, mirroring `profiles.json` /// for [`AgentProfile`]s: adding an engine is **data, not code** (Open/Closed). /// /// Invariants: /// - `id` and `name` non-empty, /// - `dimension` is non-zero (an embedder always produces fixed-length vectors). #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct EmbedderProfile { /// Stable identifier (e.g. `"local-onnx-minilm"`). pub id: String, /// Display name. pub name: String, /// Embedding strategy driving which concrete adapter is used. pub strategy: EmbedderStrategy, /// Model identifier (e.g. an ONNX model name), when the strategy needs one. #[serde(default, skip_serializing_if = "Option::is_none")] pub model: Option, /// Endpoint URL for a server/API strategy. #[serde(default, skip_serializing_if = "Option::is_none")] pub endpoint: Option, /// Name of the environment variable carrying the API key (never the key /// itself), for an `api` strategy. #[serde(default, skip_serializing_if = "Option::is_none")] pub api_key_env: Option, /// Length of the vectors this engine produces. pub dimension: usize, } impl EmbedderProfile { /// Builds a validated embedder profile. /// /// # Errors /// - [`DomainError::EmptyField`] if `id` or `name` is empty, /// - [`DomainError::EmptyField`] (`"embedder.dimension"`) if `dimension` is `0`. pub fn new( id: impl Into, name: impl Into, strategy: EmbedderStrategy, model: Option, endpoint: Option, api_key_env: Option, dimension: usize, ) -> Result { let id = id.into(); let name = name.into(); crate::validation::non_empty(&id, "embedder.id")?; crate::validation::non_empty(&name, "embedder.name")?; if dimension == 0 { return Err(DomainError::EmptyField { field: "embedder.dimension", }); } Ok(Self { id, name, strategy, model, endpoint, api_key_env, dimension, }) } /// A dependency-free default profile: strategy [`EmbedderStrategy::None`]. /// Recall stays the naïve étage 1 — nothing is imposed. #[must_use] pub fn none() -> Self { Self { id: "none".to_owned(), name: "None".to_owned(), strategy: EmbedderStrategy::None, model: None, endpoint: None, api_key_env: None, dimension: 1, } } } impl AgentProfile { /// Builds a validated profile. /// /// # Errors /// Returns [`DomainError::EmptyField`] if `name` or `command` is empty. #[allow(clippy::too_many_arguments)] pub fn new( id: ProfileId, name: impl Into, command: impl Into, args: Vec, context_injection: ContextInjection, detect: Option, cwd_template: impl Into, session: Option, ) -> Result { let name = name.into(); let command = command.into(); let cwd_template = cwd_template.into(); crate::validation::non_empty(&name, "profile.name")?; crate::validation::non_empty(&command, "profile.command")?; Ok(Self { id, name, command, args, context_injection, detect, cwd_template, session, }) } }