- 1
use std::collections::{BTreeMap, HashMap, HashSet}; - 2
use std::sync::{Mutex, OnceLock}; - 3
- 4
pub(crate) use vak_intent::control::{clean_scaffolding, is_scaffolding_line}; - 5
- 6
use vak_agent::AgentEvent; - 7
use vak_delivery::{ - 8
ArtifactRef, ArtifactStatus, DeliveryAction, OutputContent, OutputItem, OutputKind, - 9
OutputProvenance, OutputRole, OutputStatus, OutputStreamEvent, OutputTimeline, - 10
PresentationDocument, PresentationPlanner, ResultOutcome, SignalContext, built_in_adapters, - 11
compile_markdown, link_previews_from_text, signals_from_context, structured_markdown, - 12
structured_outputs_from_text, structured_outputs_from_tool_result_with, - 13
}; - 14
- 15
fn status_for_completion(completion: Option<&str>) -> OutputStatus { - 16
if completion.is_none() || completion.is_some_and(|value| value.trim() == "complete") { - 17
OutputStatus::Succeeded - 18
} else { - 19
OutputStatus::Partial - 20
} - 21
} - 22
- 23
fn result_outcome( - 24
result_id: impl Into<String>, - 25
status: OutputStatus, - 26
admitted: Option<&vak_intent::OutcomeSpec>, - 27
evaluation: Option<&str>, - 28
evidence_state: Option<&String>, - 29
human_review: Option<&String>, - 30
) -> ResultOutcome { - 31
let completion = evaluation - 32
.and_then(|value| value.split('|').nth(3)) - 33
.map(str::to_owned); - 34
ResultOutcome { - 35
result_id: result_id.into(), - 36
status, - 37
completion, - 38
evidence_state: evidence_state.cloned(), - 39
requirement_ids: admitted - 40
.map(|outcome| { - 41
outcome - 42
.requirements - 43
.iter() - 44
.map(|item| item.id.clone()) - 45
.collect() - 46
}) - 47
.unwrap_or_default(), - 48
evidence_receipt_ids: evaluation - 49
.and_then(|value| value.split('|').nth(2)) - 50
.map(|value| { - 51
value - 52
.split(',') - 53
.filter(|item| !item.is_empty()) - 54
.map(str::to_owned) - 55
.collect() - 56
}) - 57
.unwrap_or_default(), - 58
evidence: Vec::new(), - 59
human_review: human_review.cloned(), - 60
} - 61
} - 62
use vak_llm::{ContentBlock, Role}; - 63
use vak_session::{ActivityKind, ActivityStatus, EntryPayload, SessionLog}; - 64
- 65
fn sandbox_artifact_actions( - 66
execution_id: &str, - 67
path: &str, - 68
reviewable: bool, - 69
) -> Vec<DeliveryAction> { - 70
let mut open_data = BTreeMap::new(); - 71
open_data.insert("path".into(), path.into()); - 72
let mut review_data = BTreeMap::new(); - 73
review_data.insert("execution_id".into(), execution_id.into()); - 74
let mut actions = vec![DeliveryAction { - 75
id: format!("open-{execution_id}-{path}"), - 76
label: "Open".into(), - 77
verb: "open_artifact".into(), - 78
data: open_data, - 79
}]; - 80
if reviewable { - 81
actions.push(DeliveryAction { - 82
id: format!("review-{execution_id}"), - 83
label: "Review draft".into(), - 84
verb: "review_draft".into(), - 85
data: review_data, - 86
}); - 87
} - 88
actions - 89
} - 90
- 91
/// One tool call as the timeline needs it: name, input, result text, and - 92
/// whether the result was an error. Named because the inline tuple was wide - 93
/// enough that a reader had to count commas to find the error flag. - 94
type TurnTool = (String, String, serde_json::Value, Option<String>, bool); - 95
- 96
/// The text a surface that cannot render cards natively — a chat channel, a - 97
/// webhook, the inbox, a scheduled routine's summary — gets for a finished run. - 98
/// - 99
/// A card emitted through an `emit_*_card` call is not in the model's final - 100
/// text (that is only a line of narration), so delivering just that text drops - 101
/// the card entirely. This takes the cards of the latest turn from the same - 102
/// projection the desktop renders (so retries are superseded and nothing is - 103
/// counted twice) and puts their deterministic text form ahead of the - 104
/// narration. - 105
pub(crate) fn text_with_run_cards(session: &SessionLog, narration: String) -> String { - 106
let session_id = session - 107
.header() - 108
.map(|header| header.session_id.clone()) - 109
.unwrap_or_default(); - 110
let timeline = snapshot(&session_id, session); - 111
let Some(turn) = timeline - 112
.items - 113
.iter() - 114
.rev() - 115
.find(|item| item.role == OutputRole::User) - 116
.map(|item| item.turn_id.clone()) - 117
else { - 118
return narration; - 119
}; - 120
let cards: Vec<&str> = timeline - 121
.items - 122
.iter() - 123
.filter(|item| { - 124
item.turn_id == turn - 125
&& item.kind == OutputKind::Card - 126
&& item - 127
.provenance - 128
.as_ref() - 129
.and_then(|p| p.source.as_deref()) - 130
.is_some_and(|source| source.starts_with("emit_") && source.ends_with("_card")) - 131
&& matches!( - 132
item.content, - 133
OutputContent::Structured { .. } | OutputContent::Adaptive { .. } - 134
) - 135
}) - 136
.map(|item| item.fallback_text.trim()) - 137
.filter(|text| !text.is_empty()) - 138
.collect(); - 139
if cards.is_empty() { - 140
return narration; - 141
} - 142
let cards = cards.join("\n\n"); - 143
match vak_delivery::supplemental_card_note(&narration) { - 144
Some(note) => format!("{cards}\n\n{note}"), - 145
None => cards, - 146
} - 147
} - 148
- 149
pub(crate) fn snapshot(session_id: &str, session: &SessionLog) -> OutputTimeline { - 150
let builtin = PresentationPlanner { - 151
skills: vak_delivery::built_in_skill_registry(), - 152
recipes: vak_delivery::built_in_recipes(), - 153
}; - 154
snapshot_inner(session_id, session, &builtin, None) - 155
} - 156
- 157
/// Like [`snapshot`] but uses a plugin-merged `PresentationPlanner` so that - 158
/// domain-specific recipes and semantic types are recognized during - 159
/// live projection. Callers with Core access should use this for SSE and - 160
/// live session handles; historical views without Core can use `snapshot`. - 161
pub(crate) fn snapshot_with_planner( - 162
session_id: &str, - 163
session: &SessionLog, - 164
planner: &PresentationPlanner, - 165
) -> OutputTimeline { - 166
snapshot_inner(session_id, session, planner, None) - 167
} - 168
- 169
/// Live projection variant with the persisted adaptive library. The legacy - 170
/// planner remains authoritative for validation; the library only contributes - 171
/// an optional, auditable selection reference to the same document. - 172
pub(crate) fn snapshot_with_planner_and_library( - 173
session_id: &str, - 174
session: &SessionLog, - 175
planner: &PresentationPlanner, - 176
library: &vak_presentation::PresentationLibrary, - 177
) -> OutputTimeline { - 178
snapshot_inner(session_id, session, planner, Some(library)) - 179
} - 180
- 181
/// Rehydrates sandbox-created artifacts into historical presentation views. - 182
/// Sandbox events are persisted in a sidecar (because they are high-volume - 183
/// telemetry), so they must be projected explicitly when a session is opened - 184
/// after its live event bus is gone. - 185
pub(crate) fn append_sandbox_artifacts( - 186
timeline: &mut OutputTimeline, - 187
home: &std::path::Path, - 188
session_id: &str, - 189
) { - 190
// A sandbox execution id is the brokered tool-call id. Resolve it back - 191
// to the durable turn before appending sidecar artifacts, so the result - 192
// stays one coherent turn on reconnect. The old synthetic - 193
// `sandbox-{execution_id}` turn forced clients to guess the association - 194
// from prose and file paths. - 195
let execution_context: HashMap<String, (String, String)> = timeline - 196
.items - 197
.iter() - 198
.filter_map(|item| { - 199
item.provenance - 200
.as_ref()? - 201
.tool_call_id - 202
.as_ref() - 203
.map(|id| (id.clone(), (item.turn_id.clone(), item.timestamp.clone()))) - 204
}) - 205
.collect(); - 206
let result_by_turn: HashMap<String, ResultOutcome> = timeline - 207
.items - 208
.iter() - 209
.filter(|item| item.role == OutputRole::Assistant) - 210
.filter_map(|item| { - 211
item.outcome - 212
.clone() - 213
.map(|outcome| (item.turn_id.clone(), outcome)) - 214
}) - 215
.collect(); - 216
let path = home - 217
.join("sandbox") - 218
.join("executions") - 219
.join(format!("{session_id}.jsonl")); - 220
let Ok(text) = std::fs::read_to_string(path) else { - 221
return; - 222
}; - 223
let events = text - 224
.lines() - 225
.filter_map(|line| serde_json::from_str::<vak_tools::SandboxEvent>(line).ok()) - 226
.collect::<Vec<_>>(); - 227
let reviewable_roots = events - 228
.iter() - 229
.filter_map(|event| match event { - 230
vak_tools::SandboxEvent::ExecutionStarted { - 231
execution_id, - 232
scratch_dir, - 233
.. - 234
} if std::path::Path::new(scratch_dir).is_dir() => { - 235
Some((execution_id.clone(), scratch_dir.clone())) - 236
} - 237
_ => None, - 238
}) - 239
.collect::<HashMap<_, _>>(); - 240
// Unreadable records leave every draft's status unknown rather than - 241
// reporting a version the records may contradict. - 242
let drafts = vak_sandbox::load_records(&home.join("sandbox").join("records.jsonl")) - 243
.ok() - 244
.map(|records| DraftVersions::new(records, session_id)); - 245
for event in events { - 246
let vak_tools::SandboxEvent::ArtifactGenerated { - 247
execution_id, - 248
path, - 249
mime_type, - 250
size_bytes, - 251
} = event - 252
else { - 253
continue; - 254
}; - 255
let id = format!("artifact-{execution_id}-{path}"); - 256
if timeline.items.iter().any(|item| item.id == id) { - 257
continue; - 258
} - 259
let name = std::path::Path::new(&path) - 260
.file_name() - 261
.and_then(|name| name.to_str()) - 262
.unwrap_or(&path) - 263
.to_string(); - 264
let (turn_id, timestamp) = execution_context - 265
.get(&execution_id) - 266
.cloned() - 267
.unwrap_or_else(|| { - 268
( - 269
format!("sandbox-{execution_id}"), - 270
"1970-01-01T00:00:00+00:00".into(), - 271
) - 272
}); - 273
// Only an execution that ran inside `.vak/scratch/` holds a draft to - 274
// review; one that worked in the workspace already put its files - 275
// where they belong, and candidate export refuses it. - 276
let scratch = reviewable_roots - 277
.get(&execution_id) - 278
.filter(|root| draft_relative_path(&path, root).is_some()); - 279
let reviewable = scratch.is_some(); - 280
let status = match scratch { - 281
Some(root) => drafts - 282
.as_ref() - 283
.map(|drafts| drafts.status(&execution_id, &path, root)), - 284
None => Some(ArtifactStatus::InFolder), - 285
}; - 286
// A successful write/edit is already projected from the durable tool - 287
// result. Its sandbox event is stronger evidence about the same file, - 288
// not a second artifact. Merge the sidecar metadata and actions so a - 289
// result presents one file and one Canvas entry point. - 290
if let Some(existing) = timeline.items.iter_mut().find(|item| { - 291
if item.kind != OutputKind::Artifact - 292
|| item - 293
.provenance - 294
.as_ref() - 295
.and_then(|p| p.tool_call_id.as_deref()) - 296
!= Some(execution_id.as_str()) - 297
{ - 298
return false; - 299
} - 300
let OutputContent::Artifact { artifact } = &item.content else { - 301
return false; - 302
}; - 303
artifact.path.as_deref().is_some_and(|existing_path| { - 304
let existing = std::path::Path::new(existing_path); - 305
let observed = std::path::Path::new(&path); - 306
existing == observed || existing.ends_with(observed) || observed.ends_with(existing) - 307
}) - 308
}) { - 309
existing.turn_id = turn_id.clone(); - 310
existing.timestamp = timestamp; - 311
existing.outcome = result_by_turn.get(&turn_id).cloned(); - 312
existing.actions = sandbox_artifact_actions(&execution_id, &path, reviewable); - 313
existing.fallback_text = format!("Generated artifact: {path}"); - 314
if let OutputContent::Artifact { artifact } = &mut existing.content { - 315
artifact.path = Some(path.clone()); - 316
artifact.media_type = Some(mime_type); - 317
artifact.description = None; - 318
artifact.size_bytes = Some(size_bytes); - 319
artifact.status = status; - 320
} - 321
continue; - 322
} - 323
timeline.items.push(OutputItem { - 324
id, - 325
timestamp, - 326
turn_id: turn_id.clone(), - 327
role: OutputRole::Tool, - 328
kind: OutputKind::Artifact, - 329
status: OutputStatus::Succeeded, - 330
outcome: result_by_turn.get(&turn_id).cloned(), - 331
content: OutputContent::Artifact { - 332
artifact: ArtifactRef { - 333
name, - 334
path: Some(path.clone()), - 335
media_type: Some(mime_type), - 336
description: None, - 337
size_bytes: Some(size_bytes), - 338
status, - 339
}, - 340
}, - 341
provenance: Some(OutputProvenance { - 342
session_id: Some(session_id.to_string()), - 343
entry_id: None, - 344
tool_call_id: Some(execution_id.clone()), - 345
source: Some("sandbox_artifact".into()), - 346
presentation_id: None, - 347
}), - 348
actions: sandbox_artifact_actions(&execution_id, &path, reviewable), - 349
fallback_text: format!("Generated artifact: {path}"), - 350
}); - 351
} - 352
deduplicate_file_artifacts(&mut timeline.items); - 353
} - 354
- 355
/// The saved versions of each execution's draft in one conversation, in the - 356
/// order the durable records hold them. Versions of one draft are - 357
/// alternatives, so an acceptance settles every version saved before it and - 358
/// a version saved afterwards starts a new round; undoing an acceptance - 359
/// reopens its round. This is the rule Review applies - 360
/// (`vak-client-ui/src/candidateVersions.ts`), so the card and Review agree. - 361
struct DraftVersions { - 362
versions: HashMap<String, Vec<vak_sandbox::CandidateManifest>>, - 363
/// Per execution, the version index a live (not undone) acceptance - 364
/// settled, and whether a version was saved after it. - 365
accepted: HashMap<String, (usize, bool)>, - 366
} - 367
- 368
impl DraftVersions { - 369
fn new(records: Vec<vak_sandbox::DurableRecord>, session_id: &str) -> Self { - 370
use vak_sandbox::DurableRecord; - 371
let undone: HashSet<String> = records - 372
.iter() - 373
.filter_map(|record| match record { - 374
DurableRecord::PromotionUndo(undo) if undo.session_id == session_id => { - 375
Some(undo.candidate_id.clone()) - 376
} - 377
_ => None, - 378
}) - 379
.collect(); - 380
let mut owner: HashMap<String, (String, usize)> = HashMap::new(); - 381
let mut versions: HashMap<String, Vec<vak_sandbox::CandidateManifest>> = HashMap::new(); - 382
let mut accepted: HashMap<String, (usize, bool)> = HashMap::new(); - 383
for record in records { - 384
match record { - 385
DurableRecord::Candidate(candidate) if candidate.session_id == session_id => { - 386
let list = versions.entry(candidate.execution_id.clone()).or_default(); - 387
owner.insert( - 388
candidate.candidate.candidate_id.clone(), - 389
(candidate.execution_id.clone(), list.len()), - 390
); - 391
list.push(candidate.candidate); - 392
if let Some(state) = accepted.get_mut(&candidate.execution_id) { - 393
state.1 = true; - 394
} - 395
} - 396
DurableRecord::Promotion(promotion) - 397
if promotion.session_id == session_id - 398
&& !undone.contains(&promotion.candidate_id) => - 399
{ - 400
if let Some((execution, index)) = owner.get(&promotion.candidate_id) { - 401
accepted.insert(execution.clone(), (*index, false)); - 402
} - 403
} - 404
_ => {} - 405
} - 406
} - 407
Self { versions, accepted } - 408
} - 409
- 410
/// The status of the file at `path`, produced by `execution_id` in the - 411
/// scratch directory `scratch`. - 412
fn status(&self, execution_id: &str, path: &str, scratch: &str) -> ArtifactStatus { - 413
let versions = self - 414
.versions - 415
.get(execution_id) - 416
.map(Vec::as_slice) - 417
.unwrap_or_default(); - 418
let saved_as = |index: usize| { - 419
let relative = draft_relative_path(path, scratch)?; - 420
versions - 421
.get(index)? - 422
.files - 423
.iter() - 424
.find(|file| std::path::Path::new(&file.path) == relative) - 425
.map(|file| vak_delivery::VersionFile { - 426
version_id: versions[index].candidate_id.clone(), - 427
path: file.path.clone(), - 428
}) - 429
}; - 430
match self.accepted.get(execution_id) { - 431
Some(&(index, false)) => ArtifactStatus::Accepted { - 432
version: version_number(index), - 433
saved_as: saved_as(index), - 434
}, - 435
_ if versions.is_empty() => ArtifactStatus::Draft { - 436
version: 1, - 437
saved_as: None, - 438
}, - 439
_ => ArtifactStatus::Draft { - 440
version: version_number(versions.len() - 1), - 441
saved_as: saved_as(versions.len() - 1), - 442
}, - 443
} - 444
} - 445
} - 446
- 447
fn version_number(index: usize) -> u32 { - 448
u32::try_from(index).map_or(u32::MAX, |index| index.saturating_add(1)) - 449
} - 450
- 451
/// `path` (absolute, or relative to the workspace) relative to the scratch - 452
/// directory it was written in, which is how a saved version names it. - 453
fn draft_relative_path<'a>(path: &'a str, scratch: &str) -> Option<&'a std::path::Path> { - 454
let artifact = std::path::Path::new(path); - 455
let scratch = std::path::Path::new(scratch); - 456
if artifact.is_absolute() { - 457
return artifact.strip_prefix(scratch).ok(); - 458
} - 459
let workspace = scratch - 460
.ancestors() - 461
.find(|p| p.file_name().is_some_and(|n| n == ".vak")) - 462
.and_then(std::path::Path::parent)?; - 463
artifact - 464
.strip_prefix(scratch.strip_prefix(workspace).ok()?) - 465
.ok() - 466
} - 467
- 468
/// Repeated successful writes to one file within a turn are revisions of the - 469
/// same deliverable. Keep the newest observed artifact and its actions, while - 470
/// leaving identically named files in other turns or directories distinct. - 471
fn deduplicate_file_artifacts(items: &mut Vec<OutputItem>) { - 472
let mut seen = HashSet::new(); - 473
let mut latest_first = Vec::with_capacity(items.len()); - 474
for item in items.drain(..).rev() { - 475
let duplicate = if let OutputContent::Artifact { artifact } = &item.content { - 476
artifact - 477
.path - 478
.as_ref() - 479
.is_some_and(|path| !seen.insert((item.turn_id.clone(), path.clone()))) - 480
} else { - 481
false - 482
}; - 483
if !duplicate { - 484
latest_first.push(item); - 485
} - 486
} - 487
latest_first.reverse(); - 488
*items = latest_first; - 489
} - 490
- 491
/// Returns a concise, transport-neutral artifact list for channel delivery. - 492
/// The browser can resolve richer previews, while text surfaces still need an - 493
/// explicit record that the generated files exist and where they live. - 494
pub(crate) fn sandbox_artifact_markdown( - 495
home: &std::path::Path, - 496
session_id: &str, - 497
) -> Option<String> { - 498
let path = home - 499
.join("sandbox") - 500
.join("executions") - 501
.join(format!("{session_id}.jsonl")); - 502
let text = std::fs::read_to_string(path).ok()?; - 503
let mut rows = Vec::new(); - 504
for line in text.lines() { - 505
let Ok(event) = serde_json::from_str::<vak_tools::SandboxEvent>(line) else { - 506
continue; - 507
}; - 508
let vak_tools::SandboxEvent::ArtifactGenerated { - 509
path, - 510
mime_type, - 511
size_bytes, - 512
.. - 513
} = event - 514
else { - 515
continue; - 516
}; - 517
let row = format!("- `{path}` ({mime_type}, {size_bytes} bytes)"); - 518
if !rows.contains(&row) { - 519
rows.push(row); - 520
} - 521
} - 522
(!rows.is_empty()).then(|| format!("\n\nGenerated artifacts\n\n{}", rows.join("\n"))) - 523
} - 524
- 525
/// Declared domains for `tool_name`, accumulated from the chain's - 526
/// `TurnCapabilitiesBound` entries — never guessed from the tool's name - 527
/// (docs/design/68-context-engine.md §9's `SignalContext.domains` note). - 528
fn tool_domain_refs<'a>( - 529
tool_domains: &'a HashMap<String, Vec<String>>, - 530
tool_name: Option<&str>, - 531
) -> Vec<&'a str> { - 532
tool_name - 533
.and_then(|name| tool_domains.get(name)) - 534
.map(|domains| domains.iter().map(String::as_str).collect()) - 535
.unwrap_or_default() - 536
} - 537
- 538
fn snapshot_inner( - 539
session_id: &str, - 540
session: &SessionLog, - 541
planner: &PresentationPlanner, - 542
adaptive_library: Option<&vak_presentation::PresentationLibrary>, - 543
) -> OutputTimeline { - 544
let chain = session.chain_to_root(); - 545
// Presentations are ledger entries (docs/design/68-context-engine.md - 546
// §10): the card a tool call displayed is read from its own written - 547
// entry, keyed by `tool_use_id` — never rebuilt from the call's - 548
// arguments at display time. - 549
// The map's value keeps the Presentation entry's OWN id alongside the - 550
// record: `presentation_id` on the projected item is this id, not the - 551
// id of the message entry the tool call rode in on (docs/design/68 §10: - 552
// feedback/selection key on the ledger fact, i.e. this entry). - 553
let presentation_by_tool_use_id: HashMap< - 554
String, - 555
(String, &vak_session::types::PresentationRecord), - 556
> = session - 557
.presentations() - 558
.into_iter() - 559
.filter_map(|(entry_id, record)| match &record.source { - 560
vak_session::types::PresentationSource::ToolCall { tool_use_id } => { - 561
Some((tool_use_id.clone(), (entry_id, record))) - 562
} - 563
vak_session::types::PresentationSource::Fence { .. } - 564
| vak_session::types::PresentationSource::Delegated { .. } => None, - 565
}) - 566
.collect(); - 567
// A delegated call (`task`) may carry several cards: the ones its worker - 568
// showed, recorded in this ledger when the worker ended. - 569
let mut delegated_by_tool_use_id: HashMap< - 570
String, - 571
Vec<(String, &vak_session::types::PresentationRecord)>, - 572
> = HashMap::new(); - 573
for (entry_id, record) in session.presentations() { - 574
if let vak_session::types::PresentationSource::Delegated { tool_use_id, .. } = - 575
&record.source - 576
{ - 577
delegated_by_tool_use_id - 578
.entry(tool_use_id.clone()) - 579
.or_default() - 580
.push((entry_id, record)); - 581
} - 582
} - 583
let mut tool_results: HashMap<String, (String, bool)> = HashMap::new(); - 584
let mut tool_inputs: HashMap<String, (String, serde_json::Value)> = HashMap::new(); - 585
let mut turn_outcomes: HashMap<usize, vak_intent::OutcomeSpec> = HashMap::new(); - 586
let mut turn_evaluations: HashMap<usize, String> = HashMap::new(); - 587
let mut turn_evidence_state: HashMap<usize, String> = HashMap::new(); - 588
let mut turn_human_review: HashMap<usize, String> = HashMap::new(); - 589
let mut turn_review_verdict: HashMap<usize, String> = HashMap::new(); - 590
// Declared domains per tool name, accumulated from every - 591
// `TurnCapabilitiesBound` entry in the chain (docs/design/68-context- - 592
// engine.md §9's `SignalContext.domains` note): delivery signals derive - 593
// from what a capability declared it serves, never from its name. - 594
let mut tool_domains: HashMap<String, Vec<String>> = HashMap::new(); - 595
let mut selected_presentation: Option<(String, u64)> = None; - 596
let mut successful_runs = std::collections::HashSet::new(); - 597
let mut scan_turn = 0usize; - 598
let mut assistant_tool_context: HashMap<String, TurnTool> = HashMap::new(); - 599
let mut pending_tool_context: Option<TurnTool> = None; - 600
// Tracks, per semantic_type, the id of the most recently pushed - 601
// tool-emitted `Structured` card within the CURRENT logical answer — - 602
// reset on a genuine new user request. Paired with `repair_armed` - 603
// below: a same-type card is only ever superseded (not just recorded) - 604
// while armed, i.e. strictly after a `[fence-check]`/ - 605
// `[duplicate-card-check]` repair-nudge fired for this answer. Without - 606
// that guard, two intentionally distinct same-type cards the model - 607
// emits back-to-back in one turn (e.g. "here's revenue, and here's - 608
// cost", both `chart`) would be wrongly collapsed to one — nothing - 609
// else in this projection distinguishes "two calls in one batch" from - 610
// "a retry of the same call". - 611
// - 612
// Why this exists: a weak/small local model sometimes "retries" a - 613
// repair nudge by calling the same `emit_*_card` tool again rather - 614
// than only fixing its prose (observed live against gemma4:e2b-mlx), - 615
// which otherwise leaves two separate `Structured` items for what the - 616
// user experiences as one card. See `ids_to_remove` below: the earlier - 617
// attempt is dropped in favor of the retry's result, mirroring - 618
// `vak-agent`'s own bounded repair-turn semantics (the model's LATEST - 619
// attempt is authoritative). - 620
let mut card_group_by_type: HashMap<String, String> = HashMap::new(); - 621
let mut seen_cards: std::collections::HashSet<String> = std::collections::HashSet::new(); - 622
let mut repair_armed = false; - 623
let mut ids_to_remove: std::collections::HashSet<String> = std::collections::HashSet::new(); - 624
for entry in &chain { - 625
match &entry.payload { - 626
EntryPayload::Message(record) => { - 627
// What the user wrote versus what the runtime authored is a - 628
// typed fact on the record (`vak_intent::control`), not - 629
// something to re-derive from the text. - 630
let control = record.control_kind(); - 631
if record.message.role == Role::User - 632
&& control.is_none() - 633
&& record.message.content.iter().any(|block| match block { - 634
ContentBlock::Text { text } => !clean_scaffolding(text).is_empty(), - 635
_ => false, - 636
}) - 637
{ - 638
scan_turn += 1; - 639
pending_tool_context = None; - 640
card_group_by_type.clear(); - 641
seen_cards.clear(); - 642
repair_armed = false; - 643
} - 644
if control.is_some_and(|kind| kind.retries_answer()) { - 645
repair_armed = true; - 646
} - 647
for block in &record.message.content { - 648
match block { - 649
ContentBlock::ToolUse { id, name, input } => { - 650
tool_inputs.insert(id.clone(), (name.clone(), input.clone())); - 651
} - 652
ContentBlock::ToolResult { - 653
tool_use_id, - 654
content, - 655
is_error, - 656
} => { - 657
tool_results.insert(tool_use_id.clone(), (content.clone(), *is_error)); - 658
if let Some((name, input)) = tool_inputs.get(tool_use_id) { - 659
let context = ( - 660
tool_use_id.clone(), - 661
name.clone(), - 662
input.clone(), - 663
Some(content.clone()), - 664
*is_error, - 665
); - 666
pending_tool_context = Some(context); - 667
} - 668
} - 669
_ => {} - 670
} - 671
} - 672
if record.message.role == Role::Assistant - 673
&& record - 674
.message - 675
.content - 676
.iter() - 677
.any(|block| matches!(block, ContentBlock::Text { .. })) - 678
&& let Some(context) = pending_tool_context.take() - 679
{ - 680
assistant_tool_context.insert(entry.id.clone(), context); - 681
} - 682
} - 683
EntryPayload::Intent(record) => { - 684
if let Some(outcome) = &record.outcome { - 685
// Core records admission immediately before the user - 686
// message that starts the turn. Attach it to that next - 687
// turn rather than decorating the previous answer. - 688
turn_outcomes.insert(scan_turn + 1, outcome.clone()); - 689
} - 690
} - 691
EntryPayload::TurnCapabilitiesBound(bound) => { - 692
for (name, domains) in &bound.tool_domains { - 693
tool_domains - 694
.entry(name.clone()) - 695
.or_insert_with(|| domains.clone()); - 696
} - 697
} - 698
EntryPayload::Activity(activity) - 699
if activity.kind == ActivityKind::Diagnostic - 700
&& activity.label == "Outcome evaluation" => - 701
{ - 702
if let Some(turn) = activity.turn { - 703
if let Some(status) = activity.data.get("status") { - 704
turn_evaluations.insert(turn, status.clone()); - 705
} - 706
if let Some(evidence_state) = activity.data.get("evidence_state") { - 707
turn_evidence_state.insert(turn, evidence_state.clone()); - 708
} - 709
if let Some(review) = activity.data.get("human_review") { - 710
turn_human_review.insert(turn, review.clone()); - 711
} - 712
if let Some(evaluation) = activity.data.get("evaluation") { - 713
let evaluation_status = match activity.data.get("status") { - 714
Some(value) => value.as_str(), - 715
None => "unknown", - 716
}; - 717
turn_evaluations - 718
.insert(turn, format!("{}|{}", evaluation_status, evaluation)); - 719
} - 720
if let Some(receipts) = activity.data.get("evidence_receipts") { - 721
let evaluation_status = activity - 722
.data - 723
.get("status") - 724
.map_or("unknown", |value| value.as_str()); - 725
let evaluation = activity - 726
.data - 727
.get("evaluation") - 728
.map_or("[]", |value| value.as_str()); - 729
let completion = activity - 730
.data - 731
.get("completion") - 732
.map_or("unknown", |value| value.as_str()); - 733
turn_evaluations.insert( - 734
turn, - 735
format!( - 736
"{}|{}|{}|{}", - 737
evaluation_status, evaluation, receipts, completion - 738
), - 739
); - 740
} - 741
} - 742
} - 743
EntryPayload::Activity(activity) - 744
if activity.kind == ActivityKind::PresentationSelection => - 745
{ - 746
if let (Some(spec_id), Some(revision)) = ( - 747
activity.data.get("spec_id"), - 748
activity - 749
.data - 750
.get("revision") - 751
.and_then(|value| value.parse().ok()), - 752
) { - 753
selected_presentation = Some((spec_id.clone(), revision)); - 754
} - 755
} - 756
EntryPayload::Activity(activity) - 757
if activity.kind == ActivityKind::Diagnostic - 758
&& activity.label == "Outcome review" => - 759
{ - 760
if let Some(turn) = activity.turn - 761
&& let Some(verdict) = activity.data.get("verdict") - 762
{ - 763
turn_review_verdict.insert(turn, verdict.clone()); - 764
} - 765
} - 766
EntryPayload::Activity(activity) - 767
if activity.kind == ActivityKind::Run - 768
&& activity.status == ActivityStatus::Succeeded => - 769
{ - 770
if let Some(turn) = activity.turn { - 771
successful_runs.insert(turn); - 772
} - 773
} - 774
_ => {} - 775
} - 776
} - 777
- 778
let mut timeline = OutputTimeline::empty(session_id); - 779
timeline.goal = session.goal_state(); - 780
let mut turn = 0usize; - 781
// An answer the runtime sent back for a redo (a `retries_answer` nudge - 782
// followed it) is an internal draft, not something to show: the user sees - 783
// the redone answer, never both. `last_assistant_entry` is the answer the - 784
// next such nudge would be rejecting. - 785
let mut last_assistant_entry: Option<String> = None; - 786
let mut rejected_drafts: std::collections::HashSet<String> = std::collections::HashSet::new(); - 787
for entry in chain { - 788
match &entry.payload { - 789
EntryPayload::Message(record) => { - 790
let control = record.control_kind(); - 791
if control.is_some_and(|kind| kind.retries_answer()) - 792
&& let Some(draft) = last_assistant_entry.take() - 793
{ - 794
rejected_drafts.insert(draft); - 795
} - 796
if record.message.role == Role::Assistant - 797
&& record.message.content.iter().any(|block| { - 798
matches!(block, ContentBlock::Text { text } if !text.trim().is_empty()) - 799
}) - 800
{ - 801
last_assistant_entry = Some(entry.id.clone()); - 802
} - 803
if record.message.role == Role::User - 804
&& control.is_none() - 805
&& record.message.content.iter().any(|block| match block { - 806
ContentBlock::Text { text } => !clean_scaffolding(text).is_empty(), - 807
_ => false, - 808
}) - 809
{ - 810
turn += 1; - 811
} - 812
let turn_id = format!("turn-{turn}"); - 813
for (index, block) in record.message.content.iter().enumerate() { - 814
match block { - 815
ContentBlock::Text { text } if !text.trim().is_empty() => { - 816
if control.is_some() { - 817
continue; - 818
} - 819
let assistant = record.message.role == Role::Assistant; - 820
// A short presentation envelope is ledger metadata, not prose. - 821
// Projecting it creates a duplicate, empty-looking Answer card. - 822
if assistant && is_presentation_envelope(text) { - 823
continue; - 824
} - 825
let cleaned_text_storage = clean_scaffolding(text); - 826
if cleaned_text_storage.is_empty() { - 827
continue; - 828
} - 829
let text = &cleaned_text_storage; - 830
let mut candidates = if assistant { - 831
structured_outputs_from_text(text) - 832
} else { - 833
Vec::new() - 834
}; - 835
if assistant { - 836
candidates.extend(link_previews_from_text(text)); - 837
} - 838
let assistant_tool_call_id = assistant_tool_context - 839
.get(&entry.id) - 840
.map(|(id, ..)| id.clone()); - 841
let (tool_name, tool_input, tool_output, is_error) = - 842
assistant_tool_context - 843
.get(&entry.id) - 844
.map(|(_, name, input, output, err)| { - 845
(Some(name.as_str()), Some(input), output.as_deref(), *err) - 846
}) - 847
.unwrap_or((None, None, None, false)); - 848
let tool_domain_refs = tool_domain_refs(&tool_domains, tool_name); - 849
let ctx = SignalContext { - 850
text, - 851
tool_name, - 852
tool_input, - 853
tool_output, - 854
is_error, - 855
domains: &tool_domain_refs, - 856
}; - 857
let signals = signals_from_context(&ctx); - 858
let plan = planner.plan(&signals, "desktop", &[], &candidates); - 859
let mut projected_outcome = turn_outcomes.get(&turn).cloned(); - 860
let mut rejected_outcome_requirements = Vec::new(); - 861
if let Some(outcome) = projected_outcome.as_mut() { - 862
rejected_outcome_requirements = - 863
plan.merge_outcome_requirements(outcome); - 864
} - 865
// An activation is the user's standing choice for this - 866
// scope. Use it automatically for the first compatible - 867
// typed result; explicit session selection still wins. - 868
let selected_for_output = selected_presentation.clone().or_else(|| { - 869
adaptive_library.and_then(|library| { - 870
let workspace_owner = session - 871
.header() - 872
.map(|header| { - 873
header.contract_cwd().to_string_lossy().into_owned() - 874
}) - 875
.unwrap_or_else(|| "workspace".into()); - 876
plan.accepted.iter().find_map(|candidate| { - 877
library - 878
.select_preferred( - 879
&candidate.semantic_type, - 880
"user", - 881
&workspace_owner, - 882
) - 883
.filter(|stored| { - 884
stored.spec.metadata.get("seed").map(String::as_str) - 885
!= Some("true") - 886
|| stored - 887
.spec - 888
.metadata - 889
.get("certified") - 890
.map(String::as_str) - 891
== Some("true") - 892
}) - 893
.map(|stored| { - 894
(stored.spec.id.clone(), stored.spec.revision) - 895
}) - 896
}) - 897
}) - 898
}); - 899
let output_status = status_for_completion( - 900
turn_evaluations - 901
.get(&turn) - 902
.and_then(|value| value.split('|').nth(3)), - 903
); - 904
timeline.items.push(OutputItem { - 905
id: format!("{}-text-{index}", entry.id), - 906
timestamp: entry.ts.to_rfc3339(), - 907
turn_id: turn_id.clone(), - 908
role: if assistant { - 909
OutputRole::Assistant - 910
} else { - 911
OutputRole::User - 912
}, - 913
kind: if assistant { - 914
OutputKind::Outcome - 915
} else { - 916
OutputKind::Message - 917
}, - 918
status: output_status, - 919
content: OutputContent::Document { - 920
document: { - 921
let mut document = compile_markdown(text.clone()); - 922
if let Some(library) = adaptive_library { - 923
let available = plan - 924
.accepted - 925
.iter() - 926
.filter(|candidate| { - 927
library.definitions().any(|stored| { - 928
stored.spec.accepts.iter().any(|kind| { - 929
kind == &candidate.semantic_type - 930
}) - 931
}) - 932
}) - 933
.count(); - 934
if available > 0 { - 935
document.metadata.insert( - 936
"adaptive_definitions_available".into(), - 937
available.to_string(), - 938
); - 939
} - 940
if let Some((spec_id, revision)) = selected_for_output - 941
.as_ref() - 942
&& library.definitions().any(|stored| { - 943
stored.spec.id == *spec_id - 944
&& stored.spec.revision == *revision - 945
}) - 946
{ - 947
document.metadata.insert( - 948
"adaptive_selected_spec".into(), - 949
format!("{spec_id}@{revision}"), - 950
); - 951
} - 952
} - 953
if let Some(decision) = plan.recipe.as_ref() { - 954
document.metadata.insert( - 955
"recipe_id".into(), - 956
decision.recipe_id.clone(), - 957
); - 958
document.metadata.insert( - 959
"recipe_version".into(), - 960
decision.recipe_version.clone(), - 961
); - 962
document.metadata.insert( - 963
"matched_signals".into(), - 964
decision.matched_signals.join(","), - 965
); - 966
document.metadata.insert( - 967
"renderer".into(), - 968
decision.renderer.clone(), - 969
); - 970
document.metadata.insert( - 971
"renderer_blocks".into(), - 972
plan.renderers - 973
.iter() - 974
.map(|render| { - 975
format!( - 976
"{}={} ({:?})", - 977
render.semantic_type, - 978
render.renderer, - 979
render.disposition - 980
) - 981
}) - 982
.collect::<Vec<_>>() - 983
.join(", "), - 984
); - 985
} - 986
if let Some(outcome) = projected_outcome.as_ref() { - 987
document.metadata.insert( - 988
"outcome_objective".into(), - 989
outcome.objective.clone(), - 990
); - 991
document.metadata.insert( - 992
"outcome_revision".into(), - 993
outcome.revision.to_string(), - 994
); - 995
document.metadata.insert( - 996
"outcome_requirements".into(), - 997
outcome - 998
.requirements - 999
.iter() - 1000
.map(|requirement| requirement.id.as_str())
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.