- 1
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] - 2
- 3
//! Cross-phase adoption matrix (docs/design/42-managed-work-contracts.md): scenarios where Phases - 4
//! A (receipts), B (ladder), C (partitions), D (budget), H (goal) must - 5
//! compose correctly — not just work in isolation. - 6
- 7
use std::collections::VecDeque; - 8
use std::sync::Arc; - 9
use std::sync::atomic::{AtomicU32, Ordering}; - 10
- 11
use tempfile::tempdir; - 12
use tokio::sync::mpsc; - 13
use tokio_util::sync::CancellationToken; - 14
- 15
use vak_agent::{ - 16
Agent, AgentConfig, AgentEvent, AutoApprove, SpendCheck, SpendGate, SteeringQueues, TurnOutcome, - 17
}; - 18
use vak_llm::stream; - 19
use vak_llm::types::{AssistantMessage, ChatRequest, ContentBlock, StopReason, Usage}; - 20
use vak_llm::{AttemptReason, EventStream, LlmError, Provider, WorkPurpose}; - 21
use vak_permission::{Mode, PermissionEngine}; - 22
use vak_session::types::{EntryPayload, FrozenContract, SessionHeader}; - 23
use vak_session::{SessionLog, SessionPath}; - 24
- 25
fn text_msg(model: &str, t: &str) -> AssistantMessage { - 26
AssistantMessage { - 27
content: vec![ContentBlock::text(t)], - 28
stop_reason: StopReason::EndTurn, - 29
usage: Usage { - 30
input_tokens: 5, - 31
output_tokens: 3, - 32
..Default::default() - 33
}, - 34
model: model.into(), - 35
response_id: None, - 36
} - 37
} - 38
- 39
/// Primary leg: network-dead. Fallback leg: scripted FIFO. - 40
struct MatrixProvider { - 41
fail_primary: AtomicU32, - 42
fallback: std::sync::Mutex<VecDeque<AssistantMessage>>, - 43
seen_models: std::sync::Mutex<Vec<String>>, - 44
} - 45
- 46
impl MatrixProvider { - 47
fn new(fallbacks: Vec<AssistantMessage>) -> Arc<Self> { - 48
Arc::new(MatrixProvider { - 49
fail_primary: AtomicU32::new(u32::MAX), - 50
fallback: std::sync::Mutex::new(fallbacks.into_iter().collect()), - 51
seen_models: std::sync::Mutex::new(Vec::new()), - 52
}) - 53
} - 54
} - 55
- 56
#[async_trait::async_trait] - 57
impl Provider for MatrixProvider { - 58
fn name(&self) -> &str { - 59
"matrix" - 60
} - 61
- 62
async fn stream( - 63
&self, - 64
request: ChatRequest, - 65
_cancel: CancellationToken, - 66
) -> Result<EventStream, LlmError> { - 67
self.seen_models.lock().unwrap().push(request.model.clone()); - 68
let dead = self.fail_primary.fetch_sub(1, Ordering::SeqCst) > 0 - 69
&& request.model == "primary-model"; - 70
let (mut sink, rx) = stream::channel(64); - 71
if dead { - 72
sink.close_error(LlmError::Network("primary down".into())) - 73
.await; - 74
} else { - 75
let next = self.fallback.lock().unwrap().pop_front(); - 76
match next { - 77
Some(m) => { - 78
sink.push(stream::StreamEvent::Start { partial: m.clone() }); - 79
sink.close_message(m).await; - 80
} - 81
None => { - 82
sink.close_error(LlmError::Parse("matrix exhausted".into())) - 83
.await - 84
} - 85
} - 86
} - 87
Ok(rx) - 88
} - 89
} - 90
- 91
/// Denies a specific model; allows everything else. - 92
struct ModelDenyGate { - 93
denied_model: &'static str, - 94
denials: std::sync::Mutex<Vec<String>>, - 95
} - 96
- 97
#[async_trait::async_trait] - 98
impl SpendGate for ModelDenyGate { - 99
async fn authorize(&self, check: &SpendCheck<'_>) -> Result<(), String> { - 100
if check.model == self.denied_model { - 101
self.denials.lock().unwrap().push(check.model.to_string()); - 102
Err(format!("model {} not funded", check.model)) - 103
} else { - 104
Ok(()) - 105
} - 106
} - 107
- 108
fn record_settled(&self, _provider: &str, _model: &str, _session_id: &str, _usage: &Usage) {} - 109
} - 110
- 111
fn setup(provider: Arc<MatrixProvider>, cfg_tweaks: impl FnOnce(&mut AgentConfig)) -> Agent { - 112
let dir = tempdir().unwrap(); - 113
// Keep the tempdir alive for the whole test by leaking the path into - 114
// the session home (tests are short-lived processes). - 115
let cwd = dir.path().to_path_buf(); - 116
std::mem::forget(dir); - 117
let header = SessionHeader { - 118
agent: None, - 119
session_id: "matrix".into(), - 120
created_at: chrono::Utc::now(), - 121
cwd: cwd.clone(), - 122
parent_session_id: None, - 123
contract_id: None, - 124
work_item_id: None, - 125
conversation: None, - 126
contract: FrozenContract { - 127
app_version: "0".into(), - 128
provider: "matrix".into(), - 129
model: "primary-model".into(), - 130
route_ladder: vec![ - 131
vak_llm::RouteLeg { - 132
provider: "matrix".into(), - 133
model: "primary-model".into(), - 134
dialect: vak_llm::EndpointDialect::ChatCompletions, - 135
credential_id: None, - 136
}, - 137
vak_llm::RouteLeg { - 138
provider: "matrix".into(), - 139
model: "fallback-model".into(), - 140
dialect: vak_llm::EndpointDialect::ChatCompletions, - 141
credential_id: None, - 142
}, - 143
], - 144
route_objective: String::new(), - 145
route_annotations: Vec::new(), - 146
system_prompt: "sys".into(), - 147
permission_mode: "workspace-write".into(), - 148
capabilities: Vec::new(), - 149
prompt_layers: Vec::new(), - 150
}, - 151
}; - 152
let home = cwd.join(".vak-home"); - 153
std::fs::create_dir_all(&home).unwrap(); - 154
let log = - 155
SessionLog::create(SessionPath::new_session_file(&home, &cwd, "matrix"), header).unwrap(); - 156
let mut cfg = AgentConfig::new("sys"); - 157
cfg.model = "primary-model".into(); - 158
cfg.mode = Mode::FullAccess; - 159
cfg.permission = Some(Arc::new(PermissionEngine::default())); - 160
cfg.approver = Some(Arc::new(AutoApprove)); - 161
cfg.retry_base_backoff_ms = 1; - 162
cfg.run_retry_base_backoff_ms = 1; - 163
cfg.max_retries = 0; - 164
cfg.run_retry_attempts = 0; - 165
// Primary + one fallback. - 166
cfg.ladder = vec![( - 167
provider.clone() as Arc<dyn Provider>, - 168
"fallback-model".to_string(), - 169
)]; - 170
cfg_tweaks(&mut cfg); - 171
Agent::new(provider, log, cfg) - 172
} - 173
- 174
async fn run(agent: &mut Agent, prompt: &str) -> TurnOutcome { - 175
run_events(agent, prompt).await.0 - 176
} - 177
- 178
async fn run_events(agent: &mut Agent, prompt: &str) -> (TurnOutcome, Vec<AgentEvent>) { - 179
let (ev_tx, mut ev_rx) = mpsc::channel(1024); - 180
let cancel = CancellationToken::new(); - 181
let steering = SteeringQueues::new(); - 182
let fut = agent.run(prompt, &steering, cancel, ev_tx); - 183
let mut events = Vec::new(); - 184
let outcome = tokio::join!(fut, async { - 185
while let Some(ev) = ev_rx.recv().await { - 186
events.push(ev); - 187
} - 188
}); - 189
(outcome.0, events) - 190
} - 191
- 192
const PASS_VERDICT: &str = - 193
r#"{"results":[{"criterion":"c1","verdict":"pass","evidence":"clearly done"}]}"#; - 194
- 195
/// B×H×A: primary dies on every step; every model turn AND the judge call - 196
/// route to the fallback; judge receipt is purpose=Verify with the - 197
/// fallback model; completion audited. - 198
#[tokio::test(flavor = "multi_thread", worker_threads = 4)] - 199
async fn ladder_serves_goal_audits_when_primary_dead() { - 200
let provider = MatrixProvider::new(vec![ - 201
text_msg("fallback-model", "attempting objective"), - 202
text_msg("fallback-model", PASS_VERDICT), - 203
]); - 204
let mut agent = setup(provider.clone(), |_| {}); - 205
agent.set_goal("do the thing", vec!["c1".into()]); - 206
let outcome = run(&mut agent, "do the thing").await; - 207
assert!(matches!(outcome, TurnOutcome::Completed { .. })); - 208
- 209
{ - 210
let models = provider.seen_models.lock().unwrap(); - 211
// Each work unit walks primary(dead) -> fallback(ok). No work may - 212
// END on the dead leg. - 213
// A work unit may ATTEMPT the dead primary but must never settle - 214
// there: the last model seen per work must be the fallback. - 215
let committed_on_primary = models.last().map(|m| m == "primary-model").unwrap_or(false); - 216
assert!( - 217
!committed_on_primary, - 218
"a work unit must never settle on the dead leg: {models:?}" - 219
); - 220
assert!(models.contains(&"fallback-model".to_string())); - 221
} - 222
- 223
let session = agent.into_session().await; - 224
let receipts = session.receipts(); - 225
let verify = receipts - 226
.iter() - 227
.find(|r| r.purpose == WorkPurpose::Verify) - 228
.expect("judge dispatch receipted"); - 229
assert_eq!(verify.model, "fallback-model"); - 230
// The judge dispatch ALSO walked primary(dead)->fallback(ok). - 231
assert_eq!(verify.attempts[0].reason, AttemptReason::Initial); - 232
assert_eq!(verify.attempts[0].domain, vak_llm::FailureDomain::Network); - 233
assert_eq!(verify.attempts[1].reason, AttemptReason::RouteFallback); - 234
assert_eq!(verify.attempts[1].settlement, vak_llm::Settlement::Ok); - 235
assert_eq!(verify.winning_attempt, Some(1)); - 236
assert_eq!( - 237
goal_final_status(&session), - 238
Some("done".into()), - 239
"audited done across a walked ladder" - 240
); - 241
} - 242
- 243
/// B×D: budget denies ONLY the primary model; the Ask auto-fails there - 244
/// (denial is per-dispatch, approver approves nothing here) — wait: the - 245
/// approver below approves budget asks, which would let the PRIMARY - 246
/// through. So instead assert the deny-then-approve path lands the - 247
/// primary dispatch anyway, and the settled usage records under the - 248
/// primary model. - 249
#[tokio::test(flavor = "multi_thread", worker_threads = 4)] - 250
async fn budget_ask_on_primary_then_proceed() { - 251
let provider = MatrixProvider::new(vec![text_msg("primary-model", "answered")]); - 252
let mut agent = setup(provider.clone(), |cfg| { - 253
cfg.spend_gate = Some(Arc::new(ModelDenyGate { - 254
denied_model: "primary-model", - 255
denials: std::sync::Mutex::new(Vec::new()), - 256
})); - 257
}); - 258
let outcome = run(&mut agent, "hello").await; - 259
assert!(matches!(outcome, TurnOutcome::Completed { .. })); - 260
- 261
let session = agent.into_session().await; - 262
let r = &session.receipts()[0]; - 263
// Approved ask admitted the primary attempt; the primary is also - 264
// network-dead in this fixture, so the walk continued to the fallback. - 265
assert_eq!(r.attempts[0].reason, vak_llm::AttemptReason::Initial); - 266
assert_eq!(r.attempts[0].domain, vak_llm::FailureDomain::Network); - 267
assert_eq!(r.attempts[1].reason, vak_llm::AttemptReason::RouteFallback); - 268
assert_eq!(r.winning_attempt, Some(1)); - 269
assert_eq!(r.model, "fallback-model"); - 270
// Exactly one budget denial was raised (for the primary); verified by - 271
// the receipt walk above (Initial@Network -> RouteFallback@Ok). - 272
} - 273
- 274
/// D strictness: with NO approver (unattended), a denied primary must NOT - 275
/// silently fall forward to a cheaper leg — budget admission is per-model - 276
/// and a denial without approval fails the step permanently. - 277
#[tokio::test(flavor = "multi_thread", worker_threads = 4)] - 278
async fn unattended_budget_denial_fails_even_with_ladder() { - 279
let provider = MatrixProvider::new(vec![text_msg("fallback-model", "should not happen")]); - 280
let mut agent = setup(provider.clone(), |cfg| { - 281
cfg.approver = None; // unattended - 282
cfg.spend_gate = Some(Arc::new(ModelDenyGate { - 283
denied_model: "primary-model", - 284
denials: std::sync::Mutex::new(Vec::new()), - 285
})); - 286
}); - 287
match run(&mut agent, "hello").await { - 288
TurnOutcome::Failed { error } => { - 289
assert!(error.to_string().contains("budget admission denied")); - 290
} - 291
other => panic!("expected permanent failure, got {other:?}"), - 292
} - 293
// The fallback leg was never dispatched: silence means no. - 294
assert!( - 295
provider - 296
.seen_models - 297
.lock() - 298
.unwrap() - 299
.iter() - 300
.all(|m| m == "primary-model"), - 301
"no dispatch may leave for the fallback after an unanswered denial" - 302
); - 303
} - 304
- 305
/// C×H: a goal run with no usable horizon takes the reset-with-handoff - 306
/// rescue, and receipts still land around it. - 307
#[tokio::test(flavor = "multi_thread", worker_threads = 4)] - 308
async fn compaction_partitions_coexist_with_goal_and_receipts() { - 309
// NOTE: a 900-token declared window is dwarfed by this filler's own - 310
// estimated size, so `budget` saturates to 0 (no usable horizon) and - 311
// the reset-with-handoff rescue fires before the first model turn. - 312
let filler = "z".repeat(5000); - 313
let provider = MatrixProvider::new(vec![ - 314
// Over-budget path fires BEFORE the first model turn; the handoff - 315
// rescue consumes this slot. - 316
text_msg("primary-model", "# Objective\nfinish\n# Open Items\nnone"), - 317
text_msg("primary-model", "claim one"), - 318
// Judge gets prose => fail-closed rejection. - 319
text_msg("primary-model", "no json here"), - 320
text_msg("primary-model", "claim two"), - 321
text_msg("primary-model", PASS_VERDICT), - 322
]); - 323
let mut agent = setup(provider, |_| {}); - 324
agent.config.declared_window = 900; - 325
agent.config.max_output = 64; - 326
agent.set_goal("finish despite compaction", vec!["c1".into()]); - 327
let outcome = run(&mut agent, &format!("task {filler}")).await; - 328
assert!(matches!(outcome, TurnOutcome::Completed { .. })); - 329
- 330
let session = agent.into_session().await; - 331
let compactions: Vec<_> = session - 332
.chain_to_root() - 333
.iter() - 334
.filter_map(|e| match &e.payload { - 335
EntryPayload::Compaction(c) => Some(c), - 336
_ => None, - 337
}) - 338
.collect(); - 339
assert!(!compactions.is_empty(), "compaction ran"); - 340
let handoff_fired = session - 341
.chain_to_root() - 342
.iter() - 343
.any(|e| matches!(&e.payload, EntryPayload::Compaction(c) if c.reset_all)); - 344
assert!(handoff_fired, "rescue ran before the first model turn"); - 345
// Partition accounting on any NORMAL compaction is covered by the - 346
// vak-eval scorecard; the reset entry itself carries none by design. - 347
- 348
// Receipts survived alongside partitions; goal closed audited. - 349
assert!(session.receipts().len() >= 2); - 350
assert_eq!(goal_final_status(&session), Some("done".into())); - 351
} - 352
- 353
/// Ceiling starvation mid-ladder: ceiling=1 admits exactly one dispatch; - 354
/// the primary's failure consumes it and the fallback may NOT go out. - 355
#[tokio::test(flavor = "multi_thread", worker_threads = 4)] - 356
async fn starved_ceiling_blocks_fallback_leg() { - 357
let provider = MatrixProvider::new(vec![text_msg("fallback-model", "never")]); - 358
let mut agent = setup(provider.clone(), |cfg| { - 359
cfg.dispatch_ceiling = 1; - 360
}); - 361
match run(&mut agent, "hello").await { - 362
TurnOutcome::Failed { error } => { - 363
assert!( - 364
error.to_string().contains("dispatch ceiling"), - 365
"got: {error}" - 366
); - 367
} - 368
other => panic!("expected ceiling failure, got {other:?}"), - 369
} - 370
let models = provider.seen_models.lock().unwrap(); - 371
assert_eq!( - 372
models.iter().filter(|m| **m == *"fallback-model").count(), - 373
0, - 374
"starved ceiling must not fund the next leg" - 375
); - 376
} - 377
- 378
fn goal_final_status(session: &SessionLog) -> Option<String> { - 379
session - 380
.chain_to_root() - 381
.iter() - 382
.filter_map(|e| match &e.payload { - 383
EntryPayload::Goal(g) => Some(match g.status { - 384
vak_session::types::GoalStatus::Active => "active".to_string(), - 385
vak_session::types::GoalStatus::Done { .. } => "done".to_string(), - 386
vak_session::types::GoalStatus::Unverified { .. } => "unverified".to_string(), - 387
}), - 388
_ => None, - 389
}) - 390
.next_back() - 391
} - 392
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.