Skip to content

Commit 4dcb49c

Browse files
initcronclaude
andcommitted
feat: Implement schema-based rendering for structured output
Add intelligent output rendering guided by schema FormatHint: Schema-Driven Rendering: - Validate JSON output against schema before rendering - Use FormatHint to select rendering strategy: - Table: Render arrays of objects as formatted tables - List: Render arrays as bulleted lists - Json: Pretty-print JSON with syntax highlighting - Yaml: Render as YAML format - Auto: Auto-detect format (fallback to existing logic) Rendering Functions: - render_as_table(): Extract arrays from common patterns (containers, resources, items) and render with colored headers - render_as_list(): Render arrays as bulleted lists with cyan bullets - render_as_yaml(): Convert JSON to YAML format Validation: - Schema validation warns (not errors) when output doesn't match - Graceful fallback to auto-detection if schema parsing fails - Backward compatible: works without schema (None) Example Usage: # Pre-built schema with Table hint aofctl run agent docker.yaml --output-schema container-list # Pre-built schema with Table hint aofctl run agent stats.yaml --output-schema resource-stats # Pre-built schema with List hint aofctl run agent list.yaml --output-schema simple-list The format hint ensures consistent rendering regardless of LLM output variations, providing a stable CLI experience. Related: #74 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 82ac1af commit 4dcb49c

1 file changed

Lines changed: 132 additions & 6 deletions

File tree

  • crates/aofctl/src/commands

crates/aofctl/src/commands/run.rs

Lines changed: 132 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -302,9 +302,9 @@ async fn run_agent(
302302
});
303303

