- 1
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] - 2
- 3
//! Freshness and empty-step enforcement (docs/design/68-context-engine.md - 4
//! §7). Freshness: a directive - 5
//! whose reading carries the `live-data` domain asks for a value as it - 6
//! stands now. If the model answers — in prose or with a card — without any - 7
//! retrieval-shaped call succeeding in the run, the answer can only repeat - 8
//! what an earlier turn found, so it gets exactly one `[freshness-check]` - 9
//! redo. Observed live: six replays of "what is the current weather in new - 10
//! delhi" on gemma4:e2b-mlx emitted a metric card five times without - 11
//! searching, carrying a temperature from a previous turn's answer. - 12
- 13
use std::collections::VecDeque; - 14
use std::sync::{Arc, Mutex}; - 15
- 16
use async_trait::async_trait; - 17
use serde_json::Value; - 18
use tokio::sync::mpsc; - 19
use tokio_util::sync::CancellationToken; - 20
- 21
use tempfile::tempdir; - 22
- 23
use vak_agent::{Agent, AgentConfig, TurnOutcome}; - 24
use vak_llm::stream; - 25
use vak_llm::types::{AssistantMessage, ChatRequest, ContentBlock, StopReason, Usage}; - 26
use vak_llm::{EventStream, LlmError, Provider}; - 27
use vak_permission::PermissionEngine; - 28
use vak_session::types::{FrozenContract, IntentRecord, SessionHeader}; - 29
use vak_session::{SessionLog, SessionPath}; - 30
use vak_tools::context::ToolContext; - 31
use vak_tools::{Tool, ToolOutput}; - 32
- 33
struct Scripted { - 34
responses: Mutex<VecDeque<AssistantMessage>>, - 35
} - 36
- 37
#[async_trait] - 38
impl Provider for Scripted { - 39
fn name(&self) -> &str { - 40
"scripted" - 41
} - 42
- 43
async fn stream( - 44
&self, - 45
_request: ChatRequest, - 46
_cancel: CancellationToken, - 47
) -> Result<EventStream, LlmError> { - 48
let next = self.responses.lock().unwrap().pop_front(); - 49
let (mut sink, rx) = stream::channel(16); - 50
match next { - 51
Some(m) => sink.close_message(m).await, - 52
None => sink.close_error(LlmError::Parse("exhausted".into())).await, - 53
} - 54
Ok(rx) - 55
} - 56
} - 57
- 58
struct FakeSearchTool; - 59
- 60
#[async_trait] - 61
impl Tool for FakeSearchTool { - 62
fn name(&self) -> &str { - 63
"search" - 64
} - 65
fn description(&self) -> &str { - 66
"fake search tool for tests" - 67
} - 68
fn schema(&self) -> Value { - 69
serde_json::json!({"type": "object", "properties": {"query": {"type": "string"}}}) - 70
} - 71
async fn execute(&self, _args: &Value, _ctx: &ToolContext) -> ToolOutput { - 72
ToolOutput::ok( - 73
"Title: Delhi observations 05:30\nURL: https://example-met.in/delhi\nTemperature: 26.4 C\n", - 74
) - 75
} - 76
} - 77
- 78
fn search_call(id: &str) -> AssistantMessage { - 79
AssistantMessage { - 80
content: vec![ContentBlock::ToolUse { - 81
id: id.into(), - 82
name: "search".into(), - 83
input: serde_json::json!({"query": "delhi observations now"}), - 84
}], - 85
stop_reason: StopReason::ToolUse, - 86
usage: Usage::default(), - 87
model: "test-model".into(), - 88
response_id: None, - 89
} - 90
} - 91
- 92
fn text_msg(t: &str) -> AssistantMessage { - 93
AssistantMessage { - 94
content: vec![ContentBlock::text(t)], - 95
stop_reason: StopReason::EndTurn, - 96
usage: Usage { - 97
input_tokens: 1, - 98
output_tokens: 1, - 99
..Default::default() - 100
}, - 101
model: "test-model".into(), - 102
response_id: None, - 103
} - 104
} - 105
- 106
fn thinking_only() -> AssistantMessage { - 107
AssistantMessage { - 108
content: vec![ContentBlock::Thinking { - 109
text: "Final Plan: 1. Use search to get the current reading. 2. Answer.".into(), - 110
signature: None, - 111
}], - 112
stop_reason: StopReason::EndTurn, - 113
usage: Usage::default(), - 114
model: "test-model".into(), - 115
response_id: None, - 116
} - 117
} - 118
- 119
/// Seeds the reading vak-core would have written at turn start for a - 120
/// directive with temporal deixis. - 121
fn live_data_intent() -> IntentRecord { - 122
let mut reading = vak_intent::Reading::general(); - 123
reading.domains.insert("live-data".into()); - 124
IntentRecord { - 125
reading, - 126
engagement: vak_intent::Engagement::general(), - 127
provenance: vak_intent::Provenance::new(vak_intent::Tier::Signals, 1, Vec::new()), - 128
outcome: None, - 129
model_visible: None, - 130
commitment_id: None, - 131
strands: Vec::new(), - 132
strand_commitments: Default::default(), - 133
} - 134
} - 135
- 136
async fn build_agent( - 137
dir: &tempfile::TempDir, - 138
session_id: &str, - 139
responses: Vec<AssistantMessage>, - 140
live_data: bool, - 141
) -> Agent { - 142
let header = SessionHeader { - 143
agent: None, - 144
session_id: session_id.into(), - 145
created_at: chrono::Utc::now(), - 146
cwd: dir.path().to_path_buf(), - 147
parent_session_id: None, - 148
contract_id: None, - 149
work_item_id: None, - 150
conversation: None, - 151
contract: FrozenContract { - 152
app_version: "0".into(), - 153
provider: "scripted".into(), - 154
model: "test-model".into(), - 155
route_ladder: Vec::new(), - 156
route_objective: String::new(), - 157
route_annotations: Vec::new(), - 158
system_prompt: "sys".into(), - 159
permission_mode: "full-access".into(), - 160
capabilities: Vec::new(), - 161
prompt_layers: Vec::new(), - 162
}, - 163
}; - 164
let home = dir.path().join("home"); - 165
std::fs::create_dir_all(&home).unwrap(); - 166
let mut log = SessionLog::create( - 167
SessionPath::new_session_file(&home, dir.path(), session_id), - 168
header, - 169
) - 170
.unwrap(); - 171
if live_data { - 172
log.append_intent(live_data_intent()).unwrap(); - 173
} - 174
- 175
Agent::new( - 176
Arc::new(Scripted { - 177
responses: Mutex::new(VecDeque::from(responses)), - 178
}), - 179
log, - 180
{ - 181
let mut cfg = AgentConfig::new("sys"); - 182
cfg.model = "test-model".into(); - 183
cfg.tools = vec![Arc::new(FakeSearchTool)]; - 184
cfg.retrieval_check = Some(Arc::new(|name: &str, _: &Value| name == "search")); - 185
cfg.mode = vak_permission::Mode::FullAccess; - 186
cfg.permission = Some(Arc::new(PermissionEngine::default())); - 187
cfg - 188
}, - 189
) - 190
} - 191
- 192
fn user_texts(agent: &Agent) -> Vec<String> { - 193
futures::executor::block_on(async { - 194
agent - 195
.session - 196
.lock() - 197
.await - 198
.message_chain() - 199
.iter() - 200
.filter(|(_, m)| m.role == vak_llm::types::Role::User) - 201
.flat_map(|(_, m)| m.content.iter()) - 202
.filter_map(|b| match b { - 203
ContentBlock::Text { text } => Some(text.clone()), - 204
_ => None, - 205
}) - 206
.collect() - 207
}) - 208
} - 209
- 210
#[tokio::test] - 211
async fn a_live_data_answer_without_retrieval_gets_one_freshness_redo() { - 212
let dir = tempdir().unwrap(); - 213
let mut agent = build_agent( - 214
&dir, - 215
"freshness-redo", - 216
vec![ - 217
// Answers from memory of an earlier turn — no retrieval. - 218
text_msg("It is 29.1°C in New Delhi."), - 219
// After the nudge: retrieves, then answers from the result as a - 220
// cited card (the grounding check then has nothing to add). - 221
search_call("s1"), - 222
text_msg( - 223
"```vak\n{\"semantic_type\":\"metric\",\"payload\":{\"label\":\"Delhi 05:30\",\"value\":26.4,\"unit\":\"C\",\"source\":\"https://example-met.in/delhi\"}}\n```", - 224
), - 225
], - 226
true, - 227
) - 228
.await; - 229
- 230
let outcome = agent - 231
.run( - 232
"what is the current weather in new delhi", - 233
&Default::default(), - 234
CancellationToken::new(), - 235
mpsc::channel(64).0, - 236
) - 237
.await; - 238
assert!( - 239
matches!(&outcome, TurnOutcome::Completed { response } if response.text_content().contains("26.4")), - 240
"got {outcome:?}" - 241
); - 242
- 243
let nudges = user_texts(&agent); - 244
assert_eq!( - 245
nudges - 246
.iter() - 247
.filter(|t| t.starts_with("[freshness-check]")) - 248
.count(), - 249
1, - 250
"exactly one freshness nudge: {nudges:?}" - 251
); - 252
} - 253
- 254
#[tokio::test] - 255
async fn admitting_no_live_data_is_accepted_without_a_redo() { - 256
let dir = tempdir().unwrap(); - 257
let mut agent = build_agent( - 258
&dir, - 259
"freshness-admit", - 260
vec![text_msg( - 261
"I don't have live data for New Delhi right now; the last reading I saw was from an earlier turn.", - 262
)], - 263
true, - 264
) - 265
.await; - 266
let outcome = agent - 267
.run( - 268
"what is the current weather in new delhi", - 269
&Default::default(), - 270
CancellationToken::new(), - 271
mpsc::channel(64).0, - 272
) - 273
.await; - 274
assert!( - 275
matches!(outcome, TurnOutcome::Completed { .. }), - 276
"got {outcome:?}" - 277
); - 278
assert!( - 279
!user_texts(&agent) - 280
.iter() - 281
.any(|t| t.starts_with("[freshness-check]")) - 282
); - 283
} - 284
- 285
#[tokio::test] - 286
async fn a_timeless_directive_is_never_nudged() { - 287
let dir = tempdir().unwrap(); - 288
let mut agent = build_agent( - 289
&dir, - 290
"freshness-timeless", - 291
vec![text_msg("Copper is refined by electrolysis.")], - 292
false, - 293
) - 294
.await; - 295
let outcome = agent - 296
.run( - 297
"explain how copper is refined", - 298
&Default::default(), - 299
CancellationToken::new(), - 300
mpsc::channel(64).0, - 301
) - 302
.await; - 303
assert!( - 304
matches!(outcome, TurnOutcome::Completed { .. }), - 305
"got {outcome:?}" - 306
); - 307
assert!( - 308
!user_texts(&agent) - 309
.iter() - 310
.any(|t| t.starts_with("[freshness-check]")) - 311
); - 312
} - 313
- 314
#[tokio::test] - 315
async fn a_thinking_only_step_gets_one_redo_to_act() { - 316
let dir = tempdir().unwrap(); - 317
let mut agent = build_agent( - 318
&dir, - 319
"empty-step-redo", - 320
vec![ - 321
thinking_only(), - 322
text_msg("Copper is refined by electrolysis."), - 323
], - 324
false, - 325
) - 326
.await; - 327
let outcome = agent - 328
.run( - 329
"explain how copper is refined", - 330
&Default::default(), - 331
CancellationToken::new(), - 332
mpsc::channel(64).0, - 333
) - 334
.await; - 335
assert!( - 336
matches!(&outcome, TurnOutcome::Completed { response } if response.text_content().contains("electrolysis")), - 337
"got {outcome:?}" - 338
); - 339
let nudges = user_texts(&agent); - 340
assert_eq!( - 341
nudges - 342
.iter() - 343
.filter(|t| t.starts_with("[empty-step]")) - 344
.count(), - 345
1, - 346
"exactly one empty-step nudge: {nudges:?}" - 347
); - 348
assert!( - 349
nudges.iter().any(|text| { - 350
text.starts_with("[empty-step]") && text.contains("explain how copper is refined") - 351
}), - 352
"empty-step repair must retain the already-admitted target: {nudges:?}" - 353
); - 354
} - 355
- 356
#[tokio::test] - 357
async fn a_tool_call_starts_a_fresh_bounded_empty_step_boundary() { - 358
let dir = tempdir().unwrap(); - 359
let mut agent = build_agent( - 360
&dir, - 361
"empty-step-after-tool", - 362
vec![ - 363
thinking_only(), - 364
search_call("s1"), - 365
thinking_only(), - 366
text_msg("The retrieved observation reports 26.4 C in Delhi."), - 367
], - 368
false, - 369
) - 370
.await; - 371
let outcome = agent - 372
.run( - 373
"find the Delhi observation and answer", - 374
&Default::default(), - 375
CancellationToken::new(), - 376
mpsc::channel(64).0, - 377
) - 378
.await; - 379
assert!( - 380
matches!(&outcome, TurnOutcome::Completed { response } if response.text_content().contains("26.4")), - 381
"got {outcome:?}" - 382
); - 383
assert_eq!( - 384
user_texts(&agent) - 385
.iter() - 386
.filter(|text| text.starts_with("[empty-step]")) - 387
.count(), - 388
2, - 389
"each action boundary gets one bounded repair" - 390
); - 391
} - 392
- 393
#[tokio::test] - 394
async fn two_thinking_only_steps_fail_instead_of_completing_without_work() { - 395
let dir = tempdir().unwrap(); - 396
let mut agent = build_agent( - 397
&dir, - 398
"empty-step-failure", - 399
vec![thinking_only(), thinking_only()], - 400
false, - 401
) - 402
.await; - 403
let outcome = agent - 404
.run( - 405
"create a draft", - 406
&Default::default(), - 407
CancellationToken::new(), - 408
mpsc::channel(64).0, - 409
) - 410
.await; - 411
assert!( - 412
matches!(&outcome, TurnOutcome::Failed { error: vak_llm::LlmError::Parse(message) } - 413
if message.contains("no visible answer")), - 414
"got {outcome:?}" - 415
); - 416
assert_eq!( - 417
user_texts(&agent) - 418
.iter() - 419
.filter(|text| text.starts_with("[empty-step]")) - 420
.count(), - 421
1 - 422
); - 423
} - 424
- 425
fn card_call(id: &str, value: &str) -> AssistantMessage { - 426
AssistantMessage { - 427
content: vec![ContentBlock::ToolUse { - 428
id: id.into(), - 429
name: "emit_metric_card".into(), - 430
input: serde_json::json!({"semantic_type":"metric","payload":{"label":"Delhi","value":value,"unit":"C"}}), - 431
}], - 432
stop_reason: StopReason::ToolUse, - 433
usage: Usage::default(), - 434
model: "test-model".into(), - 435
response_id: None, - 436
} - 437
} - 438
- 439
struct FakeCardTool; - 440
- 441
#[async_trait] - 442
impl Tool for FakeCardTool { - 443
fn name(&self) -> &str { - 444
"emit_metric_card" - 445
} - 446
fn description(&self) -> &str { - 447
"fake card tool for tests" - 448
} - 449
fn schema(&self) -> Value { - 450
serde_json::json!({"type": "object"}) - 451
} - 452
fn presents_cards(&self) -> bool { - 453
true - 454
} - 455
async fn execute(&self, _args: &Value, _ctx: &ToolContext) -> ToolOutput { - 456
ToolOutput::ok("{\"ok\":true}") - 457
} - 458
} - 459
- 460
#[tokio::test] - 461
async fn a_card_in_a_live_data_turn_is_gated_until_something_is_retrieved() { - 462
let dir = tempdir().unwrap(); - 463
let mut agent = build_agent( - 464
&dir, - 465
"freshness-card-gate", - 466
vec![ - 467
// Card straight from memory: not executed, error value back. - 468
card_call("c1", "29.1"), - 469
// Repairs: retrieves, then the card from the result is shown. - 470
search_call("s1"), - 471
card_call("c2", "26.4"), - 472
text_msg("Delhi is at 26.4 C (example-met.in, 05:30)."), - 473
], - 474
true, - 475
) - 476
.await; - 477
agent.config.tools.push(Arc::new(FakeCardTool)); - 478
let outcome = agent - 479
.run( - 480
"what is the current weather in new delhi", - 481
&Default::default(), - 482
CancellationToken::new(), - 483
mpsc::channel(64).0, - 484
) - 485
.await; - 486
assert!( - 487
matches!(&outcome, TurnOutcome::Completed { .. }), - 488
"got {outcome:?}" - 489
); - 490
- 491
let results: Vec<(String, bool)> = futures::executor::block_on(async { - 492
agent - 493
.session - 494
.lock() - 495
.await - 496
.message_chain() - 497
.iter() - 498
.flat_map(|(_, m)| m.content.clone()) - 499
.filter_map(|b| match b { - 500
ContentBlock::ToolResult { - 501
content, is_error, .. - 502
} => Some((content, is_error)), - 503
_ => None, - 504
}) - 505
.collect() - 506
}); - 507
assert!( - 508
results - 509
.iter() - 510
.any(|(c, e)| *e && c.starts_with("[freshness-check]")), - 511
"the first card must come back as a freshness error: {results:?}" - 512
); - 513
assert!( - 514
!user_texts(&agent) - 515
.iter() - 516
.any(|t| t.starts_with("[freshness-check]")), - 517
"the gate fired at the card, so no second nudge on the final text" - 518
); - 519
} - 520
- 521
#[tokio::test] - 522
async fn a_second_stale_card_fails_closed_with_an_honest_answer() { - 523
let dir = tempdir().unwrap(); - 524
let mut agent = build_agent( - 525
&dir, - 526
"freshness-fail-closed", - 527
vec![ - 528
card_call("c1", "29.1"), - 529
// The "repair" is another carried-over figure, still no retrieval. - 530
card_call("c2", "28"), - 531
text_msg("unreachable"), - 532
], - 533
true, - 534
) - 535
.await; - 536
agent.config.tools.push(Arc::new(FakeCardTool)); - 537
let outcome = agent - 538
.run( - 539
"what is the current weather in new delhi", - 540
&Default::default(), - 541
CancellationToken::new(), - 542
mpsc::channel(64).0, - 543
) - 544
.await; - 545
match &outcome { - 546
TurnOutcome::Completed { response } => { - 547
let text = response.text_content(); - 548
assert!( - 549
text.contains("not presenting a carried-over figure as current"), - 550
"fail-closed answer expected, got: {text}" - 551
); - 552
assert!(!text.contains("unreachable")); - 553
} - 554
other => panic!("expected a completed turn, got {other:?}"), - 555
} - 556
let presented = - 557
futures::executor::block_on(async { agent.session.lock().await.presentations().len() }); - 558
assert_eq!( - 559
presented, 0, - 560
"no stale card may reach the ledger as a presentation" - 561
); - 562
// The system-authored answer is on the ledger, so the turn is closed - 563
// and carded like any other. - 564
let (last_is_answer, carded) = futures::executor::block_on(async { - 565
let session = agent.session.lock().await; - 566
let last = session - 567
.message_chain() - 568
.last() - 569
.map(|(_, m)| m.text_content().contains("carried-over figure")) - 570
.unwrap_or(false); - 571
(last, session.turn_cards().len()) - 572
}); - 573
assert!(last_is_answer, "the fail-closed answer must be logged"); - 574
assert_eq!(carded, 1, "the turn closes with a TurnCard"); - 575
} - 576
- 577
struct FakeListTool; - 578
- 579
#[async_trait] - 580
impl Tool for FakeListTool { - 581
fn name(&self) -> &str { - 582
"glob" - 583
} - 584
fn description(&self) -> &str { - 585
"fake directory listing for tests" - 586
} - 587
fn schema(&self) -> Value { - 588
serde_json::json!({"type": "object", "properties": {"pattern": {"type": "string"}}}) - 589
} - 590
async fn execute(&self, _args: &Value, _ctx: &ToolContext) -> ToolOutput { - 591
ToolOutput::ok("Cargo.toml\nsrc/main.rs\n") - 592
} - 593
} - 594
- 595
/// A current value in the workspace is observed by looking at the workspace: - 596
/// "what's in the current directory?" is answered by listing it. A local - 597
/// observation satisfies the freshness check exactly as a retrieval does, - 598
/// with no demand for a web search that could not answer it. - 599
#[tokio::test] - 600
async fn a_local_observation_satisfies_the_freshness_check() { - 601
let dir = tempdir().unwrap(); - 602
let list_call = AssistantMessage { - 603
content: vec![ContentBlock::ToolUse { - 604
id: "g1".into(), - 605
name: "glob".into(), - 606
input: serde_json::json!({"pattern": "*"}), - 607
}], - 608
stop_reason: StopReason::ToolUse, - 609
usage: Usage::default(), - 610
model: "test-model".into(), - 611
response_id: None, - 612
}; - 613
let mut agent = build_agent( - 614
&dir, - 615
"freshness-local", - 616
vec![ - 617
list_call, - 618
text_msg("The current directory holds Cargo.toml and src/main.rs."), - 619
], - 620
true, - 621
) - 622
.await; - 623
agent.config.tools.push(Arc::new(FakeListTool)); - 624
agent.config.observation_check = Some(Arc::new(|name: &str, _: &Value| name == "glob")); - 625
let outcome = agent - 626
.run( - 627
"what's in the current directory?", - 628
&Default::default(), - 629
CancellationToken::new(), - 630
mpsc::channel(64).0, - 631
) - 632
.await; - 633
assert!( - 634
matches!(&outcome, TurnOutcome::Completed { response } if response.text_content().contains("Cargo.toml")), - 635
"got {outcome:?}" - 636
); - 637
assert!( - 638
!user_texts(&agent) - 639
.iter() - 640
.any(|t| t.starts_with("[freshness-check]")), - 641
"a listing is an observation; no redo was owed" - 642
); - 643
} - 644
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.