use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub(crate) enum SubagentOutputPhase {
Plan,
Research,
Implement,
Review,
Document,
}
impl SubagentOutputPhase {
pub(crate) fn from_schema_ref(value: &str) -> Option<Self> {
match value {
"plan" => Some(Self::Plan),
"research" => Some(Self::Research),
"implement" => Some(Self::Implement),
"review" => Some(Self::Review),
"document" => Some(Self::Document),
_ => None,
}
}
fn phase_name(self) -> &'static str {
match self {
Self::Plan => "PLAN",
Self::Research => "RESEARCH",
Self::Implement => "IMPLEMENT",
Self::Review => "REVIEW",
Self::Document => "DOCUMENT",
}
}
fn status_values(self) -> &'static [&'static str] {
match self {
Self::Plan => &["READY", "NEEDS_REVISION", "BLOCKED"],
Self::Research => &["COMPLETE", "PARTIAL", "BLOCKED"],
Self::Implement => &["COMPLETE", "PARTIAL", "BLOCKED"],
Self::Review => &["COMPLETE", "PARTIAL", "BLOCKED"],
Self::Document => &["COMPLETE", "PARTIAL", "BLOCKED"],
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum SubagentOutputSchemaRef {
Builtin(SubagentOutputPhase),
Inline(Value),
None,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub(crate) struct SchemaValidationError {
pub(crate) instance_path: String,
pub(crate) schema_path: String,
pub(crate) message: String,
}
pub(crate) fn default_phase_for_identity(identity: &str) -> Option<SubagentOutputPhase> {
match identity {
"tars-plan-creation" => Some(SubagentOutputPhase::Plan),
"tars-research-investigation" => Some(SubagentOutputPhase::Research),
"tars-code-writing-execution" => Some(SubagentOutputPhase::Implement),
"tars-execution-review" | "tars-plan-review" | "tars-plan-audit" => {
Some(SubagentOutputPhase::Review)
}
"tars-document-authoring" => Some(SubagentOutputPhase::Document),
_ => None,
}
}
pub(crate) fn schema_for_ref(schema_ref: &SubagentOutputSchemaRef) -> Option<Value> {
match schema_ref {
SubagentOutputSchemaRef::Builtin(phase) => Some(builtin_schema(*phase)),
SubagentOutputSchemaRef::Inline(schema) => Some(schema.clone()),
SubagentOutputSchemaRef::None => None,
}
}
pub(crate) fn builtin_schema(phase: SubagentOutputPhase) -> Value {
let mut properties = common_properties(phase);
let mut required = vec![
"phase",
"status",
"summary",
"artifacts",
"verification",
"risks",
];
match phase {
SubagentOutputPhase::Plan => {
properties["plan_path"] = string_schema();
properties["ready_for_implementation"] = json!({"type": "boolean"});
properties["documentation"] = json!({
"type": "object",
"additionalProperties": false,
"properties": {
"prd": string_schema(),
"adr": string_schema(),
"rfc": string_schema()
},
"required": ["prd", "adr", "rfc"]
});
properties["key_assumptions"] = string_array_schema();
required.extend([
"plan_path",
"ready_for_implementation",
"documentation",
"key_assumptions",
]);
}
SubagentOutputPhase::Research => {
properties["findings"] = json!({
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"properties": {
"claim": string_schema(),
"evidence": string_schema(),
"confidence": {"enum": ["observed", "inferred", "unknown"]}
},
"required": ["claim", "evidence", "confidence"]
}
});
properties["open_questions"] = string_array_schema();
required.extend(["findings", "open_questions"]);
}
SubagentOutputPhase::Implement => {
properties["changed_files"] = string_array_schema();
properties["tests_run"] = string_array_schema();
properties["implementation_notes"] = string_array_schema();
required.extend(["changed_files", "tests_run", "implementation_notes"]);
}
SubagentOutputPhase::Review => {
properties["verdict"] = json!({"enum": ["PASS", "NEEDS_REVISION", "FAIL"]});
properties["findings"] = json!({
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"properties": {
"severity": {"enum": ["critical", "high", "medium", "low"]},
"file": string_schema(),
"issue": string_schema(),
"recommendation": string_schema()
},
"required": ["severity", "file", "issue", "recommendation"]
}
});
required.extend(["verdict", "findings"]);
}
SubagentOutputPhase::Document => {
properties["documents"] = json!({
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"properties": {
"path": string_schema(),
"status": string_schema(),
"change_summary": string_schema()
},
"required": ["path", "status", "change_summary"]
}
});
properties["ready"] = json!({"type": "boolean"});
required.extend(["documents", "ready"]);
}
}
json!({
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"additionalProperties": false,
"properties": properties,
"required": required
})
}
pub(crate) fn validate_instance(
schema: &Value,
instance: &Value,
) -> Result<(), Vec<SchemaValidationError>> {
let validator = jsonschema::validator_for(schema).map_err(|error| {
vec![SchemaValidationError {
instance_path: String::new(),
schema_path: String::new(),
message: format!("invalid output_schema: {error}"),
}]
})?;
let errors = validator
.iter_errors(instance)
.map(|error| SchemaValidationError {
instance_path: error.instance_path().to_string(),
schema_path: error.schema_path().to_string(),
message: error.to_string(),
})
.collect::<Vec<_>>();
if errors.is_empty() {
Ok(())
} else {
Err(errors)
}
}
fn common_properties(phase: SubagentOutputPhase) -> Value {
json!({
"phase": {"const": phase.phase_name()},
"status": {"enum": phase.status_values()},
"summary": string_schema(),
"artifacts": string_array_schema(),
"verification": string_array_schema(),
"risks": string_array_schema()
})
}
fn string_schema() -> Value {
json!({"type": "string"})
}
fn string_array_schema() -> Value {
json!({"type": "array", "items": string_schema()})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn custodian_phase_schemas_validate_examples() {
let examples = vec![
(
SubagentOutputPhase::Plan,
json!({
"phase": "PLAN",
"status": "READY",
"summary": "plan ready",
"artifacts": ["docs/plans/x.md"],
"verification": ["plan reviewed"],
"risks": [],
"plan_path": "docs/plans/x.md",
"ready_for_implementation": true,
"documentation": {"prd": "skipped", "adr": "skipped", "rfc": "skipped"},
"key_assumptions": ["scope fixed"]
}),
),
(
SubagentOutputPhase::Research,
json!({
"phase": "RESEARCH",
"status": "COMPLETE",
"summary": "findings done",
"artifacts": [],
"verification": ["code read"],
"risks": [],
"findings": [{"claim": "x", "evidence": "file", "confidence": "observed"}],
"open_questions": []
}),
),
(
SubagentOutputPhase::Implement,
json!({
"phase": "IMPLEMENT",
"status": "COMPLETE",
"summary": "implemented",
"artifacts": [],
"verification": ["cargo test"],
"risks": [],
"changed_files": ["src/lib.rs"],
"tests_run": ["cargo test"],
"implementation_notes": ["done"]
}),
),
(
SubagentOutputPhase::Review,
json!({
"phase": "REVIEW",
"status": "COMPLETE",
"summary": "reviewed",
"artifacts": [],
"verification": ["diff read"],
"risks": [],
"verdict": "PASS",
"findings": [{"severity": "low", "file": "src/lib.rs", "issue": "none", "recommendation": "none"}]
}),
),
(
SubagentOutputPhase::Document,
json!({
"phase": "DOCUMENT",
"status": "COMPLETE",
"summary": "documented",
"artifacts": ["docs/x.md"],
"verification": ["read docs"],
"risks": [],
"documents": [{"path": "docs/x.md", "status": "updated", "change_summary": "changed"}],
"ready": true
}),
),
];
for (phase, example) in examples {
validate_instance(&builtin_schema(phase), &example).unwrap();
}
}
#[test]
fn subagent_output_schema_rejects_missing_required_field() {
let invalid = json!({
"phase": "IMPLEMENT",
"status": "COMPLETE",
"summary": "missing arrays",
"artifacts": [],
"verification": [],
"risks": []
});
let errors = validate_instance(&builtin_schema(SubagentOutputPhase::Implement), &invalid)
.unwrap_err();
assert!(
errors
.iter()
.any(|error| error.message.contains("changed_files"))
);
}
}