agent conversation fix

This commit is contained in:
2026-06-27 12:42:37 +02:00
parent c2d1a669c5
commit 1fc7869160
38 changed files with 1173 additions and 1549 deletions

View File

@ -1069,7 +1069,9 @@ pub fn list_live_agents(
std::sync::Arc::clone(&state.terminal_sessions),
std::sync::Arc::clone(&state.structured_sessions),
);
Ok(LiveAgentListDto::from_pairs(live.live_agents()))
Ok(LiveAgentListDto::from_snapshots(
live.live_agent_snapshots(),
))
}
/// `attach_live_agent` — rebind an already-running agent session to a visible
@ -1116,9 +1118,15 @@ pub async fn stop_live_agent(
) -> Result<StopLiveAgentResponseDto, ErrorDto> {
let project = resolve_project(&request.project_id, &state).await?;
let agent_id = parse_agent_id(&request.agent_id)?;
let dependencies = state
.orchestrator_service
.active_wait_dependencies(agent_id);
// Backstop no-reply : arrêter l'observateur de fin de tour de l'agent (le handle est
// droppé ⇒ polling stoppé) avant de démonter sa session.
state.stop_turn_watch(agent_id);
for dependency in dependencies {
state.stop_turn_watch(dependency);
}
state
.stop_live_agent
.execute(StopLiveAgentInput { project, agent_id })

View File

@ -12,8 +12,8 @@ use application::{
AgentTicketState, AppError, AttachLiveAgentOutput, ConversationPreviewStatus,
ConversationTurnWorkPreview, ConversationWorkSummary, CreateProjectInput, CreateProjectOutput,
GitGraphOutput, HealthInput, HealthReport, LayoutKind, ListProjectsOutput, LiveSessionKind,
OpenProjectOutput, ProjectWorkState, StopLiveAgentOutput, TicketWorkSource, TicketWorkStatus,
TurnPage, TurnSource, TurnView,
LiveSessionSnapshot, OpenProjectOutput, ProjectWorkState, StopLiveAgentOutput,
TicketWorkSource, TicketWorkStatus, TurnPage, TurnSource, TurnView,
};
use domain::{AgentBusyState, PageCursor, PageDirection, Project, ProjectId, TurnRole};
@ -1460,6 +1460,8 @@ pub struct LiveAgentDto {
/// The live PTY session id, used to reattach a newly-opened cell without
/// respawning the agent.
pub session_id: String,
/// Runtime family that owns the session (`pty`/`structured`).
pub kind: LiveWorkSessionKindDto,
}
/// Response DTO for `list_live_agents` (transparent array on the wire).
@ -1468,7 +1470,7 @@ pub struct LiveAgentDto {
pub struct LiveAgentListDto(pub Vec<LiveAgentDto>);
impl LiveAgentListDto {
/// Builds the wire list from the registry's `(AgentId, NodeId, SessionId)` tuples.
/// Builds the wire list from the registry's live-session snapshots.
///
/// De-duplicates by `agent_id`: the "one live session per agent" invariant
/// guarantees an agent is live in at most one registry (PTY **or**
@ -1476,16 +1478,17 @@ impl LiveAgentListDto {
/// twice; the UI contract is a dup-free set (each agent appears once), so we
/// keep the first occurrence and drop any later one for the same agent.
#[must_use]
pub fn from_pairs(pairs: Vec<(AgentId, NodeId, domain::SessionId)>) -> Self {
pub fn from_snapshots(pairs: Vec<LiveSessionSnapshot>) -> Self {
let mut seen = std::collections::HashSet::new();
Self(
pairs
.into_iter()
.filter(|(agent_id, _, _)| seen.insert(*agent_id))
.map(|(agent_id, node_id, session_id)| LiveAgentDto {
agent_id: agent_id.to_string(),
node_id: node_id.to_string(),
session_id: session_id.to_string(),
.filter(|snapshot| seen.insert(snapshot.agent_id))
.map(|snapshot| LiveAgentDto {
agent_id: snapshot.agent_id.to_string(),
node_id: snapshot.node_id.to_string(),
session_id: snapshot.session_id.to_string(),
kind: snapshot.kind.into(),
})
.collect(),
)

View File

@ -28,14 +28,13 @@ use application::{
OrchestratorService, PermissionProjectorRegistry, ProposeContext, ReadAgentContext,
ReadContext, ReadConversationPage, ReadMemory, ReadMemoryIndex, ReadProjectContext, ReadSkill,
RecallMemory, ReconcileLayouts, ReconcileLiveState, ReconcileLiveStateInput, RecordTurn,
RecordTurnProvider, ReferenceProfiles,
RenameLayout, ResizeTerminal, ResolveAgentPermissions, ResolveMemoryLinks,
RotateConversationLog, SaveEmbedderProfile, SaveProfile, SessionLimitService, SetActiveLayout,
SnapshotRunningAgents, StopLiveAgent, StructuredSessions, SuggestedThisSession,
SyncAgentWithTemplate, TerminalSessions, UnassignSkillFromAgent, UpdateAgentContext,
UpdateAgentPermissions, UpdateLiveState, UpdateMemory, UpdateProjectContext,
UpdateProjectPermissions, UpdateSkill, UpdateTemplate, WriteMemory, WriteToTerminal,
AGENT_MEMORY_RECALL_BUDGET,
RecordTurnProvider, ReferenceProfiles, RenameLayout, ResizeTerminal, ResolveAgentPermissions,
ResolveMemoryLinks, RotateConversationLog, SaveEmbedderProfile, SaveProfile,
SessionLimitService, SetActiveLayout, SnapshotRunningAgents, StopLiveAgent, StructuredSessions,
SuggestedThisSession, SyncAgentWithTemplate, TerminalSessions, UnassignSkillFromAgent,
UpdateAgentContext, UpdateAgentPermissions, UpdateLiveState, UpdateMemory,
UpdateProjectContext, UpdateProjectPermissions, UpdateSkill, UpdateTemplate, WriteMemory,
WriteToTerminal, AGENT_MEMORY_RECALL_BUDGET,
};
use domain::ports::{
AgentContextStore, AgentRuntime, AgentSessionFactory, Clock, Embedder, EmbedderEnvInspector,
@ -59,8 +58,7 @@ use infrastructure::{
FsProjectStore, FsProviderSessionStore, FsSkillStore, FsTemplateStore, Git2Repository,
HeuristicHandoffSummarizer, IdeaiContextStore, InMemoryConversationRegistry, InMemoryMailbox,
LocalFileSystem, LocalProcessSpawner, McpServer, MediatedInbox, NaiveMemoryRecall,
OrchestratorWatchHandle, PortablePtyAdapter, RwFileGuard,
StructuredSessionFactory,
OrchestratorWatchHandle, PortablePtyAdapter, RwFileGuard, StructuredSessionFactory,
SystemClock, SystemMillisClock, TokioBroadcastEventBus, TokioScheduler, UuidGenerator,
VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS,
VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED,
@ -235,10 +233,7 @@ impl AppReconcileLiveState {
///
/// # Errors
/// [`AppError`] si le projet est inconnu (registre) ou si le store échoue.
pub(crate) async fn execute(
&self,
input: ReconcileLiveStateInput,
) -> Result<(), AppError> {
pub(crate) async fn execute(&self, input: ReconcileLiveStateInput) -> Result<(), AppError> {
let project = self.projects.load_project(input.project_id).await?;
let store = Arc::new(FsLiveStateStore::new(&project.root));
let uc = ReconcileLiveState::new(
@ -928,15 +923,9 @@ impl AppState {
// LaunchAgent shares the SAME pty_port and terminal_sessions as the terminal
// use cases — indispensable for the PtyBridge to work correctly.
//
// Option 1 « Terminal + MCP » (lot B-2) : on **ne câble plus** la fabrique
// structurée. La vue humaine d'un agent est désormais le **terminal brut
// natif** (PTY interactif) — réflexion live + Échap natifs CLI, zéro parsing —
// et la délégation inter-agents passe par les outils MCP (`idea_ask_agent` /
// `idea_reply`), pas par une `AgentChatView`. Sans `with_structured`, le point
// de routage §17.4 de `LaunchAgent::execute` retombe **toujours** sur le chemin
// PTH : tout profil (Claude/Codex inclus) ouvre une cellule terminal. Le code
// `launch_structured` reste en place (mort-code retiré au lot de nettoyage B-6).
let _session_factory = session_factory; // décâblé en B-2 (nettoyage B-6)
// The human-facing launcher intentionally stays PTY-only: when the user opens an
// agent cell, they keep the native Claude/Codex CLI and its commands. Inter-agent
// delegation gets its own launcher below, wired to structured/headless sessions.
// --- Permission projectors (lot LP3-5) ---
// UN seul registre, source unique de vérité, injecté à l'identique dans
@ -989,6 +978,40 @@ impl AppState {
}) as Arc<dyn LiveStateLeanProvider>),
);
// Inter-agent launcher: same context, memory, permissions and live-state
// injection as the human launcher, but with the structured factory wired. It is
// used only by OrchestratorService::ask_agent when a delegated target must be
// started headlessly; UI launches still go through `launch_agent` above.
let orchestrator_launch_agent = Arc::new(
LaunchAgent::new(
Arc::clone(&contexts_port),
Arc::clone(&profile_store_port),
Arc::clone(&runtime_port),
Arc::clone(&fs_port),
Arc::clone(&pty_port),
Arc::clone(&skill_store_port),
Arc::clone(&terminal_sessions),
Arc::clone(&events_port),
Arc::clone(&ids) as Arc<dyn IdGenerator>,
Arc::clone(&memory_recall_port),
Some(Arc::clone(&check_embedder_suggestion)),
)
.with_permission_store(Arc::clone(&permission_store_port))
.with_handoff_provider(
Arc::new(AppHandoffProvider) as Arc<dyn application::HandoffProvider>
)
.with_provider_session_provider(Arc::new(AppProviderSessionProvider)
as Arc<dyn application::ProviderSessionProvider>)
.with_permission_projectors(Arc::clone(&permission_projectors))
.with_live_state_lean(Arc::new(AppLiveStateLeanProvider {
clock: Arc::clone(&clock) as Arc<dyn Clock>,
}) as Arc<dyn LiveStateLeanProvider>)
.with_structured(
Arc::clone(&session_factory),
Arc::clone(&structured_sessions),
),
);
// Hot-swap an agent's runtime profile (§15.1). Reuses the shared context/
// profile/project/fs stores, the live-session registry and PTY port, and
// *composes* the launcher above for the in-place relaunch (no duplication).
@ -1034,9 +1057,11 @@ impl AppState {
// Backstop no-reply : observateur de fin de tour transcript (Claude `turn_duration`)
// — lit le même `<home>/.claude/projects/<encoded-run-dir>/` que l'inspecteur, via
// le même `FileSystem`. Armé par agent supporté au lancement (cf. `arm_turn_watch`).
let turn_watcher: Arc<dyn domain::ports::TurnWatcher> = Arc::new(
infrastructure::ClaudeTranscriptTurnWatcher::new(Arc::clone(&fs_port), home_dir.clone()),
);
let turn_watcher: Arc<dyn domain::ports::TurnWatcher> =
Arc::new(infrastructure::ClaudeTranscriptTurnWatcher::new(
Arc::clone(&fs_port),
home_dir.clone(),
));
let inspect_conversation = Arc::new(InspectConversation::new(
Arc::clone(&contexts_port),
Arc::clone(&profile_store_port),
@ -1259,10 +1284,6 @@ impl AppState {
// (rebind de la cellule-vue, zéro spawn) et stop (kill PTY via la primitive
// `CloseTerminal` existante / shutdown structuré). Aucune création de session.
let attach_live_agent = Arc::new(AttachLiveAgent::new(Arc::clone(&live_sessions)));
let stop_live_agent = Arc::new(StopLiveAgent::new(
Arc::clone(&live_sessions),
Arc::clone(&close_terminal),
));
// --- Limites de session des agents (ARCHITECTURE §21, LS7) ---
// Service pur-ports « détecter → planifier → reprendre » câblé sur l'existant :
@ -1334,7 +1355,7 @@ impl AppState {
let orchestrator_service = Arc::new(
OrchestratorService::new(
Arc::clone(&create_agent),
Arc::clone(&launch_agent),
Arc::clone(&orchestrator_launch_agent),
Arc::clone(&list_agents),
Arc::clone(&close_terminal),
Arc::clone(&update_agent_context),
@ -1412,7 +1433,8 @@ impl AppState {
);
let cwd = domain::project::ProjectPath::new(run_dir).ok()?;
infrastructure::transcript_activity_token(fs.as_ref(), &home, &cwd).await
}) as std::pin::Pin<Box<dyn std::future::Future<Output = Option<u64>> + Send>>
})
as std::pin::Pin<Box<dyn std::future::Future<Output = Option<u64>> + Send>>
}) as application::AskLivenessProbe
})
// Plafond absolu du rendez-vous délégué (réglage projet via
@ -1422,19 +1444,20 @@ impl AppState {
std::env::var("IDEA_ASK_RENDEZVOUS_CEILING_MS")
.ok()
.and_then(|v| v.trim().parse::<u32>().ok()),
)),
// NB (régression corrigée) : on ne câble PAS `.with_structured(...)` ici.
// Décision produit lot B-2 (« Option 1 Terminal + MCP », cf. construction
// de `LaunchAgent` plus haut) : la fabrique structurée est décâblée, donc
// AUCUN agent n'a de session structurée vivante — tous tournent en PTY brut
// et la délégation passe par les outils MCP (`idea_ask_agent`/`idea_reply`).
// Si l'orchestrateur recevait `with_structured`, `ask_agent` emprunterait la
// branche structurée (`ensure_structured_session`) qui ne peut jamais aboutir
// (le launcher ne crée plus de session structurée) ⇒ erreur systématique
// « aucune session structurée vivante après lancement ». On laisse donc
// `self.structured = None` pour que `ask_agent` retombe sur le chemin PTY+MCP
// fonctionnel. `drain_with_readiness` (readiness/heartbeat lot 1) reste dormant
// tant que la voie structurée n'est pas réactivée au composition root.
))
// Conversation inter-agent headless : le service voit le même registre que le
// launcher orchestrateur ci-dessus. Une cible à `structured_adapter` est donc
// démarrée/drainée via `AgentSession::send` et son `Final`, sans dépendre de
// `idea_reply`; les autres outils MCP restent câblés par ailleurs.
.with_structured(Arc::clone(&structured_sessions)),
);
let stop_live_agent = Arc::new(
StopLiveAgent::new(Arc::clone(&live_sessions), Arc::clone(&close_terminal))
.with_cascade(
Arc::clone(&input_mediator),
Arc::clone(&orchestrator_service),
),
);
// --- Windows (L10) ---
@ -1627,59 +1650,10 @@ impl AppState {
service_for_ready.release_agent_cold_start(AgentId::from_uuid(uuid));
}
});
// Borne du rendez-vous `idea_ask_agent` (filet serveur, plancher universel FINI) :
// réglage projet optionnel via `IDEA_ASK_RENDEZVOUS_TIMEOUT_MS`. Absent / invalide /
// `0` ⇒ défaut fini (600 s) — jamais (quasi-)infini, sinon une cible silencieuse
// wedge l'appelant.
let ask_timeout = infrastructure::resolve_ask_rendezvous_timeout(
std::env::var("IDEA_ASK_RENDEZVOUS_TIMEOUT_MS")
.ok()
.and_then(|v| v.trim().parse::<u32>().ok()),
);
// Plafond absolu du rendez-vous (réglage projet optionnel via
// `IDEA_ASK_RENDEZVOUS_CEILING_MS`, défaut 4 h) : borne dure que la fenêtre
// d'inactivité réarmée ne dépasse jamais, même contre une cible perpétuellement active.
let ask_ceiling = infrastructure::resolve_ask_rendezvous_ceiling(
std::env::var("IDEA_ASK_RENDEZVOUS_CEILING_MS")
.ok()
.and_then(|v| v.trim().parse::<u32>().ok()),
);
// Sonde d'activité de la cible (signe de vie du rendez-vous) : l'McpServer (infra) ne
// connaît la cible que par son NOM ; la composition root est la seule à savoir
// résoudre nom→AgentId puis dériver le run-dir transcript Claude. La sonde renvoie un
// jeton monotone = octets cumulés des `.jsonl` de la cible (croît même pendant un seul
// long tour sans `turn_duration`). `None` ⇒ cible/transcript introuvable ⇒ traité comme
// « pas de progrès » par le watchdog.
let service_for_probe = Arc::clone(&self.orchestrator_service);
let fs_for_probe = Arc::clone(&self.fs_port);
let home_for_probe = self.home_dir.clone();
let project_for_probe = project.clone();
let activity_probe: infrastructure::AskActivityProbe = Arc::new(move |target: String| {
let service = Arc::clone(&service_for_probe);
let fs = Arc::clone(&fs_for_probe);
let home = home_for_probe.clone();
let project = project_for_probe.clone();
Box::pin(async move {
let agent_id = service
.resolve_agent_id_by_name(&project, &target)
.await
.ok()
.flatten()?;
let run_dir = format!(
"{}/.ideai/run/{agent_id}",
project.root.as_str().trim_end_matches(['/', '\\'])
);
let cwd = domain::project::ProjectPath::new(run_dir).ok()?;
infrastructure::transcript_activity_token(fs.as_ref(), &home, &cwd).await
})
});
let handle = McpServerHandle::start(
McpServer::new(Arc::clone(&self.orchestrator_service), project.clone())
.with_events(events)
.with_ready_sink(ready_sink)
.with_ask_rendezvous_timeout(ask_timeout)
.with_ask_rendezvous_ceiling(ask_ceiling)
.with_activity_probe(activity_probe),
.with_ready_sink(ready_sink),
endpoint,
listener,
project_id,
@ -3368,8 +3342,6 @@ mod mcp_serve_peer_tests {
let names: Vec<&str> = tools.iter().map(|t| t["name"].as_str().unwrap()).collect();
for expected in [
"idea_list_agents",
"idea_ask_agent",
"idea_reply",
"idea_launch_agent",
"idea_stop_agent",
"idea_update_context",
@ -3390,10 +3362,12 @@ mod mcp_serve_peer_tests {
"missing tool {expected}; got {names:?}"
);
}
assert!(!names.contains(&"idea_ask_agent"));
assert!(!names.contains(&"idea_reply"));
assert_eq!(
tools.len(),
14,
"exactly the fourteen idea_* tools (7 base + 4 FileGuard C7 + idea_skill_read + 2 live-state LS4); got {names:?}"
12,
"exactly the twelve non-conversation idea_* tools; got {names:?}"
);
drop(client); // EOF ⇒ serve loop ends