Skip to content

Commit 4dde95c

Browse files
committed
feat(agent): wire robot CLI subcommand to expose Self-Documentation API Refs #1011
- Add Robot variant to Command enum with subcommands: capabilities, schemas, examples - Add RobotFormat enum (json, table, minimal) for --format flag - Add handle_robot_command() and format_robot_output() helpers - Wire Robot command in main() before catch-all server/offline dispatch - Add unreachable! arms in run_offline_command and run_server_command to satisfy exhaustive match (Robot is handled in main) - Add 4 integration tests: capabilities JSON, schemas JSON, examples text, all formats honoured - Fix pre-existing compilation errors in terraphim_orchestrator (config clone + concurrency_permit field)
1 parent 2e61125 commit 4dde95c

4 files changed

Lines changed: 310 additions & 1 deletion

File tree

crates/terraphim_agent/src/main.rs

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -545,6 +545,14 @@ pub enum OutputFormat {
545545
JsonCompact,
546546
}
547547

548+
#[derive(clap::ValueEnum, Debug, Clone, Default)]
549+
enum RobotFormat {
550+
#[default]
551+
Json,
552+
Table,
553+
Minimal,
554+
}
555+
548556
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
549557
enum CommandOutputMode {
550558
Human,
@@ -889,6 +897,12 @@ enum Command {
889897
#[arg(long)]
890898
config: Option<String>,
891899
},
900+
901+
/// Robot mode self-documentation commands
902+
Robot {
903+
#[command(subcommand)]
904+
sub: RobotSub,
905+
},
892906
}
893907

894908
#[derive(Subcommand, Debug)]
@@ -1214,6 +1228,32 @@ enum SessionsSub {
12141228
Stats,
12151229
}
12161230

1231+
#[derive(Subcommand, Debug)]
1232+
enum RobotSub {
1233+
/// Show robot capabilities
1234+
Capabilities {
1235+
/// Output format
1236+
#[arg(long, value_enum, default_value_t = RobotFormat::Json)]
1237+
format: RobotFormat,
1238+
},
1239+
/// Show command schemas
1240+
Schemas {
1241+
/// Command name to get schema for (all commands if omitted)
1242+
command: Option<String>,
1243+
/// Output format
1244+
#[arg(long, value_enum, default_value_t = RobotFormat::Json)]
1245+
format: RobotFormat,
1246+
},
1247+
/// Show command examples
1248+
Examples {
1249+
/// Command name to get examples for (all commands if omitted)
1250+
command: Option<String>,
1251+
/// Output format
1252+
#[arg(long, value_enum, default_value_t = RobotFormat::Table)]
1253+
format: RobotFormat,
1254+
},
1255+
}
1256+
12171257
fn emit_robot_error_and_exit(
12181258
err: &anyhow::Error,
12191259
code: robot::exit_codes::ExitCode,
@@ -1540,6 +1580,67 @@ fn apply_forgiving_parsing(args: &[String]) -> Vec<String> {
15401580
}
15411581
}
15421582

1583+
/// Format a value using robot mode output formatting.
1584+
fn format_robot_output<T: Serialize>(value: &T, format: RobotFormat) -> Result<String> {
1585+
let robot_format = match format {
1586+
RobotFormat::Json => robot::output::OutputFormat::Json,
1587+
RobotFormat::Table => robot::output::OutputFormat::Table,
1588+
RobotFormat::Minimal => robot::output::OutputFormat::Minimal,
1589+
};
1590+
let config = robot::output::RobotConfig::new().with_format(robot_format);
1591+
let formatter = robot::output::RobotFormatter::new(config);
1592+
formatter
1593+
.format(value)
1594+
.map_err(|e| anyhow::anyhow!("Failed to format output: {}", e))
1595+
}
1596+
1597+
/// Handle robot mode self-documentation commands.
1598+
fn handle_robot_command(sub: RobotSub) -> Result<()> {
1599+
let docs = robot::SelfDocumentation::new();
1600+
1601+
match sub {
1602+
RobotSub::Capabilities { format } => {
1603+
let caps = docs.capabilities_data();
1604+
let output = format_robot_output(&caps, format)?;
1605+
println!("{}", output);
1606+
}
1607+
RobotSub::Schemas { command, format } => {
1608+
if let Some(cmd) = command {
1609+
if let Some(schema) = docs.schema(&cmd) {
1610+
let output = format_robot_output(&schema, format)?;
1611+
println!("{}", output);
1612+
} else {
1613+
return Err(anyhow::anyhow!("Unknown command: {}", cmd));
1614+
}
1615+
} else {
1616+
let schemas = docs.all_schemas();
1617+
let output = format_robot_output(&schemas, format)?;
1618+
println!("{}", output);
1619+
}
1620+
}
1621+
RobotSub::Examples { command, format } => {
1622+
if let Some(cmd) = command {
1623+
if let Some(examples) = docs.examples(&cmd) {
1624+
let output = format_robot_output(&examples, format)?;
1625+
println!("{}", output);
1626+
} else {
1627+
return Err(anyhow::anyhow!("Unknown command: {}", cmd));
1628+
}
1629+
} else {
1630+
let all_examples: Vec<_> = docs
1631+
.all_schemas()
1632+
.iter()
1633+
.flat_map(|s| &s.examples)
1634+
.collect();
1635+
let output = format_robot_output(&all_examples, format)?;
1636+
println!("{}", output);
1637+
}
1638+
}
1639+
}
1640+
1641+
Ok(())
1642+
}
1643+
15431644
fn main() -> Result<()> {
15441645
let args: Vec<String> = std::env::args().collect();
15451646
let corrected_args = apply_forgiving_parsing(&args);
@@ -1632,6 +1733,10 @@ fn main() -> Result<()> {
16321733
}
16331734
rt.block_on(listener::run_listener(listener_config))
16341735
}
1736+
Some(Command::Robot { sub }) => {
1737+
handle_robot_command(sub)?;
1738+
Ok(())
1739+
}
16351740
Some(command) => {
16361741
let rt = Runtime::new()?;
16371742
let robot_mode = cli.robot;
@@ -2870,6 +2975,9 @@ async fn run_offline_command(
28702975
Command::Repl { .. } => {
28712976
unreachable!("REPL mode should be handled above")
28722977
}
2978+
Command::Robot { .. } => {
2979+
unreachable!("Robot commands are handled in main()")
2980+
}
28732981
}
28742982
}
28752983

