- 1
//! The one server-side projection from `vak_agent::AgentEvent` (the internal - 2
//! agent-loop event stream) to `ClientEvent` (what `/sessions/:id/events` and - 3
//! `/sessions/:id/side/events` are allowed to send). Both `seq_frame` (live) - 4
//! and replay run every event through `project` here, so a client can never - 5
//! observe a difference between what it missed and what it is seeing live. - 6
//! - 7
//! `AgentEvent` carries a lot that is legitimately useful to the agent loop - 8
//! and to Workbench/observability surfaces but was never meant for a chat - 9
//! reader: retry backoff, frozen-ladder route legs, context compaction, - 10
//! stop-hook continuations, worker token counts, and — critically — raw - 11
//! provider/internal error text riding in `RunFinished.summary` or a tool's - 12
//! `result_preview`. Per AGENTS.md ("Runtime-authored traffic is typed, - 13
//! never sniffed") and docs/design/30-output-engineering.md, the client - 14
//! receives only what it deliberately renders, and a run's outcome is always - 15
//! reduced to a small typed set of human sentences rather than forwarded - 16
//! text. - 17
//! - 18
//! A `TextDelta`/`ThinkingDelta` frame here carries only the delta, never the - 19
//! provider's full `partial` snapshot `AgentEvent::Stream` carries — the - 20
//! client already accumulates deltas itself (invariant 4: consumers choose - 21
//! delta or snapshot, never both forced together on the same frame). - 22
- 23
use vak_agent::AgentEvent; - 24
- 25
/// A run's outcome, reduced to the handful of states a person may see. - 26
/// `AgentEvent::RunFinished` carries only `summary: String` + `is_error: - 27
/// bool`; `summary` is internal bookkeeping that can legitimately be a raw - 28
/// provider error (`format!("failed: {error}")`) or an internal ceiling - 29
/// message. Classification below trusts only `is_error` plus the small set - 30
/// of literal sentinels the runtime is documented to emit for a clean stop - 31
/// (`"aborted"`) or a step-limit stop (`"max_turns"`) — never the rest of - 32
/// the string, which is discarded. - 33
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)] - 34
pub enum RunOutcome { - 35
Completed, - 36
Stopped, - 37
MaxTurns, - 38
Failed, - 39
} - 40
- 41
/// Classify a raw `(summary, is_error)` pair into a `RunOutcome` without - 42
/// ever exposing `summary` itself to a caller. - 43
/// - 44
/// `"cancelled by client"` is `cancel_run`'s own synthetic `RunFinished` - 45
/// (`vak-server/src/lib.rs`), which predates the run's real terminal event - 46
/// using the documented `"aborted"` sentinel; both are recognized here as - 47
/// `Stopped` until that synthetic send is retired. - 48
pub(crate) fn classify_run_outcome(summary: &str, is_error: bool) -> RunOutcome { - 49
match summary { - 50
"aborted" | "cancelled by client" => RunOutcome::Stopped, - 51
"max_turns" => RunOutcome::MaxTurns, - 52
_ if is_error => RunOutcome::Failed, - 53
_ => RunOutcome::Completed, - 54
} - 55
} - 56
- 57
/// The one human sentence for each outcome. Never raw error text — see - 58
/// `RunOutcome`'s doc comment. Shared by the live `/events` projection - 59
/// (`ClientEvent::RunFinished`) and the gateway's channel reply text - 60
/// (`gateway::outcome_text`), so a Telegram/Slack/Discord user and a client - 61
/// tab see the same words for the same outcome. - 62
pub(crate) fn run_outcome_message(outcome: RunOutcome) -> &'static str { - 63
match outcome { - 64
RunOutcome::Completed => "Completed.", - 65
RunOutcome::Stopped => "Stopped.", - 66
RunOutcome::MaxTurns => { - 67
"Vakyartha reached this run's step limit. Saved work is available; choose Continue to finish this task." - 68
} - 69
RunOutcome::Failed => "This run failed. Check the transcript for details.", - 70
} - 71
} - 72
- 73
/// What a client may receive from the live event stream. Every variant here - 74
/// is one the client deliberately renders; anything else `project` drops. - 75
#[derive(Debug, Clone, serde::Serialize)] - 76
pub enum ClientEvent { - 77
StreamOpened, - 78
TurnStart { - 79
turn: usize, - 80
}, - 81
TextDelta { - 82
delta: String, - 83
}, - 84
ThinkingDelta { - 85
delta: String, - 86
}, - 87
ToolCallStart { - 88
id: String, - 89
name: String, - 90
args_json: String, - 91
}, - 92
ToolCallEnd { - 93
id: String, - 94
name: String, - 95
is_error: bool, - 96
result_preview: Option<String>, - 97
}, - 98
ApprovalRequested { - 99
id: String, - 100
tool: String, - 101
args_json: String, - 102
reason: String, - 103
}, - 104
WorkerStarted { - 105
label: String, - 106
}, - 107
WorkerToolCall { - 108
label: String, - 109
name: String, - 110
is_error: bool, - 111
}, - 112
WorkerFinished { - 113
label: String, - 114
is_error: bool, - 115
elapsed_ms: u64, - 116
}, - 117
/// A long provider-side backoff is happening. No attempt count, delay or - 118
/// raw reason: the header shows a neutral "Retrying" state and nothing - 119
/// more (docs/audits Finding 1 — the previous wire sent the raw provider - 120
/// error text on every attempt). - 121
Retrying, - 122
Sandbox(vak_tools::SandboxEvent), - 123
/// The runtime sent a text answer back for a redo; the client drops the - 124
/// discarded draft bubble for this turn rather than showing it as an - 125
/// answer the runtime itself rejected. - 126
DraftDiscarded { - 127
turn: usize, - 128
}, - 129
RunFinished { - 130
outcome: RunOutcome, - 131
message: String, - 132
}, - 133
} - 134
- 135
/// The one projection. `None` means: internal bookkeeping, drop it — - 136
/// produces no frame on either the live or the replay path. - 137
pub(crate) fn project(event: AgentEvent) -> Option<ClientEvent> { - 138
match event { - 139
AgentEvent::StreamOpened => Some(ClientEvent::StreamOpened), - 140
AgentEvent::TurnStart { turn } => Some(ClientEvent::TurnStart { turn }), - 141
AgentEvent::Stream(vak_llm::StreamEvent::TextDelta { delta, .. }) => { - 142
Some(ClientEvent::TextDelta { delta }) - 143
} - 144
AgentEvent::Stream(vak_llm::StreamEvent::ThinkingDelta { delta, .. }) => { - 145
Some(ClientEvent::ThinkingDelta { delta }) - 146
} - 147
// Start/ToolUseStart/ToolInputDelta/End are provider-stream - 148
// bookkeeping superseded by ToolCallStart/ToolCallEnd below. - 149
AgentEvent::Stream(_) => None, - 150
AgentEvent::ToolCallStart { - 151
id, - 152
name, - 153
args_json, - 154
} => Some(ClientEvent::ToolCallStart { - 155
id, - 156
name, - 157
args_json, - 158
}), - 159
AgentEvent::ToolCallEnd { - 160
id, - 161
name, - 162
is_error, - 163
result_preview, - 164
} => Some(ClientEvent::ToolCallEnd { - 165
id, - 166
name, - 167
is_error, - 168
result_preview, - 169
}), - 170
// Session-total usage is not rendered from the live stream (only - 171
// from the durable transcript's own `usage` field); the loop's own - 172
// per-step accounting has no client-visible meaning. - 173
AgentEvent::TurnEnd { .. } => None, - 174
// Internal continuation bookkeeping for the stop-hook gate. - 175
AgentEvent::StopHookContinuation { .. } => None, - 176
AgentEvent::RetryScheduled { .. } => Some(ClientEvent::Retrying), - 177
// Frozen-ladder leg changes, context compaction and reset-with- - 178
// handoff are dispatch-contract internals; the run is still working - 179
// and the header already reflects that via "Working"/"Retrying". - 180
AgentEvent::RouteFallback { .. } => None, - 181
AgentEvent::ContextCompacting { .. } => None, - 182
AgentEvent::ContextCompacted { .. } => None, - 183
AgentEvent::HandoffReset { .. } => None, - 184
AgentEvent::ApprovalRequested { - 185
id, - 186
tool, - 187
args_json, - 188
reason, - 189
} => Some(ClientEvent::ApprovalRequested { - 190
id, - 191
tool, - 192
args_json, - 193
reason, - 194
}), - 195
AgentEvent::WorkerStarted { label } => Some(ClientEvent::WorkerStarted { label }), - 196
AgentEvent::WorkerToolCall { - 197
label, - 198
name, - 199
is_error, - 200
} => Some(ClientEvent::WorkerToolCall { - 201
label, - 202
name, - 203
is_error, - 204
}), - 205
// Per-call worker token counts are debug detail, not something the - 206
// Workbench worker card renders. - 207
AgentEvent::WorkerUsage { .. } => None, - 208
AgentEvent::WorkerFinished { - 209
label, - 210
is_error, - 211
elapsed_ms, - 212
} => Some(ClientEvent::WorkerFinished { - 213
label, - 214
is_error, - 215
elapsed_ms, - 216
}), - 217
// Managed-work state is not rendered from the live event stream - 218
// today (the work panel reads `GET /sessions/:id/work` instead). - 219
AgentEvent::WorkState { .. } => None, - 220
AgentEvent::RunFinished { summary, is_error } => { - 221
let outcome = classify_run_outcome(&summary, is_error); - 222
Some(ClientEvent::RunFinished { - 223
outcome, - 224
message: run_outcome_message(outcome).into(), - 225
}) - 226
} - 227
AgentEvent::Sandbox(sandbox_event) => Some(ClientEvent::Sandbox(sandbox_event)), - 228
AgentEvent::DraftDiscarded { turn } => Some(ClientEvent::DraftDiscarded { turn }), - 229
} - 230
} - 231
- 232
#[cfg(test)] - 233
#[allow(clippy::unwrap_used, clippy::panic)] - 234
mod tests { - 235
use super::*; - 236
use vak_llm::{AssistantMessage, StreamEvent, Usage}; - 237
- 238
fn blank_message() -> AssistantMessage { - 239
AssistantMessage::empty("test-model") - 240
} - 241
- 242
/// Every `AgentEvent` variant must be classified exactly once: kept - 243
/// (with the shape the client expects), or explicitly dropped. This - 244
/// pins the "typed, never sniffed" contract so a new variant added to - 245
/// `AgentEvent` cannot silently leak internal traffic to a client - 246
/// through a catch-all arm. - 247
#[test] - 248
fn every_variant_is_kept_or_dropped_on_purpose() { - 249
let kept = |event: AgentEvent| assert!(project(event).is_some()); - 250
let dropped = |event: AgentEvent| assert!(project(event).is_none()); - 251
- 252
kept(AgentEvent::StreamOpened); - 253
kept(AgentEvent::TurnStart { turn: 1 }); - 254
kept(AgentEvent::Stream(StreamEvent::TextDelta { - 255
delta: "hi".into(), - 256
partial: blank_message(), - 257
})); - 258
kept(AgentEvent::Stream(StreamEvent::ThinkingDelta { - 259
delta: "hmm".into(), - 260
partial: blank_message(), - 261
})); - 262
dropped(AgentEvent::Stream(StreamEvent::Start { - 263
partial: blank_message(), - 264
})); - 265
dropped(AgentEvent::Stream(StreamEvent::ToolUseStart { - 266
index: 0, - 267
id: "t1".into(), - 268
name: "read".into(), - 269
partial: blank_message(), - 270
})); - 271
dropped(AgentEvent::Stream(StreamEvent::ToolInputDelta { - 272
index: 0, - 273
delta: "{}".into(), - 274
partial: blank_message(), - 275
})); - 276
dropped(AgentEvent::Stream(StreamEvent::End { - 277
message: blank_message(), - 278
})); - 279
kept(AgentEvent::ToolCallStart { - 280
id: "t1".into(), - 281
name: "read".into(), - 282
args_json: "{}".into(), - 283
}); - 284
kept(AgentEvent::ToolCallEnd { - 285
id: "t1".into(), - 286
name: "read".into(), - 287
is_error: false, - 288
result_preview: None, - 289
}); - 290
dropped(AgentEvent::TurnEnd { - 291
usage: Usage::default(), - 292
}); - 293
dropped(AgentEvent::StopHookContinuation { - 294
reason: "internal".into(), - 295
}); - 296
kept(AgentEvent::RetryScheduled { - 297
attempt: 1, - 298
delay_ms: 100, - 299
reason: "429 from provider".into(), - 300
}); - 301
dropped(AgentEvent::RouteFallback { - 302
to_provider: "anthropic".into(), - 303
to_model: "m".into(), - 304
}); - 305
dropped(AgentEvent::ContextCompacting { - 306
estimated_tokens: 10, - 307
}); - 308
dropped(AgentEvent::ContextCompacted { - 309
before_tokens: 10, - 310
after_tokens: 5, - 311
summarized_turns: 1, - 312
}); - 313
dropped(AgentEvent::HandoffReset { before_tokens: 10 }); - 314
kept(AgentEvent::ApprovalRequested { - 315
id: "a1".into(), - 316
tool: "bash".into(), - 317
args_json: "{}".into(), - 318
reason: "writes outside workspace".into(), - 319
}); - 320
kept(AgentEvent::WorkerStarted { - 321
label: "worker-1".into(), - 322
}); - 323
kept(AgentEvent::WorkerToolCall { - 324
label: "worker-1".into(), - 325
name: "read".into(), - 326
is_error: false, - 327
}); - 328
dropped(AgentEvent::WorkerUsage { - 329
label: "worker-1".into(), - 330
input_tokens: 10, - 331
output_tokens: 5, - 332
}); - 333
kept(AgentEvent::WorkerFinished { - 334
label: "worker-1".into(), - 335
is_error: false, - 336
elapsed_ms: 10, - 337
}); - 338
dropped(AgentEvent::WorkState { - 339
projection: vak_session::work::WorkProjection { - 340
contract: vak_session::types::WorkContract { - 341
contract_id: "c1".into(), - 342
revision: 1, - 343
source_entry_id: "e1".into(), - 344
objective: "test".into(), - 345
constraints: Vec::new(), - 346
assumptions: Vec::new(), - 347
criteria: Vec::new(), - 348
items: Vec::new(), - 349
}, - 350
status: vak_session::types::WorkContractStatus::Active, - 351
items: Default::default(), - 352
criteria: Default::default(), - 353
}, - 354
}); - 355
kept(AgentEvent::RunFinished { - 356
summary: "completed".into(), - 357
is_error: false, - 358
}); - 359
kept(AgentEvent::Sandbox(vak_tools::SandboxEvent::Stdout { - 360
execution_id: "e1".into(), - 361
chunk: "hi".into(), - 362
})); - 363
kept(AgentEvent::DraftDiscarded { turn: 2 }); - 364
} - 365
- 366
#[test] - 367
fn text_and_thinking_deltas_never_carry_the_provider_snapshot() { - 368
let event = AgentEvent::Stream(StreamEvent::TextDelta { - 369
delta: "partial answer".into(), - 370
partial: blank_message(), - 371
}); - 372
let json = serde_json::to_string(&project(event).unwrap()).unwrap(); - 373
assert!(json.contains("partial answer")); - 374
// The snapshot's model-visible marker must not ride along. - 375
assert!(!json.contains("test-model")); - 376
assert!(!json.contains("end_turn")); - 377
} - 378
- 379
#[test] - 380
fn run_finished_never_carries_raw_summary_text() { - 381
for (summary, is_error) in [ - 382
( - 383
"failed: connection reset by peer at 10.0.0.1:443".to_string(), - 384
true, - 385
), - 386
("failed: dispatch ceiling of 28 exhausted".to_string(), true), - 387
("aborted".to_string(), false), - 388
("max_turns".to_string(), true), - 389
("completed".to_string(), false), - 390
] { - 391
let event = AgentEvent::RunFinished { - 392
summary: summary.clone(), - 393
is_error, - 394
}; - 395
let ClientEvent::RunFinished { message, .. } = project(event).unwrap() else { - 396
panic!("expected RunFinished"); - 397
}; - 398
assert!( - 399
!message.contains("connection reset") - 400
&& !message.contains("dispatch ceiling") - 401
&& !message.contains("10.0.0.1"), - 402
"leaked raw summary text into {message:?}" - 403
); - 404
} - 405
} - 406
- 407
#[test] - 408
fn classify_run_outcome_uses_only_documented_sentinels() { - 409
assert_eq!(classify_run_outcome("aborted", false), RunOutcome::Stopped); - 410
assert_eq!( - 411
classify_run_outcome("max_turns", true), - 412
RunOutcome::MaxTurns - 413
); - 414
assert_eq!( - 415
classify_run_outcome("failed: anything at all", true), - 416
RunOutcome::Failed - 417
); - 418
assert_eq!( - 419
classify_run_outcome("completed", false), - 420
RunOutcome::Completed - 421
); - 422
// An unrecognised string with is_error unset degrades to Completed - 423
// rather than fabricating a false-negative failure state. - 424
assert_eq!( - 425
classify_run_outcome("some future sentinel", false), - 426
RunOutcome::Completed - 427
); - 428
} - 429
- 430
#[test] - 431
fn retry_carries_no_attempt_count_delay_or_reason() { - 432
let event = AgentEvent::RetryScheduled { - 433
attempt: 7, - 434
delay_ms: 60_000, - 435
reason: "internal provider detail".into(), - 436
}; - 437
let json = serde_json::to_string(&project(event).unwrap()).unwrap(); - 438
assert_eq!(json, "\"Retrying\""); - 439
} - 440
} - 441
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.