304304
// Execute with or without schema
305-
let result = if let Some(output_schema) = schema {
305+
let result = if let Some(ref output_schema) = schema {
306306
// Create context with schema attached
307-
let mut ctx = AgentContext::new(input_str).with_output_schema(output_schema);
307+
let mut ctx = AgentContext::new(input_str).with_output_schema(output_schema.clone());
308308
runtime
309309
.execute_with_context(&agent_name, &mut ctx)
310310
.await
@@ -323,7 +323,7 @@ async fn run_agent(
323323
io::stdout().flush().ok();
324324

325325
// Output result in requested format with smart visualization
326-
output_result_smart(&agent_name, &result, output)?;
326+
output_result_smart(&agent_name, &result, output, schema.as_ref())?;
327327

328328
Ok(())
329329
}
@@ -901,8 +901,9 @@ fn ui(f: &mut Frame, agent_name: &str, app: &AppState) {
901901
}
902902

903903
/// Format and output agent result with smart visualization
904-
fn output_result_smart(agent_name: &str, result: &str, output: &str) -> Result<()> {
904+
fn output_result_smart(agent_name: &str, result: &str, output: &str, schema: Option<&OutputSchema>) -> Result<()> {
905905
use comfy_table::{Table, presets::UTF8_FULL, ContentArrangement};
906+
use aof_core::FormatHint;
906907

907908
match output {
908909
"json" => {
@@ -926,7 +927,32 @@ fn output_result_smart(agent_name: &str, result: &str, output: &str) -> Result<(
926927
println!("{} {}", ColorizeColor::bold("Agent:").cyan(), ColorizeColor::bright_white(agent_name));
927928
println!();
928929

929-
// Detect and render format
930+
// If schema is present, validate and use format hint
931+
if let Some(schema) = schema {
932+
// Try to parse result as JSON
933+
if let Ok(json) = serde_json::from_str::<serde_json::Value>(result) {
934+
// Validate against schema
935+
if let Err(e) = schema.validate(&json) {
936+
eprintln!("{} Schema validation failed: {}", ColorizeColor::bold("Warning:").yellow(), e);
937+
}
938+
939+
// Use format hint to guide rendering
940+
let rendered = match schema.format_hint {
941+
Some(FormatHint::Table) => render_as_table(&json),
942+
Some(FormatHint::List) => render_as_list(&json),
943+
Some(FormatHint::Json) => Some(render_json(&json)),
944+
Some(FormatHint::Yaml) => render_as_yaml(&json),
945+
Some(FormatHint::Auto) | None => detect_and_render(result),
946+
};
947+
948+
if let Some(rendered) = rendered {
949+
println!("{}", rendered);
950+
return Ok(());
951+
}
952+
}
953+
}
954+
955+
// Fallback to auto-detection
930956
if let Some(rendered) = detect_and_render(result) {
931957
println!("{}", rendered);
932958
} else {
@@ -985,6 +1011,106 @@ fn render_json(json: &serde_json::Value) -> String {
9851011
json_str.bright_cyan().to_string()
9861012
}
9871013

1014+
/// Render JSON as a table (for array of objects)
1015+
fn render_as_table(json: &serde_json::Value) -> Option<String> {
1016+
use comfy_table::{Table, presets::UTF8_FULL, Cell};
1017+
use colored::Colorize;
1018+
1019+
// Extract array from common patterns
1020+
let array = match json {
1021+
serde_json::Value::Array(arr) => arr,
1022+
serde_json::Value::Object(obj) => {
1023+
// Try common field names
1024+
if let Some(serde_json::Value::Array(arr)) = obj.get("containers") {
1025+
arr
1026+
} else if let Some(serde_json::Value::Array(arr)) = obj.get("resources") {
1027+
arr
1028+
} else if let Some(serde_json::Value::Array(arr)) = obj.get("items") {
1029+
arr
1030+
} else {
1031+
return None;
1032+
}
1033+
}
1034+
_ => return None,
1035+
};
1036+
1037+
if array.is_empty() {
1038+
return Some("No items".to_string());
1039+
}
1040+
1041+
// Get headers from first object
1042+
let headers: Vec<String> = if let Some(serde_json::Value::Object(first)) = array.first() {
1043+
first.keys().cloned().collect()
1044+
} else {
1045+
return None;
1046+
};
1047+
1048+
let mut table = Table::new();
1049+
table.load_preset(UTF8_FULL);
1050+
table.set_header(headers.iter().map(|h| Cell::new(h).fg(comfy_table::Color::Cyan)));
1051+
1052+
// Add rows
1053+
for item in array {
1054+
if let serde_json::Value::Object(obj) = item {
1055+
let row: Vec<String> = headers
1056+
.iter()
1057+
.map(|h| {
1058+
obj.get(h)
1059+
.map(|v| match v {
1060+
serde_json::Value::String(s) => s.clone(),
1061+
serde_json::Value::Number(n) => n.to_string(),
1062+
serde_json::Value::Bool(b) => b.to_string(),
1063+
serde_json::Value::Null => "null".to_string(),
1064+
_ => v.to_string(),
1065+
})
1066+
.unwrap_or_default()
1067+
})
1068+
.collect();
1069+
table.add_row(row);
1070+
}
1071+
}
1072+
1073+
Some(table.to_string())
1074+
}
1075+
1076+
/// Render JSON as a list
1077+
fn render_as_list(json: &serde_json::Value) -> Option<String> {
1078+
use colored::Colorize;
1079+
1080+
// Extract array from common patterns
1081+
let array = match json {
1082+
serde_json::Value::Array(arr) => arr,
1083+
serde_json::Value::Object(obj) => {
1084+
if let Some(serde_json::Value::Array(arr)) = obj.get("items") {
1085+
arr
1086+
} else {
1087+
return None;
1088+
}
1089+
}
1090+
_ => return None,
1091+
};
1092+
1093+
if array.is_empty() {
1094+
return Some("No items".to_string());
1095+
}
1096+
1097+
let mut lines = Vec::new();
1098+
for item in array {
1099+
let line = match item {
1100+
serde_json::Value::String(s) => format!(" {} {}", "•".bright_cyan(), s),
1101+
_ => format!(" {} {}", "•".bright_cyan(), item.to_string()),
1102+
};
1103+
lines.push(line);
1104+
}
1105+
1106+
Some(lines.join("\n"))
1107+
}
1108+
1109+
/// Render JSON as YAML
1110+
fn render_as_yaml(json: &serde_json::Value) -> Option<String> {
1111+
serde_yaml::to_string(json).ok()
1112+
}
1113+
9881114
/// Try to render markdown table
9891115
fn try_render_markdown_table(content: &str) -> Option<String> {
9901116
use comfy_table::{Table, presets::UTF8_FULL, Cell};
@@ -1130,7 +1256,7 @@ fn try_render_docker_stats(content: &str) -> Option<String> {
11301256

11311257
/// Format and output agent result (legacy, for backwards compat)
11321258
fn output_result(agent_name: &str, result: &str, output: &str) -> Result<()> {
1133-
output_result_smart(agent_name, result, output)
1259+
output_result_smart(agent_name, result, output, None)
11341260
}
11351261

11361262
/// Run a workflow with configuration

0 commit comments

Comments
 (0)