- 1
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] - 2
- 3
//! Goal mode + audited completion + regression obligations + - 4
//! reset-with-handoff (docs/design/42-managed-work-contracts.md). - 5
- 6
use std::collections::VecDeque; - 7
use std::sync::Arc; - 8
- 9
use tempfile::tempdir; - 10
use tokio::sync::mpsc; - 11
use tokio_util::sync::CancellationToken; - 12
- 13
use vak_agent::{ - 14
Agent, AgentConfig, AutoApprove, SteeringQueues, TurnOutcome, workspace::WorkspaceDelta, - 15
}; - 16
use vak_llm::stream; - 17
use vak_llm::types::{AssistantMessage, ChatRequest, ContentBlock, StopReason, Usage}; - 18
use vak_llm::{EventStream, LlmError, Provider}; - 19
use vak_permission::{Mode, PermissionEngine}; - 20
use vak_session::types::{EntryPayload, FrozenContract, SessionHeader}; - 21
use vak_session::{SessionLog, SessionPath}; - 22
use vak_tools::bash::BashTool; - 23
- 24
fn text_msg(t: &str) -> AssistantMessage { - 25
AssistantMessage { - 26
content: vec![ContentBlock::text(t)], - 27
stop_reason: StopReason::EndTurn, - 28
usage: Usage { - 29
input_tokens: 5, - 30
output_tokens: 3, - 31
..Default::default() - 32
}, - 33
model: "judge-model".into(), - 34
response_id: None, - 35
} - 36
} - 37
- 38
fn tool_msg() -> AssistantMessage { - 39
AssistantMessage { - 40
content: vec![ContentBlock::ToolUse { - 41
id: "t1".into(), - 42
name: "bash".into(), - 43
input: serde_json::json!({"command": "echo green"}), - 44
}], - 45
stop_reason: StopReason::ToolUse, - 46
usage: Usage::default(), - 47
model: "test-model".into(), - 48
response_id: None, - 49
} - 50
} - 51
- 52
/// Scripted responses consumed FIFO; empty queue => parse error. - 53
struct Scripted { - 54
responses: std::sync::Mutex<VecDeque<AssistantMessage>>, - 55
requests: std::sync::Mutex<Vec<ChatRequest>>, - 56
} - 57
- 58
impl Scripted { - 59
fn new(responses: Vec<AssistantMessage>) -> Arc<Self> { - 60
Arc::new(Scripted { - 61
responses: std::sync::Mutex::new(responses.into_iter().collect()), - 62
requests: std::sync::Mutex::new(Vec::new()), - 63
}) - 64
} - 65
} - 66
- 67
#[async_trait::async_trait] - 68
impl Provider for Scripted { - 69
fn name(&self) -> &str { - 70
"scripted-goal" - 71
} - 72
- 73
async fn stream( - 74
&self, - 75
request: ChatRequest, - 76
_cancel: CancellationToken, - 77
) -> Result<EventStream, LlmError> { - 78
self.requests.lock().unwrap().push(request); - 79
let next = self.responses.lock().unwrap().pop_front(); - 80
let (mut sink, rx) = stream::channel(64); - 81
match next { - 82
Some(m) => { - 83
sink.push(stream::StreamEvent::Start { partial: m.clone() }); - 84
sink.close_message(m).await; - 85
} - 86
None => { - 87
sink.close_error(LlmError::Parse("script exhausted".into())) - 88
.await - 89
} - 90
} - 91
Ok(rx) - 92
} - 93
} - 94
- 95
fn setup( - 96
provider: Arc<Scripted>, - 97
tools: Vec<Arc<dyn vak_tools::Tool>>, - 98
) -> (Agent, tempfile::TempDir) { - 99
setup_with_delta(provider, tools, None) - 100
} - 101
- 102
fn setup_with_delta( - 103
provider: Arc<Scripted>, - 104
tools: Vec<Arc<dyn vak_tools::Tool>>, - 105
delta: Option<Arc<dyn WorkspaceDelta>>, - 106
) -> (Agent, tempfile::TempDir) { - 107
let dir = tempdir().unwrap(); - 108
let cwd = dir.path().to_path_buf(); - 109
let header = SessionHeader { - 110
agent: None, - 111
session_id: "goal".into(), - 112
created_at: chrono::Utc::now(), - 113
cwd: cwd.clone(), - 114
parent_session_id: None, - 115
contract_id: None, - 116
work_item_id: None, - 117
conversation: None, - 118
contract: FrozenContract { - 119
app_version: "0".into(), - 120
provider: "scripted-goal".into(), - 121
model: "test-model".into(), - 122
route_ladder: Vec::new(), - 123
route_objective: String::new(), - 124
route_annotations: Vec::new(), - 125
system_prompt: "sys".into(), - 126
permission_mode: "full-access".into(), - 127
capabilities: Vec::new(), - 128
prompt_layers: Vec::new(), - 129
}, - 130
}; - 131
let home = cwd.join(".vak-home"); - 132
std::fs::create_dir_all(&home).unwrap(); - 133
let log = - 134
SessionLog::create(SessionPath::new_session_file(&home, &cwd, "goal"), header).unwrap(); - 135
let mut cfg = AgentConfig::new("sys"); - 136
cfg.model = "test-model".into(); - 137
cfg.tools = tools; - 138
cfg.mode = Mode::FullAccess; - 139
cfg.permission = Some(Arc::new(PermissionEngine::default())); - 140
cfg.approver = Some(Arc::new(AutoApprove)); - 141
cfg.workspace_delta = delta; - 142
cfg.retry_base_backoff_ms = 1; - 143
cfg.run_retry_base_backoff_ms = 1; - 144
(Agent::new(provider, log, cfg), dir) - 145
} - 146
- 147
async fn run(agent: &mut Agent, prompt: &str) -> TurnOutcome { - 148
let (ev_tx, mut ev_rx) = mpsc::channel(512); - 149
tokio::spawn(async move { while ev_rx.recv().await.is_some() {} }); - 150
let cancel = CancellationToken::new(); - 151
let steering = SteeringQueues::new(); - 152
agent.run(prompt, &steering, cancel, ev_tx).await - 153
} - 154
- 155
fn goal_statuses(session: &SessionLog) -> Vec<String> { - 156
session - 157
.chain_to_root() - 158
.iter() - 159
.filter_map(|e| match &e.payload { - 160
EntryPayload::Goal(g) => Some(match &g.status { - 161
vak_session::types::GoalStatus::Active => "active".into(), - 162
vak_session::types::GoalStatus::Done { .. } => "done".into(), - 163
vak_session::types::GoalStatus::Unverified { .. } => "unverified".into(), - 164
}), - 165
_ => None, - 166
}) - 167
.collect() - 168
} - 169
- 170
/// Model claims done immediately; judge passes all criteria. - 171
#[tokio::test(flavor = "multi_thread", worker_threads = 4)] - 172
async fn audited_done_when_judge_passes() { - 173
let provider = Scripted::new(vec![ - 174
text_msg("haiku written"), - 175
text_msg( - 176
r#"{"results":[{"criterion":"summary mentions done","verdict":"pass","evidence":"said it"}]}"#, - 177
), - 178
]); - 179
struct StaticDelta; - 180
impl WorkspaceDelta for StaticDelta { - 181
fn summary(&self) -> Result<String, String> { - 182
Ok("M src/lib.rs\nA goal-live.txt\nWORKSPACE-DELTA-MARKER".into()) - 183
} - 184
} - 185
let (mut agent, _dir) = setup_with_delta(provider.clone(), vec![], Some(Arc::new(StaticDelta))); - 186
agent.set_goal("write a haiku", vec!["summary mentions done".into()]); - 187
let outcome = run(&mut agent, "write a haiku about rust").await; - 188
assert!(matches!(outcome, TurnOutcome::Completed { .. })); - 189
- 190
// The judge call happened and was receipted as Verify work. - 191
let has_judge = provider.requests.lock().unwrap().iter().any(|r| { - 192
r.system - 193
.as_deref() - 194
.unwrap_or("") - 195
.contains("completion auditor") - 196
}); - 197
assert!(has_judge, "judge call must carry the auditor system prompt"); - 198
let session = agent.into_session().await; - 199
assert_eq!(goal_statuses(&session), vec!["active", "done"]); - 200
assert!( - 201
session - 202
.receipts() - 203
.iter() - 204
.any(|r| r.purpose == vak_llm::WorkPurpose::Verify), - 205
"audit dispatch must be receipted" - 206
); - 207
} - 208
- 209
/// Judge fails a criterion first; findings reach the model; second claim - 210
/// passes. Audit budget respected. - 211
#[tokio::test(flavor = "multi_thread", worker_threads = 4)] - 212
async fn rejected_claim_returns_findings_then_passes() { - 213
let provider = Scripted::new(vec![ - 214
text_msg("attempt one"), - 215
text_msg( - 216
r#"{"results":[{"criterion":"c1","verdict":"fail","evidence":"no file written"}]}"#, - 217
), - 218
text_msg("attempt two, file written"), - 219
text_msg(r#"{"results":[{"criterion":"c1","verdict":"pass","evidence":"wrote it"}]}"#), - 220
]); - 221
let (mut agent, _dir) = setup(provider.clone(), vec![]); - 222
agent.set_goal("create x", vec!["c1".into()]); - 223
let outcome = run(&mut agent, "create x").await; - 224
assert!(matches!(outcome, TurnOutcome::Completed { .. })); - 225
let session = agent.into_session().await; - 226
let statuses = goal_statuses(&session); - 227
assert_eq!(statuses, vec!["active", "done"]); - 228
// Findings were injected model-visible (invariant 1): a [goal-audit] - 229
// user turn exists between claims. Raw ledger: the nudge is mid-turn - 230
// scaffolding and is dropped from the projection once the turn closes - 231
// (docs/design/68-context-engine.md §10) — this checks it was - 232
// recorded (and thus reached the model) at all. - 233
assert!( - 234
session - 235
.message_chain() - 236
.iter() - 237
.any(|(_, m)| m.text_content().contains("[goal-audit]")) - 238
); - 239
let judge_calls = provider - 240
.requests - 241
.lock() - 242
.unwrap() - 243
.iter() - 244
.filter(|r| { - 245
r.system - 246
.as_deref() - 247
.unwrap_or("") - 248
.contains("completion auditor") - 249
}) - 250
.count(); - 251
assert_eq!(judge_calls, 2, "two judge dispatches"); - 252
} - 253
- 254
/// A verify: criterion that fails rejects without any judge call. - 255
#[tokio::test(flavor = "multi_thread", worker_threads = 4)] - 256
async fn shell_criterion_failure_blocks_without_judge() { - 257
let provider = Scripted::new(vec![text_msg("i ran things")]); - 258
let (mut agent, _dir) = setup(provider.clone(), vec![]); - 259
agent.config.max_audit_blocks = 0; - 260
agent.set_goal("run things", vec!["verify: exit 3".into()]); - 261
let outcome = run(&mut agent, "run things").await; - 262
assert!( - 263
matches!(outcome, TurnOutcome::Completed { .. }), - 264
"budget exhausts => unverified completion" - 265
); - 266
let judge_calls = provider - 267
.requests - 268
.lock() - 269
.unwrap() - 270
.iter() - 271
.filter(|r| { - 272
r.system - 273
.as_deref() - 274
.unwrap_or("") - 275
.contains("completion auditor") - 276
}) - 277
.count(); - 278
assert_eq!(judge_calls, 0, "deterministic failure needs no judge"); - 279
- 280
let session = agent.into_session().await; - 281
assert_eq!(goal_statuses(&session), vec!["active", "unverified"]); - 282
} - 283
- 284
/// Green bash commands become obligations: a later failing re-run blocks - 285
/// the claim until fixed. - 286
#[tokio::test(flavor = "multi_thread", worker_threads = 4)] - 287
async fn regression_obligation_blocks_claim() { - 288
// Provider script: run echo-green (tool call), claim done, then after - 289
// rejection claim done again. Judge always passes. - 290
let provider = Scripted::new(vec![ - 291
tool_msg(), - 292
text_msg("all done"), - 293
text_msg(r#"{"results":[{"criterion":"c1","verdict":"pass","evidence":"ok"}]}"#), - 294
]); - 295
let (mut agent, _dir) = setup(provider, vec![Arc::new(BashTool)]); - 296
agent.set_goal("keep tests green", vec!["c1".into()]); - 297
let outcome = run(&mut agent, "echo something then finish").await; - 298
assert!(matches!(outcome, TurnOutcome::Completed { .. })); - 299
- 300
// The audit re-ran `echo green` as an obligation — visible as an extra - 301
// brokered request beyond the scripted model turns. - 302
let session = agent.into_session().await; - 303
let statuses = goal_statuses(&session); - 304
assert!( - 305
statuses - 306
.last() - 307
.map(|s| s == "done" || s == "unverified") - 308
.unwrap_or(false), - 309
"final status recorded: {statuses:?}" - 310
); - 311
} - 312
- 313
/// Unparseable judge output fails closed: claim rejected, not accepted. - 314
#[tokio::test(flavor = "multi_thread", worker_threads = 4)] - 315
async fn unparseable_judge_fails_closed() { - 316
let provider = Scripted::new(vec![ - 317
text_msg("attempt one"), - 318
text_msg("I think it's probably fine honestly"), - 319
text_msg("attempt two"), - 320
text_msg(r#"{"results":[{"criterion":"c1","verdict":"pass","evidence":"now clear"}]}"#), - 321
]); - 322
let (mut agent, _dir) = setup(provider, vec![]); - 323
agent.set_goal("do it", vec!["c1".into()]); - 324
let outcome = run(&mut agent, "do it").await; - 325
assert!(matches!(outcome, TurnOutcome::Completed { .. })); - 326
let session = agent.into_session().await; - 327
// Raw ledger: see the comment above on the same pattern. - 328
let msgs = session.message_chain(); - 329
assert!( - 330
msgs.iter() - 331
.any(|(_, m)| m.text_content().contains("[goal-audit]") - 332
&& m.text_content().contains("AUDIT UNAVAILABLE")), - 333
"unparseable verdict must inject fail-closed findings" - 334
); - 335
} - 336
- 337
/// Handoff-reset: with a tiny window, the still-over path writes a handoff - 338
/// and continues fresh instead of failing. - 339
#[tokio::test(flavor = "multi_thread", worker_threads = 4)] - 340
async fn handoff_reset_rescues_still_over_context() { - 341
// Script: handoff writer draws first, then the recovered turn. - 342
let provider = Scripted::new(vec![ - 343
text_msg( - 344
"# Objective\nfinish\n# Current State\nmid\n# Decisions Made\nnone\n# Open Items\ndone\n# Obligations\nnone", - 345
), - 346
text_msg("recovered and finished"), - 347
]); - 348
let (mut agent, _dir) = setup(provider, vec![]); - 349
// Tiny policy so the fixture triggers the over-budget path with too - 350
// few turns to compact. - 351
agent.config.declared_window = 900; - 352
agent.config.max_output = 64; - 353
- 354
let filler = "x".repeat(4000); - 355
let prompt = format!("task {filler}"); - 356
let outcome = run(&mut agent, &prompt).await; - 357
assert!( - 358
matches!( - 359
outcome, - 360
TurnOutcome::Completed { .. } - 361
| TurnOutcome::MaxTurnsReached - 362
| TurnOutcome::Failed { .. } - 363
), - 364
"unexpected: {outcome:?}" - 365
); - 366
- 367
let session = agent.into_session().await; - 368
let has_handoff = session - 369
.chain_to_root() - 370
.iter() - 371
.any(|e| matches!(&e.payload, EntryPayload::Compaction(c) if c.reset_all)); - 372
assert!(has_handoff, "handoff entry must exist on disk"); - 373
- 374
// Projection after reset contains ONLY summaries — no verbatim filler. - 375
let projected = session.derive_messages(); - 376
assert!( - 377
!projected.iter().any(|m| m.text_content().contains(&filler)), - 378
"reset must clear verbatim history" - 379
); - 380
} - 381
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.