- 3001
self.obligations.push(cmd.clone()); - 3002
} - 3003
} - 3004
ToolRunOutput::Err(_) => { - 3005
receipts.failed_tool_calls += 1; - 3006
} - 3007
} - 3008
} - 3009
// `verification_stale` used to be flipped inline in the loop - 3010
// above, which made it depend on `results`' iteration order — - 3011
// the order tools finished, not the order the model issued - 3012
// them in (this agent does run tool calls within a batch - 3013
// concurrently when `config.parallel_tools` is set, so this was - 3014
// reachable, not just theoretical). See - 3015
// `resolve_verification_stale` for the order-correct logic, - 3016
// tested in isolation below. - 3017
let succeeded: std::collections::HashSet<&str> = results - 3018
.iter() - 3019
.filter(|(_, out)| matches!(out, ToolRunOutput::Ok(_))) - 3020
.map(|(id, _)| id.as_str()) - 3021
.collect(); - 3022
let bash_ids: Vec<&str> = bash_pairs.iter().map(|(id, _)| id.as_str()).collect(); - 3023
let mutation_ids: Vec<&str> = code_mutation_ids.iter().map(|id| id.as_str()).collect(); - 3024
verification_stale = resolve_verification_stale( - 3025
&call_issue_order, - 3026
&bash_ids, - 3027
&mutation_ids, - 3028
&succeeded, - 3029
verification_stale, - 3030
); - 3031
// `unresolved_error` used to be set/cleared per-result inside the - 3032
// loop above, which meant a later call in the SAME batch that - 3033
// happened to succeed would silently erase an earlier call's - 3034
// failure (order-dependent on `results`, not on whether the - 3035
// failure was actually resolved). A model that fails one call - 3036
// and succeeds at an unrelated trailing call in the same turn - 3037
// could then claim total success next turn with `stop_gate` - 3038
// never seeing the failure at all. Decide this once, after the - 3039
// whole batch, from the batch's own outcome: any error in this - 3040
// batch wins (first one, in issued order) over any success in - 3041
// the same batch; only a batch with NO errors clears a - 3042
// previous batch's still-unresolved failure. - 3043
// - 3044
// A card is a presentation of the answer, not part of the work: - 3045
// one that failed validation is simply not shown, and the answer - 3046
// is judged on what it says. Whether the turn needed a card is - 3047
// the presentation check's question, with its own bounded nudge. - 3048
// Counting a failed card as unresolved sent complete, correct - 3049
// prose answers back to repair a card a small model could not - 3050
// build, until the turn failed with the answer discarded - 3051
// (measured live on gemma4:e2b-mlx). A batch of cards alone - 3052
// neither sets nor clears the failure state. - 3053
let counted: Vec<&(String, ToolRunOutput)> = results - 3054
.iter() - 3055
.filter(|(id, _)| { - 3056
call_names - 3057
.get(id) - 3058
.is_none_or(|name| !self.tool_presents_cards(name)) - 3059
}) - 3060
.collect(); - 3061
let batch_error = counted.iter().find_map(|(id, out)| match out { - 3062
ToolRunOutput::Err(err) => Some(( - 3063
call_names.get(id).cloned().unwrap_or_else(|| id.clone()), - 3064
err.clone(), - 3065
)), - 3066
ToolRunOutput::Ok(_) => None, - 3067
}); - 3068
receipts.unresolved_error = match batch_error { - 3069
Some(error) => Some(error), - 3070
None if counted.is_empty() => receipts.unresolved_error.clone(), - 3071
None => None, - 3072
}; - 3073
let blocks = results - 3074
.into_iter() - 3075
.map(|(id, out)| match out { - 3076
ToolRunOutput::Ok(content) => ContentBlock::tool_result(id, content), - 3077
ToolRunOutput::Err(content) => ContentBlock::tool_error(id, content), - 3078
}) - 3079
.collect(); - 3080
- 3081
{ - 3082
let mut session = self.session.lock().await; - 3083
let appended = session - 3084
.append_message(MessageRecord { - 3085
message: Message { - 3086
role: Role::User, - 3087
content: blocks, - 3088
}, - 3089
meta: None, - 3090
}) - 3091
.and_then(|_| { - 3092
// The whole of each windowed result, beside the - 3093
// window the request carries (docs/design/68 §3). - 3094
for id in &call_issue_order { - 3095
if let Some(body) = yields.get_mut(id).and_then(|y| y.body.take()) { - 3096
session.append_evidence_body(id, body)?; - 3097
} - 3098
} - 3099
Ok(()) - 3100
}); - 3101
if let Err(e) = appended { - 3102
return TurnOutcome::Failed { - 3103
error: LlmError::Network(format!("session write failed: {e}")), - 3104
}; - 3105
} - 3106
} - 3107
- 3108
if let Some(outcome) = reconcile_repair_budget(self, &failed_correctable).await { - 3109
return outcome; - 3110
} - 3111
- 3112
turn += 1; - 3113
} - 3114
} - 3115
- 3116
/// Records the cards each delegated run in this batch showed (a `task` - 3117
/// worker's) as presentations of the call that delegated it, and lists - 3118
/// them in that call's result so this agent can recall one to review or - 3119
/// fix it. A worker's cards used to reach nobody: the parent received the - 3120
/// worker's final text and nothing else. - 3121
async fn record_delegated_cards( - 3122
&self, - 3123
call_issue_order: &[String], - 3124
yields: &mut HashMap<String, CallYield>, - 3125
results: &mut [(String, ToolRunOutput)], - 3126
) { - 3127
let mut session = self.session.lock().await; - 3128
let Some(turn_id) = session.latest_directive_entry_id() else { - 3129
return; - 3130
}; - 3131
for id in call_issue_order { - 3132
let Some(delegated) = yields.get(id).and_then(|y| y.delegated.clone()) else { - 3133
continue; - 3134
}; - 3135
let mut derived_from = - 3136
session.non_card_evidence_since(&turn_id, |name| self.tool_presents_cards(name)); - 3137
derived_from.push(id.clone()); - 3138
let mut listed = Vec::new(); - 3139
for card in delegated.cards { - 3140
let digest = vak_session::types::payload_digest(&card.payload); - 3141
if session.has_presentation(&turn_id, &digest) { - 3142
continue; - 3143
} - 3144
let label = format!("{} \"{}\"", card.semantic_type, card.title); - 3145
let record = vak_session::types::PresentationRecord { - 3146
turn_id: turn_id.clone(), - 3147
source: vak_session::types::PresentationSource::Delegated { - 3148
tool_use_id: id.clone(), - 3149
worker_session_id: delegated.session_id.clone(), - 3150
}, - 3151
semantic_type: card.semantic_type, - 3152
skill_id: card.skill_id, - 3153
skill_version: card.skill_version, - 3154
schema_version: card.schema_version, - 3155
payload: card.payload, - 3156
payload_digest: digest, - 3157
derived_from: derived_from.clone(), - 3158
title: card.title, - 3159
identity_digest: card.identity_digest, - 3160
}; - 3161
if let Ok(entry) = session.append_presentation(record) { - 3162
listed.push(format!("- pres:{} {label}", entry.id)); - 3163
} - 3164
} - 3165
if listed.is_empty() { - 3166
continue; - 3167
} - 3168
if let Some((_, ToolRunOutput::Ok(content) | ToolRunOutput::Err(content))) = - 3169
results.iter_mut().find(|(rid, _)| rid == id) - 3170
{ - 3171
content.push_str(&format!( - 3172
"\n\nThe worker showed the user these cards; they are on screen already. \ - 3173
recall {{\"presentation\": \"<id>\"}} opens one to review or fix it.\n{}", - 3174
listed.join("\n") - 3175
)); - 3176
} - 3177
} - 3178
} - 3179
- 3180
/// Detects ```` ```vak ```` fences in the accepted final answer and - 3181
/// writes a `Presentation` entry for each one that validates through - 3182
/// `AgentConfig::presentation_rebuild` and is not a duplicate of a card - 3183
/// already recorded for this turn — the inline-fence fallback for - 3184
/// models without tool calling (docs/design/68-context-engine.md §10). - 3185
/// `message_entry_id` is the ledger entry id of the assistant message - 3186
/// that carried the fence text. A fence whose `semantic_type` cannot be - 3187
/// mapped to a known card, or that fails validation, is silently - 3188
/// skipped — the malformed-fence repair nudge (above, in the caller) - 3189
/// already handles the "unparseable JSON" case separately. - 3190
async fn write_fence_presentations(&self, text: &str, message_entry_id: &str) { - 3191
let Some(rebuild) = self.config.presentation_rebuild.clone() else { - 3192
return; - 3193
}; - 3194
for body in vak_fence_bodies(text) { - 3195
let Ok(fence_json) = serde_json::from_str::<Value>(body.trim()) else { - 3196
continue; - 3197
}; - 3198
let Some(semantic_type) = fence_json.get("semantic_type").and_then(Value::as_str) - 3199
else { - 3200
continue; - 3201
}; - 3202
// `name` is a best-effort hint: the hook's own implementation - 3203
// (vak-core) knows how to map `semantic_type` to the matching - 3204
// `emit_*_card` tool via `presentation_tools::emit_tool_for` - 3205
// when that mapping is reachable; passing `semantic_type` here - 3206
// keeps this call meaningful even when it is not. - 3207
let Some(info) = rebuild(semantic_type, &fence_json) else { - 3208
continue; - 3209
}; - 3210
let digest = vak_session::types::payload_digest(&info.payload); - 3211
let mut session = self.session.lock().await; - 3212
let Some(turn_id) = session.latest_directive_entry_id() else { - 3213
continue; - 3214
}; - 3215
if session.has_presentation(&turn_id, &digest) { - 3216
// Duplicate of a card already recorded this turn (by tool - 3217
// call or an earlier fence) — dropped from the projection. - 3218
continue; - 3219
} - 3220
let derived_from = - 3221
session.non_card_evidence_since(&turn_id, |name| self.tool_presents_cards(name)); - 3222
let record = vak_session::types::PresentationRecord { - 3223
turn_id, - 3224
source: vak_session::types::PresentationSource::Fence { - 3225
message_entry_id: message_entry_id.to_string(), - 3226
}, - 3227
semantic_type: info.semantic_type, - 3228
skill_id: info.skill_id, - 3229
skill_version: info.skill_version, - 3230
schema_version: info.schema_version, - 3231
payload: info.payload, - 3232
payload_digest: digest, - 3233
derived_from, - 3234
title: info.title, - 3235
identity_digest: info.identity_digest, - 3236
}; - 3237
let _ = session.append_presentation(record); - 3238
} - 3239
} - 3240
- 3241
/// Builds and appends this turn's `TurnCard` (docs/design/68-context- - 3242
/// engine.md §10) once it has actually closed. Idempotent: a turn - 3243
/// already carrying a card (`TurnIndex` rebuilds `turn.card` from any - 3244
/// existing `TurnCard` entry) is left alone, since a card is written - 3245
/// once and never rewritten. - 3246
async fn close_turn(&mut self, outcome: &TurnOutcome) { - 3247
let outcome_label = match outcome { - 3248
TurnOutcome::Completed { .. } => "completed", - 3249
TurnOutcome::Aborted { .. } => "cancelled", - 3250
TurnOutcome::Failed { .. } => "failed", - 3251
TurnOutcome::MaxTurnsReached => "degraded", - 3252
}; - 3253
let Some((turn_id, raw_narration)) = ({ - 3254
let session = self.session.lock().await; - 3255
let index = TurnIndex::from_log(&session); - 3256
index.turns.last().and_then(|turn| { - 3257
(turn.closed && turn.card.is_none()).then(|| { - 3258
let narration = turn - 3259
.final_answer - 3260
.as_ref() - 3261
.map(Message::text_content) - 3262
.unwrap_or_default(); - 3263
(turn.id.clone(), narration) - 3264
}) - 3265
}) - 3266
}) else { - 3267
return; - 3268
}; - 3269
let narration = resolve_narration(&raw_narration); - 3270
// No profile wired in ⇒ a metadata-only one - 3271
// (docs/design/68-context-engine.md §4). - 3272
let profile = self.effective_capacity_profile(); - 3273
let estimate = move |s: &str| -> u64 { profile.estimate_tokens(s.chars().count() as u64) }; - 3274
let tokens_full = { - 3275
let mut session = self.session.lock().await; - 3276
let index = TurnIndex::from_log(&session); - 3277
let Some(turn) = index.turn_by_id(&turn_id) else { - 3278
return; - 3279
}; - 3280
if turn.card.is_some() { - 3281
return; // written concurrently between the two locks above - 3282
} - 3283
let card = turn.build_card(outcome_label, narration, &estimate); - 3284
let tokens_full = card.tokens_full; - 3285
let _ = session.append_turn_card(vak_session::types::TurnCardRecord { turn_id, card }); - 3286
tokens_full - 3287
}; - 3288
// Feeds the planner's reserve for the NEXT open turn (§4); a no-op - 3289
// when no live profile is wired in (nothing to persist it on). - 3290
if let Some(profile) = self.config.capacity.as_mut() { - 3291
profile.observe_current_turn_tokens(tokens_full); - 3292
} - 3293
} - 3294
- 3295
async fn resolve_managed_input(&self, answer: &str) -> Result<(), String> { - 3296
let mut session = self.session.lock().await; - 3297
let Some(projection) = session - 3298
.work_projection() - 3299
.map_err(|error| format!("managed work projection is invalid: {error}"))? - 3300
else { - 3301
return Ok(()); - 3302
}; - 3303
if projection.status != vak_session::types::WorkContractStatus::AwaitingInput { - 3304
return Ok(()); - 3305
} - 3306
let Some(answer) = answer.trim().strip_prefix("answer:").map(str::trim) else { - 3307
return Ok(()); - 3308
}; - 3309
let Some(assumption) = - 3310
projection.contract.assumptions.iter().find(|assumption| { - 3311
assumption.requires_confirmation && assumption.resolution.is_none() - 3312
}) - 3313
else { - 3314
return Ok(()); - 3315
}; - 3316
if answer.is_empty() { - 3317
return Err(format!( - 3318
"cannot resolve assumption '{}' with an empty answer", - 3319
assumption.assumption_id - 3320
)); - 3321
} - 3322
session - 3323
.append_work(vak_session::types::WorkEvent { - 3324
contract_id: projection.contract.contract_id.clone(), - 3325
revision: projection.contract.revision, - 3326
kind: vak_session::types::WorkEventKind::AssumptionResolved { - 3327
assumption_id: assumption.assumption_id.clone(), - 3328
resolution: answer.into(), - 3329
}, - 3330
}) - 3331
.map_err(|error| format!("managed assumption resolution failed: {error}"))?; - 3332
let Some(updated) = session - 3333
.work_projection() - 3334
.map_err(|error| format!("managed work projection is invalid: {error}"))? - 3335
else { - 3336
return Ok(()); - 3337
}; - 3338
if updated.status == vak_session::types::WorkContractStatus::AwaitingInput - 3339
&& updated - 3340
.contract - 3341
.assumptions - 3342
.iter() - 3343
.filter(|assumption| assumption.requires_confirmation) - 3344
.all(|assumption| assumption.resolution.is_some()) - 3345
{ - 3346
session - 3347
.append_work(vak_session::types::WorkEvent { - 3348
contract_id: updated.contract.contract_id, - 3349
revision: updated.contract.revision, - 3350
kind: vak_session::types::WorkEventKind::ContractStatusChanged { - 3351
from: vak_session::types::WorkContractStatus::AwaitingInput, - 3352
to: vak_session::types::WorkContractStatus::Active, - 3353
reason: "all required assumptions resolved from chat".into(), - 3354
}, - 3355
}) - 3356
.map_err(|error| format!("managed work activation failed: {error}"))?; - 3357
} - 3358
Ok(()) - 3359
} - 3360
- 3361
async fn start_managed_contract( - 3362
&self, - 3363
prompt: &str, - 3364
source_entry_id: String, - 3365
cancel: &CancellationToken, - 3366
events: &mpsc::Sender<AgentEvent>, - 3367
) -> Result<(), String> { - 3368
if self - 3369
.session - 3370
.lock() - 3371
.await - 3372
.work_projection() - 3373
.map_err(|error| format!("managed work projection is invalid: {error}"))? - 3374
.is_some_and(|work| { - 3375
!matches!( - 3376
work.status, - 3377
vak_session::types::WorkContractStatus::Completed - 3378
| vak_session::types::WorkContractStatus::Failed - 3379
| vak_session::types::WorkContractStatus::Cancelled - 3380
| vak_session::types::WorkContractStatus::Unverified - 3381
) - 3382
}) - 3383
{ - 3384
return Ok(()); - 3385
} - 3386
let session_id = self - 3387
.session - 3388
.lock() - 3389
.await - 3390
.header() - 3391
.map(|header| header.session_id.clone()) - 3392
.ok_or_else(|| "managed work requires a session header".to_string())?; - 3393
// config.model reflects the per-turn effective route set by run_turn_inner. - 3394
let model = self.config.model.clone(); - 3395
let authoring_request = ChatRequest { - 3396
model: model.clone(), - 3397
system: Some("You author durable work contracts. Return only one strict JSON object with keys objective, constraints, assumptions, criteria, and items. Each item must have item_id, title, instructions, dependencies, owner, required, readonly, path_claims, and criterion_ids. Owner must be one of parent_agent, worker, flow, tool, or human. Criterion kind must be one of shell, file_exists, file_contains, tool_succeeded, flow_completed, external_receipt, or semantic. Do not include markdown or commentary.".into()), - 3398
messages: vec![Message::user_text(prompt)], - 3399
tools: Vec::new(), - 3400
max_tokens: self.config.max_output.min(8_000) as u32, - 3401
temperature: None, - 3402
cache: None, - 3403
previous_response_id: None, - 3404
think: None, - 3405
effort: None, - 3406
}; - 3407
let mut ledger = StepLedger::new( - 3408
WorkPurpose::Plan, - 3409
self.provider.name(), - 3410
&model, - 3411
self.config.dispatch_ceiling.min(4), - 3412
); - 3413
let response = self - 3414
.complete_with_reliability(&authoring_request, cancel, events, false, &mut ledger) - 3415
.await - 3416
.map_err(|error| format!("managed contract authoring failed: {error}"))?; - 3417
self.session - 3418
.lock() - 3419
.await - 3420
.append_receipt(ledger.take_receipt()) - 3421
.map_err(|error| format!("managed contract receipt failed: {error}"))?; - 3422
let authored: AuthoredContract = - 3423
serde_json::from_str(&response.text_content()).map_err(|error| { - 3424
format!("managed contract authoring returned invalid JSON: {error}") - 3425
})?; - 3426
if authored.items.is_empty() || authored.items.len() > self.config.max_work_items { - 3427
return Err(format!( - 3428
"managed contract must contain between one and {} items", - 3429
self.config.max_work_items - 3430
)); - 3431
} - 3432
if authored.objective.trim().is_empty() || authored.objective.chars().count() > 16_000 { - 3433
return Err("managed contract objective is empty or too long".into()); - 3434
} - 3435
let contract_id = format!( - 3436
"work-{session_id}-{}", - 3437
chrono::Utc::now().timestamp_millis() - 3438
); - 3439
let contract = vak_session::types::WorkContract { - 3440
contract_id: contract_id.clone(), - 3441
revision: 0, - 3442
source_entry_id, - 3443
objective: authored.objective, - 3444
constraints: authored.constraints, - 3445
assumptions: authored.assumptions, - 3446
criteria: authored.criteria, - 3447
items: authored.items, - 3448
}; - 3449
vak_session::validate_contract_for_admission(&contract) - 3450
.map_err(|error| format!("managed contract validation failed: {error}"))?; - 3451
validate_work_paths(&contract)?; - 3452
let mut session = self.session.lock().await; - 3453
session - 3454
.append_work(vak_session::types::WorkEvent { - 3455
contract_id: contract_id.clone(), - 3456
revision: 0, - 3457
kind: vak_session::types::WorkEventKind::ContractCreated { contract }, - 3458
}) - 3459
.map_err(|error| format!("managed contract write failed: {error}"))?; - 3460
let awaiting_input = session - 3461
.work_projection() - 3462
.ok() - 3463
.flatten() - 3464
.is_some_and(|work| { - 3465
work.contract.assumptions.iter().any(|assumption| { - 3466
assumption.requires_confirmation && assumption.resolution.is_none() - 3467
}) - 3468
}); - 3469
session - 3470
.append_work(vak_session::types::WorkEvent { - 3471
contract_id: contract_id.clone(), - 3472
revision: 0, - 3473
kind: vak_session::types::WorkEventKind::ContractStatusChanged { - 3474
from: vak_session::types::WorkContractStatus::Draft, - 3475
to: if awaiting_input { - 3476
vak_session::types::WorkContractStatus::AwaitingInput - 3477
} else { - 3478
vak_session::types::WorkContractStatus::Active - 3479
}, - 3480
reason: if awaiting_input { - 3481
"required assumptions need confirmation".into() - 3482
} else { - 3483
"managed execution admitted".into() - 3484
}, - 3485
}, - 3486
}) - 3487
.map_err(|error| format!("managed contract activation failed: {error}"))?; - 3488
Ok(()) - 3489
} - 3490
- 3491
async fn emit_work_state(&self, events: &mpsc::Sender<AgentEvent>) { - 3492
let projection = self.session.lock().await.work_projection().ok().flatten(); - 3493
if let Some(projection) = projection { - 3494
let _ = events.send(AgentEvent::WorkState { projection }).await; - 3495
} - 3496
} - 3497
- 3498
async fn managed_work_gate( - 3499
&mut self, - 3500
cancel: &CancellationToken, - 3501
events: &mpsc::Sender<AgentEvent>, - 3502
) -> Option<String> { - 3503
if !self.config.work_enabled || self.config.work_mode != WorkMode::Managed { - 3504
return None; - 3505
} - 3506
let session = self.session.lock().await; - 3507
let projection = match session.work_projection() { - 3508
Ok(Some(projection)) => projection, - 3509
Ok(None) => return Some("managed run has no durable work contract".into()), - 3510
Err(error) => return Some(format!("managed work projection is invalid: {error}")), - 3511
}; - 3512
if matches!( - 3513
projection.status, - 3514
vak_session::types::WorkContractStatus::Completed - 3515
| vak_session::types::WorkContractStatus::Failed - 3516
| vak_session::types::WorkContractStatus::Cancelled - 3517
| vak_session::types::WorkContractStatus::Unverified - 3518
) { - 3519
return None; - 3520
} - 3521
let criteria = projection.contract.criteria.clone(); - 3522
drop(session); - 3523
self.verify_managed_criteria(&criteria, &projection, cancel, events) - 3524
.await; - 3525
let mut session = self.session.lock().await; - 3526
let projection = match session.work_projection() { - 3527
Ok(Some(projection)) => projection, - 3528
Ok(None) => return Some("managed run lost its work contract".into()), - 3529
Err(error) => return Some(format!("managed work projection is invalid: {error}")), - 3530
}; - 3531
for item in &projection.contract.items { - 3532
let Some(state) = projection.items.get(&item.item_id) else { - 3533
return Some(format!("managed work item '{}' has no state", item.item_id)); - 3534
}; - 3535
if state.status == vak_session::types::WorkItemStatus::ReadyForVerification { - 3536
let event = vak_session::types::WorkEvent { - 3537
contract_id: projection.contract.contract_id.clone(), - 3538
revision: projection.contract.revision, - 3539
kind: vak_session::types::WorkEventKind::ItemVerified { - 3540
item_id: item.item_id.clone(), - 3541
attempt: state.attempt, - 3542
}, - 3543
}; - 3544
if let Err(error) = session.append_work(event) { - 3545
return Some(format!( - 3546
"managed verification pending for '{}': {error}", - 3547
item.item_id - 3548
)); - 3549
} - 3550
} - 3551
} - 3552
let projection = match session.work_projection() { - 3553
Ok(Some(projection)) => projection, - 3554
Ok(None) => return Some("managed run lost its work contract".into()), - 3555
Err(error) => return Some(format!("managed work projection is invalid: {error}")), - 3556
}; - 3557
let incomplete: Vec<&str> = projection - 3558
.contract - 3559
.items - 3560
.iter() - 3561
.filter(|item| item.required) - 3562
.filter_map(|item| { - 3563
let state = projection.items.get(&item.item_id)?; - 3564
(!matches!(state.status, vak_session::types::WorkItemStatus::Succeeded)) - 3565
.then_some(item.item_id.as_str()) - 3566
}) - 3567
.collect(); - 3568
if !incomplete.is_empty() { - 3569
return Some(format!( - 3570
"managed work is not complete; required items pending: {}. Use the work tool and do not claim completion.", - 3571
incomplete.join(", ") - 3572
)); - 3573
} - 3574
let missing_criteria: Vec<&str> = projection - 3575
.contract - 3576
.criteria - 3577
.iter() - 3578
.filter(|criterion| criterion.required) - 3579
.filter_map(|criterion| { - 3580
(!matches!( - 3581
projection.criteria.get(&criterion.criterion_id), - 3582
Some(vak_session::types::CriterionResult::Passed { .. }) - 3583
)) - 3584
.then_some(criterion.criterion_id.as_str()) - 3585
}) - 3586
.collect(); - 3587
if !missing_criteria.is_empty() { - 3588
return Some(format!( - 3589
"managed work cannot complete; required criteria are not proven: {}", - 3590
missing_criteria.join(", ") - 3591
)); - 3592
} - 3593
if projection.status == vak_session::types::WorkContractStatus::Active { - 3594
let contract_id = projection.contract.contract_id.clone(); - 3595
let revision = projection.contract.revision; - 3596
if let Err(error) = session.append_work(vak_session::types::WorkEvent { - 3597
contract_id: contract_id.clone(), - 3598
revision, - 3599
kind: vak_session::types::WorkEventKind::ContractStatusChanged { - 3600
from: vak_session::types::WorkContractStatus::Active, - 3601
to: vak_session::types::WorkContractStatus::Verifying, - 3602
reason: "required work items verified".into(), - 3603
}, - 3604
}) { - 3605
return Some(format!("managed verification status failed: {error}")); - 3606
} - 3607
if let Err(error) = session.append_work(vak_session::types::WorkEvent { - 3608
contract_id, - 3609
revision, - 3610
kind: vak_session::types::WorkEventKind::ContractStatusChanged { - 3611
from: vak_session::types::WorkContractStatus::Verifying, - 3612
to: vak_session::types::WorkContractStatus::Completed, - 3613
reason: "all required work items verified".into(), - 3614
}, - 3615
}) { - 3616
return Some(format!("managed completion status failed: {error}")); - 3617
} - 3618
} - 3619
drop(session); - 3620
self.emit_work_state(events).await; - 3621
None - 3622
} - 3623
- 3624
async fn verify_managed_criteria( - 3625
&mut self, - 3626
criteria: &[vak_session::types::WorkCriterion], - 3627
projection: &vak_session::work::WorkProjection, - 3628
cancel: &CancellationToken, - 3629
events: &mpsc::Sender<AgentEvent>, - 3630
) { - 3631
let cwd = self - 3632
.session - 3633
.lock() - 3634
.await - 3635
.header() - 3636
.map(|header| header.contract_cwd()) - 3637
.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| ".".into())); - 3638
for criterion in criteria { - 3639
let result = match &criterion.kind { - 3640
vak_session::types::CriterionKind::Shell { command } => self - 3641
.run_audit_command(command, cancel) - 3642
.await - 3643
.map(|_| vak_session::types::CriterionResult::Passed { - 3644
evidence: format!("shell:{command}"), - 3645
}) - 3646
.unwrap_or_else(|reason| vak_session::types::CriterionResult::Failed { - 3647
reason, - 3648
}), - 3649
vak_session::types::CriterionKind::FileExists { path } => { - 3650
let Some(resolved) = workspace_criterion_path(&cwd, path) else { - 3651
self.record_managed_criterion( - 3652
criterion, - 3653
vak_session::types::CriterionResult::Failed { - 3654
reason: format!("path is outside the workspace: {}", path.display()), - 3655
}, - 3656
) - 3657
.await; - 3658
continue; - 3659
}; - 3660
if resolved.exists() { - 3661
vak_session::types::CriterionResult::Passed { - 3662
evidence: format!("file_exists:{}", path.display()), - 3663
} - 3664
} else { - 3665
vak_session::types::CriterionResult::Failed { - 3666
reason: format!("file does not exist: {}", path.display()), - 3667
} - 3668
} - 3669
} - 3670
vak_session::types::CriterionKind::FileContains { path, pattern } => { - 3671
let Some(resolved) = workspace_criterion_path(&cwd, path) else { - 3672
self.record_managed_criterion( - 3673
criterion, - 3674
vak_session::types::CriterionResult::Failed { - 3675
reason: format!("path is outside the workspace: {}", path.display()), - 3676
}, - 3677
) - 3678
.await; - 3679
continue; - 3680
}; - 3681
match std::fs::read_to_string(&resolved) { - 3682
Ok(content) if content.contains(pattern) => { - 3683
vak_session::types::CriterionResult::Passed { - 3684
evidence: format!("file_contains:{}", path.display()), - 3685
} - 3686
} - 3687
Ok(_) => vak_session::types::CriterionResult::Failed { - 3688
reason: format!("pattern not found in {}", path.display()), - 3689
}, - 3690
Err(error) => vak_session::types::CriterionResult::Failed { - 3691
reason: format!("cannot read {}: {error}", path.display()), - 3692
}, - 3693
} - 3694
} - 3695
vak_session::types::CriterionKind::Semantic => continue, - 3696
vak_session::types::CriterionKind::ToolSucceeded { tool } => { - 3697
let mut succeeded = false; - 3698
for item_id in self.criterion_item_ids(projection, &criterion.criterion_id) { - 3699
if self - 3700
.tool_succeeded_for_item(projection, &item_id, tool) - 3701
.await - 3702
{ - 3703
succeeded = true; - 3704
break; - 3705
} - 3706
} - 3707
if succeeded { - 3708
vak_session::types::CriterionResult::Passed { - 3709
evidence: format!("tool_succeeded:{tool}"), - 3710
} - 3711
} else { - 3712
vak_session::types::CriterionResult::Unknown { - 3713
reason: format!("no successful '{tool}' tool result exists yet"), - 3714
} - 3715
} - 3716
} - 3717
vak_session::types::CriterionKind::FlowCompleted { flow } => { - 3718
if self.criterion_item_ids(projection, &criterion.criterion_id).into_iter().any( - 3719
|item_id| { - 3720
projection.items.get(&item_id).is_some_and(|item| { - 3721
item.evidence.iter().any(|evidence| { - 3722
matches!(evidence, vak_session::types::EvidenceRef::FlowNode { flow: name, node_id, .. } if name == flow && node_id == "__flow_completed__") - 3723
}) - 3724
}) - 3725
}, - 3726
) { - 3727
vak_session::types::CriterionResult::Passed { - 3728
evidence: format!("flow_completed:{flow}"), - 3729
} - 3730
} else { - 3731
vak_session::types::CriterionResult::Unknown { - 3732
reason: format!("flow '{flow}' has no linked completion evidence"), - 3733
} - 3734
} - 3735
} - 3736
vak_session::types::CriterionKind::ExternalReceipt { integration } => { - 3737
if self.criterion_item_ids(projection, &criterion.criterion_id).into_iter().any( - 3738
|item_id| { - 3739
projection.items.get(&item_id).is_some_and(|item| { - 3740
item.evidence.iter().any(|evidence| { - 3741
matches!(evidence, vak_session::types::EvidenceRef::ExternalOperation { integration: name, .. } if name == integration) - 3742
}) - 3743
}) - 3744
}, - 3745
) { - 3746
vak_session::types::CriterionResult::Passed { - 3747
evidence: format!("external_receipt:{integration}"), - 3748
} - 3749
} else { - 3750
vak_session::types::CriterionResult::Unknown { - 3751
reason: format!("integration '{integration}' has no receipt evidence"), - 3752
} - 3753
} - 3754
} - 3755
}; - 3756
self.record_managed_criterion(criterion, result).await; - 3757
} - 3758
let semantic: Vec<(vak_session::types::WorkCriterion, String)> = criteria - 3759
.iter() - 3760
.filter_map(|criterion| match &criterion.kind { - 3761
vak_session::types::CriterionKind::Semantic => Some(( - 3762
criterion.clone(), - 3763
format!("[{}] {}", criterion.criterion_id, criterion.statement), - 3764
)), - 3765
_ => None, - 3766
}) - 3767
.collect(); - 3768
if semantic.is_empty() || cancel.is_cancelled() { - 3769
return; - 3770
} - 3771
let judge_criteria: Vec<String> = semantic.iter().map(|(_, text)| text.clone()).collect(); - 3772
match self.run_judge(&judge_criteria, cancel, events).await { - 3773
Ok(verdicts) => { - 3774
for (criterion, expected) in semantic { - 3775
let result = verdicts - 3776
.iter() - 3777
.find(|verdict| verdict.criterion == expected) - 3778
.map(|verdict| match verdict.verdict.as_str() { - 3779
"pass" => vak_session::types::CriterionResult::Passed { - 3780
evidence: verdict.evidence.clone(), - 3781
}, - 3782
"fail" => vak_session::types::CriterionResult::Failed { - 3783
reason: verdict.evidence.clone(), - 3784
}, - 3785
_ => vak_session::types::CriterionResult::Unknown { - 3786
reason: verdict.evidence.clone(), - 3787
}, - 3788
}) - 3789
.unwrap_or_else(|| vak_session::types::CriterionResult::Unknown { - 3790
reason: "judge returned no verdict for this criterion".into(), - 3791
}); - 3792
self.record_managed_criterion(&criterion, result).await; - 3793
} - 3794
} - 3795
Err(reason) => { - 3796
for (criterion, _) in semantic { - 3797
self.record_managed_criterion( - 3798
&criterion, - 3799
vak_session::types::CriterionResult::Unknown { - 3800
reason: reason.clone(), - 3801
}, - 3802
) - 3803
.await; - 3804
} - 3805
} - 3806
} - 3807
} - 3808
- 3809
async fn record_managed_criterion( - 3810
&self, - 3811
criterion: &vak_session::types::WorkCriterion, - 3812
result: vak_session::types::CriterionResult, - 3813
) { - 3814
let contract_id = self - 3815
.session - 3816
.lock() - 3817
.await - 3818
.work_projection() - 3819
.ok() - 3820
.flatten() - 3821
.map(|projection| { - 3822
( - 3823
projection.contract.contract_id, - 3824
projection.contract.revision, - 3825
) - 3826
}); - 3827
if let Some((contract_id, revision)) = contract_id { - 3828
let _ = self - 3829
.session - 3830
.lock() - 3831
.await - 3832
.append_work(vak_session::types::WorkEvent { - 3833
contract_id, - 3834
revision, - 3835
kind: vak_session::types::WorkEventKind::VerificationRecorded { - 3836
criterion_id: criterion.criterion_id.clone(), - 3837
result, - 3838
}, - 3839
}); - 3840
} - 3841
} - 3842
- 3843
fn criterion_item_ids( - 3844
&self, - 3845
projection: &vak_session::work::WorkProjection, - 3846
criterion_id: &str, - 3847
) -> Vec<String> { - 3848
projection - 3849
.contract - 3850
.items - 3851
.iter() - 3852
.filter(|item| item.criterion_ids.iter().any(|id| id == criterion_id)) - 3853
.map(|item| item.item_id.clone()) - 3854
.collect() - 3855
} - 3856
- 3857
async fn tool_succeeded_for_item( - 3858
&self, - 3859
projection: &vak_session::work::WorkProjection, - 3860
item_id: &str, - 3861
tool: &str, - 3862
) -> bool { - 3863
let Some(item) = projection.items.get(item_id) else { - 3864
return false; - 3865
}; - 3866
let tool_result_ids: std::collections::HashSet<&str> = item - 3867
.evidence - 3868
.iter() - 3869
.filter_map(|evidence| match evidence { - 3870
vak_session::types::EvidenceRef::ToolResult { tool_use_id, .. } => { - 3871
Some(tool_use_id.as_str()) - 3872
} - 3873
_ => None, - 3874
}) - 3875
.collect(); - 3876
if tool_result_ids.is_empty() { - 3877
return false; - 3878
} - 3879
let session = self.session.lock().await; - 3880
let mut tool_names = std::collections::HashMap::new(); - 3881
for entry in session.chain_to_root() { - 3882
if let vak_session::types::EntryPayload::Message(record) = &entry.payload { - 3883
for block in &record.message.content { - 3884
if let vak_llm::ContentBlock::ToolUse { id, name, .. } = block { - 3885
tool_names.insert(id.as_str(), name.as_str()); - 3886
} - 3887
} - 3888
} - 3889
} - 3890
session.chain_to_root().iter().any(|entry| { - 3891
let vak_session::types::EntryPayload::Message(record) = &entry.payload else { - 3892
return false; - 3893
}; - 3894
record.message.content.iter().any(|block| { - 3895
matches!( - 3896
block, - 3897
vak_llm::ContentBlock::ToolResult { - 3898
tool_use_id, - 3899
is_error: false, - 3900
.. - 3901
} if tool_result_ids.contains(tool_use_id.as_str()) - 3902
&& tool_names.get(tool_use_id.as_str()).copied() == Some(tool) - 3903
) - 3904
}) - 3905
}) - 3906
} - 3907
- 3908
/// Goal audit gate (Phase H): runs when the model claims completion. - 3909
/// Some(reason) rejects the claim and continues the run; None lets it - 3910
/// end. Completion is recorded from audit, never self-report — and the - 3911
/// audit budget is capped so this can never trap a run. - 3912
async fn goal_gate( - 3913
&mut self, - 3914
_response: &AssistantMessage, - 3915
cancel: &CancellationToken, - 3916
events: &mpsc::Sender<AgentEvent>, - 3917
) -> Option<String> { - 3918
let goal_active = self.active_goal.is_some(); - 3919
if !goal_active { - 3920
return None; - 3921
} - 3922
- 3923
let mut findings = String::new(); - 3924
- 3925
// 1) Regression obligations: everything proven green must stay so. - 3926
for cmd in self.obligations.clone() { - 3927
if cancel.is_cancelled() { - 3928
return None; - 3929
} - 3930
match self.run_audit_command(&cmd, cancel).await { - 3931
Ok(()) => {} - 3932
Err(err) => { - 3933
findings.push_str(&format!( - 3934
"REGRESSION: previously-green command failed now:\n $ {cmd}\n {err}\n" - 3935
)); - 3936
} - 3937
} - 3938
} - 3939
- 3940
let criteria = self - 3941
.active_goal - 3942
.as_ref() - 3943
.map(|g| g.criteria.clone()) - 3944
.unwrap_or_default(); - 3945
- 3946
// 2) Deterministic shell criteria. - 3947
let mut judged_criteria: Vec<String> = Vec::new(); - 3948
for criterion in &criteria { - 3949
if goal::is_shell_criterion(criterion) { - 3950
let cmd = goal::shell_command(criterion); - 3951
match self.run_audit_command(cmd, cancel).await { - 3952
Ok(()) => {} - 3953
Err(err) => { - 3954
findings.push_str(&format!("CRITERION FAILED: {criterion}\n {err}\n")); - 3955
} - 3956
} - 3957
judged_criteria.push(criterion.clone()); - 3958
} - 3959
} - 3960
- 3961
// 3) Judge call for remaining free-text criteria — only worth a - 3962
// model dispatch when deterministic checks already passed. - 3963
let text_criteria: Vec<String> = criteria - 3964
.iter() - 3965
.filter(|c| !goal::is_shell_criterion(c)) - 3966
.cloned() - 3967
.collect(); - 3968
if !text_criteria.is_empty() && findings.is_empty() { - 3969
match self.run_judge(&text_criteria, cancel, events).await { - 3970
Ok(verdicts) => { - 3971
for v in verdicts { - 3972
if v.verdict != "pass" { - 3973
findings.push_str(&format!( - 3974
"JUDGE {}: evidence: {}\n", - 3975
v.verdict.to_uppercase(), - 3976
v.evidence - 3977
)); - 3978
} - 3979
} - 3980
} - 3981
Err(e) => { - 3982
// Fail closed: an unavailable auditor cannot confirm done. - 3983
findings.push_str(&format!("AUDIT UNAVAILABLE: {e}\n")); - 3984
} - 3985
} - 3986
} - 3987
- 3988
if findings.is_empty() { - 3989
let mut session = self.session.lock().await; - 3990
let goal_snapshot = self.active_goal.clone(); - 3991
let sid = session - 3992
.header() - 3993
.map(|h| h.session_id.clone()) - 3994
.unwrap_or_default(); - 3995
if let Some(g) = goal_snapshot.as_ref() { - 3996
let _ = session.append_goal(vak_session::types::GoalEntry { - 3997
goal_id: format!("goal-{sid}"), - 3998
objective: g.objective.clone(), - 3999
criteria: g.criteria.clone(), - 4000
status: vak_session::types::GoalStatus::Done { audited: true },
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.