- 1
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] - 2
- 3
//! Regression coverage for the verified real bug: a retrieval-shaped tool - 4
//! call (e.g. `tavily_search`) succeeds and returns real results, but the - 5
//! model's next turn ignores them and answers with ungrounded, uncited - 6
//! prose. `Agent::run` must give the model exactly one bounded repair turn - 7
//! in that case instead of letting the ungrounded answer stand. - 8
//! - 9
//! Which calls count as retrieval is decided by `AgentConfig::retrieval_check`, - 10
//! supplied by `Core` from what each capability *declares it serves* — never - 11
//! from a tool's name or its output. These tests supply a check that declares - 12
//! the tool `search` as web-serving, and separately prove the name is - 13
//! irrelevant in both directions. - 14
- 15
use std::collections::VecDeque; - 16
use std::sync::{Arc, Mutex}; - 17
- 18
use async_trait::async_trait; - 19
use serde_json::Value; - 20
use tokio::sync::mpsc; - 21
use tokio_util::sync::CancellationToken; - 22
- 23
use tempfile::tempdir; - 24
- 25
use vak_agent::{Agent, AgentConfig, TurnOutcome}; - 26
use vak_llm::stream; - 27
use vak_llm::types::{AssistantMessage, ChatRequest, ContentBlock, StopReason, Usage}; - 28
use vak_llm::{EventStream, LlmError, Provider}; - 29
use vak_permission::PermissionEngine; - 30
use vak_session::types::{FrozenContract, SessionHeader}; - 31
use vak_session::{SessionLog, SessionPath}; - 32
use vak_tools::context::ToolContext; - 33
use vak_tools::{Tool, ToolOutput}; - 34
- 35
struct Scripted { - 36
responses: Mutex<VecDeque<AssistantMessage>>, - 37
} - 38
- 39
#[async_trait] - 40
impl Provider for Scripted { - 41
fn name(&self) -> &str { - 42
"scripted" - 43
} - 44
- 45
async fn stream( - 46
&self, - 47
_request: ChatRequest, - 48
_cancel: CancellationToken, - 49
) -> Result<EventStream, LlmError> { - 50
let next = self.responses.lock().unwrap().pop_front(); - 51
let (mut sink, rx) = stream::channel(64); - 52
match next { - 53
Some(m) => { - 54
sink.push(stream::StreamEvent::Start { partial: m.clone() }); - 55
sink.close_message(m).await; - 56
} - 57
None => sink.close_error(LlmError::Parse("exhausted".into())).await, - 58
} - 59
Ok(rx) - 60
} - 61
} - 62
- 63
/// Stands in for any retrieval-type MCP tool (Tavily/Exa/Firecrawl/a future - 64
/// one nobody's written yet) — real, dated, URL-bearing results, exactly - 65
/// the shape observed in the live bug's session transcript. - 66
struct FakeSearchTool; - 67
- 68
#[async_trait] - 69
impl Tool for FakeSearchTool { - 70
fn name(&self) -> &str { - 71
"search" - 72
} - 73
fn description(&self) -> &str { - 74
"fake search tool for tests" - 75
} - 76
fn schema(&self) -> Value { - 77
serde_json::json!({"type": "object", "properties": {"query": {"type": "string"}}}) - 78
} - 79
async fn execute(&self, _args: &Value, _ctx: &ToolContext) -> ToolOutput { - 80
ToolOutput::ok( - 81
"Title: Parliament debates UPI fee proposal\nURL: https://example-news.in/upi-fee-debate\n\n\ - 82
Title: RBI holds rates steady\nURL: https://example-news.in/rbi-rates\n", - 83
) - 84
} - 85
} - 86
- 87
fn search_call(id: &str) -> AssistantMessage { - 88
AssistantMessage { - 89
content: vec![ContentBlock::ToolUse { - 90
id: id.into(), - 91
name: "search".into(), - 92
input: serde_json::json!({"query": "top news in india right now"}), - 93
}], - 94
stop_reason: StopReason::ToolUse, - 95
usage: Usage::default(), - 96
model: "test-model".into(), - 97
response_id: None, - 98
} - 99
} - 100
- 101
fn text_msg(t: &str) -> AssistantMessage { - 102
AssistantMessage { - 103
content: vec![ContentBlock::text(t)], - 104
stop_reason: StopReason::EndTurn, - 105
usage: Usage { - 106
input_tokens: 1, - 107
output_tokens: 1, - 108
..Default::default() - 109
}, - 110
model: "test-model".into(), - 111
response_id: None, - 112
} - 113
} - 114
- 115
fn fenced_research_answer() -> String { - 116
"Here is what I found:\n\n```vak\n{\"semantic_type\":\"research.synthesis\",\"payload\":{\"sources\":[{\"title\":\"UPI fee debate\",\"url\":\"https://example-news.in/upi-fee-debate\"}],\"takeaways\":[{\"text\":\"Parliament is debating a UPI fee.\",\"citation_indices\":[0]}]}}\n```".into() - 117
} - 118
- 119
async fn build_agent( - 120
dir: &tempfile::TempDir, - 121
session_id: &str, - 122
responses: Vec<AssistantMessage>, - 123
) -> Agent { - 124
build_agent_with_check( - 125
dir, - 126
session_id, - 127
responses, - 128
Some(Arc::new(|name: &str, _: &Value| name == "search")), - 129
) - 130
.await - 131
} - 132
- 133
async fn build_agent_with_check( - 134
dir: &tempfile::TempDir, - 135
session_id: &str, - 136
responses: Vec<AssistantMessage>, - 137
retrieval_check: Option<vak_agent::RetrievalCheck>, - 138
) -> Agent { - 139
let header = SessionHeader { - 140
agent: None, - 141
session_id: session_id.into(), - 142
created_at: chrono::Utc::now(), - 143
cwd: dir.path().to_path_buf(), - 144
parent_session_id: None, - 145
contract_id: None, - 146
work_item_id: None, - 147
conversation: None, - 148
contract: FrozenContract { - 149
app_version: "0".into(), - 150
provider: "scripted".into(), - 151
model: "test-model".into(), - 152
route_ladder: Vec::new(), - 153
route_objective: String::new(), - 154
route_annotations: Vec::new(), - 155
system_prompt: "sys".into(), - 156
permission_mode: "full-access".into(), - 157
capabilities: Vec::new(), - 158
prompt_layers: Vec::new(), - 159
}, - 160
}; - 161
let home = dir.path().join("home"); - 162
std::fs::create_dir_all(&home).unwrap(); - 163
let log = SessionLog::create( - 164
SessionPath::new_session_file(&home, dir.path(), session_id), - 165
header, - 166
) - 167
.unwrap(); - 168
- 169
Agent::new( - 170
Arc::new(Scripted { - 171
responses: Mutex::new(VecDeque::from(responses)), - 172
}), - 173
log, - 174
{ - 175
let mut cfg = AgentConfig::new("sys"); - 176
cfg.model = "test-model".into(); - 177
cfg.tools = vec![Arc::new(FakeSearchTool)]; - 178
cfg.retrieval_check = retrieval_check; - 179
cfg.mode = vak_permission::Mode::FullAccess; - 180
cfg.permission = Some(Arc::new(PermissionEngine::default())); - 181
cfg - 182
}, - 183
) - 184
} - 185
- 186
// Raw ledger, not the model-visible projection: these tests are about the - 187
// repair loop's mechanics (how many drafts were tried, what a nudge said), - 188
// which a closed turn's full record deliberately no longer preserves - 189
// (docs/design/68-context-engine.md §10) — a rejected draft is never - 190
// projected, and every step collapses into one trace+narration message. - 191
fn assistant_texts(agent: &Agent) -> Vec<String> { - 192
futures::executor::block_on(async { - 193
agent - 194
.session - 195
.lock() - 196
.await - 197
.message_chain() - 198
.iter() - 199
.filter(|(_, m)| m.role == vak_llm::types::Role::Assistant) - 200
.flat_map(|(_, m)| m.content.iter()) - 201
.filter_map(|b| match b { - 202
ContentBlock::Text { text } => Some(text.clone()), - 203
_ => None, - 204
}) - 205
.collect() - 206
}) - 207
} - 208
- 209
#[tokio::test] - 210
async fn ungrounded_answer_after_search_gets_one_repair_turn() { - 211
let dir = tempdir().unwrap(); - 212
let mut agent = build_agent( - 213
&dir, - 214
"grounding-repair", - 215
vec![ - 216
search_call("s1"), - 217
// First answer ignores the search result entirely — the exact - 218
// observed bug shape. - 219
text_msg("I have already provided a summary. Key themes: politics, business."), - 220
// After the [grounding-check] nudge, the model does the right thing. - 221
text_msg(&fenced_research_answer()), - 222
], - 223
) - 224
.await; - 225
- 226
let outcome = agent - 227
.run( - 228
"what are top news in india right now", - 229
&Default::default(), - 230
CancellationToken::new(), - 231
mpsc::channel(64).0, - 232
) - 233
.await; - 234
assert!( - 235
matches!(outcome, TurnOutcome::Completed { .. }), - 236
"got {outcome:?}" - 237
); - 238
- 239
let texts = assistant_texts(&agent); - 240
assert!( - 241
texts.iter().any(|t| t.contains("\"semantic_type\"")), - 242
"final session state must contain a grounded card, got: {texts:?}" - 243
); - 244
- 245
// The repair nudge itself must be visible in the session (for - 246
// diagnostics/audit), and must name the tool that was ignored. - 247
let user_texts: Vec<String> = futures::executor::block_on(async { - 248
agent - 249
.session - 250
.lock() - 251
.await - 252
.message_chain() - 253
.iter() - 254
.filter(|(_, m)| m.role == vak_llm::types::Role::User) - 255
.flat_map(|(_, m)| m.content.iter()) - 256
.filter_map(|b| match b { - 257
ContentBlock::Text { text } => Some(text.clone()), - 258
_ => None, - 259
}) - 260
.collect() - 261
}); - 262
assert!( - 263
user_texts.iter().any(|t| t.contains("[grounding-check]") - 264
&& t.contains("search") - 265
&& t.contains("what are top news in india right now")), - 266
"expected a grounding-check nudge naming the ignored tool and admitted target, got: {user_texts:?}" - 267
); - 268
} - 269
- 270
#[tokio::test] - 271
async fn repair_is_bounded_to_one_attempt() { - 272
let dir = tempdir().unwrap(); - 273
let mut agent = build_agent( - 274
&dir, - 275
"grounding-bounded", - 276
vec![ - 277
search_call("s1"), - 278
text_msg("still vague, still no fence, first ignore"), - 279
// Model ignores the nudge too — must NOT loop forever; the run - 280
// completes (with the still-bad answer) rather than repeating. - 281
text_msg("still vague, still no fence, second ignore"), - 282
], - 283
) - 284
.await; - 285
- 286
let outcome = agent - 287
.run( - 288
"what are top news in india right now", - 289
&Default::default(), - 290
CancellationToken::new(), - 291
mpsc::channel(64).0, - 292
) - 293
.await; - 294
assert!( - 295
matches!(outcome, TurnOutcome::Completed { .. }), - 296
"got {outcome:?}" - 297
); - 298
- 299
let texts = assistant_texts(&agent); - 300
assert_eq!( - 301
texts.iter().filter(|t| t.contains("still vague")).count(), - 302
2, - 303
"exactly one repair retry — not zero, not a loop: {texts:?}" - 304
); - 305
} - 306
- 307
#[tokio::test] - 308
async fn honest_no_data_admission_is_not_flagged() { - 309
let dir = tempdir().unwrap(); - 310
let mut agent = build_agent( - 311
&dir, - 312
"grounding-honest", - 313
vec![ - 314
search_call("s1"), - 315
text_msg( - 316
"I couldn't find current information on that from the search results — \ - 317
the results didn't cover today's headlines.", - 318
), - 319
], - 320
) - 321
.await; - 322
- 323
let outcome = agent - 324
.run( - 325
"what are top news in india right now", - 326
&Default::default(), - 327
CancellationToken::new(), - 328
mpsc::channel(64).0, - 329
) - 330
.await; - 331
assert!( - 332
matches!(outcome, TurnOutcome::Completed { .. }), - 333
"got {outcome:?}" - 334
); - 335
- 336
let texts = assistant_texts(&agent); - 337
assert_eq!( - 338
texts.iter().filter(|t| t.contains("couldn't find")).count(), - 339
1, - 340
"an honest admission of no data must NOT trigger a repair retry: {texts:?}" - 341
); - 342
} - 343
- 344
#[tokio::test] - 345
async fn grounded_first_answer_needs_no_repair() { - 346
let dir = tempdir().unwrap(); - 347
let mut agent = build_agent( - 348
&dir, - 349
"grounding-clean", - 350
vec![search_call("s1"), text_msg(&fenced_research_answer())], - 351
) - 352
.await; - 353
- 354
let outcome = agent - 355
.run( - 356
"what are top news in india right now", - 357
&Default::default(), - 358
CancellationToken::new(), - 359
mpsc::channel(64).0, - 360
) - 361
.await; - 362
assert!( - 363
matches!(outcome, TurnOutcome::Completed { .. }), - 364
"got {outcome:?}" - 365
); - 366
- 367
let texts = assistant_texts(&agent); - 368
assert_eq!( - 369
texts.iter().filter(|t| t.contains("semantic_type")).count(), - 370
1, - 371
"a correctly-grounded first answer must not be re-run: {texts:?}" - 372
); - 373
} - 374
- 375
#[tokio::test] - 376
async fn session_search_of_the_users_own_notes_is_not_flagged() { - 377
// Regression: `session_search` (searching the user's OWN session - 378
// history/notes, not external data) must never trip the grounding - 379
// check just because its name contains "search". This exact false - 380
// positive broke a real test (crates/vak-core/tests/learning_loop.rs - 381
// `remember_propose_recall_promote_loop`) before the fix. - 382
struct SessionSearchTool; - 383
#[async_trait] - 384
impl Tool for SessionSearchTool { - 385
fn name(&self) -> &str { - 386
"session_search" - 387
} - 388
fn description(&self) -> &str { - 389
"search the user's own session/notes history" - 390
} - 391
fn schema(&self) -> Value { - 392
serde_json::json!({"type": "object"}) - 393
} - 394
async fn execute(&self, _args: &Value, _ctx: &ToolContext) -> ToolOutput { - 395
ToolOutput::ok( - 396
"1 hit(s): deploy-rollbacks — the deploy script must pause before rollback windows", - 397
) - 398
} - 399
} - 400
- 401
let dir = tempdir().unwrap(); - 402
let header = SessionHeader { - 403
agent: None, - 404
session_id: "grounding-session-search".into(), - 405
created_at: chrono::Utc::now(), - 406
cwd: dir.path().to_path_buf(), - 407
parent_session_id: None, - 408
contract_id: None, - 409
work_item_id: None, - 410
conversation: None, - 411
contract: FrozenContract { - 412
app_version: "0".into(), - 413
provider: "scripted".into(), - 414
model: "test-model".into(), - 415
route_ladder: Vec::new(), - 416
route_objective: String::new(), - 417
route_annotations: Vec::new(), - 418
system_prompt: "sys".into(), - 419
permission_mode: "full-access".into(), - 420
capabilities: Vec::new(), - 421
prompt_layers: Vec::new(), - 422
}, - 423
}; - 424
let home = dir.path().join("home"); - 425
std::fs::create_dir_all(&home).unwrap(); - 426
let log = SessionLog::create( - 427
SessionPath::new_session_file(&home, dir.path(), "grounding-session-search"), - 428
header, - 429
) - 430
.unwrap(); - 431
- 432
let mut agent = Agent::new( - 433
Arc::new(Scripted { - 434
responses: Mutex::new(VecDeque::from(vec![ - 435
AssistantMessage { - 436
content: vec![ContentBlock::ToolUse { - 437
id: "s1".into(), - 438
name: "session_search".into(), - 439
input: serde_json::json!({"query": "deploy rollback"}), - 440
}], - 441
stop_reason: StopReason::ToolUse, - 442
usage: Usage::default(), - 443
model: "test-model".into(), - 444
response_id: None, - 445
}, - 446
text_msg("Recalled from memory."), - 447
])), - 448
}), - 449
log, - 450
{ - 451
let mut cfg = AgentConfig::new("sys"); - 452
cfg.model = "test-model".into(); - 453
cfg.tools = vec![Arc::new(SessionSearchTool)]; - 454
cfg.retrieval_check = Some(Arc::new(|name, _| name == "search")); - 455
cfg.mode = vak_permission::Mode::FullAccess; - 456
cfg.permission = Some(Arc::new(PermissionEngine::default())); - 457
cfg - 458
}, - 459
); - 460
- 461
let outcome = agent - 462
.run( - 463
"what do we know about deploys?", - 464
&Default::default(), - 465
CancellationToken::new(), - 466
mpsc::channel(64).0, - 467
) - 468
.await; - 469
assert!( - 470
matches!(outcome, TurnOutcome::Completed { .. }), - 471
"session_search must not trigger a grounding repair (and stall on an exhausted script): got {outcome:?}" - 472
); - 473
- 474
let texts = assistant_texts(&agent); - 475
assert_eq!( - 476
texts - 477
.iter() - 478
.filter(|t| t.contains("Recalled from memory")) - 479
.count(), - 480
1, - 481
"no repair retry should have fired: {texts:?}" - 482
); - 483
} - 484
- 485
#[tokio::test] - 486
async fn ungrounded_prose_unrelated_to_any_tool_call_is_not_flagged() { - 487
// No search/retrieval tool ran this turn at all — an ordinary prose - 488
// answer must never be forced through the grounding check. - 489
let dir = tempdir().unwrap(); - 490
let mut agent = build_agent( - 491
&dir, - 492
"grounding-no-tool", - 493
vec![text_msg("The capital of France is Paris.")], - 494
) - 495
.await; - 496
- 497
let outcome = agent - 498
.run( - 499
"what is the capital of france", - 500
&Default::default(), - 501
CancellationToken::new(), - 502
mpsc::channel(64).0, - 503
) - 504
.await; - 505
assert!( - 506
matches!(outcome, TurnOutcome::Completed { .. }), - 507
"got {outcome:?}" - 508
); - 509
- 510
let texts = assistant_texts(&agent); - 511
assert_eq!( - 512
texts - 513
.iter() - 514
.filter(|t| t.contains("capital of France")) - 515
.count(), - 516
1, - 517
"no tool call happened, so no grounding retry should fire: {texts:?}" - 518
); - 519
} - 520
- 521
/// The agent never decides what is retrieval. A tool literally named `search` - 522
/// whose results are full of URLs — exactly what the old keyword-and-URL rule - 523
/// flagged — is not grounded on when the capability layer does not declare it - 524
/// as reaching outside information. - 525
#[tokio::test] - 526
async fn a_tool_is_retrieval_only_if_the_check_says_so_whatever_its_name_or_output() { - 527
let dir = tempdir().unwrap(); - 528
let mut agent = build_agent_with_check( - 529
&dir, - 530
"grounding-name-irrelevant", - 531
vec![ - 532
search_call("s1"), - 533
text_msg("Here is a summary with no citations at all."), - 534
], - 535
Some(Arc::new(|_: &str, _: &Value| false)), - 536
) - 537
.await; - 538
let outcome = agent - 539
.run( - 540
"look something up", - 541
&Default::default(), - 542
CancellationToken::new(), - 543
mpsc::channel(64).0, - 544
) - 545
.await; - 546
assert!( - 547
matches!(outcome, TurnOutcome::Completed { .. }), - 548
"got {outcome:?}" - 549
); - 550
let users: Vec<String> = futures::executor::block_on(async { - 551
agent - 552
.session - 553
.lock() - 554
.await - 555
.derive_messages() - 556
.iter() - 557
.filter(|m| m.role == vak_llm::types::Role::User) - 558
.flat_map(|m| m.content.iter()) - 559
.filter_map(|b| match b { - 560
ContentBlock::Text { text } => Some(text.clone()), - 561
_ => None, - 562
}) - 563
.collect() - 564
}); - 565
assert!( - 566
!users.iter().any(|t| t.contains("[grounding-check]")), - 567
"an undeclared tool must never trigger a grounding nudge: {users:?}" - 568
); - 569
} - 570
- 571
/// With no check configured at all the grounding check is inert rather than - 572
/// falling back to a guess. - 573
#[tokio::test] - 574
async fn with_no_retrieval_check_the_grounding_check_is_inert() { - 575
let dir = tempdir().unwrap(); - 576
let mut agent = build_agent_with_check( - 577
&dir, - 578
"grounding-inert", - 579
vec![search_call("s1"), text_msg("Uncited summary.")], - 580
None, - 581
) - 582
.await; - 583
let outcome = agent - 584
.run( - 585
"look something up", - 586
&Default::default(), - 587
CancellationToken::new(), - 588
mpsc::channel(64).0, - 589
) - 590
.await; - 591
assert!( - 592
matches!(outcome, TurnOutcome::Completed { .. }), - 593
"got {outcome:?}" - 594
); - 595
} - 596
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.