feat(memory): config embedders (LOT C2) + suggestion contextuelle (LOT C3) + contexte projet partagé
- LOT C2 (§14.5.3) : use cases de configuration des embedders déclaratifs (List/Save/Delete + DescribeEmbedderEngines : modèles ONNX recommandés, environnement local détecté, stratégies compilées). UI EmbedderSettings. - LOT C3 (§14.5.5) : suggestion contextuelle best-effort à l'activation quand la mémoire dépasse le budget de recall sans embedder configuré (event EmbedderSuggested, anti-spam 1×/session, « ne plus demander »). - Contexte projet partagé .ideai/CONTEXT.md (model-agnostic) injecté à tous les agents/profils au lancement, avant la persona. UI ProjectContextPanel. Tests : backend workspace vert (0 échec) ; frontend 306/306. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -13,12 +13,13 @@
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::ids::NodeId;
|
||||
use crate::skill::SkillScope;
|
||||
|
||||
/// Errors raised while validating a raw [`OrchestratorRequest`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
||||
pub enum OrchestratorError {
|
||||
/// The `action` field is not one of the supported v1 actions.
|
||||
/// The requested action/type is not one of the supported actions.
|
||||
#[error("unknown orchestrator action: {0}")]
|
||||
UnknownAction(String),
|
||||
/// A field required by the chosen action is missing or empty.
|
||||
@ -37,6 +38,14 @@ pub enum OrchestratorError {
|
||||
/// The offending scope value.
|
||||
scope: String,
|
||||
},
|
||||
/// The `visibility` field is present but not recognised.
|
||||
#[error("unknown visibility `{visibility}` for action `{action}`")]
|
||||
UnknownVisibility {
|
||||
/// The action being validated.
|
||||
action: String,
|
||||
/// The offending visibility value.
|
||||
visibility: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// The raw, wire-level orchestrator request as deserialised from a request file.
|
||||
@ -48,11 +57,22 @@ pub enum OrchestratorError {
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct OrchestratorRequest {
|
||||
/// The requested action (`spawn_agent`, `stop_agent`, `update_agent_context`).
|
||||
pub action: String,
|
||||
/// Legacy v1 action (`spawn_agent`, `stop_agent`, `update_agent_context`).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub action: Option<String>,
|
||||
/// V2 action type (`agent.run`, `agent.stop`, `agent.update_context`,
|
||||
/// `skill.create`). `type` is reserved in Rust, so the field is renamed.
|
||||
#[serde(default, rename = "type", skip_serializing_if = "Option::is_none")]
|
||||
pub request_type: Option<String>,
|
||||
/// Optional requester id/name, informational at this layer.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub requested_by: Option<String>,
|
||||
/// Target agent display name (required by every v1 action).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub name: Option<String>,
|
||||
/// V2 target agent display name (`agent.run`/`agent.stop`).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub target_agent: Option<String>,
|
||||
/// Runtime profile slug/name (required by `spawn_agent`).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub profile: Option<String>,
|
||||
@ -61,6 +81,21 @@ pub struct OrchestratorRequest {
|
||||
/// `create_skill` this carries the **new Markdown body** to write.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub context: Option<String>,
|
||||
/// Optional task/message carried by `agent.run` / future `agent.message`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub task: Option<String>,
|
||||
/// Whether a spawned agent should stay in the background or attach to a cell.
|
||||
///
|
||||
/// Accepted values are `"background"` (default) and `"visible"`. A visible
|
||||
/// spawn must also provide [`Self::node_id`].
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub visibility: Option<String>,
|
||||
/// Target layout leaf for `visibility: "visible"`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub node_id: Option<NodeId>,
|
||||
/// V2 visible-cell target (`attachToCell`), equivalent to `nodeId`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub attach_to_cell: Option<NodeId>,
|
||||
/// Skill scope for `create_skill` (`"global"` or `"project"`, case-insensitive).
|
||||
/// Optional — absent/empty defaults to [`SkillScope::Project`]. Ignored by the
|
||||
/// other actions.
|
||||
@ -79,10 +114,14 @@ pub enum OrchestratorCommand {
|
||||
SpawnAgent {
|
||||
/// Target agent display name.
|
||||
name: String,
|
||||
/// Profile slug/name to resolve against the configured profiles.
|
||||
profile: String,
|
||||
/// Profile slug/name to resolve against the configured profiles. Required
|
||||
/// for legacy `spawn_agent`; optional for `agent.run` when the agent
|
||||
/// already exists and its manifest owns the profile id.
|
||||
profile: Option<String>,
|
||||
/// Optional initial `.md` body for a freshly-created agent.
|
||||
context: Option<String>,
|
||||
/// Desired visibility for the launched session.
|
||||
visibility: OrchestratorVisibility,
|
||||
},
|
||||
/// Stop a running agent by killing its terminal session.
|
||||
StopAgent {
|
||||
@ -107,6 +146,18 @@ pub enum OrchestratorCommand {
|
||||
},
|
||||
}
|
||||
|
||||
/// Where IdeA should place a launched agent session.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum OrchestratorVisibility {
|
||||
/// Launch or keep running without attaching to a layout cell.
|
||||
Background,
|
||||
/// Launch or re-attach visibly in one layout leaf.
|
||||
Visible {
|
||||
/// Target layout leaf.
|
||||
node_id: NodeId,
|
||||
},
|
||||
}
|
||||
|
||||
impl OrchestratorRequest {
|
||||
/// Validates the raw request into a well-formed [`OrchestratorCommand`].
|
||||
///
|
||||
@ -120,25 +171,44 @@ impl OrchestratorRequest {
|
||||
/// [`OrchestratorError::UnknownAction`] for an unsupported action;
|
||||
/// [`OrchestratorError::MissingField`] when a required field is absent/empty.
|
||||
pub fn validate(&self) -> Result<OrchestratorCommand, OrchestratorError> {
|
||||
let action = self.action.trim();
|
||||
let action = self.action_name()?;
|
||||
match action {
|
||||
"spawn_agent" => Ok(OrchestratorCommand::SpawnAgent {
|
||||
name: self.require_name(action)?,
|
||||
profile: self.require("profile", action, self.profile.as_deref())?,
|
||||
profile: Some(self.require("profile", action, self.profile.as_deref())?),
|
||||
context: self.context.as_ref().filter(|c| !c.is_empty()).cloned(),
|
||||
visibility: self.parse_visibility(action)?,
|
||||
}),
|
||||
"agent.run" => Ok(OrchestratorCommand::SpawnAgent {
|
||||
name: self.require_target_agent(action)?,
|
||||
profile: self
|
||||
.profile
|
||||
.as_ref()
|
||||
.filter(|p| !p.trim().is_empty())
|
||||
.cloned(),
|
||||
context: self
|
||||
.context
|
||||
.as_ref()
|
||||
.or(self.task.as_ref())
|
||||
.filter(|c| !c.is_empty())
|
||||
.cloned(),
|
||||
visibility: self.parse_visibility(action)?,
|
||||
}),
|
||||
"stop_agent" => Ok(OrchestratorCommand::StopAgent {
|
||||
name: self.require_name(action)?,
|
||||
}),
|
||||
"agent.stop" => Ok(OrchestratorCommand::StopAgent {
|
||||
name: self.require_target_agent(action)?,
|
||||
}),
|
||||
"update_agent_context" => Ok(OrchestratorCommand::UpdateAgentContext {
|
||||
name: self.require_name(action)?,
|
||||
context: self.require("context", action, self.context.as_deref())?,
|
||||
}),
|
||||
"create_skill" => Ok(OrchestratorCommand::CreateSkill {
|
||||
"agent.update_context" => Ok(OrchestratorCommand::UpdateAgentContext {
|
||||
name: self.require_target_agent(action)?,
|
||||
context: self.require("context", action, self.context.as_deref())?,
|
||||
}),
|
||||
"create_skill" | "skill.create" => Ok(OrchestratorCommand::CreateSkill {
|
||||
name: self.require_name(action)?,
|
||||
content: self.require("context", action, self.context.as_deref())?,
|
||||
scope: self.parse_scope(action)?,
|
||||
@ -165,11 +235,51 @@ impl OrchestratorRequest {
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses `visibility` for `spawn_agent`, defaulting to background.
|
||||
fn parse_visibility(&self, action: &str) -> Result<OrchestratorVisibility, OrchestratorError> {
|
||||
match self.visibility.as_deref().map(str::trim) {
|
||||
None | Some("") | Some("background") => Ok(OrchestratorVisibility::Background),
|
||||
Some("visible") => {
|
||||
let node_id = self.node_id.or(self.attach_to_cell).ok_or_else(|| {
|
||||
OrchestratorError::MissingField {
|
||||
action: action.to_owned(),
|
||||
field: "nodeId".to_owned(),
|
||||
}
|
||||
})?;
|
||||
Ok(OrchestratorVisibility::Visible { node_id })
|
||||
}
|
||||
Some(other) => Err(OrchestratorError::UnknownVisibility {
|
||||
action: action.to_owned(),
|
||||
visibility: other.to_owned(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Requires a non-empty `name`, shared by all actions.
|
||||
fn require_name(&self, action: &str) -> Result<String, OrchestratorError> {
|
||||
self.require("name", action, self.name.as_deref())
|
||||
}
|
||||
|
||||
fn require_target_agent(&self, action: &str) -> Result<String, OrchestratorError> {
|
||||
self.require(
|
||||
"targetAgent",
|
||||
action,
|
||||
self.target_agent.as_deref().or(self.name.as_deref()),
|
||||
)
|
||||
}
|
||||
|
||||
fn action_name(&self) -> Result<&str, OrchestratorError> {
|
||||
self.request_type
|
||||
.as_deref()
|
||||
.or(self.action.as_deref())
|
||||
.map(str::trim)
|
||||
.filter(|a| !a.is_empty())
|
||||
.ok_or_else(|| OrchestratorError::MissingField {
|
||||
action: "<request>".to_owned(),
|
||||
field: "type".to_owned(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Requires `value` to be present and non-empty (after trimming), else a
|
||||
/// [`OrchestratorError::MissingField`] naming `field`/`action`.
|
||||
fn require(
|
||||
@ -205,8 +315,9 @@ mod tests {
|
||||
r.validate().unwrap(),
|
||||
OrchestratorCommand::SpawnAgent {
|
||||
name: "dev-backend".to_owned(),
|
||||
profile: "claude-code".to_owned(),
|
||||
profile: Some("claude-code".to_owned()),
|
||||
context: Some("agents/dev-backend.md".to_owned()),
|
||||
visibility: OrchestratorVisibility::Background,
|
||||
}
|
||||
);
|
||||
}
|
||||
@ -218,12 +329,43 @@ mod tests {
|
||||
r.validate().unwrap(),
|
||||
OrchestratorCommand::SpawnAgent {
|
||||
name: "a".to_owned(),
|
||||
profile: "claude-code".to_owned(),
|
||||
profile: Some("claude-code".to_owned()),
|
||||
context: None,
|
||||
visibility: OrchestratorVisibility::Background,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spawn_agent_visible_requires_and_carries_node_id() {
|
||||
let node = uuid::Uuid::from_u128(42);
|
||||
let r = req(&format!(
|
||||
r#"{{ "action": "spawn_agent", "name": "a", "profile": "claude-code", "visibility": "visible", "nodeId": "{node}" }}"#
|
||||
));
|
||||
assert_eq!(
|
||||
r.validate().unwrap(),
|
||||
OrchestratorCommand::SpawnAgent {
|
||||
name: "a".to_owned(),
|
||||
profile: Some("claude-code".to_owned()),
|
||||
context: None,
|
||||
visibility: OrchestratorVisibility::Visible {
|
||||
node_id: NodeId::from_uuid(node)
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
let missing = req(
|
||||
r#"{ "action": "spawn_agent", "name": "a", "profile": "claude-code", "visibility": "visible" }"#,
|
||||
);
|
||||
assert_eq!(
|
||||
missing.validate(),
|
||||
Err(OrchestratorError::MissingField {
|
||||
action: "spawn_agent".to_owned(),
|
||||
field: "nodeId".to_owned(),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spawn_agent_missing_profile_is_rejected() {
|
||||
let r = req(r#"{ "action": "spawn_agent", "name": "a" }"#);
|
||||
@ -236,6 +378,41 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_run_accepts_type_target_agent_and_optional_profile() {
|
||||
let r = req(
|
||||
r#"{ "type": "agent.run", "requestedBy": "Main", "targetAgent": "Architect", "task": "Analyse", "visibility": "background" }"#,
|
||||
);
|
||||
assert_eq!(
|
||||
r.validate().unwrap(),
|
||||
OrchestratorCommand::SpawnAgent {
|
||||
name: "Architect".to_owned(),
|
||||
profile: None,
|
||||
context: Some("Analyse".to_owned()),
|
||||
visibility: OrchestratorVisibility::Background,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_run_visible_accepts_attach_to_cell() {
|
||||
let node = uuid::Uuid::from_u128(77);
|
||||
let r = req(&format!(
|
||||
r#"{{ "type": "agent.run", "targetAgent": "Architect", "profile": "claude-code", "visibility": "visible", "attachToCell": "{node}" }}"#
|
||||
));
|
||||
assert_eq!(
|
||||
r.validate().unwrap(),
|
||||
OrchestratorCommand::SpawnAgent {
|
||||
name: "Architect".to_owned(),
|
||||
profile: Some("claude-code".to_owned()),
|
||||
context: None,
|
||||
visibility: OrchestratorVisibility::Visible {
|
||||
node_id: NodeId::from_uuid(node)
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stop_agent_validates() {
|
||||
let r = req(r#"{ "action": "stop_agent", "name": "dev-backend" }"#);
|
||||
@ -261,9 +438,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn update_context_requires_a_body() {
|
||||
let ok = req(
|
||||
r##"{ "action": "update_agent_context", "name": "a", "context": "# new body" }"##,
|
||||
);
|
||||
let ok =
|
||||
req(r##"{ "action": "update_agent_context", "name": "a", "context": "# new body" }"##);
|
||||
assert_eq!(
|
||||
ok.validate().unwrap(),
|
||||
OrchestratorCommand::UpdateAgentContext {
|
||||
@ -284,9 +460,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn create_skill_defaults_to_project_scope() {
|
||||
let r = req(
|
||||
r##"{ "action": "create_skill", "name": "deploy", "context": "# Deploy steps" }"##,
|
||||
);
|
||||
let r =
|
||||
req(r##"{ "action": "create_skill", "name": "deploy", "context": "# Deploy steps" }"##);
|
||||
assert_eq!(
|
||||
r.validate().unwrap(),
|
||||
OrchestratorCommand::CreateSkill {
|
||||
@ -343,7 +518,9 @@ mod tests {
|
||||
let r = req(r#"{ "action": "delete_everything", "name": "a" }"#);
|
||||
assert_eq!(
|
||||
r.validate(),
|
||||
Err(OrchestratorError::UnknownAction("delete_everything".to_owned()))
|
||||
Err(OrchestratorError::UnknownAction(
|
||||
"delete_everything".to_owned()
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user