feat: different modules per task
Some checks failed
distribution-gate / distribution-gate (push) Failing after 1m57s

This commit is contained in:
eric
2026-04-04 19:29:44 +02:00
parent b4f988018a
commit 4a5c1d3c4b
6 changed files with 1080 additions and 7 deletions

View File

@@ -0,0 +1,13 @@
policy:
router: "gpt-5.4"
info: "gpt-5.4-mini"
build: "gpt-5.3-codex-spark"
escalate_to: "gpt-5.4"
conf_threshold: 0.85
routing:
decision_rules[6]: "architectural_decision => gpt-5.4", "bug_triage => gpt-5.4", "tradeoff_decision => gpt-5.4", "ambiguous_task => gpt-5.4", "docs_api_lookup_or_comparison => gpt-5.4-mini", "debugging_or_testing => gpt-5.3-codex-spark"
gate:
require_fields[15]: "task_id", "from_model", "to_model", "objective", "scope", "constraints", "current_state", "artifact_refs", "expected_output", "acceptance_criteria", "risks", "fallback_triggers", "conf", "assumptions_made", "open_questions", "failed_checks"
fail_if_conf_less: 0.85
if_conflict_or_missing: "reroute_to_router"
if_test_failure: "reroute_to_builder_once_then_router"

View File

