Skip to content

Commit 2ec6d71

Browse files
author
Bounty Bot
committed
fix(cortex-cli): warn about missing agent references during session import [skip ci]
When importing a session that was exported from a different cortex installation with different agent configurations, the import command now validates agent references and warns the user about missing agents. Changes: - Add 'agent' and 'agent_refs' fields to SessionMetadata export format - Extract @agent mentions from messages during export - Validate agent references during import against locally available agents - Display warning about missing agents but still allow import to proceed - Add comprehensive tests for agent reference validation Fixes PlatformNetwork/bounty-challenge#2194
1 parent e47aa56 commit 2ec6d71

5 files changed

Lines changed: 243 additions & 4 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

cortex-cli/Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,9 @@ tar = "0.4"
6464
# For scrape command (HTML parsing)
6565
scraper = "0.22"
6666

67+
# For agent reference extraction from messages
68+
regex = { workspace = true }
69+
6770
# For mDNS service discovery
6871
hostname = { workspace = true }
6972

cortex-cli/src/agent_cmd.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -313,7 +313,7 @@ fn get_project_agents_dir() -> Option<PathBuf> {
313313
}
314314

315315
/// Load all agents from various sources.
316-
fn load_all_agents() -> Result<Vec<AgentInfo>> {
316+
pub fn load_all_agents() -> Result<Vec<AgentInfo>> {
317317
let mut agents = Vec::new();
318318

319319
// Load built-in agents

cortex-cli/src/export_cmd.rs

Lines changed: 94 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,12 @@ pub struct SessionMetadata {
5555
/// Model used for the session.
5656
#[serde(skip_serializing_if = "Option::is_none")]
5757
pub model: Option<String>,
58+
/// Agent used for the session (if any).
59+
#[serde(skip_serializing_if = "Option::is_none", default)]
60+
pub agent: Option<String>,
61+
/// Agent references found in messages (for validation during import).
62+
#[serde(skip_serializing_if = "Option::is_none", default)]
63+
pub agent_refs: Option<Vec<String>>,
5864
}
5965

6066
/// Message in export format.
@@ -147,6 +153,13 @@ impl ExportCommand {
147153

148154
// Extract metadata
149155
let meta = get_session_meta(&entries);
156+
157+
// Extract messages from events
158+
let messages = extract_messages(&entries);
159+
160+
// Extract agent references from messages (@agent mentions)
161+
let agent_refs = extract_agent_refs(&messages);
162+
150163
let session_meta = SessionMetadata {
151164
id: conversation_id.to_string(),
152165
title: derive_title(&entries),
@@ -155,11 +168,14 @@ impl ExportCommand {
155168
.unwrap_or_else(|| chrono::Utc::now().to_rfc3339()),
156169
cwd: meta.map(|m| m.cwd.clone()),
157170
model: meta.and_then(|m| m.model.clone()),
171+
agent: None, // TODO: Extract from session config if stored
172+
agent_refs: if agent_refs.is_empty() {
173+
None
174+
} else {
175+
Some(agent_refs)
176+
},
158177
};
159178

160-
// Extract messages from events
161-
let messages = extract_messages(&entries);
162-
163179
// Build export
164180
let export = SessionExport {
165181
version: 1,
@@ -292,6 +308,29 @@ fn extract_messages(
292308
messages
293309
}
294310

311+
/// Extract agent references (@mentions) from messages.
312+
/// Returns a deduplicated list of agent names that are referenced.
313+
fn extract_agent_refs(messages: &[ExportMessage]) -> Vec<String> {
314+
use std::collections::HashSet;
315+
316+
// Regex to match @agent mentions (e.g., @explore, @general, @my-custom-agent)
317+
let re = regex::Regex::new(r"@([a-zA-Z][a-zA-Z0-9_-]*)").unwrap();
318+
319+
let mut agent_refs: HashSet<String> = HashSet::new();
320+
321+
for message in messages {
322+
for cap in re.captures_iter(&message.content) {
323+
if let Some(agent_name) = cap.get(1) {
324+
agent_refs.insert(agent_name.as_str().to_string());
325+
}
326+
}
327+
}
328+
329+
let mut refs: Vec<String> = agent_refs.into_iter().collect();
330+
refs.sort();
331+
refs
332+
}
333+
295334
#[cfg(test)]
296335
mod tests {
297336
use super::*;
@@ -306,6 +345,8 @@ mod tests {
306345
created_at: "2024-01-01T00:00:00Z".to_string(),
307346
cwd: Some("/home/user".to_string()),
308347
model: Some("claude-3".to_string()),
348+
agent: None,
349+
agent_refs: None,
309350
},
310351
messages: vec![
311352
ExportMessage {
@@ -330,4 +371,54 @@ mod tests {
330371
assert!(json.contains("\"role\": \"user\""));
331372
assert!(json.contains("\"content\": \"Hello\""));
332373
}
374+
375+
#[test]
376+
fn test_extract_agent_refs() {
377+
let messages = vec![
378+
ExportMessage {
379+
role: "user".to_string(),
380+
content: "@explore find the main function".to_string(),
381+
tool_calls: None,
382+
tool_call_id: None,
383+
timestamp: None,
384+
},
385+
ExportMessage {
386+
role: "user".to_string(),
387+
content: "@research analyze this code @my-agent".to_string(),
388+
tool_calls: None,
389+
tool_call_id: None,
390+
timestamp: None,
391+
},
392+
];
393+
394+
let refs = extract_agent_refs(&messages);
395+
assert_eq!(refs.len(), 3);
396+
assert!(refs.contains(&"explore".to_string()));
397+
assert!(refs.contains(&"research".to_string()));
398+
assert!(refs.contains(&"my-agent".to_string()));
399+
}
400+
401+
#[test]
402+
fn test_extract_agent_refs_no_duplicates() {
403+
let messages = vec![
404+
ExportMessage {
405+
role: "user".to_string(),
406+
content: "@explore task 1".to_string(),
407+
tool_calls: None,
408+
tool_call_id: None,
409+
timestamp: None,
410+
},
411+
ExportMessage {
412+
role: "user".to_string(),
413+
content: "@explore task 2".to_string(),
414+
tool_calls: None,
415+
tool_call_id: None,
416+
timestamp: None,
417+
},
418+
];
419+
420+
let refs = extract_agent_refs(&messages);
421+
assert_eq!(refs.len(), 1);
422+
assert!(refs.contains(&"explore".to_string()));
423+
}
333424
}

cortex-cli/src/import_cmd.rs

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
55
use anyhow::{Context, Result, bail};
66
use clap::Parser;
7+
use std::collections::HashSet;
78
use std::path::PathBuf;
89

910
use cortex_engine::rollout::recorder::{RolloutRecorder, SessionMeta};
@@ -13,6 +14,7 @@ use cortex_protocol::{
1314
ParsedCommand, UserMessageEvent,
1415
};
1516

17+
use crate::agent_cmd::load_all_agents;
1618
use crate::export_cmd::{ExportMessage, SessionExport};
1719

1820
/// Import a session from JSON format.
@@ -101,6 +103,25 @@ impl ImportCommand {
101103
);
102104
}
103105

106+
// Validate agent references in the imported session
107+
let missing_agents = validate_agent_references(&export)?;
108+
if !missing_agents.is_empty() {
109+
eprintln!(
110+
"Warning: The following agent references in this session are not available locally:"
111+
);
112+
for agent in &missing_agents {
113+
eprintln!(" - @{}", agent);
114+
}
115+
eprintln!();
116+
eprintln!(
117+
"The session will be imported, but agent-related functionality may not work as expected."
118+
);
119+
eprintln!(
120+
"To fix this, create the missing agents using 'cortex agent create <name>' or copy them from the source system."
121+
);
122+
eprintln!();
123+
}
124+
104125
// Generate a new session ID (we always create a new session on import)
105126
let new_conversation_id = ConversationId::new();
106127

@@ -232,6 +253,54 @@ async fn fetch_url(url: &str) -> Result<String> {
232253
}
233254
}
234255

256+
/// Validate agent references in the imported session.
257+
/// Returns a list of missing agent names that are referenced but not available locally.
258+
fn validate_agent_references(export: &SessionExport) -> Result<Vec<String>> {
259+
// Get all locally available agents
260+
let local_agents: HashSet<String> = match load_all_agents() {
261+
Ok(agents) => agents.into_iter().map(|a| a.name).collect(),
262+
Err(e) => {
263+
// If we can't load agents, log a warning but continue
264+
tracing::warn!("Could not load local agents for validation: {}", e);
265+
HashSet::new()
266+
}
267+
};
268+
269+
let mut missing_agents = Vec::new();
270+
271+
// Check the session's agent field (if the session was started with -a <agent>)
272+
if let Some(ref agent_name) = export.session.agent {
273+
if !local_agents.contains(agent_name) {
274+
missing_agents.push(agent_name.clone());
275+
}
276+
}
277+
278+
// Check agent_refs from the export metadata (pre-extracted @mentions)
279+
if let Some(ref agent_refs) = export.session.agent_refs {
280+
for agent_ref in agent_refs {
281+
if !local_agents.contains(agent_ref) && !missing_agents.contains(agent_ref) {
282+
missing_agents.push(agent_ref.clone());
283+
}
284+
}
285+
}
286+
287+
// Also scan messages for @agent mentions (in case they weren't pre-extracted)
288+
let re = regex::Regex::new(r"@([a-zA-Z][a-zA-Z0-9_-]*)").unwrap();
289+
for message in &export.messages {
290+
for cap in re.captures_iter(&message.content) {
291+
if let Some(agent_name) = cap.get(1) {
292+
let name = agent_name.as_str().to_string();
293+
if !local_agents.contains(&name) && !missing_agents.contains(&name) {
294+
missing_agents.push(name);
295+
}
296+
}
297+
}
298+
}
299+
300+
missing_agents.sort();
301+
Ok(missing_agents)
302+
}
303+
235304
/// Convert an export message to a protocol event.
236305
fn message_to_event(message: &ExportMessage, turn_id: &mut u64, cwd: &PathBuf) -> Result<Event> {
237306
let event_msg = match message.role.as_str() {
@@ -419,4 +488,79 @@ mod tests {
419488
assert_eq!(preview_len, 200);
420489
assert_eq!(&long_content[..preview_len].len(), &200);
421490
}
491+
492+
#[test]
493+
fn test_validate_agent_references_with_missing_agents() {
494+
use crate::export_cmd::SessionMetadata;
495+
496+
// Create an export with agent references that don't exist locally
497+
let export = SessionExport {
498+
version: 1,
499+
session: SessionMetadata {
500+
id: "test-123".to_string(),
501+
title: None,
502+
created_at: "2024-01-01T00:00:00Z".to_string(),
503+
cwd: None,
504+
model: None,
505+
agent: Some("custom-nonexistent-agent".to_string()),
506+
agent_refs: Some(vec!["another-nonexistent".to_string()]),
507+
},
508+
messages: vec![ExportMessage {
509+
role: "user".to_string(),
510+
content: "@yet-another-missing help me".to_string(),
511+
tool_calls: None,
512+
tool_call_id: None,
513+
timestamp: None,
514+
}],
515+
};
516+
517+
let missing = validate_agent_references(&export).unwrap();
518+
519+
// Should find at least the explicitly nonexistent ones
520+
// (built-in agents like 'explore', 'research' will exist)
521+
assert!(missing.contains(&"custom-nonexistent-agent".to_string()));
522+
assert!(missing.contains(&"another-nonexistent".to_string()));
523+
assert!(missing.contains(&"yet-another-missing".to_string()));
524+
}
525+
526+
#[test]
527+
fn test_validate_agent_references_with_builtin_agents() {
528+
use crate::export_cmd::SessionMetadata;
529+
530+
// Create an export referencing only built-in agents
531+
let export = SessionExport {
532+
version: 1,
533+
session: SessionMetadata {
534+
id: "test-123".to_string(),
535+
title: None,
536+
created_at: "2024-01-01T00:00:00Z".to_string(),
537+
cwd: None,
538+
model: None,
539+
agent: None,
540+
agent_refs: None,
541+
},
542+
messages: vec![
543+
ExportMessage {
544+
role: "user".to_string(),
545+
content: "@build help me compile".to_string(),
546+
tool_calls: None,
547+
tool_call_id: None,
548+
timestamp: None,
549+
},
550+
ExportMessage {
551+
role: "user".to_string(),
552+
content: "@plan create a plan".to_string(),
553+
tool_calls: None,
554+
tool_call_id: None,
555+
timestamp: None,
556+
},
557+
],
558+
};
559+
560+
let missing = validate_agent_references(&export).unwrap();
561+
562+
// Built-in agents should not be reported as missing
563+
assert!(!missing.contains(&"build".to_string()));
564+
assert!(!missing.contains(&"plan".to_string()));
565+
}
422566
}

0 commit comments

Comments
 (0)