Merge branch 'feature/ticket115-ideA-skills-runtime-snapshot' into develop
This commit is contained in:
@ -18,7 +18,7 @@ use domain::ports::{
|
|||||||
AgentContextStore, AgentRuntime, AgentSessionFactory, ContextInjectionPlan, EventBus,
|
AgentContextStore, AgentRuntime, AgentSessionFactory, ContextInjectionPlan, EventBus,
|
||||||
FileSystem, FsError, IdGenerator, MemoryQuery, MemoryRecall, PermissionStore, PreparedContext,
|
FileSystem, FsError, IdGenerator, MemoryQuery, MemoryRecall, PermissionStore, PreparedContext,
|
||||||
ProfileStore, ProjectStore, PtyPort, RemotePath, SecretStore, SessionPlan, SkillStore,
|
ProfileStore, ProjectStore, PtyPort, RemotePath, SecretStore, SessionPlan, SkillStore,
|
||||||
SpawnSpec, StoreError, StructuredProviderLaunchPolicy, SystemPermissionStore,
|
SpawnSpec, StructuredProviderLaunchPolicy, SystemPermissionStore,
|
||||||
};
|
};
|
||||||
use domain::profile::{
|
use domain::profile::{
|
||||||
McpConfigStrategy, OpenCodeProviderConfig, StructuredAdapter, CODEX_CODE_MODE_FEATURES_TABLE,
|
McpConfigStrategy, OpenCodeProviderConfig, StructuredAdapter, CODEX_CODE_MODE_FEATURES_TABLE,
|
||||||
@ -29,9 +29,9 @@ use domain::{
|
|||||||
bound_handoff_summary, Agent, AgentId, AgentManifest, AgentOrigin, AgentProfile,
|
bound_handoff_summary, Agent, AgentId, AgentManifest, AgentOrigin, AgentProfile,
|
||||||
ContextInjection, ConversationId, ConversationParty, DomainEvent, EffectivePermissions,
|
ContextInjection, ConversationId, ConversationParty, DomainEvent, EffectivePermissions,
|
||||||
Handoff, HandoffStore, ManifestEntry, MarkdownDoc, MemoryIndexEntry, MemoryType, NetworkPolicy,
|
Handoff, HandoffStore, ManifestEntry, MarkdownDoc, MemoryIndexEntry, MemoryType, NetworkPolicy,
|
||||||
NodeId, PermissionProjector, Posture, ProfileId, Project, ProjectPath, ProjectedFile,
|
NodeId, OrchestrationCapabilitySnapshot, PermissionProjector, Posture, ProfileId, Project,
|
||||||
ProjectionContext, ProjectorKey, ProviderSessionStore, PtySize, SessionId, SessionKind,
|
ProjectPath, ProjectedFile, ProjectionContext, ProjectorKey, ProviderSessionStore, PtySize,
|
||||||
SessionStatus, Skill, TerminalSession, HANDOFF_SUMMARY_MAX_CHARS,
|
SessionId, SessionKind, SessionStatus, TerminalSession, HANDOFF_SUMMARY_MAX_CHARS,
|
||||||
};
|
};
|
||||||
|
|
||||||
use domain::live_state::WorkStatus;
|
use domain::live_state::WorkStatus;
|
||||||
@ -42,6 +42,7 @@ use crate::model_server::{
|
|||||||
EnsureLocalModelServer, EnsureLocalModelServerInput, ModelServerUseGuard,
|
EnsureLocalModelServer, EnsureLocalModelServerInput, ModelServerUseGuard,
|
||||||
};
|
};
|
||||||
use crate::project::project_context_path;
|
use crate::project::project_context_path;
|
||||||
|
use crate::skill::AssignedSkillResolver;
|
||||||
use crate::terminal::{StructuredSessions, TerminalSessions};
|
use crate::terminal::{StructuredSessions, TerminalSessions};
|
||||||
use crate::workstate::GetLiveStateLean;
|
use crate::workstate::GetLiveStateLean;
|
||||||
|
|
||||||
@ -117,6 +118,36 @@ pub struct InjectedLiveRow {
|
|||||||
pub intent: String,
|
pub intent: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One assigned skill resolved for the effective context.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct ResolvedAssignedSkill {
|
||||||
|
/// Agent-facing snapshot metadata.
|
||||||
|
pub snapshot: domain::AssignedSkillSnapshot,
|
||||||
|
/// Full Markdown body, used only on non-MCP profiles that cannot call
|
||||||
|
/// `idea_skill_read`.
|
||||||
|
pub content: MarkdownDoc,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Effective model context resolved once per runtime launch and shared by every
|
||||||
|
/// provider path.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct EffectiveAgentContext {
|
||||||
|
/// Raw persona Markdown from the agent `.md`.
|
||||||
|
pub persona: MarkdownDoc,
|
||||||
|
/// Shared project context.
|
||||||
|
pub project_context: String,
|
||||||
|
/// Provider-agnostic orchestration capabilities exposed to the agent.
|
||||||
|
pub capabilities: OrchestrationCapabilitySnapshot,
|
||||||
|
/// Resolved assigned skill bodies for fallback non-MCP injection.
|
||||||
|
pub assigned_skills: Vec<ResolvedAssignedSkill>,
|
||||||
|
/// Project-memory recall selected for this launch.
|
||||||
|
pub memory: Vec<MemoryIndexEntry>,
|
||||||
|
/// Optional conversation handoff for this launch.
|
||||||
|
pub handoff: Option<Handoff>,
|
||||||
|
/// Lean live-state rows for other agents.
|
||||||
|
pub live_rows: Vec<InjectedLiveRow>,
|
||||||
|
}
|
||||||
|
|
||||||
/// Fournit le [`GetLiveStateLean`] **lié au project root** du lancement en cours
|
/// Fournit le [`GetLiveStateLean`] **lié au project root** du lancement en cours
|
||||||
/// (lot LS4), pour injecter l'aperçu `# État du projet` dans le contexte composé.
|
/// (lot LS4), pour injecter l'aperçu `# État du projet` dans le contexte composé.
|
||||||
///
|
///
|
||||||
@ -1153,7 +1184,7 @@ pub struct LaunchAgent {
|
|||||||
runtime: Arc<dyn AgentRuntime>,
|
runtime: Arc<dyn AgentRuntime>,
|
||||||
fs: Arc<dyn FileSystem>,
|
fs: Arc<dyn FileSystem>,
|
||||||
pty: Arc<dyn PtyPort>,
|
pty: Arc<dyn PtyPort>,
|
||||||
skills: Arc<dyn SkillStore>,
|
assigned_skills: Arc<AssignedSkillResolver>,
|
||||||
sessions: Arc<TerminalSessions>,
|
sessions: Arc<TerminalSessions>,
|
||||||
events: Arc<dyn EventBus>,
|
events: Arc<dyn EventBus>,
|
||||||
ids: Arc<dyn IdGenerator>,
|
ids: Arc<dyn IdGenerator>,
|
||||||
@ -1243,7 +1274,7 @@ impl LaunchAgent {
|
|||||||
runtime,
|
runtime,
|
||||||
fs,
|
fs,
|
||||||
pty,
|
pty,
|
||||||
skills,
|
assigned_skills: Arc::new(AssignedSkillResolver::new(skills)),
|
||||||
sessions,
|
sessions,
|
||||||
events,
|
events,
|
||||||
ids,
|
ids,
|
||||||
@ -1381,31 +1412,44 @@ impl LaunchAgent {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolves the Markdown bodies of an agent's assigned skills, in the
|
/// Builds the effective runtime context once, before the provider split, so
|
||||||
/// **manifest order** (deterministic). A skill that no longer exists in its
|
/// PTY, structured/headless, hot relaunch and handoff rebuild paths consume the
|
||||||
/// store (deleted out from under the assignment) is silently skipped — a
|
/// same capability snapshot.
|
||||||
/// dangling [`domain::SkillRef`] must not block a launch.
|
async fn build_effective_context(
|
||||||
///
|
|
||||||
/// # Errors
|
|
||||||
/// [`AppError::Store`] on any store failure other than a missing skill.
|
|
||||||
async fn resolve_skills(
|
|
||||||
&self,
|
&self,
|
||||||
|
project: &Project,
|
||||||
|
manifest: &AgentManifest,
|
||||||
agent: &Agent,
|
agent: &Agent,
|
||||||
root: &ProjectPath,
|
persona: MarkdownDoc,
|
||||||
) -> Result<Vec<Skill>, AppError> {
|
cell_conversation_id: Option<&str>,
|
||||||
let mut out = Vec::with_capacity(agent.skills.len());
|
) -> Result<EffectiveAgentContext, AppError> {
|
||||||
for skill_ref in &agent.skills {
|
let assigned_skills: Vec<ResolvedAssignedSkill> = self
|
||||||
match self
|
.assigned_skills
|
||||||
.skills
|
.resolve_for_agent_with_content(agent, &project.root)
|
||||||
.get(skill_ref.scope, root, skill_ref.skill_id)
|
.await?
|
||||||
.await
|
.into_iter()
|
||||||
{
|
.map(|(snapshot, content)| ResolvedAssignedSkill { snapshot, content })
|
||||||
Ok(skill) => out.push(skill),
|
.collect();
|
||||||
Err(StoreError::NotFound) => {}
|
let capabilities = OrchestrationCapabilitySnapshot::new(
|
||||||
Err(e) => return Err(e.into()),
|
assigned_skills.iter().map(|s| s.snapshot.clone()).collect(),
|
||||||
}
|
);
|
||||||
}
|
let project_context = self.resolve_project_context(project).await?;
|
||||||
Ok(out)
|
let memory = self.resolve_memory(&project.root, persona.as_str()).await;
|
||||||
|
let handoff = self
|
||||||
|
.resolve_handoff(&project.root, cell_conversation_id)
|
||||||
|
.await;
|
||||||
|
let live_rows = self
|
||||||
|
.resolve_live_state(&project.root, manifest, agent.id)
|
||||||
|
.await;
|
||||||
|
Ok(EffectiveAgentContext {
|
||||||
|
persona,
|
||||||
|
project_context,
|
||||||
|
capabilities,
|
||||||
|
assigned_skills,
|
||||||
|
memory,
|
||||||
|
handoff,
|
||||||
|
live_rows,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolves the project's memory recall (index/hooks) to inject into the
|
/// Resolves the project's memory recall (index/hooks) to inject into the
|
||||||
@ -1742,25 +1786,18 @@ impl LaunchAgent {
|
|||||||
self.runtime
|
self.runtime
|
||||||
.prepare_invocation(&profile, &prepared, &run_dir, &session_plan)?;
|
.prepare_invocation(&profile, &prepared, &run_dir, &session_plan)?;
|
||||||
|
|
||||||
// 5. Resolve the agent's assigned skills (their `.md` bodies), then apply
|
// 5. Build the effective context once, then apply the injection side
|
||||||
// the injection plan side effects *before* spawning.
|
// effects before spawning. The capability snapshot inside this context
|
||||||
let skills = self.resolve_skills(&agent, &input.project.root).await?;
|
// is also the authorization source for `idea_skill_read`.
|
||||||
let project_context = self.resolve_project_context(&input.project).await?;
|
let effective_context = self
|
||||||
let memory = self
|
.build_effective_context(
|
||||||
.resolve_memory(&input.project.root, content.as_str())
|
&input.project,
|
||||||
.await;
|
&manifest,
|
||||||
// Reprise conversationnelle (lot P7) : best-effort, additif. Si la cellule a une
|
&agent,
|
||||||
// conversation et qu'un handoff existe, son résumé est injecté dans le convention
|
content.clone(),
|
||||||
// file (à côté de la mémoire projet). Indépendant du provider/resumable id.
|
input.conversation_id.as_deref(),
|
||||||
let handoff = self
|
)
|
||||||
.resolve_handoff(&input.project.root, input.conversation_id.as_deref())
|
.await?;
|
||||||
.await;
|
|
||||||
// Aperçu live-state des autres agents (lot LS4) : best-effort, additif. Injecté
|
|
||||||
// comme section `# État du projet`. Ordre manifeste, self exclu, borné, vide ⇒
|
|
||||||
// section omise. Indépendant du handoff/mémoire.
|
|
||||||
let live_rows = self
|
|
||||||
.resolve_live_state(&input.project.root, &manifest, agent.id)
|
|
||||||
.await;
|
|
||||||
// Best-effort contextual embedder suggestion (LOT C3, §14.5.5): the agent
|
// Best-effort contextual embedder suggestion (LOT C3, §14.5.5): the agent
|
||||||
// has just read the project memory, so this is the moment to check whether a
|
// has just read the project memory, so this is the moment to check whether a
|
||||||
// semantic embedder would now help. Fully isolated from the launch outcome —
|
// semantic embedder would now help. Fully isolated from the launch outcome —
|
||||||
@ -1776,12 +1813,7 @@ impl LaunchAgent {
|
|||||||
self.apply_injection(
|
self.apply_injection(
|
||||||
&input.project,
|
&input.project,
|
||||||
&agent.context_path,
|
&agent.context_path,
|
||||||
&content,
|
&effective_context,
|
||||||
&project_context,
|
|
||||||
&skills,
|
|
||||||
&memory,
|
|
||||||
handoff.as_ref(),
|
|
||||||
&live_rows,
|
|
||||||
profile.mcp.is_some(),
|
profile.mcp.is_some(),
|
||||||
&mut spec,
|
&mut spec,
|
||||||
)
|
)
|
||||||
@ -2338,12 +2370,7 @@ impl LaunchAgent {
|
|||||||
&self,
|
&self,
|
||||||
project: &Project,
|
project: &Project,
|
||||||
context_rel_path: &str,
|
context_rel_path: &str,
|
||||||
content: &MarkdownDoc,
|
effective: &EffectiveAgentContext,
|
||||||
project_context: &str,
|
|
||||||
skills: &[Skill],
|
|
||||||
memory: &[MemoryIndexEntry],
|
|
||||||
handoff: Option<&Handoff>,
|
|
||||||
live_rows: &[InjectedLiveRow],
|
|
||||||
mcp_enabled: bool,
|
mcp_enabled: bool,
|
||||||
spec: &mut SpawnSpec,
|
spec: &mut SpawnSpec,
|
||||||
) -> Result<(), AppError> {
|
) -> Result<(), AppError> {
|
||||||
@ -2356,16 +2383,8 @@ impl LaunchAgent {
|
|||||||
// composed: an absolute project-root header (so the agent knows
|
// composed: an absolute project-root header (so the agent knows
|
||||||
// where to operate, since its cwd is *not* the root), the agent's
|
// where to operate, since its cwd is *not* the root), the agent's
|
||||||
// persona `.md`, then the bodies of its assigned skills (§14.2).
|
// persona `.md`, then the bodies of its assigned skills (§14.2).
|
||||||
let document = compose_convention_file(
|
let document =
|
||||||
project.root.as_str(),
|
compose_convention_file(project.root.as_str(), effective, mcp_enabled);
|
||||||
project_context,
|
|
||||||
content.as_str(),
|
|
||||||
skills,
|
|
||||||
memory,
|
|
||||||
handoff,
|
|
||||||
live_rows,
|
|
||||||
mcp_enabled,
|
|
||||||
);
|
|
||||||
let path = RemotePath::new(join(&spec.cwd, &target));
|
let path = RemotePath::new(join(&spec.cwd, &target));
|
||||||
self.fs.write(&path, document.as_bytes()).await?;
|
self.fs.write(&path, document.as_bytes()).await?;
|
||||||
}
|
}
|
||||||
@ -3331,12 +3350,7 @@ fn append_block(input: &str, block: &str) -> String {
|
|||||||
#[must_use]
|
#[must_use]
|
||||||
pub(crate) fn compose_convention_file(
|
pub(crate) fn compose_convention_file(
|
||||||
project_root: &str,
|
project_root: &str,
|
||||||
project_context: &str,
|
effective: &EffectiveAgentContext,
|
||||||
agent_md: &str,
|
|
||||||
skills: &[Skill],
|
|
||||||
memory: &[MemoryIndexEntry],
|
|
||||||
handoff: Option<&Handoff>,
|
|
||||||
live_rows: &[InjectedLiveRow],
|
|
||||||
mcp_enabled: bool,
|
mcp_enabled: bool,
|
||||||
) -> String {
|
) -> String {
|
||||||
let mut out = String::new();
|
let mut out = String::new();
|
||||||
@ -3432,49 +3446,55 @@ pub(crate) fn compose_convention_file(
|
|||||||
// pour que l'agent sache qu'ils existent et charge le détail à la demande via
|
// pour que l'agent sache qu'ils existent et charge le détail à la demande via
|
||||||
// `idea_skill_read`. Réservé au mode MCP (le mode sans MCP conserve l'ancien dump
|
// `idea_skill_read`. Réservé au mode MCP (le mode sans MCP conserve l'ancien dump
|
||||||
// du corps complet en fin de fichier, plus bas). Omis si zéro skill.
|
// du corps complet en fin de fichier, plus bas). Omis si zéro skill.
|
||||||
if mcp_enabled && !skills.is_empty() {
|
if mcp_enabled && !effective.capabilities.assigned_skills.is_empty() {
|
||||||
out.push_str("# Skills disponibles\n\n");
|
out.push_str("# Skills disponibles\n\n");
|
||||||
|
out.push_str("Snapshot version: ");
|
||||||
|
out.push_str(&effective.capabilities.version.to_string());
|
||||||
|
out.push_str("\n\n");
|
||||||
out.push_str(
|
out.push_str(
|
||||||
"Les skills suivants te sont assignés. Pour en exécuter un ou consulter son \
|
"Les skills suivants te sont assignés. Pour en exécuter un ou consulter son \
|
||||||
détail, appelle l'outil `idea_skill_read(name=…)` — n'improvise pas un \
|
détail, appelle l'outil `idea_skill_read(name=…)` — n'improvise pas un \
|
||||||
workflow déjà couvert par un skill, charge-le.\n\n",
|
workflow déjà couvert par un skill, charge-le.\n\n",
|
||||||
);
|
);
|
||||||
for skill in skills {
|
for skill in &effective.capabilities.assigned_skills {
|
||||||
out.push_str("**");
|
out.push_str("**");
|
||||||
out.push_str(&skill.name);
|
out.push_str(&skill.name);
|
||||||
out.push_str("** — ");
|
out.push_str("** — ");
|
||||||
out.push_str(&skill.effective_description());
|
out.push_str(&skill.description);
|
||||||
out.push('\n');
|
out.push('\n');
|
||||||
}
|
}
|
||||||
out.push_str("\n---\n\n");
|
out.push_str("\n---\n\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
if !project_context.trim().is_empty() {
|
if !effective.project_context.trim().is_empty() {
|
||||||
out.push_str("# Contexte projet\n\n");
|
out.push_str("# Contexte projet\n\n");
|
||||||
out.push_str(project_context.trim());
|
out.push_str(effective.project_context.trim());
|
||||||
out.push_str("\n\n---\n\n");
|
out.push_str("\n\n---\n\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
out.push_str(agent_md);
|
out.push_str(effective.persona.as_str());
|
||||||
|
|
||||||
// MODE SANS MCP (exigence zéro régression, décision produit 4.2(b)) : on conserve
|
// MODE SANS MCP (exigence zéro régression, décision produit 4.2(b)) : on conserve
|
||||||
// l'ancien dump du **corps complet** des skills en fin de fichier. En mode MCP, le
|
// l'ancien dump du **corps complet** des skills en fin de fichier. En mode MCP, le
|
||||||
// corps n'est PAS injecté ici (l'agent le charge à la demande via `idea_skill_read`,
|
// corps n'est PAS injecté ici (l'agent le charge à la demande via `idea_skill_read`,
|
||||||
// cf. la section « # Skills disponibles » à haute altitude plus haut).
|
// cf. la section « # Skills disponibles » à haute altitude plus haut).
|
||||||
if !skills.is_empty() && !mcp_enabled {
|
if !effective.assigned_skills.is_empty() && !mcp_enabled {
|
||||||
out.push_str("\n\n---\n\n# Skills\n");
|
out.push_str("\n\n---\n\n# Skills\n");
|
||||||
for skill in skills {
|
out.push_str("\nSnapshot version: ");
|
||||||
|
out.push_str(&effective.capabilities.version.to_string());
|
||||||
|
out.push('\n');
|
||||||
|
for skill in &effective.assigned_skills {
|
||||||
out.push_str("\n## ");
|
out.push_str("\n## ");
|
||||||
out.push_str(&skill.name);
|
out.push_str(&skill.snapshot.name);
|
||||||
out.push_str("\n\n");
|
out.push_str("\n\n");
|
||||||
out.push_str(skill.content_md.as_str());
|
out.push_str(skill.content.as_str());
|
||||||
out.push('\n');
|
out.push('\n');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !memory.is_empty() {
|
if !effective.memory.is_empty() {
|
||||||
out.push_str("\n\n---\n\n# Mémoire projet\n\n");
|
out.push_str("\n\n---\n\n# Mémoire projet\n\n");
|
||||||
for entry in memory {
|
for entry in &effective.memory {
|
||||||
out.push_str("- [");
|
out.push_str("- [");
|
||||||
out.push_str(&entry.title);
|
out.push_str(&entry.title);
|
||||||
out.push_str("](");
|
out.push_str("](");
|
||||||
@ -3493,14 +3513,14 @@ pub(crate) fn compose_convention_file(
|
|||||||
// ligne par agent (`- **Nom** — status · intent`), intent omis si vide. Jamais
|
// ligne par agent (`- **Nom** — status · intent`), intent omis si vide. Jamais
|
||||||
// d'UUID (ticket/lastDelegation), de progress ni de transcript. `live_rows` vide ⇒
|
// d'UUID (ticket/lastDelegation), de progress ni de transcript. `live_rows` vide ⇒
|
||||||
// section entièrement omise (document octet-identique à sans-section).
|
// section entièrement omise (document octet-identique à sans-section).
|
||||||
if !live_rows.is_empty() {
|
if !effective.live_rows.is_empty() {
|
||||||
out.push_str("\n\n---\n\n# État du projet\n\n");
|
out.push_str("\n\n---\n\n# État du projet\n\n");
|
||||||
out.push_str(
|
out.push_str(
|
||||||
"Aperçu de ce que font les autres agents en ce moment (last-writer-wins, \
|
"Aperçu de ce que font les autres agents en ce moment (last-writer-wins, \
|
||||||
non temps-réel). Pour le détail ou pour publier ton propre statut, utilise \
|
non temps-réel). Pour le détail ou pour publier ton propre statut, utilise \
|
||||||
`idea_workstate_read` / `idea_workstate_set`.\n\n",
|
`idea_workstate_read` / `idea_workstate_set`.\n\n",
|
||||||
);
|
);
|
||||||
for row in live_rows {
|
for row in &effective.live_rows {
|
||||||
out.push_str("- **");
|
out.push_str("- **");
|
||||||
out.push_str(&row.name);
|
out.push_str(&row.name);
|
||||||
out.push_str("** — ");
|
out.push_str("** — ");
|
||||||
@ -3515,7 +3535,7 @@ pub(crate) fn compose_convention_file(
|
|||||||
|
|
||||||
// Reprise conversationnelle (lot P7) : section finale, la plus situationnelle
|
// Reprise conversationnelle (lot P7) : section finale, la plus situationnelle
|
||||||
// (« où on en était »), placée après la mémoire projet. Omise sans handoff.
|
// (« où on en était »), placée après la mémoire projet. Omise sans handoff.
|
||||||
if let Some(handoff) = handoff {
|
if let Some(handoff) = effective.handoff.as_ref() {
|
||||||
out.push_str("\n\n---\n\n# Reprise de la conversation\n\n");
|
out.push_str("\n\n---\n\n# Reprise de la conversation\n\n");
|
||||||
if let Some(objective) = &handoff.objective {
|
if let Some(objective) = &handoff.objective {
|
||||||
if !objective.trim().is_empty() {
|
if !objective.trim().is_empty() {
|
||||||
@ -3612,6 +3632,46 @@ fn slugify(name: &str) -> String {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use domain::Skill;
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
fn compose_convention_file(
|
||||||
|
project_root: &str,
|
||||||
|
project_context: &str,
|
||||||
|
agent_md: &str,
|
||||||
|
skills: &[domain::Skill],
|
||||||
|
memory: &[MemoryIndexEntry],
|
||||||
|
handoff: Option<&Handoff>,
|
||||||
|
live_rows: &[InjectedLiveRow],
|
||||||
|
mcp_enabled: bool,
|
||||||
|
) -> String {
|
||||||
|
let assigned_skills: Vec<ResolvedAssignedSkill> = skills
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(index, skill)| ResolvedAssignedSkill {
|
||||||
|
snapshot: domain::AssignedSkillSnapshot {
|
||||||
|
skill_id: skill.id,
|
||||||
|
scope: skill.scope,
|
||||||
|
name: skill.name.clone(),
|
||||||
|
description: skill.effective_description(),
|
||||||
|
assignment_index: index as u32,
|
||||||
|
},
|
||||||
|
content: skill.content_md.clone(),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let effective = EffectiveAgentContext {
|
||||||
|
persona: MarkdownDoc::new(agent_md),
|
||||||
|
project_context: project_context.to_owned(),
|
||||||
|
capabilities: OrchestrationCapabilitySnapshot::new(
|
||||||
|
assigned_skills.iter().map(|s| s.snapshot.clone()).collect(),
|
||||||
|
),
|
||||||
|
assigned_skills,
|
||||||
|
memory: memory.to_vec(),
|
||||||
|
handoff: handoff.cloned(),
|
||||||
|
live_rows: live_rows.to_vec(),
|
||||||
|
};
|
||||||
|
super::compose_convention_file(project_root, &effective, mcp_enabled)
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn agent_run_dir_is_under_ideai_run_and_unique_per_agent() {
|
fn agent_run_dir_is_under_ideai_run_and_unique_per_agent() {
|
||||||
@ -3731,6 +3791,37 @@ mod tests {
|
|||||||
assert!(doc.contains("## review"));
|
assert!(doc.contains("## review"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn compose_convention_file_exposes_assigned_skill_snapshot_version() {
|
||||||
|
let skill = Skill::new(
|
||||||
|
domain::SkillId::from_uuid(uuid::Uuid::from_u128(1)),
|
||||||
|
"review",
|
||||||
|
MarkdownDoc::new("REVIEW_BODY"),
|
||||||
|
domain::SkillScope::Global,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
for mcp_enabled in [true, false] {
|
||||||
|
let doc = compose_convention_file(
|
||||||
|
"/root",
|
||||||
|
"",
|
||||||
|
"# Persona",
|
||||||
|
std::slice::from_ref(&skill),
|
||||||
|
&[],
|
||||||
|
None,
|
||||||
|
&[],
|
||||||
|
mcp_enabled,
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
doc.contains(&format!(
|
||||||
|
"Snapshot version: {}",
|
||||||
|
domain::ORCHESTRATION_CAPABILITY_SNAPSHOT_VERSION
|
||||||
|
)),
|
||||||
|
"assigned skill snapshot version must be visible (mcp_enabled={mcp_enabled})"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn compose_convention_file_skill_awareness_precedes_project_context() {
|
fn compose_convention_file_skill_awareness_precedes_project_context() {
|
||||||
let doc = compose_convention_file(
|
let doc = compose_convention_file(
|
||||||
|
|||||||
@ -169,10 +169,10 @@ pub use project::{
|
|||||||
};
|
};
|
||||||
pub use remote::{ConnectRemote, ConnectRemoteInput, ConnectRemoteOutput};
|
pub use remote::{ConnectRemote, ConnectRemoteInput, ConnectRemoteOutput};
|
||||||
pub use skill::{
|
pub use skill::{
|
||||||
AssignSkillToAgent, AssignSkillToAgentInput, CreateSkill, CreateSkillInput, CreateSkillOutput,
|
AssignSkillToAgent, AssignSkillToAgentInput, AssignedSkillResolver, CreateSkill,
|
||||||
DeleteSkill, DeleteSkillInput, ListSkills, ListSkillsInput, ListSkillsOutput, ReadSkill,
|
CreateSkillInput, CreateSkillOutput, DeleteSkill, DeleteSkillInput, ListSkills,
|
||||||
ReadSkillInput, UnassignSkillFromAgent, UnassignSkillFromAgentInput, UpdateSkill,
|
ListSkillsInput, ListSkillsOutput, ReadSkill, ReadSkillInput, UnassignSkillFromAgent,
|
||||||
UpdateSkillInput, UpdateSkillOutput,
|
UnassignSkillFromAgentInput, UpdateSkill, UpdateSkillInput, UpdateSkillOutput,
|
||||||
};
|
};
|
||||||
pub use sprints::{
|
pub use sprints::{
|
||||||
normalized_reorder, AssignTicketToSprint, AssignTicketToSprintInput,
|
normalized_reorder, AssignTicketToSprint, AssignTicketToSprintInput,
|
||||||
|
|||||||
@ -1353,7 +1353,7 @@ impl OrchestratorService {
|
|||||||
&self,
|
&self,
|
||||||
project: &Project,
|
project: &Project,
|
||||||
name: String,
|
name: String,
|
||||||
_requester: ConversationParty,
|
requester: ConversationParty,
|
||||||
) -> Result<OrchestratorOutcome, AppError> {
|
) -> Result<OrchestratorOutcome, AppError> {
|
||||||
let read_skill = self.read_skill.as_deref().ok_or_else(|| {
|
let read_skill = self.read_skill.as_deref().ok_or_else(|| {
|
||||||
AppError::Invalid("the idea_skill_read tool is not configured".to_owned())
|
AppError::Invalid("the idea_skill_read tool is not configured".to_owned())
|
||||||
@ -1361,7 +1361,8 @@ impl OrchestratorService {
|
|||||||
let md = read_skill
|
let md = read_skill
|
||||||
.execute(ReadSkillInput {
|
.execute(ReadSkillInput {
|
||||||
name: name.clone(),
|
name: name.clone(),
|
||||||
project_root: project.root.clone(),
|
project: project.clone(),
|
||||||
|
requester,
|
||||||
})
|
})
|
||||||
.await?;
|
.await?;
|
||||||
Ok(OrchestratorOutcome {
|
Ok(OrchestratorOutcome {
|
||||||
|
|||||||
@ -14,8 +14,8 @@
|
|||||||
mod usecases;
|
mod usecases;
|
||||||
|
|
||||||
pub use usecases::{
|
pub use usecases::{
|
||||||
AssignSkillToAgent, AssignSkillToAgentInput, CreateSkill, CreateSkillInput, CreateSkillOutput,
|
AssignSkillToAgent, AssignSkillToAgentInput, AssignedSkillResolver, CreateSkill,
|
||||||
DeleteSkill, DeleteSkillInput, ListSkills, ListSkillsInput, ListSkillsOutput, ReadSkill,
|
CreateSkillInput, CreateSkillOutput, DeleteSkill, DeleteSkillInput, ListSkills,
|
||||||
ReadSkillInput, UnassignSkillFromAgent, UnassignSkillFromAgentInput, UpdateSkill,
|
ListSkillsInput, ListSkillsOutput, ReadSkill, ReadSkillInput, UnassignSkillFromAgent,
|
||||||
UpdateSkillInput, UpdateSkillOutput,
|
UnassignSkillFromAgentInput, UpdateSkill, UpdateSkillInput, UpdateSkillOutput,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -8,10 +8,11 @@
|
|||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use domain::ports::{AgentContextStore, EventBus, IdGenerator, SkillStore};
|
use domain::ports::{AgentContextStore, EventBus, IdGenerator, SkillStore, StoreError};
|
||||||
use domain::{
|
use domain::{
|
||||||
AgentId, AgentManifest, DomainEvent, MarkdownDoc, Project, ProjectPath, Skill, SkillId,
|
Agent, AgentId, AgentManifest, AssignedSkillSnapshot, ConversationParty, DomainEvent,
|
||||||
SkillRef, SkillScope,
|
MarkdownDoc, OrchestrationCapabilitySnapshot, Project, ProjectPath, Skill, SkillId, SkillRef,
|
||||||
|
SkillScope,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
@ -213,70 +214,173 @@ impl DeleteSkill {
|
|||||||
// ReadSkill
|
// ReadSkill
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/// Input for [`ReadSkill::execute`].
|
/// Resolves assigned skills into the runtime capability snapshot used by both
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
/// context rendering and `idea_skill_read` authorization.
|
||||||
pub struct ReadSkillInput {
|
pub struct AssignedSkillResolver {
|
||||||
/// Skill display name to resolve (case-insensitive).
|
|
||||||
pub name: String,
|
|
||||||
/// Active project root (used for the [`SkillScope::Project`] lookup).
|
|
||||||
pub project_root: ProjectPath,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Reads a skill's Markdown body **by name** — the application side of the MCP
|
|
||||||
/// `idea_skill_read` tool (feature « skills à la MCP »).
|
|
||||||
///
|
|
||||||
/// Resolution follows the affordance contract: **project scope first, then
|
|
||||||
/// global** (a project skill shadows a global one of the same name). It composes
|
|
||||||
/// the **existing** [`SkillStore`] port only — no new port. Read-only.
|
|
||||||
pub struct ReadSkill {
|
|
||||||
skills: Arc<dyn SkillStore>,
|
skills: Arc<dyn SkillStore>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ReadSkill {
|
impl AssignedSkillResolver {
|
||||||
/// Builds the use case from the existing skill store port.
|
/// Builds the resolver from the existing skill store port.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn new(skills: Arc<dyn SkillStore>) -> Self {
|
pub fn new(skills: Arc<dyn SkillStore>) -> Self {
|
||||||
Self { skills }
|
Self { skills }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolves `name` in `scope`, returning the single match.
|
/// Resolves the assigned skills for `agent` in manifest order.
|
||||||
///
|
///
|
||||||
/// `Ok(None)` ⇒ no skill by that name in this scope; `Err(Invalid)` ⇒ the
|
/// Dangling assignments are skipped: a deleted skill must not block a launch,
|
||||||
/// name is **ambiguous** (more than one skill shares it in this scope).
|
/// and a skipped skill is therefore not readable via `idea_skill_read`.
|
||||||
async fn resolve_in(
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// [`AppError::Store`] on store failures other than a missing skill.
|
||||||
|
pub async fn resolve_for_agent(
|
||||||
&self,
|
&self,
|
||||||
scope: SkillScope,
|
agent: &Agent,
|
||||||
input: &ReadSkillInput,
|
root: &ProjectPath,
|
||||||
) -> Result<Option<Skill>, AppError> {
|
) -> Result<OrchestrationCapabilitySnapshot, AppError> {
|
||||||
let all = self.skills.list(scope, &input.project_root).await?;
|
let resolved = self.resolve_for_agent_with_content(agent, root).await?;
|
||||||
let mut matches = all
|
Ok(OrchestrationCapabilitySnapshot::new(
|
||||||
.into_iter()
|
resolved.into_iter().map(|(snapshot, _)| snapshot).collect(),
|
||||||
.filter(|s| s.name.eq_ignore_ascii_case(&input.name));
|
))
|
||||||
match (matches.next(), matches.next()) {
|
}
|
||||||
(None, _) => Ok(None),
|
|
||||||
(Some(skill), None) => Ok(Some(skill)),
|
/// Resolves assigned skills and keeps their Markdown bodies for non-MCP
|
||||||
(Some(_), Some(_)) => Err(AppError::Invalid(format!(
|
/// fallback context injection.
|
||||||
"skill name `{}` is ambiguous in {scope:?} scope (several skills share it)",
|
///
|
||||||
input.name
|
/// # Errors
|
||||||
))),
|
/// [`AppError::Store`] on store failures other than a missing skill.
|
||||||
|
pub async fn resolve_for_agent_with_content(
|
||||||
|
&self,
|
||||||
|
agent: &Agent,
|
||||||
|
root: &ProjectPath,
|
||||||
|
) -> Result<Vec<(AssignedSkillSnapshot, MarkdownDoc)>, AppError> {
|
||||||
|
let mut assigned = Vec::with_capacity(agent.skills.len());
|
||||||
|
for (index, skill_ref) in agent.skills.iter().enumerate() {
|
||||||
|
match self
|
||||||
|
.skills
|
||||||
|
.get(skill_ref.scope, root, skill_ref.skill_id)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(skill) => {
|
||||||
|
let description = skill.effective_description();
|
||||||
|
let snapshot = AssignedSkillSnapshot {
|
||||||
|
skill_id: skill.id,
|
||||||
|
scope: skill.scope,
|
||||||
|
name: skill.name,
|
||||||
|
description,
|
||||||
|
assignment_index: index as u32,
|
||||||
|
};
|
||||||
|
assigned.push((snapshot, skill.content_md));
|
||||||
|
}
|
||||||
|
Err(StoreError::NotFound) => {}
|
||||||
|
Err(e) => return Err(e.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(assigned)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads the Markdown body for one skill by name, but only inside the supplied
|
||||||
|
/// assigned-skill snapshot.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// - [`AppError::NotFound`] if `name` is not assigned in `snapshot`,
|
||||||
|
/// - [`AppError::Invalid`] if the assigned snapshot has ambiguous names,
|
||||||
|
/// - [`AppError::Store`] on a store failure.
|
||||||
|
pub async fn read_assigned_by_name(
|
||||||
|
&self,
|
||||||
|
snapshot: &OrchestrationCapabilitySnapshot,
|
||||||
|
root: &ProjectPath,
|
||||||
|
name: &str,
|
||||||
|
) -> Result<MarkdownDoc, AppError> {
|
||||||
|
let mut matches = snapshot
|
||||||
|
.assigned_skills
|
||||||
|
.iter()
|
||||||
|
.filter(|s| s.name.eq_ignore_ascii_case(name));
|
||||||
|
let selected = match (matches.next(), matches.next()) {
|
||||||
|
(None, _) => return Err(AppError::NotFound(format!("assigned skill `{name}`"))),
|
||||||
|
(Some(skill), None) => skill,
|
||||||
|
(Some(_), Some(_)) => {
|
||||||
|
return Err(AppError::Invalid(format!(
|
||||||
|
"assigned skill name `{name}` is ambiguous for this agent"
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let skill = self
|
||||||
|
.skills
|
||||||
|
.get(selected.scope, root, selected.skill_id)
|
||||||
|
.await?;
|
||||||
|
Ok(skill.content_md)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Input for [`ReadSkill::execute`].
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct ReadSkillInput {
|
||||||
|
/// Skill display name to resolve (case-insensitive).
|
||||||
|
pub name: String,
|
||||||
|
/// Active project.
|
||||||
|
pub project: Project,
|
||||||
|
/// Current requester identity from the orchestration handshake.
|
||||||
|
pub requester: ConversationParty,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads an assigned skill's Markdown body **by name** — the application side of
|
||||||
|
/// the MCP `idea_skill_read` tool.
|
||||||
|
///
|
||||||
|
/// The same [`AssignedSkillResolver`] that builds the runtime capability
|
||||||
|
/// snapshot authorizes the read. A model can read only skills assigned to the
|
||||||
|
/// requesting agent in the current manifest.
|
||||||
|
pub struct ReadSkill {
|
||||||
|
contexts: Arc<dyn AgentContextStore>,
|
||||||
|
resolver: Arc<AssignedSkillResolver>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ReadSkill {
|
||||||
|
/// Builds the use case from existing ports.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(contexts: Arc<dyn AgentContextStore>, skills: Arc<dyn SkillStore>) -> Self {
|
||||||
|
Self {
|
||||||
|
contexts,
|
||||||
|
resolver: Arc::new(AssignedSkillResolver::new(skills)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolves the skill by name and returns its Markdown body.
|
/// Builds the use case from a shared resolver.
|
||||||
|
#[must_use]
|
||||||
|
pub fn with_resolver(
|
||||||
|
contexts: Arc<dyn AgentContextStore>,
|
||||||
|
resolver: Arc<AssignedSkillResolver>,
|
||||||
|
) -> Self {
|
||||||
|
Self { contexts, resolver }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolves the requester's assigned snapshot and returns the skill body.
|
||||||
///
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
/// - [`AppError::Invalid`] if the name is ambiguous within a scope,
|
/// - [`AppError::Invalid`] if requester is not an agent or the assigned name is ambiguous,
|
||||||
/// - [`AppError::NotFound`] if no skill carries that name in either scope,
|
/// - [`AppError::NotFound`] if requester/skill is absent,
|
||||||
/// - [`AppError::Store`] on a store failure.
|
/// - [`AppError::Store`] on store failures.
|
||||||
pub async fn execute(&self, input: ReadSkillInput) -> Result<MarkdownDoc, AppError> {
|
pub async fn execute(&self, input: ReadSkillInput) -> Result<MarkdownDoc, AppError> {
|
||||||
// Project scope shadows global: try it first, then fall back to global.
|
let requester = input.requester.as_agent().ok_or_else(|| {
|
||||||
if let Some(skill) = self.resolve_in(SkillScope::Project, &input).await? {
|
AppError::Invalid("idea_skill_read requires an agent requester".to_owned())
|
||||||
return Ok(skill.content_md);
|
})?;
|
||||||
}
|
let manifest = self.contexts.load_manifest(&input.project).await?;
|
||||||
if let Some(skill) = self.resolve_in(SkillScope::Global, &input).await? {
|
let entry = manifest
|
||||||
return Ok(skill.content_md);
|
.entries
|
||||||
}
|
.iter()
|
||||||
Err(AppError::NotFound(format!("skill `{}`", input.name)))
|
.find(|e| e.agent_id == requester)
|
||||||
|
.ok_or_else(|| AppError::NotFound(format!("agent {requester}")))?;
|
||||||
|
let agent = entry
|
||||||
|
.to_agent()
|
||||||
|
.map_err(|e| AppError::Invalid(e.to_string()))?;
|
||||||
|
let snapshot = self
|
||||||
|
.resolver
|
||||||
|
.resolve_for_agent(&agent, &input.project.root)
|
||||||
|
.await?;
|
||||||
|
self.resolver
|
||||||
|
.read_assigned_by_name(&snapshot, &input.project.root, &input.name)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -432,7 +536,8 @@ mod tests {
|
|||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use domain::ports::{SkillStore, StoreError};
|
use domain::ports::{AgentContextStore, SkillStore, StoreError};
|
||||||
|
use domain::remote::RemoteRef;
|
||||||
|
|
||||||
/// In-memory [`SkillStore`] fake: skills are bucketed by scope, ignoring the
|
/// In-memory [`SkillStore`] fake: skills are bucketed by scope, ignoring the
|
||||||
/// project root (the [`ReadSkill`] resolution logic is root-agnostic — it only
|
/// project root (the [`ReadSkill`] resolution logic is root-agnostic — it only
|
||||||
@ -494,10 +599,88 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct FakeAgentContextStore {
|
||||||
|
manifest: Mutex<AgentManifest>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FakeAgentContextStore {
|
||||||
|
fn new(manifest: AgentManifest) -> Self {
|
||||||
|
Self {
|
||||||
|
manifest: Mutex::new(manifest),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl AgentContextStore for FakeAgentContextStore {
|
||||||
|
async fn read_context(
|
||||||
|
&self,
|
||||||
|
_project: &Project,
|
||||||
|
_agent: &AgentId,
|
||||||
|
) -> Result<MarkdownDoc, StoreError> {
|
||||||
|
Ok(MarkdownDoc::new("# Persona"))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn write_context(
|
||||||
|
&self,
|
||||||
|
_project: &Project,
|
||||||
|
_agent: &AgentId,
|
||||||
|
_md: &MarkdownDoc,
|
||||||
|
) -> Result<(), StoreError> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn load_manifest(&self, _project: &Project) -> Result<AgentManifest, StoreError> {
|
||||||
|
Ok(self.manifest.lock().unwrap().clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn save_manifest(
|
||||||
|
&self,
|
||||||
|
_project: &Project,
|
||||||
|
manifest: &AgentManifest,
|
||||||
|
) -> Result<(), StoreError> {
|
||||||
|
*self.manifest.lock().unwrap() = manifest.clone();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn root() -> ProjectPath {
|
fn root() -> ProjectPath {
|
||||||
ProjectPath::new("/proj").unwrap()
|
ProjectPath::new("/proj").unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn project() -> Project {
|
||||||
|
Project::new(
|
||||||
|
domain::ProjectId::from_uuid(uuid::Uuid::from_u128(100)),
|
||||||
|
"Project",
|
||||||
|
root(),
|
||||||
|
RemoteRef::Local,
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn agent_id(n: u128) -> AgentId {
|
||||||
|
AgentId::from_uuid(uuid::Uuid::from_u128(n))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn profile_id(n: u128) -> domain::ProfileId {
|
||||||
|
domain::ProfileId::from_uuid(uuid::Uuid::from_u128(n))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn manifest_for(agent_id: AgentId, skills: Vec<SkillRef>) -> AgentManifest {
|
||||||
|
let agent = Agent::new(
|
||||||
|
agent_id,
|
||||||
|
"Dev",
|
||||||
|
"agents/dev.md",
|
||||||
|
profile_id(900),
|
||||||
|
domain::AgentOrigin::Scratch,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
.with_skills(skills);
|
||||||
|
AgentManifest::new(1, vec![domain::ManifestEntry::from_agent(&agent)]).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
fn skill(id: u128, name: &str, body: &str, scope: SkillScope) -> Skill {
|
fn skill(id: u128, name: &str, body: &str, scope: SkillScope) -> Skill {
|
||||||
Skill::new(
|
Skill::new(
|
||||||
SkillId::from_uuid(uuid::Uuid::from_u128(id)),
|
SkillId::from_uuid(uuid::Uuid::from_u128(id)),
|
||||||
@ -508,20 +691,35 @@ mod tests {
|
|||||||
.unwrap()
|
.unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn read_skill_uc(store: Arc<FakeSkillStore>) -> ReadSkill {
|
fn read_skill_uc(
|
||||||
ReadSkill::new(store)
|
store: Arc<FakeSkillStore>,
|
||||||
|
manifest: AgentManifest,
|
||||||
|
) -> (ReadSkill, Project, AgentId) {
|
||||||
|
let requester = manifest.entries[0].agent_id;
|
||||||
|
let contexts = Arc::new(FakeAgentContextStore::new(manifest));
|
||||||
|
(ReadSkill::new(contexts, store), project(), requester)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn read_skill_resolves_project_scope() {
|
async fn read_skill_returns_assigned_project_skill() {
|
||||||
// (a) skill present in the project scope ⇒ its body is returned.
|
|
||||||
let store = Arc::new(FakeSkillStore::default());
|
let store = Arc::new(FakeSkillStore::default());
|
||||||
store.push(skill(1, "deploy", "PROJECT_BODY", SkillScope::Project));
|
store.push(skill(1, "deploy", "PROJECT_BODY", SkillScope::Project));
|
||||||
|
|
||||||
let body = read_skill_uc(store)
|
let requester = agent_id(10);
|
||||||
|
let manifest = manifest_for(
|
||||||
|
requester,
|
||||||
|
vec![SkillRef {
|
||||||
|
skill_id: SkillId::from_uuid(uuid::Uuid::from_u128(1)),
|
||||||
|
scope: SkillScope::Project,
|
||||||
|
}],
|
||||||
|
);
|
||||||
|
let (uc, project, requester) = read_skill_uc(store, manifest);
|
||||||
|
|
||||||
|
let body = uc
|
||||||
.execute(ReadSkillInput {
|
.execute(ReadSkillInput {
|
||||||
name: "deploy".to_owned(),
|
name: "deploy".to_owned(),
|
||||||
project_root: root(),
|
project,
|
||||||
|
requester: ConversationParty::agent(requester),
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@ -529,15 +727,25 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn read_skill_falls_back_to_global_scope() {
|
async fn read_skill_returns_assigned_global_skill() {
|
||||||
// (b) absent from project but present globally ⇒ resolved via global.
|
|
||||||
let store = Arc::new(FakeSkillStore::default());
|
let store = Arc::new(FakeSkillStore::default());
|
||||||
store.push(skill(1, "deploy", "GLOBAL_BODY", SkillScope::Global));
|
store.push(skill(1, "deploy", "GLOBAL_BODY", SkillScope::Global));
|
||||||
|
|
||||||
let body = read_skill_uc(store)
|
let requester = agent_id(10);
|
||||||
|
let manifest = manifest_for(
|
||||||
|
requester,
|
||||||
|
vec![SkillRef {
|
||||||
|
skill_id: SkillId::from_uuid(uuid::Uuid::from_u128(1)),
|
||||||
|
scope: SkillScope::Global,
|
||||||
|
}],
|
||||||
|
);
|
||||||
|
let (uc, project, requester) = read_skill_uc(store, manifest);
|
||||||
|
|
||||||
|
let body = uc
|
||||||
.execute(ReadSkillInput {
|
.execute(ReadSkillInput {
|
||||||
name: "deploy".to_owned(),
|
name: "deploy".to_owned(),
|
||||||
project_root: root(),
|
project,
|
||||||
|
requester: ConversationParty::agent(requester),
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@ -545,45 +753,19 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn read_skill_project_shadows_global() {
|
async fn read_skill_refuses_unassigned_name_even_if_skill_exists() {
|
||||||
// (c) same name in both scopes ⇒ project wins.
|
|
||||||
let store = Arc::new(FakeSkillStore::default());
|
let store = Arc::new(FakeSkillStore::default());
|
||||||
store.push(skill(1, "deploy", "GLOBAL_BODY", SkillScope::Global));
|
store.push(skill(1, "deploy", "PROJECT_BODY", SkillScope::Project));
|
||||||
store.push(skill(2, "deploy", "PROJECT_BODY", SkillScope::Project));
|
|
||||||
|
|
||||||
let body = read_skill_uc(store)
|
let requester = agent_id(10);
|
||||||
|
let manifest = manifest_for(requester, Vec::new());
|
||||||
|
let (uc, project, requester) = read_skill_uc(store, manifest);
|
||||||
|
|
||||||
|
let err = uc
|
||||||
.execute(ReadSkillInput {
|
.execute(ReadSkillInput {
|
||||||
name: "deploy".to_owned(),
|
name: "deploy".to_owned(),
|
||||||
project_root: root(),
|
project,
|
||||||
})
|
requester: ConversationParty::agent(requester),
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(body.as_str(), "PROJECT_BODY");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn read_skill_is_case_insensitive() {
|
|
||||||
let store = Arc::new(FakeSkillStore::default());
|
|
||||||
store.push(skill(1, "Deploy", "BODY", SkillScope::Project));
|
|
||||||
|
|
||||||
let body = read_skill_uc(store)
|
|
||||||
.execute(ReadSkillInput {
|
|
||||||
name: "deploy".to_owned(),
|
|
||||||
project_root: root(),
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(body.as_str(), "BODY");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn read_skill_unknown_is_not_found() {
|
|
||||||
// (d) unknown in both scopes ⇒ NotFound.
|
|
||||||
let store = Arc::new(FakeSkillStore::default());
|
|
||||||
let err = read_skill_uc(store)
|
|
||||||
.execute(ReadSkillInput {
|
|
||||||
name: "ghost".to_owned(),
|
|
||||||
project_root: root(),
|
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
@ -591,16 +773,76 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn read_skill_ambiguous_name_is_invalid() {
|
async fn read_skill_is_case_insensitive_within_assigned_snapshot() {
|
||||||
// (e) two skills share a name within a scope ⇒ Invalid (ambiguous).
|
|
||||||
let store = Arc::new(FakeSkillStore::default());
|
let store = Arc::new(FakeSkillStore::default());
|
||||||
store.push(skill(1, "deploy", "ONE", SkillScope::Project));
|
store.push(skill(1, "Deploy", "BODY", SkillScope::Project));
|
||||||
store.push(skill(2, "Deploy", "TWO", SkillScope::Project));
|
|
||||||
|
|
||||||
let err = read_skill_uc(store)
|
let requester = agent_id(10);
|
||||||
|
let manifest = manifest_for(
|
||||||
|
requester,
|
||||||
|
vec![SkillRef {
|
||||||
|
skill_id: SkillId::from_uuid(uuid::Uuid::from_u128(1)),
|
||||||
|
scope: SkillScope::Project,
|
||||||
|
}],
|
||||||
|
);
|
||||||
|
let (uc, project, requester) = read_skill_uc(store, manifest);
|
||||||
|
|
||||||
|
let body = uc
|
||||||
.execute(ReadSkillInput {
|
.execute(ReadSkillInput {
|
||||||
name: "deploy".to_owned(),
|
name: "deploy".to_owned(),
|
||||||
project_root: root(),
|
project,
|
||||||
|
requester: ConversationParty::agent(requester),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(body.as_str(), "BODY");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn read_skill_requires_agent_requester() {
|
||||||
|
let store = Arc::new(FakeSkillStore::default());
|
||||||
|
let requester = agent_id(10);
|
||||||
|
let manifest = manifest_for(requester, Vec::new());
|
||||||
|
let (uc, project, _) = read_skill_uc(store, manifest);
|
||||||
|
|
||||||
|
let err = uc
|
||||||
|
.execute(ReadSkillInput {
|
||||||
|
name: "ghost".to_owned(),
|
||||||
|
project,
|
||||||
|
requester: ConversationParty::User,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(matches!(err, AppError::Invalid(_)), "got {err:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn read_skill_ambiguous_assigned_name_is_invalid() {
|
||||||
|
let store = Arc::new(FakeSkillStore::default());
|
||||||
|
store.push(skill(1, "deploy", "ONE", SkillScope::Project));
|
||||||
|
store.push(skill(2, "Deploy", "TWO", SkillScope::Global));
|
||||||
|
|
||||||
|
let requester = agent_id(10);
|
||||||
|
let manifest = manifest_for(
|
||||||
|
requester,
|
||||||
|
vec![
|
||||||
|
SkillRef {
|
||||||
|
skill_id: SkillId::from_uuid(uuid::Uuid::from_u128(1)),
|
||||||
|
scope: SkillScope::Project,
|
||||||
|
},
|
||||||
|
SkillRef {
|
||||||
|
skill_id: SkillId::from_uuid(uuid::Uuid::from_u128(2)),
|
||||||
|
scope: SkillScope::Global,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
let (uc, project, requester) = read_skill_uc(store, manifest);
|
||||||
|
|
||||||
|
let err = uc
|
||||||
|
.execute(ReadSkillInput {
|
||||||
|
name: "deploy".to_owned(),
|
||||||
|
project,
|
||||||
|
requester: ConversationParty::agent(requester),
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
|
|||||||
@ -2227,10 +2227,13 @@ impl BackendCore {
|
|||||||
let update_skill = Arc::new(UpdateSkill::new(Arc::clone(&skill_store_port)));
|
let update_skill = Arc::new(UpdateSkill::new(Arc::clone(&skill_store_port)));
|
||||||
let list_skills = Arc::new(ListSkills::new(Arc::clone(&skill_store_port)));
|
let list_skills = Arc::new(ListSkills::new(Arc::clone(&skill_store_port)));
|
||||||
let delete_skill = Arc::new(DeleteSkill::new(Arc::clone(&skill_store_port)));
|
let delete_skill = Arc::new(DeleteSkill::new(Arc::clone(&skill_store_port)));
|
||||||
// Lecture d'un skill par nom pour l'outil MCP `idea_skill_read` (feature
|
// Lecture d'un skill par nom pour l'outil MCP `idea_skill_read` : compose
|
||||||
// « skills à la MCP ») — compose le SkillStore existant, câblé plus bas sur
|
// le SkillStore et le manifeste agent afin d'autoriser uniquement les
|
||||||
// l'OrchestratorService via le builder additif `.with_read_skill(...)`.
|
// skills assignés au requester courant.
|
||||||
let read_skill = Arc::new(ReadSkill::new(Arc::clone(&skill_store_port)));
|
let read_skill = Arc::new(ReadSkill::new(
|
||||||
|
Arc::clone(&contexts_port),
|
||||||
|
Arc::clone(&skill_store_port),
|
||||||
|
));
|
||||||
let assign_skill = Arc::new(AssignSkillToAgent::new(
|
let assign_skill = Arc::new(AssignSkillToAgent::new(
|
||||||
Arc::clone(&contexts_port),
|
Arc::clone(&contexts_port),
|
||||||
Arc::clone(&events_port),
|
Arc::clone(&events_port),
|
||||||
|
|||||||
@ -169,6 +169,11 @@ pub use memory_harvest::{
|
|||||||
MAX_BLOCK_BYTES, MAX_DESCRIPTION_CHARS,
|
MAX_BLOCK_BYTES, MAX_DESCRIPTION_CHARS,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
pub use orchestrator::{
|
||||||
|
AssignedSkillSnapshot, OrchestrationCapabilitySnapshot,
|
||||||
|
ORCHESTRATION_CAPABILITY_SNAPSHOT_VERSION,
|
||||||
|
};
|
||||||
|
|
||||||
pub use model_catalogue::{
|
pub use model_catalogue::{
|
||||||
evaluate_compatibility, CliVersion, CompatibilityMatrix, ModelCatalogSource,
|
evaluate_compatibility, CliVersion, CompatibilityMatrix, ModelCatalogSource,
|
||||||
ModelCatalogueError, ModelCompatibility,
|
ModelCatalogueError, ModelCompatibility,
|
||||||
|
|||||||
@ -14,11 +14,65 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::conversation::ConversationParty;
|
use crate::conversation::ConversationParty;
|
||||||
use crate::ids::{AgentId, NodeId};
|
use crate::ids::{AgentId, NodeId, SkillId};
|
||||||
use crate::live_state::WorkStatus;
|
use crate::live_state::WorkStatus;
|
||||||
use crate::mailbox::TicketId;
|
use crate::mailbox::TicketId;
|
||||||
use crate::skill::SkillScope;
|
use crate::skill::SkillScope;
|
||||||
|
|
||||||
|
/// Current schema version for orchestration capabilities injected into an
|
||||||
|
/// agent's effective runtime context.
|
||||||
|
pub const ORCHESTRATION_CAPABILITY_SNAPSHOT_VERSION: u32 = 1;
|
||||||
|
|
||||||
|
/// Runtime snapshot of one skill assigned to an agent.
|
||||||
|
///
|
||||||
|
/// This is the agent-facing capability contract: launch context rendering and
|
||||||
|
/// `idea_skill_read` authorization are both derived from these snapshots, not
|
||||||
|
/// from ad hoc skill-name lookups.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct AssignedSkillSnapshot {
|
||||||
|
/// Stable skill id from the selected scope.
|
||||||
|
pub skill_id: SkillId,
|
||||||
|
/// Store scope used to resolve the skill body.
|
||||||
|
pub scope: SkillScope,
|
||||||
|
/// Display name shown to the model and accepted by `idea_skill_read`.
|
||||||
|
pub name: String,
|
||||||
|
/// One-line affordance description shown in the model context.
|
||||||
|
pub description: String,
|
||||||
|
/// Position in the agent assignment list, preserving manifest order.
|
||||||
|
pub assignment_index: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Provider-agnostic orchestration capability snapshot for one agent at runtime.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct OrchestrationCapabilitySnapshot {
|
||||||
|
/// Snapshot schema version.
|
||||||
|
pub version: u32,
|
||||||
|
/// Skills assigned to the agent and resolved for this runtime.
|
||||||
|
pub assigned_skills: Vec<AssignedSkillSnapshot>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OrchestrationCapabilitySnapshot {
|
||||||
|
/// Builds an empty snapshot at the current schema version.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn empty() -> Self {
|
||||||
|
Self {
|
||||||
|
version: ORCHESTRATION_CAPABILITY_SNAPSHOT_VERSION,
|
||||||
|
assigned_skills: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds a snapshot from assigned skills.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(assigned_skills: Vec<AssignedSkillSnapshot>) -> Self {
|
||||||
|
Self {
|
||||||
|
version: ORCHESTRATION_CAPABILITY_SNAPSHOT_VERSION,
|
||||||
|
assigned_skills,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Errors raised while validating a raw [`OrchestratorRequest`].
|
/// Errors raised while validating a raw [`OrchestratorRequest`].
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
||||||
pub enum OrchestratorError {
|
pub enum OrchestratorError {
|
||||||
@ -293,15 +347,15 @@ pub enum OrchestratorCommand {
|
|||||||
/// The reading party (handshake identity).
|
/// The reading party (handshake identity).
|
||||||
requester: ConversationParty,
|
requester: ConversationParty,
|
||||||
},
|
},
|
||||||
/// Read a reusable skill's Markdown body **by name** (`idea_skill_read`,
|
/// Read a reusable skill's Markdown body **by name** (`idea_skill_read`).
|
||||||
/// feature « skills à la MCP »). Resolution is project-scope-first then global;
|
/// The application layer authorizes the read against the requester's assigned
|
||||||
/// the body is returned inline. Read-only — no [`crate::fileguard::FileGuard`]
|
/// runtime skill snapshot; unassigned skills are not readable. Read-only — no
|
||||||
/// lease (skills are not mutated through this path).
|
/// [`crate::fileguard::FileGuard`] lease (skills are not mutated through this path).
|
||||||
ReadSkill {
|
ReadSkill {
|
||||||
/// Skill display name to resolve (case-insensitive).
|
/// Skill display name to resolve (case-insensitive).
|
||||||
name: String,
|
name: String,
|
||||||
/// The party that issued the read (handshake identity). Carried for
|
/// The party that issued the read (handshake identity). Must be an agent
|
||||||
/// symmetry/auditing with the other read tools; skill reads need no lease.
|
/// requester for authorization against assigned skills.
|
||||||
requester: ConversationParty,
|
requester: ConversationParty,
|
||||||
},
|
},
|
||||||
/// Write a memory note under the [`crate::fileguard::FileGuard`] (cadrage C7).
|
/// Write a memory note under the [`crate::fileguard::FileGuard`] (cadrage C7).
|
||||||
|
|||||||
Reference in New Issue
Block a user