ticket115: snapshot runtime skills assignés + durcissement idea_skill_read
This commit is contained in:
@ -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(
|
||||
|
||||
Reference in New Issue
Block a user