@@ -1,18 +1,22 @@
use std::path::PathBuf; use std::path::{Path, PathBuf};
use std::sync::mpsc::{Receiver, Sender, TryRecvError}; use std::sync::mpsc::{Receiver, Sender, TryRecvError};
use anyhow::Result; use anyhow::Result;
use serde_json::json;
use crate::app::{AppEvent, ControlCommand}; use crate::app::{AppEvent, ControlCommand};
use crate::controller::{executor, goal_checker, planner, verifier}; use crate::controller::{executor, goal_checker, planner, verifier};
use crate::model::{ use crate::model::{
ControllerPhase, GoalStatus, PlannerResponse, SessionEntry, SessionSource, SessionStream, ControllerPhase, GoalStatus, HandoffContext, HandoffCriteria, HandoffEnvelope, HandoffOut,
StepStatus, TaskConfig, PlannerResponse, RoutingPolicy, SessionEntry, SessionSource, SessionStream, StepStatus, StringCell,
TaskConfig, DEFAULT_RUN_MODEL,
}; };
use crate::prompt; use crate::prompt;
use crate::repo; use crate::repo;
use crate::storage::toon; use crate::storage::toon;
const DEFAULT_CONTROLLER_POLICY_PATH: &str = ".agent/controller-loop/policy.toon";
pub fn runtime_loop( pub fn runtime_loop(
repo_root: PathBuf, repo_root: PathBuf,
config: TaskConfig, config: TaskConfig,
@@ -20,6 +24,7 @@ pub fn runtime_loop(
event_tx: Sender<AppEvent>, event_tx: Sender<AppEvent>,
) -> Result<()> { ) -> Result<()> {
toon::ensure_controller_files(&config)?; toon::ensure_controller_files(&config)?;
let routing_policy = load_controller_policy();
let _ = event_tx.send(AppEvent::Session(SessionEntry { let _ = event_tx.send(AppEvent::Session(SessionEntry {
source: SessionSource::Controller, source: SessionSource::Controller,
stream: SessionStream::Status, stream: SessionStream::Status,
@@ -160,8 +165,16 @@ pub fn runtime_loop(
body: "Refining plan".to_string(), body: "Refining plan".to_string(),
run_id: repo::next_run_id(), run_id: repo::next_run_id(),
})); }));
let planner_model = pick_model_for_planner(&routing_policy, &state, &plan, &config);
plan = plan =
planner::refine_without_user_input(&repo_root, &config, &plan, &state, &event_tx)?; planner::refine_without_user_input(
&repo_root,
&config,
&plan,
&state,
&planner_model,
&event_tx,
)?;
state.replan_required = false; state.replan_required = false;
toon::write_plan(&config.plan_file, &plan)?; toon::write_plan(&config.plan_file, &plan)?;
toon::write_state(&config.state_file, &state)?; toon::write_state(&config.state_file, &state)?;
@@ -213,7 +226,16 @@ pub fn runtime_loop(
toon::write_state(&config.state_file, &state)?; toon::write_state(&config.state_file, &state)?;
emit_snapshot(&event_tx, &goal_md, &standards_md, &plan, &state); emit_snapshot(&event_tx, &goal_md, &standards_md, &plan, &state);
let exec = executor::implement(&repo_root, &config, &state, &plan, &step, &event_tx)?; let executor_model = pick_model_for_step(&routing_policy, &state, &step, &config);
let exec = executor::implement(
&repo_root,
&config,
&state,
&plan,
&step,
&executor_model,
&event_tx,
)?;
if goal_checker::needs_goal_clarification(&exec) { if goal_checker::needs_goal_clarification(&exec) {
state.phase = ControllerPhase::Planning; state.phase = ControllerPhase::Planning;
state.set_stop_reason(format!( state.set_stop_reason(format!(
@@ -323,6 +345,254 @@ fn emit_snapshot(
}); });
} }
fn pick_model_for_planner(
policy: &Option<RoutingPolicy>,
state: &crate::model::ControllerState,
plan: &crate::model::Plan,
config: &TaskConfig,
) -> String {
let fallback_model = state.run_model();
if fallback_model != DEFAULT_RUN_MODEL {
return fallback_model.to_string();
}
let handoff = build_planning_handoff(config, state, plan, fallback_model);
select_model_with_policy(policy.as_ref(), &handoff, fallback_model)
}
fn pick_model_for_step(
policy: &Option<RoutingPolicy>,
state: &crate::model::ControllerState,
step: &crate::model::PlanStep,
config: &TaskConfig,
) -> String {
let fallback_model = state.run_model();
if fallback_model != DEFAULT_RUN_MODEL {
return fallback_model.to_string();
}
let handoff = build_execution_handoff(config, state, step, fallback_model);
select_model_with_policy(policy.as_ref(), &handoff, fallback_model)
}
fn select_model_with_policy(
policy: Option<&RoutingPolicy>,
handoff: &HandoffEnvelope,
fallback_model: &str,
) -> String {
let policy = match policy {
Some(policy) => policy,
None => return fallback_model.to_string(),
};
policy
.parse_route(handoff)
.map(|decision| decision.model)
.unwrap_or_else(|_| policy.policy.escalate_to.to_string())
}
fn load_controller_policy() -> Option<RoutingPolicy> {
let policy_path = repo::absolute(Path::new(DEFAULT_CONTROLLER_POLICY_PATH));
if !policy_path.exists() {
return None;
}
let policy = std::fs::read_to_string(policy_path).ok()?;
RoutingPolicy::from_toon(&policy).ok()
}
fn build_planning_handoff(
config: &TaskConfig,
state: &crate::model::ControllerState,
plan: &crate::model::Plan,
model: &str,
) -> HandoffEnvelope {
HandoffEnvelope {
task_id: Some(StringCell::Value(format!("{}-planner", config.controller_id()))),
from_model: Some(StringCell::Value(model.to_string())),
to_model: Some(StringCell::Value("gpt-5.4".to_string())),
objective: Some("Refine execution plan using current controller context.".to_string()),
scope: Some("architectural_decision".to_string()),
constraints: Some(vec![
"plan-generation".to_string(),
"minimal-delta".to_string(),
"no-questions".to_string(),
]),
current_state: Some(format!(
"controller={} iteration={} phase={:?} replan_required={}",
config.controller_id(),
state.iteration,
state.phase,
state.replan_required
)),
context: HandoffContext {
refs: vec![
config.plan_file.to_string_lossy().to_string(),
format!("total_steps:{}", plan.steps.len()),
],
state: None,
},
artifact_refs: Some(vec![
config.plan_file.to_string_lossy().to_string(),
config.goal_file.to_string_lossy().to_string(),
format!("controller={}", config.controller_id()),
]),
expected_output: Some(json!({
"single_plan_delta": true,
"notes": ["minimal delta",
"stable ids",
"no unchanged pending steps",
]
})),
acceptance_criteria: Some(vec![
"minimal-plan-delta".to_string(),
"stable-step-ids".to_string(),
"no-unchanged-pending-step-duplication".to_string(),
]),
risks: Some(vec![
"router-policy-governance".to_string(),
]),
fallback_triggers: Some(vec![
"low-confidence".to_string(),
"scope-mismatch".to_string(),
]),
confidence_threshold: Some(0.85),
conf: Some(1.0),
assumptions_made: Some(vec![
"existing plan is mostly valid".to_string(),
"planner output can remain in-place".to_string(),
]),
open_questions: Some(vec!["none".to_string()]),
failed_checks: Some(Vec::new()),
out: HandoffOut {
alternatives: Some(1),
recommended: true,
cite_sources: false,
format: Some("json".to_string()),
diff_summary: Some(false),
files: Vec::new(),
tests: Vec::new(),
risks: Some("none".to_string()),
open_questions: Some("none".to_string()),
},
criteria: HandoffCriteria {
must: vec!["single_plan_delta".to_string()],
fail: Vec::new(),
optional: Vec::new(),
},
}
}
fn build_execution_handoff(
config: &TaskConfig,
state: &crate::model::ControllerState,
step: &crate::model::PlanStep,
model: &str,
) -> HandoffEnvelope {
let mut constraints = vec![
"execution".to_string(),
"minimal-change".to_string(),
format!("step-status:{:?}", step.status),
];
constraints.extend(
step.dependencies
.iter()
.map(|dependency| format!("dependency:{dependency}")),
);
let title = if step.title.trim().is_empty() {
"execute step".to_string()
} else {
step.title.clone()
};
HandoffEnvelope {
task_id: Some(StringCell::Value(format!("{}::{}", config.controller_id(), step.id))),
from_model: Some(StringCell::Value(model.to_string())),
to_model: Some(StringCell::Value("gpt-5.4".to_string())),
objective: Some(title),
scope: Some(infer_step_scope(step).to_string()),
constraints: Some(constraints),
current_state: Some(format!("controller={} iteration={}", config.controller_id(), state.iteration)),
artifact_refs: Some(vec![
format!("step:{}", step.id),
config.goal_file.to_string_lossy().to_string(),
config.plan_file.to_string_lossy().to_string(),
]),
expected_output: Some(json!({
"code_diff_summary": "requested changes should be implemented",
"test_plan": ["targeted unit tests", "targeted integration checks"],
"risk_notes": ["validate behavior and no regressions"],
"self_check": "status/diff sanity checks"
})),
acceptance_criteria: Some(vec![
"implemented changes pass".to_string(),
"targeted test commands executed".to_string(),
"behavior remains stable".to_string(),
]),
risks: Some(vec![
if step.status == crate::model::StepStatus::Blocked {
"blocked-step-resume".to_string()
} else {
"implementation-risk".to_string()
},
]),
fallback_triggers: Some(vec![
"failing_tests_or_verification".to_string(),
"contradictory-findings".to_string(),
]),
confidence_threshold: Some(0.85),
conf: Some(1.0),
assumptions_made: Some(vec![
"existing behavior should not regress".to_string(),
"targeted files are sufficient".to_string(),
]),
open_questions: Some(vec!["none".to_string()]),
failed_checks: Some(Vec::new()),
context: HandoffContext {
refs: vec![
format!("step:{}", step.id),
config.goal_file.to_string_lossy().to_string(),
],
state: Some(format!("controller={} iteration={}", config.controller_id(), state.iteration)),
},
out: HandoffOut {
alternatives: None,
recommended: true,
cite_sources: false,
format: None,
diff_summary: Some(true),
files: step.inputs.clone(),
tests: Vec::new(),
risks: Some("none".to_string()),
open_questions: Some("none".to_string()),
},
criteria: HandoffCriteria {
must: vec!["implementation_complete".to_string()],
fail: vec!["no_regression_for_existing_paths".to_string()],
optional: Vec::new(),
},
}
}
fn infer_step_scope(step: &crate::model::PlanStep) -> &'static str {
let haystack = step.title.to_lowercase() + " " + &step.purpose.to_lowercase();
if haystack.contains("research")
|| haystack.contains("investigate")
|| haystack.contains("lookup")
|| haystack.contains("api")
|| haystack.contains("contract")
|| haystack.contains("compare")
{
"information_gathering"
} else if haystack.contains("test") || haystack.contains("lint") || haystack.contains("debug") {
"debugging_or_testing"
} else if haystack.contains("api") && haystack.contains("contract") {
"docs_api_lookup_or_comparison"
} else {
"code"
}
}
fn resumable_step( fn resumable_step(
plan: &crate::model::Plan, plan: &crate::model::Plan,
state: &crate::model::ControllerState, state: &crate::model::ControllerState,

View File

@@ -15,6 +15,7 @@ pub fn implement(
state: &ControllerState, state: &ControllerState,
plan: &Plan, plan: &Plan,
step: &PlanStep, step: &PlanStep,
model: &str,
event_tx: &Sender<AppEvent>, event_tx: &Sender<AppEvent>,
) -> Result<ExecutionResponse> { ) -> Result<ExecutionResponse> {
let goal_md = toon::read_markdown(&config.goal_file)?; let goal_md = toon::read_markdown(&config.goal_file)?;
@@ -67,7 +68,7 @@ pub fn implement(
repo_root, repo_root,
&prompt, &prompt,
&schema, &schema,
state.run_model(), model,
event_tx, event_tx,
SessionSource::Executor, SessionSource::Executor,
Some(step.id.clone()), Some(step.id.clone()),

View File

@@ -14,6 +14,7 @@ pub fn refine_without_user_input(
config: &TaskConfig, config: &TaskConfig,
plan: &Plan, plan: &Plan,
state: &ControllerState, state: &ControllerState,
model: &str,
event_tx: &Sender<AppEvent>, event_tx: &Sender<AppEvent>,
) -> Result<Plan> { ) -> Result<Plan> {
let goal_md = toon::read_markdown(&config.goal_file)?; let goal_md = toon::read_markdown(&config.goal_file)?;
@@ -60,7 +61,7 @@ pub fn refine_without_user_input(
repo_root, repo_root,
&prompt, &prompt,
&schema, &schema,
state.run_model(), model,
event_tx, event_tx,
SessionSource::Planner, SessionSource::Planner,
Some(config.controller_id()), Some(config.controller_id()),

View File

@@ -2,6 +2,7 @@ mod controller;
mod plan; mod plan;
mod response; mod response;
mod schema; mod schema;
mod policy;
mod session; mod session;
mod usage; mod usage;
@@ -32,6 +33,12 @@ pub use self::schema::{
verification_check_schema, verification_check_schema,
}; };
#[allow(unused_imports)] #[allow(unused_imports)]
pub use self::policy::{
HandoffContext, HandoffCriteria, HandoffEnvelope, HandoffOut, ModelRolePolicy,
PolicyDecision, PolicyGate, ReviewEnvelope, ReviewPayload, RoutingPolicy, RoutingRules,
StringCell,
};
#[allow(unused_imports)]
pub use self::session::{ pub use self::session::{
group_session_entries, SessionCursor, SessionEntry, SessionGroup, SessionSelection, group_session_entries, SessionCursor, SessionEntry, SessionGroup, SessionSelection,
SessionSource, SessionStream, SessionSource, SessionStream,

781
src/model/policy.rs Normal file
View File

@@ -0,0 +1,781 @@
use anyhow::{bail, Context, Result};
use serde::{de::DeserializeOwned, Deserialize, Serialize};
use serde_json::Value;
use toon_format::decode_default;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case", default)]
pub struct RoutingPolicy {
pub policy: ModelRolePolicy,
pub routing: RoutingRules,
pub gate: PolicyGate,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case", default)]
pub struct ModelRolePolicy {
pub router: String,
#[serde(alias = "research")]
pub info: String,
#[serde(alias = "builder")]
pub build: String,
pub escalate_to: String,
pub conf_threshold: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default)]
pub struct RoutingRules {
pub decision_rules: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case", default)]
pub struct PolicyGate {
pub require_fields: Vec<String>,
pub fail_if_conf_less: f64,
pub if_conflict_or_missing: String,
pub if_test_failure: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PolicyDecision {
pub model: String,
pub reason: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case", default)]
pub struct HandoffEnvelope {
#[serde(alias = "id")]
pub task_id: Option<StringCell>,
#[serde(alias = "from", alias = "src")]
pub from_model: Option<StringCell>,
#[serde(alias = "to", alias = "dst")]
pub to_model: Option<StringCell>,
#[serde(alias = "obj")]
pub objective: Option<String>,
pub scope: Option<String>,
pub constraints: Option<Vec<String>>,
pub current_state: Option<String>,
#[serde(alias = "refs")]
pub artifact_refs: Option<Vec<String>>,
pub expected_output: Option<Value>,
pub acceptance_criteria: Option<Vec<String>>,
pub risks: Option<Vec<String>>,
pub fallback_triggers: Option<Vec<String>>,
pub confidence_threshold: Option<f64>,
#[serde(alias = "confidence")]
pub conf: Option<f64>,
pub assumptions_made: Option<Vec<String>>,
pub open_questions: Option<Vec<String>>,
pub failed_checks: Option<Vec<String>>,
#[serde(default)]
pub context: HandoffContext,
#[serde(default)]
pub out: HandoffOut,
#[serde(default)]
pub criteria: HandoffCriteria,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum StringCell {
Value(String),
Cell { value: String },
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "kebab-case", default)]
pub struct HandoffContext {
#[serde(default)]
pub state: Option<String>,
#[serde(default)]
pub refs: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "kebab-case", default)]
pub struct HandoffOut {
#[serde(default)]
pub alternatives: Option<u32>,
#[serde(default)]
pub recommended: bool,
#[serde(default)]
pub cite_sources: bool,
#[serde(default)]
pub format: Option<String>,
#[serde(default)]
pub diff_summary: Option<bool>,
#[serde(default)]
pub files: Vec<String>,
#[serde(default)]
pub tests: Vec<String>,
#[serde(default)]
pub risks: Option<String>,
#[serde(default)]
pub open_questions: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "kebab-case", default)]
pub struct HandoffCriteria {
#[serde(default)]
pub must: Vec<String>,
#[serde(default)]
pub fail: Vec<String>,
#[serde(default)]
pub optional: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case", default)]
pub struct ReviewEnvelope {
pub from: StringCell,
pub to: StringCell,
pub review: ReviewPayload,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case", default)]
pub struct ReviewPayload {
#[serde(default)]
pub task: Option<String>,
#[serde(default)]
pub conf: Option<f64>,
#[serde(default)]
pub findings: Vec<String>,
#[serde(default)]
pub required: Vec<String>,
#[serde(default)]
pub gate: Option<String>,
}
impl Default for RoutingPolicy {
fn default() -> Self {
Self {
policy: ModelRolePolicy::default(),
routing: RoutingRules::default(),
gate: PolicyGate {
require_fields: vec![
"task_id".to_string(),
"from_model".to_string(),
"to_model".to_string(),
"objective".to_string(),
"scope".to_string(),
"constraints".to_string(),
"current_state".to_string(),
"artifact_refs".to_string(),
"expected_output".to_string(),
"acceptance_criteria".to_string(),
"risks".to_string(),
"fallback_triggers".to_string(),
"conf".to_string(),
"assumptions_made".to_string(),
"open_questions".to_string(),
"failed_checks".to_string(),
],
fail_if_conf_less: 0.85,
if_conflict_or_missing: "reroute_to_router".to_string(),
if_test_failure: "reroute_to_builder_once_then_router".to_string(),
},
}
}
}
impl Default for ModelRolePolicy {
fn default() -> Self {
Self {
router: "gpt-5.4".to_string(),
info: "gpt-5.4-mini".to_string(),
build: "gpt-5.3-codex-spark".to_string(),
escalate_to: "gpt-5.4".to_string(),
conf_threshold: 0.85,
}
}
}
impl Default for StringCell {
fn default() -> Self {
Self::Value(String::new())
}
}
impl StringCell {
pub fn as_str(&self) -> &str {
match self {
Self::Value(value) => value,
Self::Cell { value } => value,
}
}
pub fn is_non_empty(&self) -> bool {
!self.as_str().trim().is_empty()
}
}
impl RoutingPolicy {
pub fn from_toon(content: &str) -> Result<Self> {
parse_toon(content)
}
pub fn parse_route(&self, handoff: &HandoffEnvelope) -> Result<PolicyDecision> {
handoff.validate_required_fields(self)?;
let conf_threshold = handoff.confidence_threshold.unwrap_or(self.policy.conf_threshold);
let effective_fail_threshold = self.gate.fail_if_conf_less.max(conf_threshold).max(self.policy.conf_threshold);
let conf = handoff.conf.unwrap_or(1.0);
if conf < effective_fail_threshold {
return Ok(PolicyDecision {
model: self.policy.escalate_to.clone(),
reason: "confidence below configured threshold".to_string(),
});
}
if handoff.is_security_or_reliability_sensitive() || handoff.is_contradictory_or_ambiguous() {
return Ok(PolicyDecision {
model: self.policy.escalate_to.clone(),
reason: "risk/contradiction or ambiguous classification requires router".to_string(),
});
}
if let Some(to_model) = handoff.to_model.as_ref().map(StringCell::as_str) {
let scope = handoff.scope.as_deref().unwrap_or_default();
let objective = handoff.objective.as_deref().unwrap_or_default();
if is_allowed_model(self, to_model)
&& (is_builder_scope(scope) || is_info_scope(scope) || is_router_scope(scope))
{
return Ok(PolicyDecision {
model: to_model.to_string(),
reason: "handoff destination honored".to_string(),
});
}
if is_allowed_model(self, to_model)
&& (is_builder_scope(objective) || is_info_scope(objective) || is_router_scope(objective))
{
return Ok(PolicyDecision {
model: to_model.to_string(),
reason: "handoff destination honored".to_string(),
});
}
}
let scope = handoff.scope.as_deref().unwrap_or_default();
let objective = handoff.objective.as_deref().unwrap_or_default();
let model = if is_router_scope(scope) || is_router_scope(objective) {
self.policy.router.clone()
} else if is_info_scope(scope) || is_info_scope(objective) {
self.policy.info.clone()
} else if is_builder_scope(scope) || is_builder_scope(objective) {
self.policy.build.clone()
} else {
self.routing
.decision_rules
.iter()
.find_map(|raw_rule| {
parse_rule(raw_rule).and_then(|rule| {
rule.matches(handoff).then_some(rule.model.clone())
})
})
.unwrap_or_else(|| self.policy.router.clone())
};
let reason = if model == self.policy.info {
"matched information-gathering scope".to_string()
} else if model == self.policy.build {
"matched implementation/debugging scope".to_string()
} else if model == self.policy.router {
"matched architectural/decision scope".to_string()
} else {
format!(
"matched decision rule {}",
handoff.scope.as_deref().unwrap_or("unknown")
)
};
Ok(PolicyDecision { model, reason })
}
}
impl HandoffEnvelope {
pub fn from_toon(content: &str) -> Result<Self> {
parse_toon(content)
}
pub fn validate_required_fields(&self, policy: &RoutingPolicy) -> Result<()> {
let mut missing = Vec::new();
for field in &policy.gate.require_fields {
if !self.has_required_field(field) {
missing.push(field.to_string());
}
}
if !missing.is_empty() {
missing.sort();
missing.dedup();
bail!("missing required handoff fields: {}", missing.join(", "));
}
if !self.has_required_contract() {
bail!(
"handoff must include acceptance_criteria and verification fields (assumptions_made/open_questions/failed_checks)"
);
}
if let Some(conf) = self.conf
&& !(0.0..=1.0).contains(&conf)
{
bail!("confidence value must be normalized in 0.0..1.0, got {conf}");
}
Ok(())
}
fn has_required_field(&self, field: &str) -> bool {
match field {
"task_id" => self.task_id.as_ref().is_some_and(StringCell::is_non_empty),
"from_model" => self.from_model.as_ref().is_some_and(StringCell::is_non_empty),
"to_model" => self.to_model.as_ref().is_some_and(StringCell::is_non_empty),
"objective" => self
.objective
.as_ref()
.is_some_and(|value| !value.trim().is_empty()),
"scope" => self
.scope
.as_ref()
.is_some_and(|value| !value.trim().is_empty()),
"constraints" => self.constraints.as_ref().is_some_and(|values| !values.is_empty()),
"current_state" => self
.current_state
.as_ref()
.is_some_and(|value| !value.trim().is_empty())
|| self.context.state.as_ref().is_some_and(|value| !value.trim().is_empty()),
"artifact_refs" => self
.artifact_refs
.as_ref()
.is_some_and(|values| !values.is_empty())
|| !self.context.refs.is_empty(),
"expected_output" => self.expected_output.as_ref().is_some_and(|value| !value.is_null()),
"acceptance_criteria" => {
self.acceptance_criteria.as_ref().is_some_and(|values| !values.is_empty())
}
"risks" => self.risks.as_ref().is_some_and(|values| !values.is_empty()),
"fallback_triggers" => {
self.fallback_triggers
.as_ref()
.is_some_and(|values| !values.is_empty())
}
"conf" => self.conf.is_some(),
"assumptions_made" => self
.assumptions_made
.as_ref()
.is_some_and(|values| !values.is_empty()),
"open_questions" => self
.open_questions
.as_ref()
.is_some_and(|values| !values.is_empty()),
"failed_checks" => self
.failed_checks
.as_ref()
.is_some_and(|values| !values.is_empty()),
// Compatibility aliases for earlier envelope versions.
"id" => self.task_id.as_ref().is_some_and(StringCell::is_non_empty),
"src" => self.from_model.as_ref().is_some_and(StringCell::is_non_empty),
"dst" => self.to_model.as_ref().is_some_and(StringCell::is_non_empty),
"obj" => self
.objective
.as_ref()
.is_some_and(|value| !value.trim().is_empty()),
"refs" => {
self.artifact_refs.as_ref().is_some_and(|values| !values.is_empty())
|| !self.context.refs.is_empty()
}
"out" => self.out.has_output(),
"criteria" => self.criteria.has_requirements(),
_ => true,
}
}
fn has_required_contract(&self) -> bool {
self.acceptance_criteria
.as_ref()
.is_some_and(|values| !values.is_empty())
&& self
.assumptions_made
.as_ref()
.is_some_and(|values| !values.is_empty())
&& self.open_questions.as_ref().is_some_and(|values| !values.is_empty())
&& self.failed_checks.as_ref().is_some_and(|values| !values.is_empty())
}
fn is_router_scope_hint(&self) -> bool {
let scope = self.scope.as_deref().unwrap_or("");
let objective = self.objective.as_deref().unwrap_or("");
is_router_scope(scope) || is_router_scope(objective)
}
fn is_info_scope_hint(&self) -> bool {
let scope = self.scope.as_deref().unwrap_or("");
let objective = self.objective.as_deref().unwrap_or("");
is_info_scope(scope) || is_info_scope(objective)
}
fn is_builder_scope_hint(&self) -> bool {
let scope = self.scope.as_deref().unwrap_or("");
let objective = self.objective.as_deref().unwrap_or("");
is_builder_scope(scope) || is_builder_scope(objective)
}
fn is_security_or_reliability_sensitive(&self) -> bool {
let haystack = format!(
"{} {} {} {}",
self.scope.as_deref().unwrap_or_default(),
self.objective.as_deref().unwrap_or_default(),
self.risks
.as_ref()
.map(|items| items.join(" "))
.unwrap_or_default(),
self.fallback_triggers
.as_ref()
.map(|items| items.join(" "))
.unwrap_or_default()
);
is_security_signal(&haystack) || self
.artifact_refs
.as_ref()
.is_some_and(|refs| refs.iter().any(|value| {
let normalized = value.to_ascii_lowercase();
normalized.contains("security") || normalized.contains("privacy")
}))
}
fn is_contradictory_or_ambiguous(&self) -> bool {
let haystack = format!(
"{} {} {}",
self.scope.as_deref().unwrap_or_default(),
self.objective.as_deref().unwrap_or_default(),
self.fallback_triggers
.as_ref()
.map(|items| items.join(" "))
.unwrap_or_default(),
);
let is_contradictory = self
.failed_checks
.as_ref()
.is_some_and(|checks| {
checks
.iter()
.any(|value| is_contradictory_signal(value))
});
let is_ambiguous = is_ambiguous_signal(&haystack);
is_contradictory || is_ambiguous
}
}
impl ReviewEnvelope {
pub fn from_toon(content: &str) -> Result<Self> {
parse_toon(content)
}
}
impl HandoffOut {
fn has_output(&self) -> bool {
self.alternatives.is_some()
|| self.recommended
|| self.cite_sources
|| self.format.is_some()
|| self.diff_summary.unwrap_or(false)
|| !self.files.is_empty()
|| !self.tests.is_empty()
|| self.risks.is_some()
|| self.open_questions.is_some()
}
}
impl HandoffCriteria {
fn has_requirements(&self) -> bool {
!self.must.is_empty() || !self.fail.is_empty()
}
}
#[derive(Debug, Clone)]
struct Rule {
left: String,
model: String,
}
impl Rule {
fn matches(&self, handoff: &HandoffEnvelope) -> bool {
if self
.left
.split("->")
.next()
.is_some_and(|left| contains_token_or_phrase(handoff.scope.as_deref().unwrap_or(""), left))
{
return true;
}
if contains_token_or_phrase(
handoff.objective.as_deref().unwrap_or(""),
&self.left,
) {
return true;
}
handoff
.constraints
.as_ref()
.into_iter()
.flat_map(|constraints| constraints.iter())
.chain(self.artifact_refs_or_fallback(handoff).iter())
.chain(handoff.context.refs.iter())
.any(|value| contains_token_or_phrase(value, &self.left))
}
fn artifact_refs_or_fallback<'a>(&self, handoff: &'a HandoffEnvelope) -> &'a Vec<String> {
handoff.artifact_refs.as_ref().unwrap_or(&handoff.context.refs)
}
}
fn parse_rule(raw: &str) -> Option<Rule> {
let mut split = raw.splitn(2, "=>");
let left = split.next()?.trim().to_ascii_lowercase();
let model = split.next()?.trim().to_string();
if left.is_empty() || model.is_empty() {
return None;
}
Some(Rule { left, model })
}
fn is_router_scope(scope: &str) -> bool {
is_scope_like(scope, &[
"architectural",
"architectural_decision",
"triage",
"tradeoff",
"ambiguous",
"ambiguity",
"security",
"privacy",
"migration",
"incident",
"reliability",
])
}
fn is_info_scope(scope: &str) -> bool {
is_scope_like(scope, &[
"research",
"information",
"information_gathering",
"docs",
"api",
"contract",
"comparison",
"search",
"options",
"lookup",
"investigate",
])
}
fn is_builder_scope(scope: &str) -> bool {
is_scope_like(scope, &[
"code",
"code_change",
"implementation",
"refactor",
"tests",
"test",
"debug",
"debugging",
"execution",
"implement",
"build",
])
}
fn is_scope_like(scope: &str, tokens: &[&str]) -> bool {
tokens.iter().any(|token| contains_token_or_phrase(scope, token))
}
fn is_security_signal(value: &str) -> bool {
is_token_or_phrase_in_set(
value,
&[
"security",
"privacy",
"migration",
"incident",
"data loss",
"data-loss",
"reliability",
"production",
],
)
}
fn is_ambiguous_signal(value: &str) -> bool {
is_token_or_phrase_in_set(value, &["ambiguous", "ambiguity", "underdetermined", "unclear"])
}
fn is_contradictory_signal(value: &str) -> bool {
is_token_or_phrase_in_set(value, &["contradictory", "conflict", "inconsistent", "mismatch"])
}
fn is_token_or_phrase_in_set(value: &str, tokens: &[&str]) -> bool {
let normalized = value.to_ascii_lowercase();
tokens.iter().any(|token| contains_token_or_phrase(&normalized, token))
}
fn is_allowed_model(policy: &RoutingPolicy, model: &str) -> bool {
let m = model.trim();
m == policy.policy.router || m == policy.policy.info || m == policy.policy.build || m == policy.policy.escalate_to
}
fn contains_token_or_phrase(target: &str, expected: &str) -> bool {
if expected.is_empty() {
return false;
}
let normalized = target.to_ascii_lowercase();
normalized.split(|c: char| !c.is_ascii_alphanumeric() && c != '_')
.any(|token| !token.is_empty() && token == expected)
|| normalized.contains(expected)
}
fn parse_toon<T: DeserializeOwned>(content: &str) -> Result<T> {
decode_default(content).context("failed to decode TOON payload")
}
#[cfg(test)]
mod tests {
use super::*;
const POLICY_TP: &str = r##"
policy:
router: "gpt-5.4"
info: "gpt-5.4-mini"
build: "gpt-5.3-codex-spark"
escalate_to: "gpt-5.4"
conf_threshold: 0.85
routing:
decision_rules[4]: "architectural_decision => gpt-5.4", "debugging_or_testing => gpt-5.3-codex-spark", "docs_api_lookup_or_comparison => gpt-5.4-mini", "security_privacy_migration_incident => gpt-5.4"
gate:
require_fields[15]: "task_id", "from_model", "to_model", "objective", "scope", "constraints", "current_state", "artifact_refs", "expected_output", "acceptance_criteria", "risks", "fallback_triggers", "conf", "assumptions_made", "open_questions", "failed_checks"
fail_if_conf_less: 0.85
if_conflict_or_missing: "reroute_to_router"
if_test_failure: "reroute_to_builder_once_then_router"
"##;
const RESEARCH_ENVELOPE: &str = r##"
task_id:
value: "task-102"
from_model: "gpt-5.4"
to_model: "gpt-5.4-mini"
objective: "Find current API contract changes needed for feature X."
scope: "information_gathering"
constraints[3]: "no code edits", "cite source path", "stack: rust"
current_state: "prior_work: baseline-impl-v2"
artifact_refs[2]: "docs/api.md#120-210", "issues/412"
expected_output:
alternatives: ["A", "B"]
recommended_choice: "A"
citations: ["docs/api.md#120-210"]
acceptance_criteria[3]: "produce >=2 options", "explain tradeoffs", "state confidence"
risks[1]: "none"
fallback_triggers[1]: "none"
conf: 0.88
assumptions_made[1]: "contracts are stable"
open_questions[1]: "none"
failed_checks[1]: "none"
"##;
const IMPLEMENTER_ENVELOPE: &str = r##"
task_id:
value: "task-102"
from_model: "gpt-5.4"
to_model: "gpt-5.3-codex-spark"
objective: "Implement the chosen API adjustment with tests."
scope: "code_change"
constraints[4]: "edit only listed files", "maintain existing behavior", "no_semver_break", "no code edits"
current_state: "decision_outcome: option_a_selected"
artifact_refs[3]: "research/task-102.toon", "src/current_api.ts", "tests/api.test.ts"
expected_output:
code_diff_summary: "..."
test_plan: ["/ path"]
risk_notes: ["none"]
self_check: "ok"
acceptance_criteria[3]: "tests added/updated", "no breaking change", "backwards behavior unchanged"
risks[1]: "none"
fallback_triggers[1]: "implementation validation required"
conf: 0.9
assumptions_made[1]: "prior research is current"
open_questions[1]: "none"
failed_checks[1]: "none"
"##;
const REVIEW_ENVELOPE: &str = r##"
from:
value: "gpt-5.3-codex-spark"
to:
value: "gpt-5.4"
review:
task: "task-102"
conf: 0.82
findings[3]: "assumptions", "risks", "validation_status"
required[3]: "evidence", "counterexamples", "alternative_if_incomplete"
gate: "approve | revise | escalate"
"##;
#[test]
fn parses_policy_toon_and_routes_by_scope() {
let policy = RoutingPolicy::from_toon(POLICY_TP).expect("parse policy");
let handoff = HandoffEnvelope::from_toon(RESEARCH_ENVELOPE).expect("parse research");
assert_eq!(
policy
.parse_route(&handoff)
.expect("route")
.model,
"gpt-5.4-mini"
);
}
#[test]
fn validates_required_envelope_fields() {
let policy = RoutingPolicy::from_toon(POLICY_TP).expect("parse policy");
let handoff = HandoffEnvelope::from_toon(IMPLEMENTER_ENVELOPE).expect("parse implementer");
assert!(handoff.validate_required_fields(&policy).is_ok());
}
#[test]
fn escalates_when_confidence_below_threshold() {
let policy = RoutingPolicy::from_toon(POLICY_TP).expect("parse policy");
let mut handoff = HandoffEnvelope::from_toon(IMPLEMENTER_ENVELOPE).expect("parse implementer");
handoff.conf = Some(0.6);
let routed = policy.parse_route(&handoff).expect("route");
assert_eq!(routed.model, "gpt-5.4");
}
#[test]
fn escalates_on_security_signal() {
let policy = RoutingPolicy::from_toon(POLICY_TP).expect("parse policy");
let mut handoff = HandoffEnvelope::from_toon(IMPLEMENTER_ENVELOPE).expect("parse implementer");
handoff.risks = Some(vec!["privacy".to_string(), "migration".to_string()]);
let routed = policy.parse_route(&handoff).expect("route");
assert_eq!(routed.model, "gpt-5.4");
}
#[test]
fn parses_review_envelope_toon() {
let review = ReviewEnvelope::from_toon(REVIEW_ENVELOPE).expect("parse review");
assert_eq!(review.from.as_str(), "gpt-5.3-codex-spark");
assert_eq!(review.review.task.as_deref(), Some("task-102"));
}
}