- 1
//! The vocabulary of runtime control traffic: model-visible text the runtime - 2
//! itself authors, as opposed to text a user or the model wrote. - 3
//! - 4
//! Every layer that needs to tell "the user said this" from "the runtime said - 5
//! this" — session projection, the desktop and admin clients, channel - 6
//! delivery, compaction — reads this one module. Before it existed each layer - 7
//! kept its own hand-copied list of text prefixes, and the lists drifted: a - 8
//! nudge added to the agent loop was hidden by the server but shown by the - 9
//! client as a message from the user, and the client's turn count then - 10
//! disagreed with the server's, displacing every later turn. - 11
//! - 12
//! Three classes, each with a different lifetime: - 13
//! - 14
//! * [`ControlKind`] — a synthetic *user-role message the agent loop appends to - 15
//! the ledger* (repair nudges, stop guards). Persisted, and tagged - 16
//! structurally at creation (`MessageMeta::control`). That tag is the only - 17
//! way anything recognises one: there is no text sniffing to drift. - 18
//! * [`CONTEXT_BLOCK_TAGS`] — a `<tag>…</tag>` block the runtime *derives into a - 19
//! message* when it assembles model input (compaction summary, intent note, - 20
//! work contract, conversation thread). Never persisted as its own message. - 21
//! * [`InlineHint`] — a marker *line inside other text* (a tool result, a stop - 22
//! guard's reason). Not a message, so it is stripped line by line. - 23
- 24
use serde::{Deserialize, Serialize}; - 25
- 26
/// A synthetic user-role message the agent loop appends to the ledger. - 27
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] - 28
#[serde(rename_all = "snake_case")] - 29
pub enum ControlKind { - 30
/// A stop hook blocked the turn from ending. - 31
StopHook, - 32
/// The stop gate (verification, goal, managed work) blocked completion. - 33
StopGuard, - 34
/// A search/fetch result was ignored by the answer that followed it. - 35
GroundingCheck, - 36
/// A `vak` fence in the answer did not parse. - 37
FenceCheck, - 38
/// The answer restated a card that a tool call had already shown. - 39
DuplicateCardCheck, - 40
/// The answer reads as a card but was written as prose. - 41
PresentationCheck, - 42
/// A current value was asked for and nothing was retrieved this turn - 43
/// (docs/design/68-context-engine.md §7). - 44
FreshnessCheck, - 45
/// The response carried neither text nor a tool call (a thinking-only - 46
/// completion): act on the plan, or answer. - 47
EmptyStep, - 48
/// Model drift (docs/design/68-context-engine.md §7): the step served a - 49
/// different directive than the current one — a mismatched-domain tool - 50
/// call, or a verbatim repeat of a past answer. - 51
SteeringDrift, - 52
/// An `emit_*_card` call after a successful retrieval THIS run whose - 53
/// own payload shares no topic word with either the directive or what - 54
/// was just retrieved — never checked when nothing was retrieved this - 55
/// run, since a card built from the model's own reasoning or from data - 56
/// already in the directive routinely has no vocabulary overlap with - 57
/// either and would otherwise be gated for being right - 58
/// (docs/design/68-context-engine.md §7). - 59
TopicMismatchCheck, - 60
/// Correctable tool failures went unrepaired across steps: the admitted - 61
/// schema of each failing tool, re-surfaced with the retries left. - 62
RepairDirective, - 63
} - 64
- 65
impl ControlKind { - 66
pub const ALL: [ControlKind; 11] = [ - 67
ControlKind::StopHook, - 68
ControlKind::StopGuard, - 69
ControlKind::GroundingCheck, - 70
ControlKind::FenceCheck, - 71
ControlKind::DuplicateCardCheck, - 72
ControlKind::PresentationCheck, - 73
ControlKind::FreshnessCheck, - 74
ControlKind::EmptyStep, - 75
ControlKind::SteeringDrift, - 76
ControlKind::TopicMismatchCheck, - 77
ControlKind::RepairDirective, - 78
]; - 79
- 80
/// The literal the message body begins with, for the model's benefit. - 81
/// Nothing else reads it: consumers use the structural tag. - 82
pub const fn marker(self) -> &'static str { - 83
match self { - 84
ControlKind::StopHook => "[stop-hook]", - 85
ControlKind::StopGuard => "[stop-guard]", - 86
ControlKind::GroundingCheck => "[grounding-check]", - 87
ControlKind::FenceCheck => "[fence-check]", - 88
ControlKind::DuplicateCardCheck => "[duplicate-card-check]", - 89
ControlKind::PresentationCheck => "[presentation-check]", - 90
ControlKind::FreshnessCheck => "[freshness-check]", - 91
ControlKind::EmptyStep => "[empty-step]", - 92
ControlKind::SteeringDrift => "[steering-drift]", - 93
ControlKind::TopicMismatchCheck => "[topic-mismatch]", - 94
ControlKind::RepairDirective => "[repair-directive]", - 95
} - 96
} - 97
- 98
/// Whether this asks the model to redo the answer it just gave, so a card - 99
/// the model emits afterwards replaces the earlier attempt rather than - 100
/// adding a second one to the same answer. - 101
pub const fn retries_answer(self) -> bool { - 102
matches!( - 103
self, - 104
ControlKind::GroundingCheck - 105
| ControlKind::FenceCheck - 106
| ControlKind::DuplicateCardCheck - 107
| ControlKind::PresentationCheck - 108
| ControlKind::FreshnessCheck - 109
| ControlKind::EmptyStep - 110
| ControlKind::TopicMismatchCheck - 111
) - 112
} - 113
} - 114
- 115
/// A `<tag>…</tag>` block the runtime derives into a model-visible message. - 116
pub const CONTEXT_BLOCK_TAGS: [&str; 10] = [ - 117
"conversation_thread", - 118
"context_summary", - 119
"intent", - 120
"work_contract", - 121
"managed_work", - 122
"context_packet", - 123
"system_reminder", - 124
"runtime_guidance", - 125
"scratchpad", - 126
"workspace_delta", - 127
]; - 128
- 129
/// A marker line inside other text (a tool result or a stop guard's reason). - 130
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] - 131
pub enum InlineHint { - 132
Recovery, - 133
PostToolUseHook, - 134
} - 135
- 136
impl InlineHint { - 137
pub const ALL: [InlineHint; 2] = [InlineHint::Recovery, InlineHint::PostToolUseHook]; - 138
- 139
pub const fn marker(self) -> &'static str { - 140
match self { - 141
InlineHint::Recovery => "[recovery]", - 142
InlineHint::PostToolUseHook => "[post-tool-use hook]", - 143
} - 144
} - 145
- 146
/// Everything from the marker to the end of the text is the hint (its - 147
/// body can run over several lines), as opposed to a hint that ends at - 148
/// "Please continue.". - 149
pub const fn runs_to_end(self) -> bool { - 150
matches!(self, InlineHint::Recovery) - 151
} - 152
} - 153
- 154
/// Whether one line is an inline runtime hint rather than conversation. - 155
pub fn is_control_line(line: &str) -> bool { - 156
let trimmed = line.trim(); - 157
InlineHint::ALL - 158
.into_iter() - 159
.any(|hint| trimmed.starts_with(hint.marker())) - 160
} - 161
- 162
/// Every inline marker a text-based recogniser (one working on message text - 163
/// rather than the structural tag, such as the desktop client) must know. The - 164
/// TypeScript side is checked against this list in `vak-server`'s tests. - 165
pub fn inline_markers() -> Vec<&'static str> { - 166
InlineHint::ALL - 167
.into_iter() - 168
.map(InlineHint::marker) - 169
.collect() - 170
} - 171
- 172
/// Lines of runtime narration that are not conversation either: the surface - 173
/// stamp, the outcome banner, and the like. Not control *messages* (they have - 174
/// no kind), so they are matched by text alone. - 175
const NARRATION_PREFIXES: [&str; 5] = [ - 176
"Surface:", - 177
"Outcome:", - 178
"primary deliverable:", - 179
"contract_id:", - 180
"I will write and execute this within the sandbox", - 181
]; - 182
- 183
/// Whether one line is scaffolding a reader should never see: an inline - 184
/// runtime hint or runtime narration. - 185
pub fn is_scaffolding_line(line: &str) -> bool { - 186
let trimmed = line.trim(); - 187
NARRATION_PREFIXES - 188
.iter() - 189
.any(|prefix| trimmed.starts_with(prefix)) - 190
|| trimmed.eq_ignore_ascii_case("completed") - 191
|| trimmed.eq_ignore_ascii_case("vak") - 192
|| is_control_line(trimmed) - 193
} - 194
- 195
/// Removes `<tag>…</tag>` context blocks (an unterminated one runs to the end - 196
/// of the text) and the `[marker]: … Please continue.` spans that stop hooks, - 197
/// stop guards and tool-result hints embed in other text. - 198
pub fn strip_control_blocks(text: &str) -> String { - 199
let mut out = text.to_string(); - 200
for tag in CONTEXT_BLOCK_TAGS { - 201
let close_pattern = format!("</{tag}>"); - 202
while let Some(start) = find_open_tag(&out, tag) { - 203
if let Some(end_offset) = out[start..].find(&close_pattern) { - 204
let end = start + end_offset + close_pattern.len(); - 205
out.replace_range(start..end, ""); - 206
} else { - 207
out.truncate(start); - 208
break; - 209
} - 210
} - 211
} - 212
- 213
// (marker, runs to the end of the text). Persisted control messages are - 214
// never embedded in other text, so only the inline hints appear here. - 215
let embedded: [(String, bool); 2] = [ - 216
( - 217
InlineHint::Recovery.marker().to_string(), - 218
InlineHint::Recovery.runs_to_end(), - 219
), - 220
(format!("{}:", InlineHint::PostToolUseHook.marker()), false), - 221
]; - 222
for (prefix, runs_to_end) in &embedded { - 223
while let Some(start) = out.find(prefix.as_str()) { - 224
let remainder = &out[start..]; - 225
if *runs_to_end { - 226
out.truncate(start); - 227
break; - 228
} - 229
if let Some(end_offset) = remainder.find("Please continue.") { - 230
let end = start + end_offset + "Please continue.".len(); - 231
out.replace_range(start..end, ""); - 232
} else if let Some(end_offset) = remainder.find("Please continue") { - 233
let end = start + end_offset + "Please continue".len(); - 234
out.replace_range(start..end, ""); - 235
} else if let Some(newline_offset) = remainder.find('\n') { - 236
let end = start + newline_offset + 1; - 237
out.replace_range(start..end, ""); - 238
} else { - 239
out.truncate(start); - 240
break; - 241
} - 242
} - 243
} - 244
out - 245
} - 246
- 247
/// Position of the first `<tag>` / `<tag …>` opener, as a whole tag name. - 248
/// - 249
/// A bare prefix match (`<intent`) also matched `<intentional>` and, finding - 250
/// no `</intent>`, truncated the rest of the text. - 251
fn find_open_tag(text: &str, tag: &str) -> Option<usize> { - 252
let prefix = format!("<{tag}"); - 253
let mut from = 0; - 254
while let Some(offset) = text[from..].find(&prefix) { - 255
let start = from + offset; - 256
let after = text[start + prefix.len()..].chars().next(); - 257
if matches!(after, Some('>') | Some('/')) || after.is_some_and(char::is_whitespace) { - 258
return Some(start); - 259
} - 260
from = start + prefix.len(); - 261
} - 262
None - 263
} - 264
- 265
/// Whether a line opens or closes a fenced code block. - 266
fn is_fence(line: &str) -> bool { - 267
let trimmed = line.trim_start(); - 268
trimmed.starts_with("```") || trimmed.starts_with("~~~") - 269
} - 270
- 271
/// The text with all scaffolding removed and outer blank lines trimmed. - 272
/// - 273
/// Fenced code is content, whatever it contains, and passes through - 274
/// verbatim: a block that shows an `<intent>` element, a line reading `vak` - 275
/// or `Outcome: ok` is part of the answer, and removing it silently - 276
/// corrupts code a person will copy. Only the prose between fences is - 277
/// cleaned; an unterminated fence runs to the end of the text. - 278
pub fn clean_scaffolding(text: &str) -> String { - 279
let had_trailing_newline = text.ends_with('\n'); - 280
let mut lines: Vec<String> = Vec::new(); - 281
let mut prose = String::new(); - 282
let mut in_code = false; - 283
let flush = |prose: &mut String, lines: &mut Vec<String>| { - 284
let stripped = strip_control_blocks(prose); - 285
lines.extend( - 286
stripped - 287
.lines() - 288
.filter(|line| !is_scaffolding_line(line)) - 289
.map(str::to_string), - 290
); - 291
prose.clear(); - 292
}; - 293
for line in text.lines() { - 294
if in_code { - 295
lines.push(line.to_string()); - 296
if is_fence(line) { - 297
in_code = false; - 298
} - 299
} else if is_fence(line) { - 300
flush(&mut prose, &mut lines); - 301
lines.push(line.to_string()); - 302
in_code = true; - 303
} else { - 304
prose.push_str(line); - 305
prose.push('\n'); - 306
} - 307
} - 308
flush(&mut prose, &mut lines); - 309
while let Some(first) = lines.first() { - 310
if first.trim().is_empty() { - 311
lines.remove(0); - 312
} else { - 313
break; - 314
} - 315
} - 316
while let Some(last) = lines.last() { - 317
if last.trim().is_empty() { - 318
lines.pop(); - 319
} else { - 320
break; - 321
} - 322
} - 323
let mut out = lines.join("\n"); - 324
if had_trailing_newline && !out.is_empty() { - 325
out.push('\n'); - 326
} - 327
out - 328
} - 329
- 330
#[cfg(test)] - 331
#[allow(clippy::unwrap_used)] - 332
mod tests { - 333
use super::*; - 334
- 335
#[test] - 336
fn every_marker_is_unique_and_bracketed() { - 337
let mut markers: Vec<&str> = ControlKind::ALL - 338
.into_iter() - 339
.map(ControlKind::marker) - 340
.collect(); - 341
markers.extend(inline_markers()); - 342
let mut sorted = markers.clone(); - 343
sorted.sort_unstable(); - 344
sorted.dedup(); - 345
assert_eq!( - 346
sorted.len(), - 347
markers.len(), - 348
"duplicate marker in {markers:?}" - 349
); - 350
assert!( - 351
markers - 352
.iter() - 353
.all(|m| m.starts_with('[') && m.ends_with(']')) - 354
); - 355
} - 356
- 357
/// Code a person will copy is never edited: inside a fence, a line that - 358
/// looks like runtime narration or a context tag is content. - 359
#[test] - 360
fn fenced_code_passes_through_verbatim() { - 361
let text = "Run this:\n```\nvak\nOutcome: ok\n<intent>\n```\nSurface: cli\nDone."; - 362
assert_eq!( - 363
clean_scaffolding(text), - 364
"Run this:\n```\nvak\nOutcome: ok\n<intent>\n```\nDone." - 365
); - 366
// An unterminated fence runs to the end, and the prose before it is - 367
// still cleaned. - 368
let open = "<intent>note</intent>Answer:\n~~~html\n<intent class=\"x\">"; - 369
assert_eq!( - 370
clean_scaffolding(open), - 371
"Answer:\n~~~html\n<intent class=\"x\">" - 372
); - 373
} - 374
- 375
#[test] - 376
fn ordinary_text_is_never_a_control_line() { - 377
for text in [ - 378
"how did the market do", - 379
"[ERROR]: it broke", - 380
"[note] remember this", - 381
] { - 382
assert!(!is_control_line(text), "{text}"); - 383
} - 384
} - 385
- 386
#[test] - 387
fn inline_hints_are_control_lines() { - 388
for hint in InlineHint::ALL { - 389
assert!(is_control_line(&format!(" {} something", hint.marker()))); - 390
} - 391
} - 392
- 393
#[test] - 394
fn only_answer_retries_arm_the_card_supersede() { - 395
let armed: Vec<_> = ControlKind::ALL - 396
.into_iter() - 397
.filter(|k| k.retries_answer()) - 398
.collect(); - 399
assert_eq!( - 400
armed, - 401
vec![ - 402
ControlKind::GroundingCheck, - 403
ControlKind::FenceCheck, - 404
ControlKind::DuplicateCardCheck, - 405
ControlKind::PresentationCheck, - 406
ControlKind::FreshnessCheck, - 407
ControlKind::EmptyStep, - 408
ControlKind::TopicMismatchCheck, - 409
] - 410
); - 411
} - 412
- 413
#[test] - 414
fn serialises_as_snake_case() { - 415
assert_eq!( - 416
serde_json::to_string(&ControlKind::DuplicateCardCheck).unwrap(), - 417
"\"duplicate_card_check\"" - 418
); - 419
} - 420
- 421
#[test] - 422
fn inline_hints_are_scaffolding_and_clean_to_nothing() { - 423
for marker in inline_markers() { - 424
let line = format!("{marker}: something the runtime said"); - 425
assert!(is_scaffolding_line(&line), "{line}"); - 426
assert_eq!(clean_scaffolding(&line), "", "{line}"); - 427
} - 428
} - 429
- 430
#[test] - 431
fn real_answer_text_survives_around_embedded_hints() { - 432
let text = "<intent>select</intent><context_packet>d</context_packet>Final result."; - 433
assert_eq!(clean_scaffolding(text), "Final result."); - 434
let text = "Answer body.\n[recovery] retry the failing call"; - 435
assert_eq!(clean_scaffolding(text), "Answer body."); - 436
let text = "Tool output\n[post-tool-use hook]: blocked\nMore output"; - 437
assert_eq!(clean_scaffolding(text), "Tool output\nMore output"); - 438
} - 439
} - 440
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.