feat(agents): role orchestrateur du contexte global expose et configurable par agent — isOrchestrator distinct des permissions MCP, DTO update_project_orchestrator (terminologie wire legacy conservee), refus idea_context_update explicite avec writer effectif + orientation idea_context_propose, surface AgentsPanel + adaptateurs frontend — #276 (QA verte: cargo fmt/check, tests cibles application/app-tauri/infrastructure/backend, npm typecheck, 69/69 tests frontend agents/adapters)

This commit is contained in:
2026-09-07 22:49:12 +02:00
parent ad2c6b4354
commit cb5196fd69
24 changed files with 663 additions and 37 deletions

View File

@ -255,9 +255,10 @@ impl CreateAgentFromScratch {
.map_err(|e| AppError::Invalid(e.to_string()))?;
// Append the entry and re-validate the whole manifest (unique md_paths).
let orchestrator = manifest.orchestrator;
let mut entries = manifest.entries;
entries.push(ManifestEntry::from_agent(&agent));
let manifest = AgentManifest::new(manifest.version, entries)
let manifest = AgentManifest::with_orchestrator(manifest.version, entries, orchestrator)
.map_err(|e| AppError::Invalid(e.to_string()))?;
self.contexts
.save_manifest(&input.project, &manifest)
@ -345,6 +346,83 @@ impl ListAgentsOutput {
}
}
// ---------------------------------------------------------------------------
// UpdateProjectOrchestrator
// ---------------------------------------------------------------------------
/// Input for [`UpdateProjectOrchestrator::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UpdateProjectOrchestratorInput {
/// The project whose global-context writer changes.
pub project: Project,
/// The existing agent that becomes the single effective writer.
pub agent_id: AgentId,
}
/// Output of [`UpdateProjectOrchestrator::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UpdateProjectOrchestratorOutput {
/// The effective writer after the update.
pub orchestrator: AgentId,
}
/// Changes the project's single global-context writer.
///
/// The manifest is validated and persisted as one document before the change event is
/// published. Selecting the already-effective writer is an idempotent no-op.
pub struct UpdateProjectOrchestrator {
contexts: Arc<dyn AgentContextStore>,
events: Arc<dyn EventBus>,
}
impl UpdateProjectOrchestrator {
/// Builds the use case.
#[must_use]
pub fn new(contexts: Arc<dyn AgentContextStore>, events: Arc<dyn EventBus>) -> Self {
Self { contexts, events }
}
/// Designates an existing project agent as the global-context writer.
///
/// # Errors
/// - [`AppError::NotFound`] if the agent is unknown to the project,
/// - [`AppError::Store`] on persistence failure.
pub async fn execute(
&self,
input: UpdateProjectOrchestratorInput,
) -> Result<UpdateProjectOrchestratorOutput, AppError> {
let mut manifest = self.contexts.load_manifest(&input.project).await?;
if !manifest
.entries
.iter()
.any(|entry| entry.agent_id == input.agent_id)
{
return Err(AppError::NotFound(format!("agent {}", input.agent_id)));
}
if manifest.effective_orchestrator() == Some(input.agent_id) {
return Ok(UpdateProjectOrchestratorOutput {
orchestrator: input.agent_id,
});
}
manifest
.designate(input.agent_id)
.map_err(|err| AppError::Invalid(err.to_string()))?;
self.contexts
.save_manifest(&input.project, &manifest)
.await?;
self.events.publish(DomainEvent::OrchestratorChanged {
project_id: input.project.id,
orchestrator: Some(input.agent_id),
});
Ok(UpdateProjectOrchestratorOutput {
orchestrator: input.agent_id,
})
}
}
/// Lists a project's agents by reconstructing them from the manifest entries.
pub struct ListAgents {
contexts: Arc<dyn AgentContextStore>,
@ -579,8 +657,12 @@ impl UpdateAgentContactable {
.with_contactable(input.contactable);
*entry = ManifestEntry::from_agent(&agent);
let manifest = AgentManifest::new(manifest.version, manifest.entries)
.map_err(|err| AppError::Invalid(err.to_string()))?;
let manifest = AgentManifest::with_orchestrator(
manifest.version,
manifest.entries,
manifest.orchestrator,
)
.map_err(|err| AppError::Invalid(err.to_string()))?;
self.contexts
.save_manifest(&input.project, &manifest)
.await?;
@ -625,8 +707,12 @@ impl UpdateAgentEffort {
.with_effort(input.effort);
*entry = ManifestEntry::from_agent(&agent);
let manifest = AgentManifest::new(manifest.version, manifest.entries)
.map_err(|err| AppError::Invalid(err.to_string()))?;
let manifest = AgentManifest::with_orchestrator(
manifest.version,
manifest.entries,
manifest.orchestrator,
)
.map_err(|err| AppError::Invalid(err.to_string()))?;
self.contexts
.save_manifest(&input.project, &manifest)
.await?;
@ -800,6 +886,7 @@ impl ChangeAgentProfile {
// 4. Mutate the entry (new profile), re-validate (to_agent + manifest)
// and persist.
let orchestrator = manifest.orchestrator;
let mut entries = manifest.entries;
let mut mutated_agent = None;
for e in &mut entries {
@ -813,7 +900,7 @@ impl ChangeAgentProfile {
}
let agent =
mutated_agent.ok_or_else(|| AppError::NotFound(format!("agent {}", input.agent_id)))?;
let manifest = AgentManifest::new(manifest.version, entries)
let manifest = AgentManifest::with_orchestrator(manifest.version, entries, orchestrator)
.map_err(|e| AppError::Invalid(e.to_string()))?;
self.contexts
.save_manifest(&input.project, &manifest)
@ -1143,14 +1230,13 @@ impl DeleteAgent {
/// - [`AppError::NotFound`] if the agent is not in the manifest,
/// - [`AppError::Store`] on persistence failure.
pub async fn execute(&self, input: DeleteAgentInput) -> Result<(), AppError> {
let manifest = self.contexts.load_manifest(&input.project).await?;
let mut manifest = self.contexts.load_manifest(&input.project).await?;
let previous_orchestrator = manifest.effective_orchestrator();
let before = manifest.entries.len();
let entries: Vec<ManifestEntry> = manifest
manifest
.entries
.into_iter()
.filter(|e| e.agent_id != input.agent_id)
.collect();
if entries.len() == before {
.retain(|entry| entry.agent_id != input.agent_id);
if manifest.entries.len() == before {
return Err(AppError::NotFound(format!("agent {}", input.agent_id)));
}
if let Some((live, close)) = &self.runtime_cleanup {
@ -1158,11 +1244,17 @@ impl DeleteAgent {
stop_live_agent_runtime_in_project(&input.project, input.agent_id, live, close)
.await?;
}
let manifest = AgentManifest::new(manifest.version, entries)
.map_err(|e| AppError::Invalid(e.to_string()))?;
manifest.on_agent_deleted(input.agent_id);
let next_orchestrator = manifest.effective_orchestrator();
self.contexts
.save_manifest(&input.project, &manifest)
.await?;
if previous_orchestrator != next_orchestrator {
self.events.publish(DomainEvent::OrchestratorChanged {
project_id: input.project.id,
orchestrator: manifest.orchestrator,
});
}
self.events.publish(DomainEvent::LayoutChanged {
project_id: input.project.id,
});

View File

@ -40,6 +40,7 @@ pub use lifecycle::{
StructuredRoutingMode, StructuredSessionDescriptor, UpdateAgentContactable,
UpdateAgentContactableInput, UpdateAgentContactableOutput, UpdateAgentContext,
UpdateAgentContextInput, UpdateAgentEffort, UpdateAgentEffortInput, UpdateAgentEffortOutput,
UpdateProjectOrchestrator, UpdateProjectOrchestratorInput, UpdateProjectOrchestratorOutput,
AGENT_MEMORY_RECALL_BUDGET, DEFAULT_OPENCODE_MCP_TIMEOUT_MS, LIVE_STATE_INJECT_MAX,
};
pub use model_catalogue::{

View File

@ -68,7 +68,8 @@ pub use agent::{
SaveProfileInput, SaveProfileOutput, SessionLimitService, StructuredRoutingMode,
StructuredSessionDescriptor, TurnOutcome, UpdateAgentContactable, UpdateAgentContactableInput,
UpdateAgentContactableOutput, UpdateAgentContext, UpdateAgentContextInput, UpdateAgentEffort,
UpdateAgentEffortInput, UpdateAgentEffortOutput, AGENT_MEMORY_RECALL_BUDGET,
UpdateAgentEffortInput, UpdateAgentEffortOutput, UpdateProjectOrchestrator,
UpdateProjectOrchestratorInput, UpdateProjectOrchestratorOutput, AGENT_MEMORY_RECALL_BUDGET,
CODEX_SUBMIT_DELAY_MS, LIVE_STATE_INJECT_MAX, RESUME_PROMPT,
};
pub use background::{

View File

@ -243,7 +243,7 @@ impl UpdateProjectContext {
let designation = manifest.orchestrator_designation();
let resource = GuardedResource::ProjectContext;
if !may_write_directly(requester, &resource, &designation) {
return Err(map_guard_err(GuardError::Forbidden));
return Err(AppError::Invalid(context_update_denial_message(&manifest)));
}
let _lease = self
@ -533,6 +533,25 @@ fn map_guard_err(e: GuardError) -> AppError {
}
}
fn context_update_denial_message(manifest: &domain::AgentManifest) -> String {
let writer = manifest.effective_orchestrator().and_then(|id| {
manifest
.entries
.iter()
.find(|entry| entry.agent_id == id)
.map(|entry| format!("{} ({id})", entry.name))
});
match writer {
Some(writer) => format!(
"only the global project context writer may use idea_context_update; current writer: \
{writer}. Use idea_context_propose instead"
),
None => "only the global project context writer may use idea_context_update; this project \
currently has no writer. Use idea_context_propose instead"
.to_owned(),
}
}
#[cfg(test)]
mod tests {
use super::*;
@ -998,6 +1017,13 @@ mod tests {
.unwrap_err();
assert_eq!(err.code(), "INVALID");
let message = err.to_string();
assert!(
message.contains("only the global project context writer may use idea_context_update")
);
assert!(message.contains("Dev"));
assert!(message.contains(&AgentId::from_uuid(uuid::Uuid::from_u128(7)).to_string()));
assert!(message.contains("idea_context_propose"));
let files = fs.files.lock().unwrap();
assert_eq!(files.get(project_context_file()).unwrap(), b"# old");
assert!(

View File

@ -533,8 +533,12 @@ impl AssignSkillToAgent {
skill_id: SkillId,
assigned: bool,
) -> Result<(), AppError> {
let manifest = AgentManifest::new(manifest.version, manifest.entries)
.map_err(|e| AppError::Invalid(e.to_string()))?;
let manifest = AgentManifest::with_orchestrator(
manifest.version,
manifest.entries,
manifest.orchestrator,
)
.map_err(|e| AppError::Invalid(e.to_string()))?;
self.contexts.save_manifest(project, &manifest).await?;
self.events.publish(DomainEvent::SkillAssigned {
agent_id,
@ -591,8 +595,12 @@ impl UnassignSkillFromAgent {
*entry = domain::ManifestEntry::from_agent(&agent);
if changed {
let manifest = AgentManifest::new(manifest.version, manifest.entries)
.map_err(|e| AppError::Invalid(e.to_string()))?;
let manifest = AgentManifest::with_orchestrator(
manifest.version,
manifest.entries,
manifest.orchestrator,
)
.map_err(|e| AppError::Invalid(e.to_string()))?;
self.contexts
.save_manifest(&input.project, &manifest)
.await?;

View File

@ -408,9 +408,10 @@ impl CreateAgentFromTemplate {
.map_err(|e| AppError::Invalid(e.to_string()))?
.with_contactable(template.default_contactable);
let orchestrator = manifest.orchestrator;
let mut entries = manifest.entries;
entries.push(ManifestEntry::from_agent(&agent));
let manifest = AgentManifest::new(manifest.version, entries)
let manifest = AgentManifest::with_orchestrator(manifest.version, entries, orchestrator)
.map_err(|e| AppError::Invalid(e.to_string()))?;
self.contexts
.save_manifest(&input.project, &manifest)
@ -624,8 +625,12 @@ impl SyncAgentWithTemplate {
manifest.entries[idx].synced_template_version = Some(template.version);
// Persist the manifest (revalidated) and overwrite the agent context.
let manifest = AgentManifest::new(manifest.version, manifest.entries)
.map_err(|e| AppError::Invalid(e.to_string()))?;
let manifest = AgentManifest::with_orchestrator(
manifest.version,
manifest.entries,
manifest.orchestrator,
)
.map_err(|e| AppError::Invalid(e.to_string()))?;
self.contexts
.save_manifest(&input.project, &manifest)
.await?;