- 2090
let mut receipt = ledger.take_receipt(); - 2091
let settled_provider = receipt.provider.clone(); - 2092
if !receipt.prefix_digest.is_empty() { - 2093
// A digest that differs from the immediately preceding - 2094
// receipt's is a cache-breaking event, surfaced so a - 2095
// regression is visible in the ledger rather than only in - 2096
// the bill (docs/design/68-context-engine.md §7). - 2097
if let Some(previous) = last_prefix_digest(&session) - 2098
.filter(|previous| previous != &receipt.prefix_digest) - 2099
{ - 2100
let now = chrono::Utc::now(); - 2101
let activity = vak_session::ActivityRecord { - 2102
activity_id: format!( - 2103
"activity-{}", - 2104
now.timestamp_nanos_opt() - 2105
.unwrap_or_else(|| now.timestamp_micros() * 1_000) - 2106
), - 2107
turn: None, - 2108
kind: vak_session::ActivityKind::Diagnostic, - 2109
status: vak_session::ActivityStatus::Succeeded, - 2110
label: "prefix-changed".to_string(), - 2111
detail: None, - 2112
data: [ - 2113
("previous".to_string(), previous), - 2114
("current".to_string(), receipt.prefix_digest.clone()), - 2115
] - 2116
.into_iter() - 2117
.collect(), - 2118
}; - 2119
let _ = session.append_activity(activity); - 2120
} - 2121
// Measured once per digest: the provider's reported input - 2122
// tokens minus an estimate of the messages alone. A later - 2123
// request with the same digest reuses this measurement - 2124
// rather than re-deriving it from a cache-served step. - 2125
if !prefix_digest_seen(&session, &receipt.prefix_digest) { - 2126
let profile = self.effective_capacity_profile(); - 2127
let messages_tokens = - 2128
profile.estimate_tokens(messages_chars(&request.messages)); - 2129
receipt.prefix_tokens = - 2130
Some(usage.prompt_tokens().saturating_sub(messages_tokens)); - 2131
} - 2132
} - 2133
let _ = session.append_receipt(receipt); - 2134
settled_provider_slot.replace(settled_provider); - 2135
sid - 2136
}; - 2137
if let Some(gate) = &self.config.spend_gate { - 2138
let provider = settled_provider_slot.as_deref().unwrap_or_default(); - 2139
gate.record_settled_with_latency( - 2140
provider, - 2141
&response.model, - 2142
&settled_session_id, - 2143
&usage, - 2144
ledger.receipt.attempts.iter().map(|a| a.latency_ms).sum(), - 2145
); - 2146
} - 2147
self.record_capacity_usage_feedback(&request, &usage, ledger.last_first_token_ms) - 2148
.await; - 2149
let response_entry_id = self.append_assistant(&response).await; - 2150
let _ = events.send(AgentEvent::TurnEnd { usage }).await; - 2151
- 2152
// Model drift (docs/design/68-context-engine.md §7): the step - 2153
// served a different directive than the current one. Never a - 2154
// cut — the steering nudge is appended and the turn continues; - 2155
// only three CONSECUTIVE drift events end it. - 2156
if let Some(drift_reason) = self.detect_model_drift(&response, &calls).await { - 2157
self.drift_streak += 1; - 2158
if self.drift_streak >= MODEL_DRIFT_EXHAUSTION_THRESHOLD { - 2159
return self.degraded_drift_outcome(&drift_reason).await; - 2160
} - 2161
// A request near the horizon that also drifted is evidence - 2162
// the horizon itself is optimistic (§1, §6). - 2163
self.record_capacity_instruction_failure(response.usage.prompt_tokens()) - 2164
.await; - 2165
// Never quotes the directive back: an echo after a tool result - 2166
// reads as the user asking again (docs/design/68 §6). - 2167
let _ = self.session.lock().await.append_message(MessageRecord::control( - 2168
vak_intent::control::ControlKind::SteeringDrift, - 2169
format!( - 2170
"[steering-drift]: {drift_reason}. Refocus your next step on the user's latest message." - 2171
), - 2172
)); - 2173
if calls.is_empty() { - 2174
// A drifted final answer is not accepted as the turn's - 2175
// answer: redo it, same as the other repair nudges. - 2176
if turn + 1 >= self.config.max_turns { - 2177
return TurnOutcome::MaxTurnsReached; - 2178
} - 2179
let _ = events.send(AgentEvent::DraftDiscarded { turn }).await; - 2180
turn += 1; - 2181
continue; - 2182
} - 2183
// A drifted tool call still needs its result dispatched - 2184
// (API validity requires the pair); the nudge above steers - 2185
// the NEXT step instead of interrupting this one. - 2186
} else { - 2187
self.drift_streak = 0; - 2188
} - 2189
- 2190
if calls.is_empty() { - 2191
// Empty-step enforcement: the response carried neither text - 2192
// nor a tool call — a thinking-only completion, which a - 2193
// model with a reasoning channel produces when it plans an - 2194
// action and then stops (observed live: "Final Plan: 1. Use - 2195
// tavily_search…" followed by end of turn, four runs out of - 2196
// six). That is not an answer; one bounded redo asks it to - 2197
// act on the plan it already made. A card emitted earlier in - 2198
// the run IS the answer, so a card-only turn is left alone. - 2199
if response.text_content().trim().is_empty() && !cards_emitted_this_run { - 2200
if empty_step_repair_attempted { - 2201
return TurnOutcome::Failed { - 2202
error: LlmError::Parse( - 2203
"model returned no visible answer or tool call after one retry" - 2204
.into(), - 2205
), - 2206
}; - 2207
} - 2208
empty_step_repair_attempted = true; - 2209
if turn + 1 >= self.config.max_turns { - 2210
return TurnOutcome::MaxTurnsReached; - 2211
} - 2212
let _ = self - 2213
.session - 2214
.lock() - 2215
.await - 2216
.append_message(MessageRecord::control( - 2217
vak_intent::control::ControlKind::EmptyStep, - 2218
format!( - 2219
"[empty-step]: Your last response had no visible answer and no tool call. \ - 2220
Complete this already-admitted target now (this is context, not a new request): {:?}. \ - 2221
Make the tool call you planned, or write the answer as text.", - 2222
prompt_owned.chars().take(600).collect::<String>() - 2223
), - 2224
)); - 2225
let _ = events.send(AgentEvent::DraftDiscarded { turn }).await; - 2226
turn += 1; - 2227
continue; - 2228
} - 2229
// Freshness enforcement (docs/design/68 §7): the directive - 2230
// asked for a current value and nothing was retrieved in - 2231
// this run, so the answer — prose or card — can only be a - 2232
// repeat of an earlier turn's data. One bounded redo naming - 2233
// the gap; the model may decline by saying it has no live - 2234
// data, which the grounding phrases below already accept. - 2235
if wants_live_data && !observed_this_run { - 2236
let admits_no_data = admits_no_data(&response.text_content()); - 2237
if !admits_no_data && freshness_repair_attempted { - 2238
// Repaired once already and still nothing retrieved - 2239
// (a thinking-only end, or the same figure again): - 2240
// fail closed rather than accept a stale answer. - 2241
return self.stale_data_outcome().await; - 2242
} - 2243
if !admits_no_data { - 2244
freshness_repair_attempted = true; - 2245
if turn + 1 >= self.config.max_turns { - 2246
return TurnOutcome::MaxTurnsReached; - 2247
} - 2248
let available = freshness_retrieval_hint(&tool_defs); - 2249
let _ = self.session.lock().await.append_message(MessageRecord::control( - 2250
vak_intent::control::ControlKind::FreshnessCheck, - 2251
format!("[freshness-check]: This asks for a value as it stands now, but nothing was \ - 2252
retrieved on this turn — a number carried over from an earlier answer is \ - 2253
stale. {available} Retrieve a current reading and answer from what it \ - 2254
returns (a card is fine). If retrieval fails, say what failed."), - 2255
)); - 2256
let _ = events.send(AgentEvent::DraftDiscarded { turn }).await; - 2257
turn += 1; - 2258
continue; - 2259
} - 2260
} - 2261
// Grounding enforcement: the immediately preceding step ran a - 2262
// retrieval and this final answer shows no sign of using what - 2263
// it returned. One bounded redo. "Uses" is judged from the - 2264
// result itself — a source, host or figure it contained — so - 2265
// a cited prose answer passes; a card emitted with the - 2266
// retrieval clears the check where it is recorded. - 2267
if !grounding_repair_attempted - 2268
&& let Some(tool_names) = pending_grounding_check.take() - 2269
&& !tool_names.is_empty() - 2270
{ - 2271
let text = response.text_content(); - 2272
let grounded = text.contains("\"semantic_type\"") - 2273
|| admits_no_data(&text) - 2274
|| last_evidence_snippet - 2275
.as_deref() - 2276
.is_none_or(|evidence| uses_evidence(&text, evidence)); - 2277
if !grounded { - 2278
grounding_repair_attempted = true; - 2279
if turn + 1 >= self.config.max_turns { - 2280
return TurnOutcome::MaxTurnsReached; - 2281
} - 2282
let tool_list = tool_names.join(", "); - 2283
let target = prompt_owned.chars().take(600).collect::<String>(); - 2284
let _ = self.session.lock().await.append_message(MessageRecord::control(vak_intent::control::ControlKind::GroundingCheck, format!( - 2285
"[grounding-check]: Your last answer does not use what {tool_list} just returned. \ - 2286
Complete this already-admitted target (this is context, not a new request): {target:?}. \ - 2287
Answer from those results and name the sources you used, or, if they do not \ - 2288
answer the target, say so plainly instead of answering from memory." - 2289
))); - 2290
let _ = events.send(AgentEvent::DraftDiscarded { turn }).await; - 2291
turn += 1; - 2292
continue; - 2293
} - 2294
} - 2295
// Malformed-fence enforcement: the model emitted an explicit - 2296
// vak-tagged card fence, but its JSON body doesn't parse - 2297
// (mismatched brackets, an unquoted key, etc.) — a real, - 2298
// observed failure mode from small/local models. Rather - 2299
// than letting a broken card reach the user (where it - 2300
// degrades to a "could not be rendered" notice at best), - 2301
// give the model one bounded repair turn naming the exact - 2302
// parse error, mirroring the grounding-check pattern above. - 2303
if !malformed_fence_repair_attempted { - 2304
let text = response.text_content(); - 2305
if let Some(parse_error) = find_malformed_vak_fence(&text) { - 2306
malformed_fence_repair_attempted = true; - 2307
if turn + 1 >= self.config.max_turns { - 2308
return TurnOutcome::MaxTurnsReached; - 2309
} - 2310
let _ = self.session.lock().await.append_message(MessageRecord::control(vak_intent::control::ControlKind::FenceCheck, format!( - 2311
"[fence-check]: The vak-fence in your last answer has invalid JSON and failed to parse \ - 2312
({parse_error}). Resend the same answer with a syntactically valid JSON body this time — \ - 2313
double-check every object/array is closed and every key is quoted. If you can't produce \ - 2314
valid JSON for it, drop the fence and answer in plain prose instead." - 2315
))); - 2316
let _ = events.send(AgentEvent::DraftDiscarded { turn }).await; - 2317
turn += 1; - 2318
continue; - 2319
} - 2320
} - 2321
// Duplicate-card enforcement: the model already emitted a - 2322
// card via `emit_*_card` this turn, then its own trailing - 2323
// text repeats the same semantic_type as a `vak` fence — - 2324
// that fence renders as a SECOND card (vak-server's - 2325
// projection and the client's own fence-parsing are - 2326
// independent paths; nothing dedupes across them). One - 2327
// bounded repair turn asking the model to drop the - 2328
// redundant fence, mirroring the two checks above. - 2329
if !duplicate_card_repair_attempted - 2330
&& let Some(emitted_types) = pending_duplicate_card_check.take() - 2331
&& !emitted_types.is_empty() - 2332
{ - 2333
let text = response.text_content(); - 2334
if let Some(dup_type) = find_duplicate_card_fence(&text, &emitted_types) { - 2335
duplicate_card_repair_attempted = true; - 2336
if turn + 1 >= self.config.max_turns { - 2337
return TurnOutcome::MaxTurnsReached; - 2338
} - 2339
let _ = self.session.lock().await.append_message(MessageRecord::control(vak_intent::control::ControlKind::DuplicateCardCheck, format!( - 2340
"[duplicate-card-check]: You already emitted a `{dup_type}` card via the matching \ - 2341
emit_*_card tool call above, and the user already sees it. Resend your answer \ - 2342
WITHOUT the ```vak fence that repeats it — just the short narration around the \ - 2343
card is needed, no restated JSON." - 2344
))); - 2345
let _ = events.send(AgentEvent::DraftDiscarded { turn }).await; - 2346
turn += 1; - 2347
continue; - 2348
} - 2349
} - 2350
// Presentation check: the answer reads as something the app - 2351
// presents as a card (the app's own signal/recipe detection, - 2352
// supplied by Core), yet no card was emitted and none is - 2353
// written inline — the model answered in prose. One bounded - 2354
// nudge; the model may decline by resending unchanged. - 2355
if !presentation_repair_attempted - 2356
&& !cards_emitted_this_run - 2357
&& !file_delivered_this_run - 2358
&& let Some(check) = &self.config.presentation_check - 2359
{ - 2360
let text = response.text_content(); - 2361
if !text.trim().is_empty() && !text.contains("```vak") { - 2362
let offered: Vec<String> = self - 2363
.config - 2364
.tools - 2365
.iter() - 2366
.map(|t| t.name().to_string()) - 2367
.collect(); - 2368
if let Some(nudge) = check(&text, &offered) { - 2369
presentation_repair_attempted = true; - 2370
if let Some(tool) = - 2371
self.config.tools.iter().find(|t| t.name() == nudge.tool) - 2372
{ - 2373
self.config - 2374
.discovered_tools - 2375
.lock() - 2376
.unwrap_or_else(std::sync::PoisonError::into_inner) - 2377
.push(vak_llm::ToolDefinition::new( - 2378
tool.name(), - 2379
tool.description(), - 2380
tool.schema(), - 2381
)); - 2382
} - 2383
if turn + 1 >= self.config.max_turns { - 2384
return TurnOutcome::MaxTurnsReached; - 2385
} - 2386
// Required card not emitted: evidence the request - 2387
// may already be past this model's real - 2388
// instruction-following horizon (§1, §6). - 2389
self.record_capacity_instruction_failure( - 2390
response.usage.prompt_tokens(), - 2391
) - 2392
.await; - 2393
let _ = - 2394
self.session - 2395
.lock() - 2396
.await - 2397
.append_message(MessageRecord::control( - 2398
vak_intent::control::ControlKind::PresentationCheck, - 2399
nudge.text, - 2400
)); - 2401
let _ = events.send(AgentEvent::DraftDiscarded { turn }).await; - 2402
turn += 1; - 2403
continue; - 2404
} - 2405
} - 2406
} - 2407
if let Some(hooks) = &self.config.hooks { - 2408
let session_id = self - 2409
.session - 2410
.lock() - 2411
.await - 2412
.header() - 2413
.map(|h| h.session_id.clone()) - 2414
.unwrap_or_default(); - 2415
let cwd = self - 2416
.session - 2417
.lock() - 2418
.await - 2419
.header() - 2420
.map(|h| h.contract_cwd()) - 2421
.unwrap_or_else(|| ".".into()); - 2422
let stop = vak_hooks::run_hooks_with_recorder( - 2423
hooks.clone(), - 2424
vak_hooks::HookEvent::Stop, - 2425
&session_id, - 2426
&cwd, - 2427
None, - 2428
Some(&response.text_content()), - 2429
&cancel, - 2430
self.config.hook_recorder.as_deref(), - 2431
) - 2432
.await; - 2433
if stop.blocked { - 2434
if turn + 1 >= self.config.max_turns { - 2435
return TurnOutcome::MaxTurnsReached; - 2436
} - 2437
let reason = stop - 2438
.reason - 2439
.unwrap_or_else(|| "continue required by hook".into()); - 2440
let _ = events - 2441
.send(AgentEvent::StopHookContinuation { - 2442
reason: reason.clone(), - 2443
}) - 2444
.await; - 2445
let _ = self - 2446
.session - 2447
.lock() - 2448
.await - 2449
.append_message(MessageRecord::control( - 2450
vak_intent::control::ControlKind::StopHook, - 2451
format!("[stop-hook]: {reason}\nPlease continue."), - 2452
)); - 2453
let _ = events.send(AgentEvent::DraftDiscarded { turn }).await; - 2454
turn += 1; - 2455
continue; - 2456
} - 2457
} - 2458
if let Some(reason) = self - 2459
.stop_gate( - 2460
&prompt_owned, - 2461
&response, - 2462
&receipts, - 2463
verification_stale, - 2464
&mut stop_blocks_left, - 2465
user_completion_released, - 2466
) - 2467
.await - 2468
{ - 2469
// Stop-policy block: the model tried to end the turn - 2470
// prematurely against an explicit completion - 2471
// requirement (§1, §6). - 2472
self.record_capacity_instruction_failure(response.usage.prompt_tokens()) - 2473
.await; - 2474
if self.guard_continue(reason, &events, turn).await { - 2475
turn += 1; - 2476
continue; - 2477
} - 2478
return TurnOutcome::MaxTurnsReached; - 2479
} - 2480
if let Some(rejection) = self.goal_gate(&response, &cancel, &events).await { - 2481
if self.guard_continue(rejection, &events, turn).await { - 2482
turn += 1; - 2483
continue; - 2484
} - 2485
return TurnOutcome::MaxTurnsReached; - 2486
} - 2487
if let Some(rejection) = self.managed_work_gate(&cancel, &events).await { - 2488
if self.guard_continue(rejection, &events, turn).await { - 2489
turn += 1; - 2490
continue; - 2491
} - 2492
return TurnOutcome::MaxTurnsReached; - 2493
} - 2494
// Fence-path presentations (docs/design/68-context-engine.md - 2495
// §10): only for the answer actually being accepted — every - 2496
// gate above has already passed, so this text will not be - 2497
// redone. A repair-nudged draft never reaches here. - 2498
if let Some(entry_id) = &response_entry_id { - 2499
self.write_fence_presentations(&response.text_content(), entry_id) - 2500
.await; - 2501
} - 2502
return TurnOutcome::Completed { response }; - 2503
} - 2504
- 2505
// A real tool call is forward progress and starts a new - 2506
// model-action boundary. Keep the empty-step retry bounded for - 2507
// consecutive thinking-only completions, but do not spend that - 2508
// retry forever: after a later tool result (including a - 2509
// correctable tool error with an exact repair schema), the model - 2510
// may legitimately need one fresh nudge to perform its next - 2511
// planned action. - 2512
empty_step_repair_attempted = false; - 2513
- 2514
for call in &calls { - 2515
receipts.total_tool_calls += 1; - 2516
if call.name == "bash" { - 2517
let is_subst = call - 2518
.input - 2519
.get("command") - 2520
.and_then(|v| v.as_str()) - 2521
.map(stop_policy::is_substantive_command) - 2522
.unwrap_or(true); - 2523
if is_subst { - 2524
receipts.substantive_bash_calls += 1; - 2525
} - 2526
} else if matches!( - 2527
call.name.as_str(), - 2528
"write" | "edit" | "patch" | "remember" | "propose_skill" - 2529
) || self.delivered_file(&call.name, &call.input).is_some() - 2530
{ - 2531
receipts.files_modified += 1; - 2532
let path = call.input.get("path").and_then(|v| v.as_str()); - 2533
if let Some(p) = path { - 2534
if stop_policy::is_code_path(p) { - 2535
receipts.code_files_modified += 1; - 2536
} else { - 2537
receipts.doc_files_modified += 1; - 2538
} - 2539
} else { - 2540
receipts.doc_files_modified += 1; - 2541
} - 2542
} else if matches!( - 2543
call.name.as_str(), - 2544
"read" - 2545
| "read_file" - 2546
| "glob" - 2547
| "grep" - 2548
| "inspect" - 2549
| "browse" - 2550
| "webfetch" - 2551
| "session_search" - 2552
| "search" - 2553
| "session_list" - 2554
) { - 2555
receipts.read_or_inspected += 1; - 2556
} - 2557
} - 2558
// Regression obligations (Phase H): commands proven GREEN this - 2559
// run must stay green before any completion claim. - 2560
let bash_pairs: Vec<(String, String)> = calls - 2561
.iter() - 2562
.filter(|c| c.name == "bash") - 2563
.filter_map(|c| { - 2564
c.input - 2565
.get("command") - 2566
.and_then(|v| v.as_str()) - 2567
.map(|cmd| (c.id.clone(), cmd.to_string())) - 2568
}) - 2569
.collect(); - 2570
let code_mutation_ids: Vec<String> = calls - 2571
.iter() - 2572
.filter(|call| matches!(call.name.as_str(), "edit" | "write" | "patch")) - 2573
.filter(|call| { - 2574
call.input - 2575
.get("path") - 2576
.and_then(|v| v.as_str()) - 2577
.map(stop_policy::is_code_path) - 2578
.unwrap_or(false) - 2579
}) - 2580
.map(|call| call.id.clone()) - 2581
.collect(); - 2582
let task_assignments: Vec<(String, String, String)> = calls - 2583
.iter() - 2584
.filter(|call| call.name == "task") - 2585
.filter_map(|call| { - 2586
Some(( - 2587
call.id.clone(), - 2588
call.input.get("contract_id")?.as_str()?.to_string(), - 2589
call.input.get("work_item_id")?.as_str()?.to_string(), - 2590
)) - 2591
}) - 2592
.collect(); - 2593
let call_names: HashMap<String, String> = calls - 2594
.iter() - 2595
.map(|c| (c.id.clone(), c.name.clone())) - 2596
.collect(); - 2597
let call_inputs: HashMap<String, serde_json::Value> = calls - 2598
.iter() - 2599
.map(|c| (c.id.clone(), c.input.clone())) - 2600
.collect(); - 2601
// The order the model actually issued these calls in, captured - 2602
// before `execute_batch` (which may run calls concurrently and - 2603
// return `results` in completion order, not issue order). - 2604
// `verification_stale` below needs issue order specifically: - 2605
// "ran bash after editing code" and "edited code after running - 2606
// bash" are different situations even if both calls land in the - 2607
// same batch and finish in the opposite order. - 2608
let call_issue_order: Vec<String> = calls.iter().map(|c| c.id.clone()).collect(); - 2609
// Two card gates at the earliest point, before execution - 2610
// (docs/design/68 §7). Freshness: a card in a live-data turn - 2611
// with nothing retrieved yet would show a carried-over figure. - 2612
// Topic mismatch: a card whose own payload shares no topic word - 2613
// with the directive is unrelated to what was asked, whatever - 2614
// it claims to be derived from — found live, a card correctly - 2615
// linked to a real, on-topic search result still carried an - 2616
// entirely different topic's payload, copied from an older - 2617
// turn's own tool call still sitting in context. Each gated - 2618
// call gets an error value naming which check refused it; - 2619
// either one gets exactly one repair before failing closed. - 2620
enum CardGate { - 2621
Fresh, - 2622
Topic, - 2623
} - 2624
let mut gated: Vec<(PendingToolCall, CardGate)> = Vec::new(); - 2625
let calls: Vec<PendingToolCall> = calls - 2626
.into_iter() - 2627
.filter(|call| { - 2628
if !self.tool_presents_cards(&call.name) { - 2629
return true; - 2630
} - 2631
if wants_live_data && !observed_this_run { - 2632
gated.push((call.clone(), CardGate::Fresh)); - 2633
return false; - 2634
} - 2635
// Scoped to "a retrieval actually succeeded this run": - 2636
// that is the one circumstance the real bug needs and - 2637
// the only one this check can safely judge. Many - 2638
// legitimate cards have sparse, structural payloads with - 2639
// no vocabulary of their own at all (an empty chart - 2640
// skeleton, a bare numeric metric) and share no word - 2641
// with any directive whether they are right or wrong — - 2642
// checked live, this exact shape broke a real, - 2643
// previously-passing test. Only when the model has just - 2644
// retrieved something is "the card is unrelated to - 2645
// both the question and what was found" a signal worth - 2646
// trusting; a card built from the model's own reasoning - 2647
// or from data already in the directive gets no such - 2648
// check; the evidence text (far richer than the terse - 2649
// question) is what lets a correctly-derived card that - 2650
// renames or paraphrases what was found still pass even - 2651
// with zero overlap against the directive alone. - 2652
if let Some(evidence) = &last_evidence_snippet { - 2653
let topic_context = format!("{prompt_owned} {evidence}"); - 2654
if !card_shares_a_topic_with(&topic_context, &call.name, &call.input) { - 2655
gated.push((call.clone(), CardGate::Topic)); - 2656
return false; - 2657
} - 2658
} - 2659
true - 2660
}) - 2661
.collect(); - 2662
if gated - 2663
.iter() - 2664
.any(|(_, gate)| matches!(gate, CardGate::Fresh)) - 2665
{ - 2666
if freshness_repair_attempted { - 2667
// The repair was another carried-over card (measured - 2668
// live: "New Delhi 29.1°C" gated, then "Noida 28°C" - 2669
// from an older turn offered instead). Fail closed: - 2670
// no stale figure is presented as current. - 2671
return self.stale_data_outcome().await; - 2672
} - 2673
freshness_repair_attempted = true; - 2674
} - 2675
if gated - 2676
.iter() - 2677
.any(|(_, gate)| matches!(gate, CardGate::Topic)) - 2678
{ - 2679
if topic_repair_attempted { - 2680
return self - 2681
.topic_mismatch_outcome(last_evidence_snippet.clone()) - 2682
.await; - 2683
} - 2684
topic_repair_attempted = true; - 2685
} - 2686
// A raw search-results HTML page is not a retrieved source for a - 2687
// current fact. When a genuine discovery route is offered, turn - 2688
// this mistaken fetch into a correctable tool result instead of - 2689
// spending context on script-heavy markup and treating it as - 2690
// fresh evidence. This identifies the URL shape, not a search - 2691
// vendor or an MCP server instance. - 2692
let discovery_offered = tool_defs - 2693
.iter() - 2694
.any(|definition| matches!(definition.name.as_str(), "mcp" | "find_tools")); - 2695
let mut search_page_fetches = Vec::new(); - 2696
let calls: Vec<PendingToolCall> = calls - 2697
.into_iter() - 2698
.filter(|call| { - 2699
if wants_live_data - 2700
&& discovery_offered - 2701
&& call.name == "webfetch" - 2702
&& is_search_results_fetch(&call.input) - 2703
{ - 2704
search_page_fetches.push(call.id.clone()); - 2705
false - 2706
} else { - 2707
true - 2708
} - 2709
}) - 2710
.collect(); - 2711
let inspection_ids = calls - 2712
.iter() - 2713
.filter(|call| { - 2714
matches!( - 2715
call.name.as_str(), - 2716
"read" - 2717
| "read_file" - 2718
| "glob" - 2719
| "grep" - 2720
| "inspect" - 2721
| "browse" - 2722
| "webfetch" - 2723
| "session_search" - 2724
| "search" - 2725
| "session_list" - 2726
) - 2727
}) - 2728
.map(|call| call.id.clone()) - 2729
.collect::<std::collections::HashSet<_>>(); - 2730
let mut results = self.execute_batch(calls, &cancel, &events).await; - 2731
let available = freshness_retrieval_hint(&tool_defs); - 2732
results.extend(search_page_fetches.into_iter().map(|id| { - 2733
( - 2734
id, - 2735
ToolRunOutput::Err(format!( - 2736
"[source-discovery]: webfetch retrieves a known page, not a search-results URL for a current fact. {available} Discover a search capability, then use its returned source URLs as evidence." - 2737
)), - 2738
) - 2739
})); - 2740
results.extend(gated.into_iter().map(|(call, gate)| { - 2741
let message = match gate { - 2742
CardGate::Fresh => { - 2743
format!("[freshness-check]: not shown — this asks for a value as it stands now and \ - 2744
nothing has been retrieved on this turn, so the card would carry a figure \ - 2745
from an earlier answer. {available} Retrieve current evidence first and \ - 2746
build the card from what it returns. If retrieval fails, say what failed.") - 2747
} - 2748
CardGate::Topic => { - 2749
"[topic-mismatch]: not shown — this card's own content has nothing to do \ - 2750
with what was asked. Build the card from what the current directive and \ - 2751
this turn's own tool results actually say, not from an earlier turn's data." - 2752
.to_string() - 2753
} - 2754
}; - 2755
(call.id, ToolRunOutput::Err(message)) - 2756
})); - 2757
self.record_worker_work(&task_assignments, &results).await; - 2758
// Identical-card repeat breaker: the no-op ack ("already - 2759
// displayed") is enough for a model that reads it; one that - 2760
// re-emits the same card anyway (measured live: up to nineteen - 2761
// times in one turn) would otherwise spend the whole turn budget - 2762
// on acks. The card it keeps re-emitting IS its answer, so after - 2763
// CARD_REPEAT_EXHAUSTION_THRESHOLD consecutive all-repeat batches - 2764
// the turn closes on that answer. - 2765
let all_repeats = !results.is_empty() - 2766
&& results.iter().all( - 2767
|(_, output)| matches!(output, ToolRunOutput::Ok(text) if is_no_op_ack(text)), - 2768
); - 2769
card_repeat_streak = if all_repeats { - 2770
card_repeat_streak + 1 - 2771
} else { - 2772
0 - 2773
}; - 2774
if card_repeat_streak >= CARD_REPEAT_EXHAUSTION_THRESHOLD { - 2775
return self.card_repeat_outcome().await; - 2776
} - 2777
// Classify unresolved correctable tool failures this turn for the - 2778
// repair budget (see `reconcile_repair_budget`). Computed before - 2779
// `results` is consumed into tool-result blocks below, and keyed - 2780
// by admitted tool name so the controller can resurface the exact - 2781
// schema that was rejected. - 2782
let failed_correctable: Vec<(String, ToolErrorKind)> = results - 2783
.iter() - 2784
.filter_map(|(id, out)| match out { - 2785
ToolRunOutput::Err(content) => { - 2786
let kind = ToolErrorKind::classify(content); - 2787
if kind.is_correctable() { - 2788
Some(( - 2789
call_names.get(id).cloned().unwrap_or_else(|| id.clone()), - 2790
kind, - 2791
)) - 2792
} else { - 2793
None - 2794
} - 2795
} - 2796
_ => None, - 2797
}) - 2798
.collect(); - 2799
// Recomputed every batch (not accumulated) so the grounding - 2800
// check below only ever looks at the IMMEDIATELY preceding - 2801
// turn's retrieval calls, matching the observed bug shape - 2802
// (search succeeds, the very next answer ignores it). - 2803
let retrieval_tool_names: Vec<String> = results - 2804
.iter() - 2805
.filter_map(|(id, out)| match out { - 2806
ToolRunOutput::Ok(_) => { - 2807
let name = call_names.get(id).map(|s| s.as_str()).unwrap_or("tool"); - 2808
let input = call_inputs.get(id)?; - 2809
self.config - 2810
.retrieval_check - 2811
.as_ref() - 2812
.is_some_and(|check| check(name, input)) - 2813
.then(|| name.to_string()) - 2814
} - 2815
ToolRunOutput::Err(_) => None, - 2816
}) - 2817
.collect(); - 2818
if !observed_this_run { - 2819
observed_this_run = !retrieval_tool_names.is_empty() - 2820
|| results.iter().any(|(id, out)| { - 2821
matches!(out, ToolRunOutput::Ok(_)) - 2822
&& self.config.observation_check.as_ref().is_some_and(|check| { - 2823
let name = call_names.get(id).map(|s| s.as_str()).unwrap_or(""); - 2824
call_inputs.get(id).is_some_and(|input| check(name, input)) - 2825
}) - 2826
}); - 2827
} - 2828
if !retrieval_tool_names.is_empty() { - 2829
// Every retrieval this batch returned, as its result block - 2830
// carries it: judging a card or an answer against only the - 2831
// first result, or the first part of one, flagged answers - 2832
// built from the rest. - 2833
let retrieved: Vec<&str> = results - 2834
.iter() - 2835
.filter_map(|(id, out)| match out { - 2836
ToolRunOutput::Ok(content) - 2837
if call_names - 2838
.get(id) - 2839
.is_some_and(|name| retrieval_tool_names.contains(name)) => - 2840
{ - 2841
Some(content.as_str()) - 2842
} - 2843
_ => None, - 2844
}) - 2845
.collect(); - 2846
if !retrieved.is_empty() { - 2847
last_evidence_snippet = Some(retrieved.join("\n\n")); - 2848
} - 2849
} - 2850
pending_grounding_check = if retrieval_tool_names.is_empty() { - 2851
None - 2852
} else { - 2853
Some(retrieval_tool_names) - 2854
}; - 2855
let emitted_card_types: Vec<String> = results - 2856
.iter() - 2857
.filter_map(|(id, out)| match out { - 2858
ToolRunOutput::Ok(_) => { - 2859
let name = call_names.get(id).map(|s| s.as_str()).unwrap_or(""); - 2860
self.tool_presents_cards(name) - 2861
.then(|| call_inputs.get(id)) - 2862
.flatten() - 2863
.and_then(|input| input.get("semantic_type")) - 2864
.and_then(|s| s.as_str()) - 2865
.map(str::to_string) - 2866
} - 2867
ToolRunOutput::Err(_) => None, - 2868
}) - 2869
.collect(); - 2870
cards_emitted_this_run |= !emitted_card_types.is_empty(); - 2871
file_delivered_this_run |= results.iter().any(|(id, out)| { - 2872
matches!(out, ToolRunOutput::Ok(_)) - 2873
&& call_names - 2874
.get(id) - 2875
.zip(call_inputs.get(id)) - 2876
.is_some_and(|(name, input)| self.delivered_file(name, input).is_some()) - 2877
}); - 2878
if !emitted_card_types.is_empty() { - 2879
// A card emitted alongside the retrieval is the grounded - 2880
// answer; its payload was validated when it was recorded. - 2881
pending_grounding_check = None; - 2882
} - 2883
pending_duplicate_card_check = if emitted_card_types.is_empty() { - 2884
None - 2885
} else { - 2886
Some(emitted_card_types) - 2887
}; - 2888
// Presentations are ledger entries - 2889
// (docs/design/68-context-engine.md §10): a validated - 2890
// `emit_*_card` call gets its own hash-linked entry, written - 2891
// HERE rather than inside the tool itself — the tool executes - 2892
// across the worker/broker boundary (AGENTS.md invariant 14) - 2893
// and has no session-log access. `execute()`'s generic ack is - 2894
// replaced with a short one carrying the new entry's id. - 2895
let mut yields: HashMap<String, CallYield> = { - 2896
let mut pending = self - 2897
.call_yields - 2898
.lock() - 2899
.unwrap_or_else(std::sync::PoisonError::into_inner); - 2900
call_issue_order - 2901
.iter() - 2902
.filter_map(|id| pending.remove_entry(id)) - 2903
.collect() - 2904
}; - 2905
self.record_delegated_cards(&call_issue_order, &mut yields, &mut results) - 2906
.await; - 2907
cards_emitted_this_run |= yields.values().any(|y| y.delegated.is_some()); - 2908
if let Some(rebuild) = self.config.presentation_rebuild.clone() { - 2909
let mut session = self.session.lock().await; - 2910
if let Some(turn_id) = session.latest_directive_entry_id() { - 2911
let prior_evidence = session - 2912
.non_card_evidence_since(&turn_id, |name| self.tool_presents_cards(name)); - 2913
let mut in_batch_evidence: Vec<String> = Vec::new(); - 2914
for id in &call_issue_order { - 2915
let Some(name) = call_names.get(id) else { - 2916
continue; - 2917
}; - 2918
let succeeded_here = results - 2919
.iter() - 2920
.any(|(rid, out)| rid == id && matches!(out, ToolRunOutput::Ok(_))); - 2921
if !succeeded_here { - 2922
continue; - 2923
} - 2924
if !self.tool_presents_cards(name) { - 2925
in_batch_evidence.push(id.clone()); - 2926
continue; - 2927
} - 2928
if self - 2929
.deliveries - 2930
.lock() - 2931
.unwrap_or_else(std::sync::PoisonError::into_inner) - 2932
.withheld_cards - 2933
.contains(id) - 2934
{ - 2935
continue; - 2936
} - 2937
let Some(input) = call_inputs.get(id) else { - 2938
continue; - 2939
}; - 2940
let Some(info) = rebuild(name.as_str(), input) else { - 2941
continue; - 2942
}; - 2943
let digest = vak_session::types::payload_digest(&info.payload); - 2944
if session.has_presentation(&turn_id, &digest) { - 2945
// Already recorded — a repeated identical call - 2946
// (`execute_batch`'s own short-circuit reuses the - 2947
// exact same arguments) or a fence that beat this - 2948
// write to it. Nothing new to append; the tool's - 2949
// own result text (ack or "already displayed") - 2950
// stands. - 2951
continue; - 2952
} - 2953
let mut derived_from = prior_evidence.clone(); - 2954
derived_from.extend(in_batch_evidence.iter().cloned()); - 2955
let record = vak_session::types::PresentationRecord { - 2956
turn_id: turn_id.clone(), - 2957
source: vak_session::types::PresentationSource::ToolCall { - 2958
tool_use_id: id.clone(), - 2959
}, - 2960
semantic_type: info.semantic_type, - 2961
skill_id: info.skill_id, - 2962
skill_version: info.skill_version, - 2963
schema_version: info.schema_version, - 2964
payload: info.payload, - 2965
payload_digest: digest, - 2966
derived_from, - 2967
title: info.title, - 2968
identity_digest: info.identity_digest, - 2969
}; - 2970
if let Ok(entry) = session.append_presentation(record) - 2971
&& let Some(slot) = results.iter_mut().find(|(rid, _)| rid == id) - 2972
{ - 2973
slot.1 = ToolRunOutput::Ok( - 2974
serde_json::json!({"presentation": entry.id, "ok": true}) - 2975
.to_string(), - 2976
); - 2977
} - 2978
} - 2979
} - 2980
} - 2981
for (id, out) in &results { - 2982
match out { - 2983
ToolRunOutput::Ok(_) => { - 2984
receipts.successful_tool_calls += 1; - 2985
if inspection_ids.contains(id) { - 2986
receipts.successful_inspections += 1; - 2987
} - 2988
let external = match call_names.get(id).map(String::as_str) { - 2989
Some("task") => true, - 2990
Some("mcp") => call_inputs.get(id).is_some_and(|input| { - 2991
input.get("action").and_then(Value::as_str) == Some("call") - 2992
}), - 2993
_ => false, - 2994
}; - 2995
if external { - 2996
receipts.external_effects += 1; - 2997
} - 2998
if let Some((_, cmd)) = bash_pairs.iter().find(|(bid, _)| bid == id) - 2999
&& !self.obligations.iter().any(|o| o == cmd) - 3000
{ - 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,
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.