@@ -4590,6 +4698,9 @@ async fn run_server_command(
45904698
eprintln!("The listener runs in offline mode only.");
45914699
std::process::exit(1);
45924700
}
4701+
Command::Robot { .. } => {
4702+
unreachable!("Robot commands are handled in main()")
4703+
}
45934704
}
45944705
}
45954706

crates/terraphim_agent/tests/offline_mode_tests.rs

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -528,3 +528,90 @@ async fn test_forgiving_parser_case_insensitive() -> Result<()> {
528528

529529
Ok(())
530530
}
531+
532+
#[tokio::test]
533+
#[serial]
534+
async fn test_robot_capabilities_json() -> Result<()> {
535+
let (stdout, stderr, code) =
536+
run_offline_command(&["robot", "capabilities", "--format", "json"])?;
537+
538+
assert_eq!(
539+
code, 0,
540+
"robot capabilities should succeed, stderr: {}",
541+
stderr
542+
);
543+
544+
// Should output valid JSON
545+
let json: serde_json::Value = serde_json::from_str(&stdout).expect("Should be valid JSON");
546+
assert!(json.get("name").is_some(), "Should have name field");
547+
assert!(json.get("version").is_some(), "Should have version field");
548+
assert!(json.get("commands").is_some(), "Should have commands field");
549+
550+
Ok(())
551+
}
552+
553+
#[tokio::test]
554+
#[serial]
555+
async fn test_robot_schemas_json() -> Result<()> {
556+
let (stdout, stderr, code) =
557+
run_offline_command(&["robot", "schemas", "search", "--format", "json"])?;
558+
559+
assert_eq!(
560+
code, 0,
561+
"robot schemas search should succeed, stderr: {}",
562+
stderr
563+
);
564+
565+
// Should output valid JSON schema
566+
let json: serde_json::Value = serde_json::from_str(&stdout).expect("Should be valid JSON");
567+
assert!(json.get("name").is_some(), "Should have name field");
568+
assert!(
569+
json.get("arguments").is_some(),
570+
"Should have arguments field"
571+
);
572+
573+
Ok(())
574+
}
575+
576+
#[tokio::test]
577+
#[serial]
578+
async fn test_robot_examples_text() -> Result<()> {
579+
let (stdout, stderr, code) = run_offline_command(&["robot", "examples", "search"])?;
580+
581+
assert_eq!(
582+
code, 0,
583+
"robot examples search should succeed, stderr: {}",
584+
stderr
585+
);
586+
587+
// Should output readable text (not error)
588+
assert!(
589+
!stdout.starts_with("error:") && !stdout.starts_with("Error:"),
590+
"Should not show error: {}",
591+
stdout
592+
);
593+
assert!(!stdout.is_empty(), "Should have output");
594+
595+
Ok(())
596+
}
597+
598+
#[tokio::test]
599+
#[serial]
600+
async fn test_robot_capabilities_all_formats() -> Result<()> {
601+
for format in ["json", "table", "minimal"] {
602+
let (stdout, stderr, code) =
603+
run_offline_command(&["robot", "capabilities", "--format", format])?;
604+
assert_eq!(
605+
code, 0,
606+
"robot capabilities --format {} should succeed, stderr: {}",
607+
format, stderr
608+
);
609+
assert!(
610+
!stdout.is_empty(),
611+
"Should have output for format {}",
612+
format
613+
);
614+
}
615+
616+
Ok(())
617+
}

crates/terraphim_orchestrator/src/concurrency.rs

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,40 @@ impl ConcurrencyController {
162162
self.acquire(AgentMode::MentionDriven, project).await
163163
}
164164

165+
/// Try to acquire a generic slot without mode-specific quota checks.
166+
///
167+
/// Only enforces the global semaphore and per-project caps. Used when the
168+
/// caller does not know the agent mode (e.g. `spawn_agent` is shared across
169+
/// cron, mention, and issue-driven paths).
170+
pub async fn acquire_any(&self, project: &str) -> Option<AgentPermit> {
171+
if !self
172+
.project_has_capacity(AgentMode::TimeDriven, project)
173+
.await
174+
{
175+
return None;
176+
}
177+
let global_permit = match self.global.clone().try_acquire_owned() {
178+
Ok(p) => p,
179+
Err(_) => {
180+
tracing::debug!("global concurrency limit reached");
181+
return None;
182+
}
183+
};
184+
{
185+
let mut counts = self.running.lock().await;
186+
counts.time_driven += 1;
187+
let entry = counts.per_project.entry(project.to_string()).or_default();
188+
entry.total += 1;
189+
}
190+
tracing::debug!(project, "acquired generic concurrency slot");
191+
Some(AgentPermit {
192+
_global: global_permit,
193+
mode: AgentMode::TimeDriven,
194+
project: project.to_string(),
195+
running: self.running.clone(),
196+
})
197+
}
198+
165199
/// Get current running counts (time_driven, issue_driven).
166200
pub async fn running_counts(&self) -> (usize, usize) {
167201
let counts = self.running.lock().await;

0 commit comments

Comments
 (0)