ticket115: snapshot runtime skills assignés + durcissement idea_skill_read

This commit is contained in:
2026-07-31 13:56:18 +02:00
parent fe5fe7cb70
commit fbe3c51fd4
8 changed files with 611 additions and 215 deletions

View File

@ -18,7 +18,7 @@ use domain::ports::{
AgentContextStore, AgentRuntime, AgentSessionFactory, ContextInjectionPlan, EventBus,
FileSystem, FsError, IdGenerator, MemoryQuery, MemoryRecall, PermissionStore, PreparedContext,
ProfileStore, ProjectStore, PtyPort, RemotePath, SecretStore, SessionPlan, SkillStore,
SpawnSpec, StoreError, StructuredProviderLaunchPolicy, SystemPermissionStore,
SpawnSpec, StructuredProviderLaunchPolicy, SystemPermissionStore,
};
use domain::profile::{
McpConfigStrategy, OpenCodeProviderConfig, StructuredAdapter, CODEX_CODE_MODE_FEATURES_TABLE,
@ -29,9 +29,9 @@ use domain::{
bound_handoff_summary, Agent, AgentId, AgentManifest, AgentOrigin, AgentProfile,
ContextInjection, ConversationId, ConversationParty, DomainEvent, EffectivePermissions,
Handoff, HandoffStore, ManifestEntry, MarkdownDoc, MemoryIndexEntry, MemoryType, NetworkPolicy,
NodeId, PermissionProjector, Posture, ProfileId, Project, ProjectPath, ProjectedFile,
ProjectionContext, ProjectorKey, ProviderSessionStore, PtySize, SessionId, SessionKind,
SessionStatus, Skill, TerminalSession, HANDOFF_SUMMARY_MAX_CHARS,
NodeId, OrchestrationCapabilitySnapshot, PermissionProjector, Posture, ProfileId, Project,
ProjectPath, ProjectedFile, ProjectionContext, ProjectorKey, ProviderSessionStore, PtySize,
SessionId, SessionKind, SessionStatus, TerminalSession, HANDOFF_SUMMARY_MAX_CHARS,
};
use domain::live_state::WorkStatus;
@ -42,6 +42,7 @@ use crate::model_server::{
EnsureLocalModelServer, EnsureLocalModelServerInput, ModelServerUseGuard,
};
use crate::project::project_context_path;
use crate::skill::AssignedSkillResolver;
use crate::terminal::{StructuredSessions, TerminalSessions};
use crate::workstate::GetLiveStateLean;
@ -117,6 +118,36 @@ pub struct InjectedLiveRow {
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
/// (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>,
fs: Arc<dyn FileSystem>,
pty: Arc<dyn PtyPort>,
skills: Arc<dyn SkillStore>,
assigned_skills: Arc<AssignedSkillResolver>,
sessions: Arc<TerminalSessions>,
events: Arc<dyn EventBus>,
ids: Arc<dyn IdGenerator>,
@ -1243,7 +1274,7 @@ impl LaunchAgent {
runtime,
fs,
pty,
skills,
assigned_skills: Arc::new(AssignedSkillResolver::new(skills)),
sessions,
events,
ids,
@ -1381,31 +1412,44 @@ impl LaunchAgent {
self
}
/// Resolves the Markdown bodies of an agent's assigned skills, in the
/// **manifest order** (deterministic). A skill that no longer exists in its
/// store (deleted out from under the assignment) is silently skipped — a
/// dangling [`domain::SkillRef`] must not block a launch.
///
/// # Errors
/// [`AppError::Store`] on any store failure other than a missing skill.
async fn resolve_skills(
/// Builds the effective runtime context once, before the provider split, so
/// PTY, structured/headless, hot relaunch and handoff rebuild paths consume the
/// same capability snapshot.
async fn build_effective_context(
&self,
project: &Project,
manifest: &AgentManifest,
agent: &Agent,
root: &ProjectPath,
) -> Result<Vec<Skill>, AppError> {
let mut out = Vec::with_capacity(agent.skills.len());
for skill_ref in &agent.skills {
match self
.skills
.get(skill_ref.scope, root, skill_ref.skill_id)
.await
{
Ok(skill) => out.push(skill),
Err(StoreError::NotFound) => {}
Err(e) => return Err(e.into()),
}
}
Ok(out)
persona: MarkdownDoc,
cell_conversation_id: Option<&str>,
) -> Result<EffectiveAgentContext, AppError> {
let assigned_skills: Vec<ResolvedAssignedSkill> = self
.assigned_skills
.resolve_for_agent_with_content(agent, &project.root)
.await?
.into_iter()
.map(|(snapshot, content)| ResolvedAssignedSkill { snapshot, content })
.collect();
let capabilities = OrchestrationCapabilitySnapshot::new(
assigned_skills.iter().map(|s| s.snapshot.clone()).collect(),
);
let project_context = self.resolve_project_context(project).await?;
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
@ -1742,25 +1786,18 @@ impl LaunchAgent {
self.runtime
.prepare_invocation(&profile, &prepared, &run_dir, &session_plan)?;
// 5. Resolve the agent's assigned skills (their `.md` bodies), then apply
// the injection plan side effects *before* spawning.
let skills = self.resolve_skills(&agent, &input.project.root).await?;
let project_context = self.resolve_project_context(&input.project).await?;
let memory = self
.resolve_memory(&input.project.root, content.as_str())
.await;
// Reprise conversationnelle (lot P7) : best-effort, additif. Si la cellule a une
// conversation et qu'un handoff existe, son résumé est injecté dans le convention
// file (à côté de la mémoire projet). Indépendant du provider/resumable id.
let handoff = self
.resolve_handoff(&input.project.root, input.conversation_id.as_deref())
.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;
// 5. Build the effective context once, then apply the injection side
// effects before spawning. The capability snapshot inside this context
// is also the authorization source for `idea_skill_read`.
let effective_context = self
.build_effective_context(
&input.project,
&manifest,
&agent,
content.clone(),
input.conversation_id.as_deref(),
)
.await?;
// 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
// semantic embedder would now help. Fully isolated from the launch outcome —
@ -1776,12 +1813,7 @@ impl LaunchAgent {
self.apply_injection(
&input.project,
&agent.context_path,
&content,
&project_context,
&skills,
&memory,
handoff.as_ref(),
&live_rows,
&effective_context,
profile.mcp.is_some(),
&mut spec,
)
@ -2338,12 +2370,7 @@ impl LaunchAgent {
&self,
project: &Project,
context_rel_path: &str,
content: &MarkdownDoc,
project_context: &str,
skills: &[Skill],
memory: &[MemoryIndexEntry],
handoff: Option<&Handoff>,
live_rows: &[InjectedLiveRow],
effective: &EffectiveAgentContext,
mcp_enabled: bool,
spec: &mut SpawnSpec,
) -> Result<(), AppError> {
@ -2356,16 +2383,8 @@ impl LaunchAgent {
// composed: an absolute project-root header (so the agent knows
// where to operate, since its cwd is *not* the root), the agent's
// persona `.md`, then the bodies of its assigned skills (§14.2).
let document = compose_convention_file(
project.root.as_str(),
project_context,
content.as_str(),
skills,
memory,
handoff,
live_rows,
mcp_enabled,
);
let document =
compose_convention_file(project.root.as_str(), effective, mcp_enabled);
let path = RemotePath::new(join(&spec.cwd, &target));
self.fs.write(&path, document.as_bytes()).await?;
}
@ -3331,12 +3350,7 @@ fn append_block(input: &str, block: &str) -> String {
#[must_use]
pub(crate) fn compose_convention_file(
project_root: &str,
project_context: &str,
agent_md: &str,
skills: &[Skill],
memory: &[MemoryIndexEntry],
handoff: Option<&Handoff>,
live_rows: &[InjectedLiveRow],
effective: &EffectiveAgentContext,
mcp_enabled: bool,
) -> String {
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
// `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.
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("Snapshot version: ");
out.push_str(&effective.capabilities.version.to_string());
out.push_str("\n\n");
out.push_str(
"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 \
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(&skill.name);
out.push_str("** — ");
out.push_str(&skill.effective_description());
out.push_str(&skill.description);
out.push('\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(project_context.trim());
out.push_str(effective.project_context.trim());
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
// 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`,
// 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");
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(&skill.name);
out.push_str(&skill.snapshot.name);
out.push_str("\n\n");
out.push_str(skill.content_md.as_str());
out.push_str(skill.content.as_str());
out.push('\n');
}
}
if !memory.is_empty() {
if !effective.memory.is_empty() {
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(&entry.title);
out.push_str("](");
@ -3493,14 +3513,14 @@ pub(crate) fn compose_convention_file(
// ligne par agent (`- **Nom** — status · intent`), intent omis si vide. Jamais
// d'UUID (ticket/lastDelegation), de progress ni de transcript. `live_rows` vide ⇒
// 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(
"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 \
`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(&row.name);
out.push_str("** — ");
@ -3515,7 +3535,7 @@ pub(crate) fn compose_convention_file(
// Reprise conversationnelle (lot P7) : section finale, la plus situationnelle
// (« 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");
if let Some(objective) = &handoff.objective {
if !objective.trim().is_empty() {
@ -3612,6 +3632,46 @@ fn slugify(name: &str) -> String {
#[cfg(test)]
mod tests {
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]
fn agent_run_dir_is_under_ideai_run_and_unique_per_agent() {
@ -3731,6 +3791,37 @@ mod tests {
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]
fn compose_convention_file_skill_awareness_precedes_project_context() {
let doc = compose_convention_file(