- 1
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] - 2
- 3
//! A worker's cards reach the conversation that delegated to it, and an - 4
//! over-long tool result is recorded whole behind the window its request - 5
//! carries (docs/design/68-context-engine.md §3, §10). - 6
- 7
use std::collections::VecDeque; - 8
use std::sync::{Arc, Mutex}; - 9
- 10
use async_trait::async_trait; - 11
use tokio::sync::mpsc; - 12
use tokio_util::sync::CancellationToken; - 13
- 14
use tempfile::tempdir; - 15
- 16
use vak_agent::{Agent, AgentConfig, TaskDeps, TaskTool, TurnOutcome}; - 17
use vak_llm::stream; - 18
use vak_llm::types::{AssistantMessage, ChatRequest, ContentBlock, StopReason, Usage}; - 19
use vak_llm::{EventStream, LlmError, Provider}; - 20
use vak_permission::{Mode, PermissionEngine}; - 21
use vak_session::types::{EntryPayload, FrozenContract, PresentationSource, SessionHeader}; - 22
use vak_session::{SessionLog, SessionPath}; - 23
use vak_tools::context::ToolContext; - 24
use vak_tools::{PresentationCard, Tool, ToolOutput}; - 25
- 26
struct Scripted { - 27
responses: Mutex<VecDeque<AssistantMessage>>, - 28
requests: Mutex<Vec<ChatRequest>>, - 29
} - 30
- 31
#[async_trait] - 32
impl Provider for Scripted { - 33
fn name(&self) -> &str { - 34
"scripted" - 35
} - 36
- 37
async fn stream( - 38
&self, - 39
request: ChatRequest, - 40
_cancel: CancellationToken, - 41
) -> Result<EventStream, LlmError> { - 42
self.requests.lock().unwrap().push(request); - 43
let next = self.responses.lock().unwrap().pop_front(); - 44
let (mut sink, rx) = stream::channel(64); - 45
match next { - 46
Some(m) => { - 47
sink.push(stream::StreamEvent::Start { partial: m.clone() }); - 48
sink.close_message(m).await; - 49
} - 50
None => sink.close_error(LlmError::Parse("exhausted".into())).await, - 51
} - 52
Ok(rx) - 53
} - 54
} - 55
- 56
fn scripted(responses: Vec<AssistantMessage>) -> Arc<Scripted> { - 57
Arc::new(Scripted { - 58
responses: Mutex::new(VecDeque::from(responses)), - 59
requests: Mutex::new(Vec::new()), - 60
}) - 61
} - 62
- 63
fn text_msg(t: &str) -> AssistantMessage { - 64
AssistantMessage { - 65
content: vec![ContentBlock::text(t)], - 66
stop_reason: StopReason::EndTurn, - 67
usage: Usage { - 68
input_tokens: 1, - 69
output_tokens: 1, - 70
..Default::default() - 71
}, - 72
model: "test-model".into(), - 73
response_id: None, - 74
} - 75
} - 76
- 77
fn call(id: &str, name: &str, input: serde_json::Value) -> AssistantMessage { - 78
AssistantMessage { - 79
content: vec![ContentBlock::ToolUse { - 80
id: id.into(), - 81
name: name.into(), - 82
input, - 83
}], - 84
stop_reason: StopReason::ToolUse, - 85
usage: Usage::default(), - 86
model: "test-model".into(), - 87
response_id: None, - 88
} - 89
} - 90
- 91
struct FakeEmitChartCard; - 92
- 93
#[async_trait] - 94
impl Tool for FakeEmitChartCard { - 95
fn name(&self) -> &str { - 96
"emit_chart_card" - 97
} - 98
fn description(&self) -> &str { - 99
"test stand-in" - 100
} - 101
fn schema(&self) -> serde_json::Value { - 102
serde_json::json!({"type": "object"}) - 103
} - 104
fn presents_cards(&self) -> bool { - 105
true - 106
} - 107
async fn execute(&self, _args: &serde_json::Value, _ctx: &ToolContext) -> ToolOutput { - 108
ToolOutput::ok("shown") - 109
} - 110
} - 111
- 112
/// Returns 5,000 numbered lines — far past what one request carries whole. - 113
struct BigOutput; - 114
- 115
#[async_trait] - 116
impl Tool for BigOutput { - 117
fn name(&self) -> &str { - 118
"big_output" - 119
} - 120
fn description(&self) -> &str { - 121
"test stand-in" - 122
} - 123
fn schema(&self) -> serde_json::Value { - 124
serde_json::json!({"type": "object"}) - 125
} - 126
async fn execute(&self, _args: &serde_json::Value, _ctx: &ToolContext) -> ToolOutput { - 127
ToolOutput::ok(big_output()) - 128
} - 129
} - 130
- 131
fn big_output() -> String { - 132
(1..=5000) - 133
.map(|n| format!("record {n:05}")) - 134
.collect::<Vec<_>>() - 135
.join("\n") - 136
} - 137
- 138
fn fake_rebuild() -> vak_agent::PresentationRebuild { - 139
Arc::new(|_name, input| { - 140
let payload = vak_session::types::canonicalize_json(input.get("payload")?); - 141
Some(PresentationCard { - 142
semantic_type: "chart".into(), - 143
skill_id: "test".into(), - 144
skill_version: "1".into(), - 145
schema_version: 1, - 146
title: payload.get("title")?.as_str()?.to_string(), - 147
payload, - 148
identity_digest: "digest".into(), - 149
}) - 150
}) - 151
} - 152
- 153
fn header(dir: &tempfile::TempDir, session_id: &str) -> SessionHeader { - 154
SessionHeader { - 155
agent: None, - 156
session_id: session_id.into(), - 157
created_at: chrono::Utc::now(), - 158
cwd: dir.path().to_path_buf(), - 159
parent_session_id: None, - 160
contract_id: None, - 161
work_item_id: None, - 162
conversation: None, - 163
contract: FrozenContract { - 164
app_version: "0".into(), - 165
provider: "scripted".into(), - 166
model: "test-model".into(), - 167
route_ladder: Vec::new(), - 168
route_objective: String::new(), - 169
route_annotations: Vec::new(), - 170
system_prompt: "sys".into(), - 171
permission_mode: "full-access".into(), - 172
capabilities: Vec::new(), - 173
prompt_layers: Vec::new(), - 174
}, - 175
} - 176
} - 177
- 178
fn agent( - 179
dir: &tempfile::TempDir, - 180
session_id: &str, - 181
provider: Arc<Scripted>, - 182
tools: Vec<Arc<dyn Tool>>, - 183
) -> Agent { - 184
let home = dir.path().join("home"); - 185
std::fs::create_dir_all(&home).unwrap(); - 186
let log = SessionLog::create( - 187
SessionPath::new_session_file(&home, dir.path(), session_id), - 188
header(dir, session_id), - 189
) - 190
.unwrap(); - 191
let mut cfg = AgentConfig::new("sys"); - 192
cfg.model = "test-model".into(); - 193
cfg.mode = Mode::FullAccess; - 194
cfg.permission = Some(Arc::new(PermissionEngine::default())); - 195
cfg.approver = Some(Arc::new(vak_agent::AutoApprove)); - 196
cfg.tools = tools; - 197
Agent::new(provider, log, cfg) - 198
} - 199
- 200
fn task_tool(dir: &tempfile::TempDir, provider: Arc<Scripted>, parent: &str) -> Arc<dyn Tool> { - 201
Arc::new(TaskTool::new(TaskDeps { - 202
parent_agent_identity: None, - 203
role_prompts: Default::default(), - 204
provider, - 205
system_prompt: "child-sys".into(), - 206
tail: Default::default(), - 207
model: "test-model".into(), - 208
tools: vec![Arc::new(FakeEmitChartCard)], - 209
capabilities: Vec::new(), - 210
hooks: None, - 211
revocation_check: None, - 212
presentation_rebuild: Some(fake_rebuild()), - 213
mcp_tool_index: None, - 214
input_normalizer: None, - 215
read_only_tools: Vec::new(), - 216
max_turns: 5, - 217
outcome_objective: None, - 218
outcome: None, - 219
max_retries: 0, - 220
retry_base_backoff_ms: 0, - 221
request_timeout: None, - 222
circuit_breaker: None, - 223
run_retry_attempts: 0, - 224
run_retry_base_backoff_ms: 0, - 225
dispatch_ceiling: 1, - 226
spend_gate: None, - 227
permission: Some(Arc::new(PermissionEngine::default())), - 228
mode: Mode::FullAccess, - 229
approval_mode: vak_agent::ApprovalMode::Ask, - 230
approver: Some(Arc::new(vak_agent::AutoApprove)), - 231
sandbox: None, - 232
cwd: dir.path().to_path_buf(), - 233
sessions_home: dir.path().join("home"), - 234
parent_session_id: parent.into(), - 235
contract_id: None, - 236
work_item_id: None, - 237
work_item_ids: vec![], - 238
events: None, - 239
registry: None, - 240
})) - 241
} - 242
- 243
async fn run(agent: &mut Agent, prompt: &str) -> TurnOutcome { - 244
agent - 245
.run( - 246
prompt, - 247
&Default::default(), - 248
CancellationToken::new(), - 249
mpsc::channel(256).0, - 250
) - 251
.await - 252
} - 253
- 254
fn tool_result(session: &SessionLog, id: &str) -> String { - 255
session - 256
.message_chain() - 257
.iter() - 258
.flat_map(|(_, m)| m.content.iter()) - 259
.find_map(|block| match block { - 260
ContentBlock::ToolResult { - 261
tool_use_id, - 262
content, - 263
.. - 264
} if tool_use_id == id => Some(content.clone()), - 265
_ => None, - 266
}) - 267
.expect("tool result recorded") - 268
} - 269
- 270
#[tokio::test] - 271
async fn a_workers_card_is_shown_and_recallable_in_the_delegating_conversation() { - 272
let dir = tempdir().unwrap(); - 273
let provider = scripted(vec![ - 274
call( - 275
"t1", - 276
"task", - 277
serde_json::json!({"prompt": "chart the sales"}), - 278
), - 279
call( - 280
"c1", - 281
"emit_chart_card", - 282
serde_json::json!({"payload": {"title": "Sales by month", "series": [1, 2, 3]}}), - 283
), - 284
text_msg("charted"), - 285
text_msg("the worker charted it"), - 286
]); - 287
let task = task_tool(&dir, provider.clone(), "parent-cards"); - 288
let mut parent = agent(&dir, "parent-cards", provider.clone(), vec![task]); - 289
let outcome = run(&mut parent, "chart the sales by month").await; - 290
assert!( - 291
matches!(outcome, TurnOutcome::Completed { .. }), - 292
"{outcome:?}" - 293
); - 294
- 295
let session = parent.session.lock().await; - 296
let delegated: Vec<(String, _)> = session - 297
.presentations() - 298
.into_iter() - 299
.filter(|(_, record)| { - 300
matches!(&record.source, PresentationSource::Delegated { tool_use_id, .. } if tool_use_id == "t1") - 301
}) - 302
.collect(); - 303
assert_eq!( - 304
delegated.len(), - 305
1, - 306
"the worker's card is in the parent ledger" - 307
); - 308
let (presentation_id, record) = &delegated[0]; - 309
assert_eq!(record.title, "Sales by month"); - 310
assert!(record.derived_from.contains(&"t1".to_string())); - 311
- 312
let result = tool_result(&session, "t1"); - 313
assert!(result.starts_with("charted"), "{result}"); - 314
assert!( - 315
result.contains(&format!( - 316
"- pres:{presentation_id} chart \"Sales by month\"" - 317
)), - 318
"the task result lists the card by id: {result}" - 319
); - 320
let payload = record.payload.clone(); - 321
let presentation_id = presentation_id.clone(); - 322
drop(session); - 323
- 324
// The delegating agent opens the worker's card to review it. - 325
provider.responses.lock().unwrap().extend([ - 326
call( - 327
"r1", - 328
"recall", - 329
serde_json::json!({"presentation": presentation_id}), - 330
), - 331
text_msg("reviewed"), - 332
]); - 333
parent.config.tools.push(Arc::new(vak_tools::RecallTool)); - 334
let outcome = run(&mut parent, "check the chart the worker made").await; - 335
assert!( - 336
matches!(outcome, TurnOutcome::Completed { .. }), - 337
"{outcome:?}" - 338
); - 339
let session = parent.session.lock().await; - 340
assert_eq!(tool_result(&session, "r1"), payload.to_string()); - 341
} - 342
- 343
#[tokio::test] - 344
async fn an_over_long_result_is_windowed_in_the_request_and_whole_in_the_ledger() { - 345
let dir = tempdir().unwrap(); - 346
let provider = scripted(vec![ - 347
call("b1", "big_output", serde_json::json!({})), - 348
call( - 349
"r1", - 350
"recall", - 351
serde_json::json!({"id": "b1", "range": {"start": 3000, "end": 3002}}), - 352
), - 353
text_msg("found it"), - 354
]); - 355
let mut agent = agent( - 356
&dir, - 357
"windowed", - 358
provider.clone(), - 359
vec![Arc::new(BigOutput), Arc::new(vak_tools::RecallTool)], - 360
); - 361
let outcome = run(&mut agent, "read the big output").await; - 362
assert!( - 363
matches!(outcome, TurnOutcome::Completed { .. }), - 364
"{outcome:?}" - 365
); - 366
- 367
let session = agent.session.lock().await; - 368
let carried = tool_result(&session, "b1"); - 369
assert!(carried.chars().count() <= vak_tools::RESULT_WINDOW_CHARS + 200); - 370
assert!(carried.starts_with("record 00001\n") && carried.ends_with("record 05000")); - 371
assert!( - 372
carried.contains("omitted") && carried.contains("recall {\"id\": \"b1\""), - 373
"the window says what it left out and how to read it" - 374
); - 375
assert!(!carried.contains("record 03000")); - 376
- 377
let body = session - 378
.chain_to_root() - 379
.into_iter() - 380
.find_map(|entry| match &entry.payload { - 381
EntryPayload::EvidenceBody(body) if body.tool_use_id == "b1" => { - 382
Some(body.content.clone()) - 383
} - 384
_ => None, - 385
}) - 386
.expect("the whole result is in the ledger"); - 387
assert_eq!(body, big_output()); - 388
assert_eq!( - 389
session.evidence("b1").expect("evidence").content, - 390
big_output() - 391
); - 392
- 393
assert_eq!( - 394
tool_result(&session, "r1"), - 395
"record 03000\nrecord 03001\nrecord 03002", - 396
"recall returns the omitted lines exactly" - 397
); - 398
- 399
let last_request = provider.requests.lock().unwrap().last().cloned().unwrap(); - 400
let carried_in_request = last_request - 401
.messages - 402
.iter() - 403
.flat_map(|m| m.content.iter()) - 404
.find_map(|block| match block { - 405
ContentBlock::ToolResult { - 406
tool_use_id, - 407
content, - 408
.. - 409
} if tool_use_id == "b1" => Some(content.clone()), - 410
_ => None, - 411
}) - 412
.expect("the result is in the next request"); - 413
assert_eq!( - 414
carried_in_request, carried, - 415
"the request carries what the ledger logged" - 416
); - 417
} - 418
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.