Skip to content

Commit b8ae4dd

Browse files
committed
Merge PR #236: fix(cortex-cli): warn about missing agent references during session import
2 parents 0c2624e + 2ec6d71 commit b8ae4dd

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
@@ -68,6 +68,9 @@ tar = "0.4"
6868
# For scrape command (HTML parsing)
6969
scraper = "0.22"
7070

71+
# For agent reference extraction from messages
72+
regex = { workspace = true }
73+
7174
# For mDNS service discovery
7275
hostname = { workspace = true }
7376

cortex-cli/src/agent_cmd.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -323,7 +323,7 @@ fn get_project_agents_dirs() -> Vec<PathBuf> {
323323
}
324324

325325
/// Load all agents from various sources.
326-
fn load_all_agents() -> Result<Vec<AgentInfo>> {
326+
pub fn load_all_agents() -> Result<Vec<AgentInfo>> {
327327
let mut agents = Vec::new();
328328
let mut seen_names = std::collections::HashSet::new();
329329

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 crate::styled_output::{print_info, print_success, print_warning};
@@ -14,6 +15,7 @@ use cortex_protocol::{
1415
ParsedCommand, UserMessageEvent,
1516
};
1617

18+
use crate::agent_cmd::load_all_agents;
1719
use crate::export_cmd::{ExportMessage, SessionExport};
1820

1921
/// Import a session from JSON format.
@@ -102,6 +104,25 @@ impl ImportCommand {
102104
);
103105
}
104106

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

@@ -235,6 +256,54 @@ async fn fetch_url(url: &str) -> Result<String> {
235256
}
236257
}
237258

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

0 commit comments

Comments
 (0)