- 1
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] - 2
- 3
//! Request tail assembly (docs/design/68-context-engine.md §6/§10): the - 4
//! per-turn control block lands as the final text block of the last user - 5
//! message, stays byte-identical across every step of one turn, carries - 6
//! cache breakpoints at the three documented positions, and a change in the - 7
//! stable prefix is surfaced as a `prefix-changed` activity exactly when the - 8
//! digest actually changes. - 9
- 10
use std::collections::VecDeque; - 11
use std::sync::{Arc, Mutex}; - 12
- 13
use tempfile::tempdir; - 14
use tokio::sync::mpsc; - 15
use tokio_util::sync::CancellationToken; - 16
- 17
use vak_agent::{Agent, AgentConfig, AutoApprove, SteeringQueues, TailInput, TurnOutcome}; - 18
use vak_llm::stream; - 19
use vak_llm::types::{ - 20
AssistantMessage, ChatRequest, ContentBlock, Message, Role, StopReason, Usage, - 21
}; - 22
use vak_llm::{EventStream, LlmError, Provider}; - 23
use vak_permission::{Mode, PermissionEngine}; - 24
use vak_session::types::{FrozenContract, MessageRecord, SessionHeader}; - 25
use vak_session::{SessionLog, SessionPath}; - 26
use vak_tools::bash::BashTool; - 27
- 28
/// Records every request it receives (for tail/cache inspection) and - 29
/// replays a fixed queue of responses. - 30
struct Recording { - 31
requests: Mutex<Vec<ChatRequest>>, - 32
responses: Mutex<VecDeque<AssistantMessage>>, - 33
} - 34
- 35
impl Recording { - 36
fn new(responses: Vec<AssistantMessage>) -> Self { - 37
Recording { - 38
requests: Mutex::new(Vec::new()), - 39
responses: Mutex::new(responses.into_iter().collect()), - 40
} - 41
} - 42
- 43
fn requests(&self) -> Vec<ChatRequest> { - 44
self.requests.lock().unwrap().clone() - 45
} - 46
} - 47
- 48
#[async_trait::async_trait] - 49
impl Provider for Recording { - 50
fn name(&self) -> &str { - 51
"recording" - 52
} - 53
- 54
async fn stream( - 55
&self, - 56
request: ChatRequest, - 57
_cancel: CancellationToken, - 58
) -> Result<EventStream, LlmError> { - 59
self.requests.lock().unwrap().push(request); - 60
let next = self.responses.lock().unwrap().pop_front(); - 61
let (mut sink, rx) = stream::channel(64); - 62
match next { - 63
Some(m) => { - 64
sink.push(stream::StreamEvent::Start { partial: m.clone() }); - 65
sink.close_message(m).await; - 66
} - 67
None => sink.close_error(LlmError::Parse("exhausted".into())).await, - 68
} - 69
Ok(rx) - 70
} - 71
} - 72
- 73
fn bash_call(id: &str, cmd: &str) -> AssistantMessage { - 74
AssistantMessage { - 75
content: vec![ContentBlock::ToolUse { - 76
id: id.into(), - 77
name: "bash".into(), - 78
input: serde_json::json!({"command": cmd}), - 79
}], - 80
stop_reason: StopReason::ToolUse, - 81
usage: Usage::default(), - 82
model: "test-model".into(), - 83
response_id: None, - 84
} - 85
} - 86
- 87
fn text_msg(t: &str) -> AssistantMessage { - 88
AssistantMessage { - 89
content: vec![ContentBlock::text(t)], - 90
stop_reason: StopReason::EndTurn, - 91
usage: Usage { - 92
input_tokens: 50, - 93
output_tokens: 3, - 94
..Default::default() - 95
}, - 96
model: "test-model".into(), - 97
response_id: None, - 98
} - 99
} - 100
- 101
fn header(cwd: &std::path::Path) -> SessionHeader { - 102
SessionHeader { - 103
agent: None, - 104
session_id: "tail-test".into(), - 105
created_at: chrono::Utc::now(), - 106
cwd: cwd.to_path_buf(), - 107
parent_session_id: None, - 108
contract_id: None, - 109
work_item_id: None, - 110
conversation: None, - 111
contract: FrozenContract { - 112
app_version: "0".into(), - 113
provider: "recording".into(), - 114
model: "test-model".into(), - 115
route_ladder: Vec::new(), - 116
route_objective: String::new(), - 117
route_annotations: Vec::new(), - 118
system_prompt: "sys".into(), - 119
permission_mode: "workspace-write".into(), - 120
capabilities: Vec::new(), - 121
prompt_layers: Vec::new(), - 122
}, - 123
} - 124
} - 125
- 126
fn build_agent(provider: Arc<Recording>, dir: &std::path::Path) -> Agent { - 127
let home = dir.join(".vak-home"); - 128
std::fs::create_dir_all(&home).unwrap(); - 129
let path = SessionPath::new_session_file(&home, dir, "tail-test"); - 130
let log = SessionLog::create(path, header(dir)).unwrap(); - 131
let mut cfg = AgentConfig::new("sys"); - 132
cfg.model = "test-model".into(); - 133
cfg.mode = Mode::FullAccess; - 134
cfg.permission = Some(Arc::new(PermissionEngine::default())); - 135
cfg.approver = Some(Arc::new(AutoApprove)); - 136
cfg.tools = vec![Arc::new(BashTool)]; - 137
cfg.tool_definitions = Some(vak_tools::definitions(&cfg.tools)); - 138
cfg.retry_base_backoff_ms = 1; - 139
cfg.run_retry_base_backoff_ms = 1; - 140
cfg.tail = TailInput { - 141
temporal: "current UTC instant 2026-09-19T00:00:00Z".into(), - 142
stance: "Provide a clear, direct answer.".into(), - 143
}; - 144
Agent::new(provider, log, cfg) - 145
} - 146
- 147
async fn run(agent: &mut Agent, prompt: &str) -> TurnOutcome { - 148
let (ev_tx, mut ev_rx) = mpsc::channel(256); - 149
tokio::spawn(async move { while ev_rx.recv().await.is_some() {} }); - 150
let cancel = CancellationToken::new(); - 151
let steering = SteeringQueues::new(); - 152
agent.run(prompt, &steering, cancel, ev_tx).await - 153
} - 154
- 155
/// The tail is appended as a final text block on the turn's DIRECTIVE - 156
/// message — never re-homed onto whatever message a later step happens to - 157
/// end with — so the directive (tail included) stays byte-identical across - 158
/// every step of the same turn: an append-only request - 159
/// (docs/design/68-context-engine.md §6/§7). - 160
#[tokio::test(flavor = "multi_thread", worker_threads = 4)] - 161
async fn tail_precedes_the_users_words_and_is_stable_within_a_turn() { - 162
let dir = tempdir().unwrap(); - 163
let provider = Arc::new(Recording::new(vec![ - 164
bash_call("call-1", "echo hi"), - 165
text_msg("done"), - 166
])); - 167
let mut agent = build_agent(provider.clone(), dir.path()); - 168
let outcome = run(&mut agent, "run the check").await; - 169
assert!(matches!(outcome, TurnOutcome::Completed { .. })); - 170
- 171
let requests = provider.requests(); - 172
assert_eq!(requests.len(), 2, "one step per response"); - 173
- 174
// Step 1: only the directive exists yet, so it is also the last (and - 175
// only) message; the tail sits BEFORE it so the user's own words are - 176
// the last thing the model reads (docs/design/68-context-engine.md - 177
// §6), and there is no echo because the directive itself follows. - 178
let step1_directive = requests[0].messages.last().expect("a message").clone(); - 179
assert_eq!(step1_directive.role, Role::User); - 180
let texts: Vec<&str> = step1_directive - 181
.content - 182
.iter() - 183
.filter_map(|b| match b { - 184
ContentBlock::Text { text } => Some(text.as_str()), - 185
_ => None, - 186
}) - 187
.collect(); - 188
assert_eq!(texts.len(), 2, "tail block then directive: {texts:?}"); - 189
let tail_step_1 = texts[0].to_string(); - 190
assert!(tail_step_1.contains("<turn_context>")); - 191
assert!(tail_step_1.contains("current UTC instant 2026-09-19T00:00:00Z")); - 192
assert!(tail_step_1.contains("<stance>")); - 193
assert!(tail_step_1.contains("Provide a clear, direct answer.")); - 194
assert!(!tail_step_1.contains("<directive>")); - 195
assert_eq!(texts[1], "run the check"); - 196
- 197
// Step 2: the tool call and its result are appended AFTER the - 198
// directive. The directive -- tail included -- is byte-identical to - 199
// step 1's own first message, and the tail is never re-attached to the - 200
// trailing tool-result message. - 201
assert_eq!( - 202
requests[1].messages[0], step1_directive, - 203
"the directive (with its tail) must be byte-identical across steps" - 204
); - 205
let step2_last = requests[1].messages.last().unwrap(); - 206
assert!(matches!( - 207
step2_last.content.first(), - 208
Some(ContentBlock::ToolResult { .. }) - 209
)); - 210
assert!( - 211
!step2_last - 212
.content - 213
.iter() - 214
.any(|b| matches!(b, ContentBlock::Text { .. })), - 215
"the tail must not be re-homed onto the tool-result message: {:?}", - 216
step2_last.content - 217
); - 218
} - 219
- 220
/// Append-only requests within a turn (docs/design/68-context-engine.md - 221
/// §7): across every step of one multi-step tool turn, a later request's - 222
/// `system`, `tools`, and its messages up to the length of an earlier - 223
/// request are byte-identical to that earlier request — nothing already - 224
/// sent ever silently changes shape underneath a replayed thinking block. - 225
#[tokio::test(flavor = "multi_thread", worker_threads = 4)] - 226
async fn later_requests_in_a_turn_are_byte_identical_prefixes_of_earlier_ones() { - 227
let dir = tempdir().unwrap(); - 228
let provider = Arc::new(Recording::new(vec![ - 229
bash_call("call-1", "echo one"), - 230
bash_call("call-2", "echo two"), - 231
bash_call("call-3", "echo three"), - 232
text_msg("done"), - 233
])); - 234
let mut agent = build_agent(provider.clone(), dir.path()); - 235
let outcome = run(&mut agent, "run three checks").await; - 236
assert!(matches!(outcome, TurnOutcome::Completed { .. })); - 237
- 238
let requests = provider.requests(); - 239
assert_eq!(requests.len(), 4, "one step per response"); - 240
for k in 0..requests.len() - 1 { - 241
assert_eq!( - 242
requests[k + 1].system, - 243
requests[k].system, - 244
"system prompt must not change mid-turn (step {k} -> {})", - 245
k + 1 - 246
); - 247
assert_eq!( - 248
requests[k + 1].tools, - 249
requests[k].tools, - 250
"tools array must not change mid-turn (step {k} -> {})", - 251
k + 1 - 252
); - 253
assert_eq!( - 254
requests[k + 1].messages[..requests[k].messages.len()], - 255
requests[k].messages[..], - 256
"request {}'s messages must carry request {k}'s as a byte-identical prefix", - 257
k + 1 - 258
); - 259
} - 260
} - 261
- 262
/// Cache breakpoints land after the stable prefix, after the last message of - 263
/// any previous turn, and on the last message of the request being built. - 264
#[tokio::test(flavor = "multi_thread", worker_threads = 4)] - 265
async fn cache_breakpoints_mark_prefix_previous_turn_and_current_step() { - 266
let dir = tempdir().unwrap(); - 267
let provider = Arc::new(Recording::new(vec![text_msg("first answer")])); - 268
let mut agent = build_agent(provider.clone(), dir.path()); - 269
let outcome = run(&mut agent, "first question").await; - 270
assert!(matches!(outcome, TurnOutcome::Completed { .. })); - 271
- 272
// First-ever turn: no earlier turn exists, so only two positions apply - 273
// (after the prefix, and on the last — and only — message). - 274
let first_turn_requests = provider.requests(); - 275
let breakpoints = first_turn_requests[0] - 276
.cache - 277
.as_ref() - 278
.expect("cache hints present") - 279
.breakpoints - 280
.clone(); - 281
assert_eq!( - 282
breakpoints - 283
.iter() - 284
.map(|b| b.after_message) - 285
.collect::<Vec<_>>(), - 286
vec![None, Some(0)] - 287
); - 288
- 289
provider - 290
.responses - 291
.lock() - 292
.unwrap() - 293
.push_back(text_msg("second answer")); - 294
let outcome = run(&mut agent, "second question").await; - 295
assert!(matches!(outcome, TurnOutcome::Completed { .. })); - 296
- 297
let all_requests = provider.requests(); - 298
let second_turn_request = all_requests.last().expect("second turn request"); - 299
// messages: [user1, assistant1, user2] — three positions now apply. - 300
assert_eq!(second_turn_request.messages.len(), 3); - 301
let hints = second_turn_request - 302
.cache - 303
.as_ref() - 304
.expect("cache hints present"); - 305
assert!(!hints.session_key.is_empty()); - 306
assert_eq!( - 307
hints - 308
.breakpoints - 309
.iter() - 310
.map(|b| b.after_message) - 311
.collect::<Vec<_>>(), - 312
vec![None, Some(1), Some(2)], - 313
"prefix, end of previous turn, and the current step's last message" - 314
); - 315
} - 316
- 317
/// A digest that differs from the previous receipt's is surfaced as a - 318
/// `prefix-changed` Activity; an unchanged digest across turns writes none. - 319
#[tokio::test(flavor = "multi_thread", worker_threads = 4)] - 320
async fn prefix_changed_activity_recorded_only_when_digest_changes() { - 321
let dir = tempdir().unwrap(); - 322
let provider = Arc::new(Recording::new(vec![ - 323
text_msg("first"), - 324
text_msg("second"), - 325
text_msg("third"), - 326
])); - 327
let mut agent = build_agent(provider.clone(), dir.path()); - 328
- 329
// Turn 1: first digest ever seen — nothing to compare against yet. - 330
assert!(matches!( - 331
run(&mut agent, "one").await, - 332
TurnOutcome::Completed { .. } - 333
)); - 334
// Turn 2: same prefix and tools — digest unchanged. - 335
assert!(matches!( - 336
run(&mut agent, "two").await, - 337
TurnOutcome::Completed { .. } - 338
)); - 339
{ - 340
let session = agent.session.lock().await; - 341
assert!( - 342
session - 343
.activities() - 344
.iter() - 345
.all(|(_, _, activity)| activity.label != "prefix-changed"), - 346
"an unchanged prefix must never write a prefix-changed activity" - 347
); - 348
} - 349
- 350
// Turn 3: the prefix changes — this must be the one and only regression - 351
// surfaced in the ledger. - 352
agent.config.system_prefix = "a different system prefix".into(); - 353
assert!(matches!( - 354
run(&mut agent, "three").await, - 355
TurnOutcome::Completed { .. } - 356
)); - 357
- 358
let session = agent.into_session().await; - 359
let prefix_changed: Vec<_> = session - 360
.activities() - 361
.into_iter() - 362
.filter(|(_, _, activity)| activity.label == "prefix-changed") - 363
.collect(); - 364
assert_eq!( - 365
prefix_changed.len(), - 366
1, - 367
"exactly one prefix change happened" - 368
); - 369
let (_, _, activity) = &prefix_changed[0]; - 370
assert!(activity.data.contains_key("previous")); - 371
assert!(activity.data.contains_key("current")); - 372
assert_ne!(activity.data["previous"], activity.data["current"]); - 373
- 374
// Every receipt still carries a digest, and only the third run's usage - 375
// measured a fresh `prefix_tokens` (the first two share one digest). - 376
let receipts = session.receipts(); - 377
assert_eq!(receipts.len(), 3); - 378
assert!(receipts.iter().all(|r| !r.prefix_digest.is_empty())); - 379
assert!( - 380
receipts[0].prefix_tokens.is_some(), - 381
"the first request with a digest measures prefix_tokens" - 382
); - 383
assert!( - 384
receipts[1].prefix_tokens.is_none(), - 385
"the digest repeats, so it is not re-measured" - 386
); - 387
assert!( - 388
receipts[2].prefix_tokens.is_some(), - 389
"a new digest is measured again" - 390
); - 391
} - 392
- 393
/// The conversation thread in the assembled request lists only directives - 394
/// the projection leaves out (docs/design/68-context-engine.md §6/§10): a - 395
/// directive hidden behind a reset-with-handoff resurfaces there, but one - 396
/// still present — including the turn's own new directive — is never - 397
/// repeated. (The plan-driven case, a packeted turn, is covered at the - 398
/// session level: `tail_sections(Some(&plan))` in vak-session's tests.) - 399
#[tokio::test(flavor = "multi_thread", worker_threads = 4)] - 400
async fn thread_in_the_assembled_request_lists_only_non_verbatim_directives() { - 401
let dir = tempdir().unwrap(); - 402
let home = dir.path().join(".vak-home"); - 403
std::fs::create_dir_all(&home).unwrap(); - 404
let path = SessionPath::new_session_file(&home, dir.path(), "tail-test"); - 405
let mut log = SessionLog::create(path, header(dir.path())).unwrap(); - 406
- 407
log.append_goal_update(vak_intent::GoalUpdate { - 408
revision: 1, - 409
relation: vak_intent::GoalRelation::New, - 410
request: "research on WEF".into(), - 411
supersedes_revision: None, - 412
explicit: false, - 413
}) - 414
.unwrap(); - 415
log.append_message(MessageRecord { - 416
message: Message::user_text("research on WEF"), - 417
meta: None, - 418
}) - 419
.unwrap(); - 420
log.append_message(MessageRecord { - 421
message: Message::assistant(vec![ContentBlock::text("here are findings")]), - 422
meta: None, - 423
}) - 424
.unwrap(); - 425
// Reset-with-handoff after the first turn: everything before it is - 426
// invisible to the model; the second turn, appended after, stays - 427
// verbatim. - 428
log.append_handoff_reset("summary of the WEF research turn".into(), 999) - 429
.unwrap(); - 430
log.append_goal_update(vak_intent::GoalUpdate { - 431
revision: 2, - 432
relation: vak_intent::GoalRelation::AddsTo, - 433
request: "use python sandbox".into(), - 434
supersedes_revision: None, - 435
explicit: false, - 436
}) - 437
.unwrap(); - 438
log.append_message(MessageRecord { - 439
message: Message::user_text("use python sandbox"), - 440
meta: None, - 441
}) - 442
.unwrap(); - 443
log.append_message(MessageRecord { - 444
message: Message::assistant(vec![ContentBlock::text("running in sandbox")]), - 445
meta: None, - 446
}) - 447
.unwrap(); - 448
- 449
let provider = Arc::new(Recording::new(vec![text_msg( - 450
"the global economy grew modestly", - 451
)])); - 452
let mut cfg = AgentConfig::new("sys"); - 453
cfg.model = "test-model".into(); - 454
cfg.mode = Mode::FullAccess; - 455
cfg.permission = Some(Arc::new(PermissionEngine::default())); - 456
cfg.approver = Some(Arc::new(AutoApprove)); - 457
cfg.retry_base_backoff_ms = 1; - 458
cfg.run_retry_base_backoff_ms = 1; - 459
let mut agent = Agent::new(provider.clone(), log, cfg); - 460
- 461
let outcome = run(&mut agent, "now evaluate global GDP past 5 years").await; - 462
assert!(matches!(outcome, TurnOutcome::Completed { .. })); - 463
- 464
let requests = provider.requests(); - 465
assert_eq!(requests.len(), 1); - 466
let last = requests[0].messages.last().unwrap(); - 467
// The tail is the text block before the directive (§6). - 468
let tail = last - 469
.content - 470
.iter() - 471
.filter_map(|b| match b { - 472
ContentBlock::Text { text } => Some(text.clone()), - 473
_ => None, - 474
}) - 475
.find(|text| text.contains("<conversation_thread")) - 476
.expect("thread section present for a reset-hidden directive"); - 477
- 478
let thread_start = tail.find("<conversation_thread").unwrap_or(0); - 479
let thread_text = &tail[thread_start..]; - 480
assert!( - 481
thread_text.contains("research on WEF"), - 482
"the reset-hidden directive must resurface: {thread_text}" - 483
); - 484
assert!( - 485
!thread_text.contains("use python sandbox"), - 486
"a directive still verbatim in the working set must not repeat: {thread_text}" - 487
); - 488
assert!( - 489
!thread_text.contains("now evaluate global GDP"), - 490
"the turn's own directive is already in `messages` and must not repeat: {thread_text}" - 491
); - 492
} - 493
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.