- 92
StreamEvent, Usage, - 93
work::{AttemptReason, FailureDomain, Settlement, StepLedger, WorkPurpose}, - 94
}; - 95
use vak_permission::{AskSource, Decision, Mode, PermissionEngine}; - 96
use vak_session::{MessageMeta, MessageRecord, SessionLog, TurnIndex}; - 97
use vak_tools::{RecallRequest, Tool, ToolContext, ToolErrorKind, ToolOutput}; - 98
- 99
pub use steering::{DrainMode, SteeringQueues}; - 100
- 101
pub use async_trait; - 102
- 103
/// Host-owned transformation applied to every user message admitted to a - 104
/// running agent, including messages queued as steering while a turn is busy. - 105
/// It is intentionally outside model control and receives the frozen session - 106
/// capability packet through its closure. - 107
pub type InputNormalizer = Arc<dyn Fn(Message) -> Result<Message, String> + Send + Sync>; - 108
- 109
/// Host-owned executor for a managed static flow. The agent owns admission - 110
/// and the live ledger; the host owns the flow engine to avoid a crate cycle. - 111
#[async_trait::async_trait] - 112
pub trait FlowDispatcher: Send + Sync { - 113
async fn dispatch( - 114
&self, - 115
args: &Value, - 116
session: Arc<Mutex<SessionLog>>, - 117
ctx: &ToolContext, - 118
approver: Option<Arc<dyn Approver>>, - 119
) -> ToolOutput; - 120
} - 121
- 122
/// What the managed `flow` tool serves; read by capability declarations, - 123
/// which see the capability before any dispatcher exists. - 124
pub const FLOW_SERVES: &[&str] = &["orchestration"]; - 125
- 126
struct ManagedFlowTool { - 127
dispatcher: Arc<dyn FlowDispatcher>, - 128
session: Arc<Mutex<SessionLog>>, - 129
approver: Option<Arc<dyn Approver>>, - 130
} - 131
- 132
#[async_trait::async_trait] - 133
impl Tool for ManagedFlowTool { - 134
fn name(&self) -> &str { - 135
"flow" - 136
} - 137
- 138
fn serves(&self) -> &'static [&'static str] { - 139
FLOW_SERVES - 140
} - 141
- 142
fn description(&self) -> &str { - 143
"Run a named static flow assigned by the active managed-work contract." - 144
} - 145
- 146
fn schema(&self) -> Value { - 147
serde_json::json!({ - 148
"type": "object", - 149
"properties": { - 150
"flow": {"type": "string", "description": "Managed flow name"}, - 151
"contract_id": {"type": "string", "description": "Active managed contract"}, - 152
"work_item_id": {"type": "string", "description": "Flow-owned work item"} - 153
}, - 154
"required": ["flow", "contract_id", "work_item_id"] - 155
}) - 156
} - 157
- 158
fn claims(&self, _args: &Value) -> vak_tools::ResourceClaims { - 159
vak_tools::ResourceClaims { - 160
exclusive: true, - 161
read_only: false, - 162
paths: Vec::new(), - 163
} - 164
} - 165
- 166
async fn execute(&self, args: &Value, ctx: &ToolContext) -> ToolOutput { - 167
self.dispatcher - 168
.dispatch(args, self.session.clone(), ctx, self.approver.clone()) - 169
.await - 170
} - 171
} - 172
- 173
#[derive(Debug, Clone, serde::Serialize)] - 174
pub enum AgentEvent { - 175
TurnStart { - 176
turn: usize, - 177
}, - 178
/// A text-only draft answer was sent back for a redo instead of being - 179
/// accepted as the turn's final answer — every gate that appends a - 180
/// control nudge (`[stop-guard]`, `[presentation-check]`, - 181
/// `[grounding-check]`, `[freshness-check]`, `[fence-check]`, - 182
/// `[duplicate-card-check]`, `[empty-step]`, `[steering-drift]`, - 183
/// `[stop-hook]`) plus `guard_continue`/stop policy, the goal gate, and - 184
/// the managed-work gate all emit this exactly once per discarded - 185
/// draft. `turn` is the loop step index of the discarded draft, the - 186
/// same numbering `TurnStart` uses. - 187
DraftDiscarded { - 188
turn: usize, - 189
}, - 190
Stream(StreamEvent), - 191
ToolCallStart { - 192
id: String, - 193
name: String, - 194
args_json: String, - 195
}, - 196
ToolCallEnd { - 197
id: String, - 198
name: String, - 199
is_error: bool, - 200
result_preview: Option<String>, - 201
}, - 202
TurnEnd { - 203
usage: Usage, - 204
}, - 205
StopHookContinuation { - 206
reason: String, - 207
}, - 208
RetryScheduled { - 209
attempt: u32, - 210
delay_ms: u64, - 211
reason: String, - 212
}, - 213
/// First dispatch of the NEXT frozen-ladder leg after a typed failure - 214
/// of the previous one (Phase B). Walking the frozen ladder is contract - 215
/// execution; this event surfaces each leg change to every consumer. - 216
RouteFallback { - 217
to_provider: String, - 218
to_model: String, - 219
}, - 220
ContextCompacting { - 221
estimated_tokens: u64, - 222
}, - 223
/// An incremental compaction (docs/design/68-context-engine.md §4) ran: - 224
/// the plan's packet range collapsed into a new `Compaction` entry. - 225
/// `after_tokens` is the re-planned budget spend once the packet is - 226
/// covered. - 227
ContextCompacted { - 228
before_tokens: u64, - 229
after_tokens: u64, - 230
summarized_turns: usize, - 231
}, - 232
/// Reset-with-handoff fired (Phase H): the whole projection was - 233
/// replaced by a structured handoff summary. - 234
HandoffReset { - 235
before_tokens: u64, - 236
}, - 237
StreamOpened, - 238
ApprovalRequested { - 239
id: String, - 240
tool: String, - 241
args_json: String, - 242
reason: String, - 243
}, - 244
WorkerStarted { - 245
label: String, - 246
}, - 247
WorkerToolCall { - 248
label: String, - 249
name: String, - 250
is_error: bool, - 251
}, - 252
WorkerUsage { - 253
label: String, - 254
input_tokens: u64, - 255
output_tokens: u64, - 256
}, - 257
WorkerFinished { - 258
label: String, - 259
is_error: bool, - 260
elapsed_ms: u64, - 261
}, - 262
/// Latest durable managed-work projection. This is a live projection only; - 263
/// the session ledger remains the source of truth and can rebuild it. - 264
WorkState { - 265
projection: vak_session::work::WorkProjection, - 266
}, - 267
RunFinished { - 268
summary: String, - 269
/// Whether the run ended badly. Consumers must not have to sniff - 270
/// `summary` for a "failed:" prefix to know something broke — - 271
/// errors are values (see WorkerFinished above). - 272
is_error: bool, - 273
}, - 274
/// Live execution events from sandbox or bash commands. - 275
Sandbox(vak_tools::SandboxEvent), - 276
} - 277
- 278
#[derive(Debug)] - 279
pub enum TurnOutcome { - 280
Completed { response: AssistantMessage }, - 281
Aborted { partial: Option<AssistantMessage> }, - 282
Failed { error: LlmError }, - 283
MaxTurnsReached, - 284
} - 285
- 286
pub struct AgentConfig { - 287
/// The admitted result contract for this run. It is execution context, - 288
/// not model-authored authority; permission and broker checks remain the - 289
/// enforcement boundary. - 290
pub outcome: Option<vak_intent::OutcomeSpec>, - 291
/// A prior bounded turn in the same intent thread successfully saved a - 292
/// file which still exists in this workspace. A continuation must inspect - 293
/// it in this turn before the stop gate treats that effect as completed. - 294
pub continued_saved_file: bool, - 295
pub work_mode: WorkMode, - 296
pub work_enabled: bool, - 297
pub max_work_items: usize, - 298
pub max_work_revisions: u32, - 299
/// The stable prefix: identity, contract, guardrails, tool surface. Byte- - 300
/// identical across steps of a turn and across turns for an unchanged - 301
/// capability packet, so a provider's prefix cache can key on it - 302
/// (docs/design/68-context-engine.md §4/§6). Per-turn content never - 303
/// belongs here — see `tail`. - 304
pub system_prefix: String, - 305
/// Per-turn content rendered into the moving tail instead of the - 306
/// prefix: the clock instant and the epistemic stance. Session-derived - 307
/// tail content (intent, work contract, conversation thread) is read - 308
/// from `SessionLog::tail_sections()` at request-assembly time instead, - 309
/// since it is not host-supplied configuration. - 310
pub tail: TailInput, - 311
pub model: String, - 312
pub tools: Vec<Arc<dyn Tool>>, - 313
/// Bare MCP tool name → owning server. MCP tools are only ever called - 314
/// through the `mcp` broker; a call a model addresses by the bare name - 315
/// (or an `mcp` call missing its server) is repaired to that form before - 316
/// authorization and dispatch. - 317
pub mcp_tool_index: McpToolIndex, - 318
/// Optional host dispatcher exposed only as the managed `flow` tool. - 319
pub flow_dispatcher: Option<Arc<dyn FlowDispatcher>>, - 320
/// Exact tool schemas admitted with the session. When absent, standalone - 321
/// agent users derive schemas from their runtime tools. - 322
pub tool_definitions: Option<Vec<vak_llm::ToolDefinition>>, - 323
/// Applies host commands and capability-bound prompt expansion before a - 324
/// message reaches the ledger or provider. - 325
pub input_normalizer: Option<InputNormalizer>, - 326
pub max_turns: usize, - 327
pub parallel_tools: bool, - 328
pub permission: Option<Arc<PermissionEngine>>, - 329
pub mode: Mode, - 330
pub approver: Option<Arc<dyn Approver>>, - 331
pub approval_mode: ApprovalMode, - 332
pub sandbox: Option<Arc<dyn vak_tools::sandbox::Sandbox>>, - 333
pub hooks: Option<Arc<Vec<vak_hooks::HookDef>>>, - 334
pub revocation_check: Option<RevocationCheck>, - 335
pub presentation_check: Option<PresentationCheck>, - 336
pub retrieval_check: Option<RetrievalCheck>, - 337
/// What satisfies the freshness check: any call that observed current - 338
/// state this run. See `ObservationCheck`. Absent, only retrieval counts. - 339
pub observation_check: Option<ObservationCheck>, - 340
/// Pre-authorization from a live envelope, consulted at an `Ask` gate - 341
/// before the approver. See `EnvelopeCheck`. Absent, every gate asks. - 342
pub envelope_check: Option<EnvelopeCheck>, - 343
/// Writes a `Presentation` ledger entry at the moment a card validates. - 344
/// See `PresentationRebuild`. `None` disables the write (the ack stays - 345
/// the tool's own generic text — no id to embed). - 346
pub presentation_rebuild: Option<PresentationRebuild>, - 347
pub hook_recorder: Option<HookRecorder>, - 348
pub tool_activity_recorder: Option<ToolActivityRecorder>, - 349
/// Retries for transient provider errors (429/529/network) per step. - 350
pub max_retries: u32, - 351
/// Exponential backoff base: delay = base * 2^(attempt-1), jittered. - 352
pub retry_base_backoff_ms: u64, - 353
/// Whole-step deadline (connect + stream + collect). None disables. - 354
pub request_timeout: Option<std::time::Duration>, - 355
/// Shared cross-run provider-health breaker. None disables. - 356
pub circuit_breaker: Option<Arc<CircuitBreaker>>, - 357
/// Run-level endurance: when a model step exhausts its retry budget with - 358
/// a transient error (rate limit / overload / network / truncated - 359
/// stream), back off and re-attempt the same turn this many times - 360
/// before failing the run. Nothing has been committed to the ledger at - 361
/// that point, so the re-attempt is exact. 0 disables (fail on first - 362
/// step exhaustion). - 363
pub run_retry_attempts: u32, - 364
/// Exponential backoff base for run-level endurance, capped at 30s. - 365
pub run_retry_base_backoff_ms: u64, - 366
/// Hard cap on provider dispatches for one unit of work (docs/design/42-managed-work-contracts.mdPhase - 367
/// A). Exhaustion fails closed before another paid call goes out. The - 368
/// single-ladder default codifies today's worst case: - 369
/// `(max_retries + 1) * (run_retry_attempts + 1)`; the frozen ladder - 370
/// (Phase B) tightens this to `ladder + repair allowance`. - 371
pub dispatch_ceiling: u32, - 372
/// Reserve for the completion (`max_tokens`), subtracted from the - 373
/// horizon by `CapacityProfile::budget` (docs/design/68-context-engine.md - 374
/// §4). - 375
pub max_output: u64, - 376
/// Provider-declared context window, used only to build a - 377
/// metadata-only `CapacityProfile` (`CapacityProfile::from_metadata_only`) - 378
/// when the host has not wired real capacity measurement in (e.g. - 379
/// standalone agent use, or a test) — so an unmeasured window is still a - 380
/// real number from configuration, never a hardcoded magic default - 381
/// baked into the planning math itself. - 382
pub declared_window: u64, - 383
/// Built-in premature-completion gate. None disables entirely. - 384
pub stop_policy: Option<StopPolicy>, - 385
/// Pre-dispatch budget admission (docs/design/15-reliability.md). None - 386
/// disables spend gating entirely. - 387
pub spend_gate: Option<Arc<dyn SpendGate>>, - 388
/// Frozen route ladder (Phase B): primary-first candidate legs beyond - 389
/// the configured provider/model. Empty ⇒ single-model legacy. - 390
pub ladder: Vec<(Arc<dyn Provider>, String)>, - 391
/// Canonical configured provider names parallel to `ladder`. Adapter - 392
/// names are implementation details and must not enter routing evidence. - 393
pub ladder_provider_names: Vec<String>, - 394
/// The primary leg's canonical configured provider name (parallel to - 395
/// `ladder_provider_names`, but for `provider`/leg 0 rather than a - 396
/// fallback leg). `None` falls back to `provider.name()` -- the adapter - 397
/// name -- for a caller that has not wired this in yet. - 398
pub provider_name: Option<String>, - 399
/// Workspace-delta provider (Phase H MEA): supplies a bounded summary - 400
/// of what changed since the run-start checkpoint, feeding goal-mode - 401
/// auditors environment facts instead of transcript-only claims. - 402
pub workspace_delta: Option<Arc<dyn WorkspaceDelta>>, - 403
/// Reset-with-handoff rescue on still-over contexts (Phase H). - 404
pub handoff_reset: bool, - 405
/// Audit blocks per goal before degrading to Unverified. - 406
pub max_audit_blocks: u32, - 407
/// Measured capacity for this turn's model (docs/design/68-context-engine.md - 408
/// §1), supplied by `Core::capacity_profile_for`. `None` when the host - 409
/// has not wired capacity measurement in (e.g. standalone agent use); - 410
/// the feedback calls in the turn loop are then no-ops. - 411
pub capacity: Option<CapacityProfile>, - 412
/// The `ProfileKey` `capacity` was probed under, so feedback activities - 413
/// land on the same key (docs/design/68 §1). - 414
pub capacity_key: Option<capacity::ProfileKey>, - 415
/// Full schemas `find_tools` has surfaced so far this run - 416
/// (docs/design/68-context-engine.md §5). `tool_definitions()` appends - 417
/// these after the core set on every call, in discovery order, never - 418
/// reordered — so a tool the model asked for by name stays reachable - 419
/// for the rest of the turn without re-entering the stable prefix. - 420
pub discovered_tools: Arc<StdMutex<Vec<vak_llm::ToolDefinition>>>, - 421
} - 422
- 423
pub type HookRecorder = Arc<dyn Fn(&vak_hooks::HookDef, bool, u64) + Send + Sync>; - 424
pub type ToolActivityRecorder = Arc<dyn Fn(&str, &serde_json::Value, bool, u64) + Send + Sync>; - 425
pub type RevocationCheck = Arc<dyn Fn(&str, &serde_json::Value) -> bool + Send + Sync>; - 426
- 427
/// Given a final answer's text and the names of the tools admitted this turn, - 428
/// returns a nudge when the answer reads as something that should have been - 429
/// presented as a card. Supplied by `Core` (which owns the presentation - 430
/// vocabulary) so the agent loop stays free of any card knowledge. - 431
pub type PresentationCheck = - 432
Arc<dyn Fn(&str, &[String]) -> Option<PresentationNudge> + Send + Sync>; - 433
- 434
/// A presentation-check redo: the card tool it asks for and the nudge text. - 435
/// The loop loads `tool` for the redo, since a card tool the turn's reading - 436
/// did not predict was deferred. - 437
#[derive(Debug, Clone, PartialEq, Eq)] - 438
pub struct PresentationNudge { - 439
pub tool: String, - 440
pub text: String, - 441
} - 442
- 443
/// Whether a tool call reaches information from outside the machine and the - 444
/// conversation (the kind an answer should cite), given the tool's name and - 445
/// the call's input. Supplied by `Core`, which knows what each capability - 446
/// *declares it serves*; the agent loop never guesses from a tool's name or - 447
/// its output. Absent, no call counts as retrieval and the grounding check is - 448
/// inert. - 449
pub type RetrievalCheck = Arc<dyn Fn(&str, &serde_json::Value) -> bool + Send + Sync>; - 450
- 451
/// Whether a call observes the current state of something — a file, a - 452
/// repository, a running service, a web page — as opposed to recalling what - 453
/// the conversation or memory already holds. Supplied by `Core` from what each - 454
/// capability declares it serves. The freshness check is satisfied by any - 455
/// such call: "what's in the current directory?" is answered by listing it, - 456
/// not by a web search, and demanding a retrieval there replaced correct - 457
/// answers with "I could not retrieve a current value". - 458
pub type ObservationCheck = Arc<dyn Fn(&str, &serde_json::Value) -> bool + Send + Sync>; - 459
- 460
/// Whether a gated call falls inside a live envelope the human granted, - 461
/// returning that envelope's id. Supplied by `Core` only for a `delegated` - 462
/// turn whose work is not irreversible, and the grant is read fresh on every - 463
/// call, so a revocation applies to the very next gate. Never consulted for - 464
/// an `Ask` a rule or the circuit breaker raised. - 465
pub type EnvelopeCheck = Arc<dyn Fn(&str, &serde_json::Value) -> Option<String> + Send + Sync>; - 466
- 467
/// Re-validates a card call from its own arguments and returns the info - 468
/// needed to write its `Presentation` entry, or `None` if it no longer - 469
/// validates (unreachable in practice: `execute()` already validated it - 470
/// before this is ever consulted). Supplied by `Core` - 471
/// (`presentation_tools::presentation_info`) because a card tool executes - 472
/// across the worker/broker boundary (AGENTS.md invariant 14) and has no - 473
/// session-log access itself, so the agent loop writes the entry here, at - 474
/// the point the tool result is appended to the session. - 475
pub type PresentationRebuild = - 476
Arc<dyn Fn(&str, &serde_json::Value) -> Option<vak_tools::PresentationCard> + Send + Sync>; - 477
- 478
impl AgentConfig { - 479
pub fn new(system_prefix: impl Into<String>) -> Self { - 480
AgentConfig { - 481
outcome: None, - 482
continued_saved_file: false, - 483
work_mode: WorkMode::Direct, - 484
work_enabled: true, - 485
max_work_items: 20, - 486
max_work_revisions: 8, - 487
system_prefix: system_prefix.into(), - 488
tail: TailInput::default(), - 489
model: String::new(), - 490
tools: Vec::new(), - 491
mcp_tool_index: McpToolIndex::default(), - 492
flow_dispatcher: None, - 493
tool_definitions: None, - 494
input_normalizer: None, - 495
max_turns: 40, - 496
parallel_tools: true, - 497
permission: None, - 498
mode: Mode::WorkspaceWrite, - 499
approver: None, - 500
approval_mode: ApprovalMode::Ask, - 501
sandbox: None, - 502
hooks: None, - 503
revocation_check: None, - 504
presentation_check: None, - 505
retrieval_check: None, - 506
observation_check: None, - 507
envelope_check: None, - 508
presentation_rebuild: None, - 509
hook_recorder: None, - 510
tool_activity_recorder: None, - 511
max_retries: 3, - 512
retry_base_backoff_ms: 500, - 513
request_timeout: Some(std::time::Duration::from_secs(600)), - 514
circuit_breaker: None, - 515
run_retry_attempts: 6, - 516
run_retry_base_backoff_ms: 2_000, - 517
dispatch_ceiling: (3 + 1) * (6 + 1), - 518
max_output: 8_192, - 519
declared_window: 128_000, - 520
stop_policy: Some(StopPolicy::default()), - 521
spend_gate: None, - 522
ladder: Vec::new(), - 523
ladder_provider_names: Vec::new(), - 524
provider_name: None, - 525
workspace_delta: None, - 526
handoff_reset: true, - 527
max_audit_blocks: 2, - 528
capacity: None, - 529
capacity_key: None, - 530
discovered_tools: Arc::new(StdMutex::new(Vec::new())), - 531
} - 532
} - 533
} - 534
- 535
/// Renders the one control block appended to the last user message of a - 536
/// request (docs/design/68-context-engine.md §6/§10): the host-supplied - 537
/// per-turn content under its own tag, followed by whichever - 538
/// session-derived sections `SessionLog::tail_sections()` returned. - 539
/// Sections absent from `sections` are omitted entirely, never emitted as an - 540
/// empty tag pair. - 541
/// The no-op result for an `emit_*_card` call identical to one already shown - 542
/// this run (cards are Vak's own display channel; a repeat is not an error). - 543
const CARD_REPEAT_ACK: &str = "Card already displayed to the user. Do not call it again: finish now (any text is shown only if it begins with `Note:`)."; - 544
- 545
/// The result for a call identical to one that already delivered a file this - 546
/// run (`Tool::delivered_file`): the first draft stands and no second one is - 547
/// written. The first call's result follows it. - 548
const DRAFT_REPEAT_ACK: &str = "Already drafted: this exact call ran earlier in this turn and its draft stands, so no second draft was written. Do not call it again; answer with one sentence saying what you changed. The earlier result:"; - 549
- 550
/// The start of the result for a card that previews a file this run already - 551
/// delivered: the card is not shown, because the draft is in front of the - 552
/// person with its change list and an imitation of it is not. - 553
const WITHHELD_CARD_ACK: &str = "Not shown:"; - 554
- 555
/// The start of the result for a shell command that would copy a file this - 556
/// run delivered for review out of `.vak/scratch/`: it is not run, because - 557
/// the draft reaches the workspace only when the person accepts it. - 558
const DRAFT_COPY_REFUSED: &str = "Not run:"; - 559
- 560
fn is_no_op_ack(text: &str) -> bool { - 561
text.starts_with(CARD_REPEAT_ACK) - 562
|| text.starts_with(DRAFT_REPEAT_ACK) - 563
|| text.starts_with(WITHHELD_CARD_ACK) - 564
|| text.starts_with(DRAFT_COPY_REFUSED) - 565
} - 566
- 567
/// The delivered path a shell command reaches into `.vak/scratch/` for, by - 568
/// the draft's file name. - 569
fn copies_delivered_draft<'a>( - 570
command: &str, - 571
delivered: impl IntoIterator<Item = &'a String>, - 572
) -> Option<&'a String> { - 573
if !command.contains(".vak/scratch/") { - 574
return None; - 575
} - 576
delivered.into_iter().find(|path| { - 577
std::path::Path::new(path.trim()) - 578
.file_name() - 579
.and_then(|name| name.to_str()) - 580
.is_some_and(|name| command.contains(name)) - 581
}) - 582
} - 583
- 584
/// What calls delivered this run (`Tool::delivered_file`), reset per run. - 585
#[derive(Default)] - 586
struct RunDeliveries { - 587
/// `name + input` of each delivering call → its result. - 588
results: HashMap<String, String>, - 589
/// Workspace paths delivered, as the calls named them. - 590
paths: std::collections::HashSet<String>, - 591
/// Card calls withheld because they preview a delivered path; their - 592
/// presentation is never recorded. - 593
withheld_cards: std::collections::HashSet<String>, - 594
} - 595
- 596
fn same_workspace_path(a: &str, b: &str) -> bool { - 597
let trim = |path: &str| path.trim().trim_start_matches("./").to_string(); - 598
trim(a) == trim(b) - 599
} - 600
- 601
/// Consecutive all-repeat card batches after which the turn closes on the - 602
/// card as its answer. Three: one repeat is a slip the ack corrects, two is - 603
/// a model that did not read it, three is one that will not. - 604
const CARD_REPEAT_EXHAUSTION_THRESHOLD: u32 = 3; - 605
- 606
/// The most recent Execute-purpose receipt's prefix digest recorded in this - 607
/// session, or `None` when no receipt has recorded one yet. - 608
fn last_prefix_digest(session: &SessionLog) -> Option<String> { - 609
session - 610
.chain_to_root() - 611
.into_iter() - 612
.rev() - 613
.find_map(|entry| match &entry.payload { - 614
vak_session::EntryPayload::Receipt(receipt) if !receipt.prefix_digest.is_empty() => { - 615
Some(receipt.prefix_digest.clone()) - 616
} - 617
_ => None, - 618
}) - 619
} - 620
- 621
/// Whether any earlier receipt in this session already carries `digest` — - 622
/// used to measure `prefix_tokens` only on the first request seen with a - 623
/// given digest. - 624
fn prefix_digest_seen(session: &SessionLog, digest: &str) -> bool { - 625
session.chain_to_root().into_iter().any(|entry| { - 626
matches!(&entry.payload, vak_session::EntryPayload::Receipt(receipt) if receipt.prefix_digest == digest) - 627
}) - 628
} - 629
- 630
/// Bare MCP tool name → owning server, shared with the broker's live - 631
/// catalogue observer so a tool discovered mid-turn is indexed at once. - 632
pub type McpToolIndex = Arc<StdMutex<std::collections::HashMap<String, String>>>; - 633
- 634
#[async_trait::async_trait] - 635
pub trait Approver: Send + Sync { - 636
async fn approve(&self, tool: &str, args_json: &str, reason: &str) -> bool; - 637
- 638
/// Whether a gate raised here reaches somebody who can answer it. - 639
/// - 640
/// `false` means every `Ask` on this surface is a foregone denial — - 641
/// nobody is listening, and `approve` will return `false` without - 642
/// having asked anyone. That is correct behaviour (AGENTS.md invariant - 643
/// 15: unattended surfaces fail closed) but it is also a *fact about - 644
/// this turn's callable interface*, and it has to be knowable before - 645
/// dispatch rather than only discoverable by burning a tool call on it. - 646
/// `vak_core::reach` reads this to decide what the system prompt may - 647
/// honestly advertise as usable. - 648
/// - 649
/// Defaults to `true`: an approver that does not say otherwise is one - 650
/// that resolves gates. - 651
fn answerable(&self) -> bool { - 652
true - 653
} - 654
} - 655
- 656
/// Errors worth surviving at run level: sustained fault windows, hung or - 657
/// truncated streams. Permanent errors (auth, bad request, non-2xx api, - 658
/// aborts) are excluded — retrying them cannot help. - 659
fn is_transient_step_error(e: &LlmError) -> bool { - 660
match e { - 661
LlmError::RateLimit { .. } => e.is_retryable(), - 662
LlmError::Overloaded(_) | LlmError::Network(_) | LlmError::Parse(_) => true, - 663
_ => false, - 664
} - 665
} - 666
- 667
/// The breaker protects against a DEAD provider: blind failures with no - 668
/// server guidance (network loss, watchdog deadlines, truncated or malformed - 669
/// streams). Informed transience — 429 with Retry-After, explicit 503/529 - 670
/// overload — is the server saying "try again later"; endurance handles it - 671
/// by waiting, and it must not open the circuit mid-window (found in the - 672
/// live chaos campaign: an opened breaker killed runs the window would have - 673
/// released seconds later). - 674
fn trips_breaker(e: &LlmError) -> bool { - 675
matches!(e, LlmError::Network(_) | LlmError::Parse(_)) - 676
} - 677
- 678
/// Drops every `ContentBlock::Thinking` block from `messages` (docs/design/ - 679
/// 68-context-engine.md §7): used only when a mid-turn over-length rejection - 680
/// forces a smaller re-plan, since that request's earlier shape already - 681
/// differs from what was sent before it — replaying a thinking block - 682
/// produced under the old shape is exactly the case a provider that - 683
/// requires nothing earlier to have changed rejects outright. A closed - 684
/// turn's `full_record` never carries thinking to begin with; this only - 685
/// ever has anything to remove from the still-open turn's own verbatim tail. - 686
fn strip_replayed_thinking(messages: &mut [Message]) { - 687
for message in messages.iter_mut() { - 688
message - 689
.content - 690
.retain(|block| !matches!(block, ContentBlock::Thinking { .. })); - 691
} - 692
} - 693
- 694
/// Per-leg tool inclusion (docs/design/68-context-engine.md §5): Anthropic - 695
/// legs get the full core+deferred set (deferred schemas withheld from the - 696
/// prefix there via `defer_loading`, discoverable through the server-side - 697
/// tool-search tool); every other provider gets core only — its adapter - 698
/// ignores `ToolDefinition::defer` and would otherwise send the deferred - 699
/// schema in full, defeating the point of deferring it. - 700
fn tools_for_leg( - 701
tools: &[vak_llm::ToolDefinition], - 702
provider_name: &str, - 703
) -> Vec<vak_llm::ToolDefinition> { - 704
if provider_name == "anthropic" { - 705
return tools.to_vec(); - 706
} - 707
tools.iter().filter(|t| !t.defer).cloned().collect() - 708
} - 709
- 710
/// Promote tools `find_tools` returned this turn from deferred to loaded, so - 711
/// the next step declares them on every provider. A deferred copy of the same - 712
/// name is replaced in place, not kept beside it: a non-Anthropic leg drops - 713
/// every deferred definition (`tools_for_leg`), so keeping only that copy - 714
/// meant a discovered tool was never callable there. - 715
fn load_discovered( - 716
definitions: &mut Vec<vak_llm::ToolDefinition>, - 717
discovered: Vec<vak_llm::ToolDefinition>, - 718
) { - 719
for mut found in discovered { - 720
found.defer = false; - 721
match definitions - 722
.iter_mut() - 723
.find(|existing| existing.name == found.name) - 724
{ - 725
Some(existing) => existing.defer = false, - 726
None => definitions.push(found), - 727
} - 728
} - 729
} - 730
- 731
#[cfg(test)] - 732
mod grounding_tests { - 733
use super::{admits_no_data, uses_evidence}; - 734
- 735
const RESULT: &str = "Weather for Paris: 18°C, light rain. Source: https://wttr.in/Paris"; - 736
- 737
#[test] - 738
fn a_cited_prose_answer_is_grounded() { - 739
assert!(uses_evidence( - 740
"It is 18°C and raining in Paris (wttr.in).", - 741
RESULT - 742
)); - 743
assert!(uses_evidence("Per wttr.in, light rain today.", RESULT)); - 744
} - 745
- 746
#[test] - 747
fn an_answer_from_memory_is_not() { - 748
assert!(!uses_evidence( - 749
"Paris is usually mild this time of year.", - 750
RESULT - 751
)); - 752
} - 753
- 754
#[test] - 755
fn evidence_with_nothing_checkable_never_nags() { - 756
assert!(uses_evidence("anything", "the service said hello")); - 757
} - 758
- 759
#[test] - 760
fn an_honest_no_is_recognised_by_the_one_shared_list() { - 761
assert!(admits_no_data("I couldn't find current figures for that.")); - 762
assert!(admits_no_data("I have no live data for this.")); - 763
assert!(!admits_no_data("It is 18°C.")); - 764
} - 765
} - 766
- 767
#[cfg(test)] - 768
mod tool_loading_tests { - 769
use super::{load_discovered, tools_for_leg}; - 770
use vak_llm::ToolDefinition; - 771
- 772
fn def(name: &str) -> ToolDefinition { - 773
ToolDefinition::new(name, "d", serde_json::json!({"type": "object"})) - 774
} - 775
- 776
#[test] - 777
fn a_discovered_tool_is_declared_on_a_non_anthropic_leg() { - 778
let mut defs = vec![def("find_tools"), def("bash").deferred()]; - 779
assert!( - 780
!tools_for_leg(&defs, "ollama") - 781
.iter() - 782
.any(|d| d.name == "bash"), - 783
"deferred tools stay out of a non-Anthropic request until found" - 784
); - 785
load_discovered(&mut defs, vec![def("bash")]); - 786
assert_eq!(defs.len(), 2, "promoted in place, never duplicated"); - 787
let sent = tools_for_leg(&defs, "ollama"); - 788
assert!(sent.iter().any(|d| d.name == "bash")); - 789
} - 790
} - 791
- 792
/// Renders a turn's full record (docs/design/68-context-engine.md §10) as - 793
/// plain text for a `recall({ turn })` result: one `role: text` line per - 794
/// message, in order. - 795
fn render_full_record(messages: &[Message]) -> String { - 796
messages - 797
.iter() - 798
.map(|message| { - 799
let role = match message.role { - 800
vak_llm::Role::User => "user", - 801
vak_llm::Role::Assistant => "assistant", - 802
}; - 803
format!("{role}: {}", message.text_content()) - 804
}) - 805
.collect::<Vec<_>>() - 806
.join("\n") - 807
} - 808
- 809
/// The text up to and including its first sentence-ending punctuation, - 810
/// used as the deterministic fallback when the narration-gist side call - 811
/// (docs/design/68-context-engine.md §10) errors. A semantic boundary, never - 812
/// a character count. - 813
fn first_sentence_fallback(text: &str) -> String { - 814
let trimmed = text.trim(); - 815
let bytes = trimmed.as_bytes(); - 816
for (i, b) in bytes.iter().enumerate() { - 817
if matches!(b, b'.' | b'!' | b'?') { - 818
let after = i + 1; - 819
if after >= bytes.len() || matches!(bytes[after], b' ' | b'\n') { - 820
return trimmed[..after].to_string(); - 821
} - 822
} - 823
} - 824
trimmed.to_string() - 825
} - 826
- 827
/// The caller-visible narration for a `TurnCard`: verbatim when short (≤ 60 - 828
/// words), otherwise its leading sentence via `first_sentence_fallback` - 829
/// (docs/design/68-context-engine.md §10). Purely deterministic — turn - 830
/// close must never dispatch a model call of its own and block the run - 831
/// finishing on it (measured live: a 3s mock provider delay showed up as a - 832
/// 3.02s gap between the last streamed text and `RunFinished`). - 833
fn resolve_narration(narration: &str) -> String { - 834
if narration.split_whitespace().count() <= 60 { - 835
narration.to_string() - 836
} else { - 837
first_sentence_fallback(narration) - 838
} - 839
} - 840
- 841
/// Words too generic to establish that a card is *about* the same thing as - 842
/// the directive: recency deixis ("current", "now") appears in both a - 843
/// legitimate live-data directive and an unrelated one, and would make any - 844
/// two such directives look related if left in; ordinary function words and - 845
/// a few request-shaped verbs are excluded for the same reason. Deliberately - 846
/// small and topic-neutral — this never grows into a per-domain keyword - 847
/// list, it only strips words that carry no topic of their own. - 848
const TOPIC_STOPWORDS: &[&str] = &[ - 849
"a", - 850
"an", - 851
"the", - 852
"is", - 853
"are", - 854
"was", - 855
"were", - 856
"what", - 857
"who", - 858
"when", - 859
"where", - 860
"how", - 861
"why", - 862
"current", - 863
"currently", - 864
"now", - 865
"today", - 866
"tonight", - 867
"latest", - 868
"right", - 869
"this", - 870
"that", - 871
"of", - 872
"in", - 873
"on", - 874
"at", - 875
"to", - 876
"for", - 877
"and", - 878
"or", - 879
"with", - 880
"me", - 881
"please", - 882
"give", - 883
"tell", - 884
"show", - 885
"get", - 886
"find", - 887
"you", - 888
]; - 889
- 890
/// Lower-cased, stopword-stripped word set of `text`, for a cheap topic - 891
/// overlap check — not a search index, just "do these two strings share a - 892
/// real word". - 893
fn topic_tokens(text: &str) -> std::collections::HashSet<String> { - 894
text.to_ascii_lowercase() - 895
.split(|c: char| !c.is_alphanumeric()) - 896
.filter(|word| word.len() > 2 && !TOPIC_STOPWORDS.contains(word)) - 897
.map(str::to_string) - 898
.collect() - 899
} - 900
- 901
/// Whether an `emit_*_card` call's own text (its title/payload, serialized) - 902
/// shares at least one real word with the directive it is supposed to - 903
/// answer. Found live: a model that had just run a correct, on-topic search - 904
/// still wrote a card from an unrelated older turn's payload ("Noida - 905
/// Weather", "28°C") in answer to "what is the current top news in AI" — the - 906
/// call executed, `derived_from` correctly pointed at the real search - 907
/// result, and nothing else in the loop notices the payload itself has - 908
/// nothing to do with either the question or that evidence. A directive with - 909
/// no topic words of its own (every word is a stopword) is never gated — - 910
/// there is nothing to compare against, and refusing everything would be a - 911
/// worse failure than missing this one. - 912
fn card_shares_a_topic_with(directive: &str, name: &str, input: &serde_json::Value) -> bool { - 913
let directive_words = topic_tokens(directive); - 914
if directive_words.is_empty() { - 915
return true; - 916
} - 917
let card_text = serde_json::to_string(input).unwrap_or_default() + " " + name; - 918
let card_words = topic_tokens(&card_text); - 919
directive_words.iter().any(|word| card_words.contains(word)) - 920
} - 921
- 922
#[cfg(test)] - 923
mod topic_gate_tests { - 924
use super::card_shares_a_topic_with; - 925
- 926
#[test] - 927
fn a_weather_card_matches_a_weather_directive() { - 928
let input = serde_json::json!({ - 929
"semantic_type": "weather", - 930
"payload": {"label": "Noida Weather", "unit": "Celsius", "value": "28°C"} - 931
}); - 932
assert!(card_shares_a_topic_with( - 933
"what is the current weather in noida", - 934
"emit_metric_card", - 935
&input - 936
)); - 937
} - 938
- 939
#[test] - 940
fn the_real_regression_a_weather_card_does_not_match_an_ai_news_directive() { - 941
// Exactly what shipped and broke live: a correct on-topic search for - 942
// "current top news in AI" ran, then the model wrote this weather - 943
// card from an unrelated older turn anyway. - 944
let input = serde_json::json!({ - 945
"semantic_type": "weather", - 946
"payload": {"label": "Noida Weather", "unit": "Celsius", "value": "28°C"} - 947
}); - 948
assert!(!card_shares_a_topic_with( - 949
"what is the current top news in AI", - 950
"emit_metric_card", - 951
&input - 952
)); - 953
} - 954
- 955
#[test] - 956
fn an_on_topic_ai_card_matches() { - 957
let input = serde_json::json!({ - 958
"semantic_type": "research.synthesis", - 959
"payload": {"title": "AI news roundup", "sources": [{"title": "Latest AI breakthroughs"}]} - 960
}); - 961
assert!(card_shares_a_topic_with( - 962
"what is the current top news in AI", - 963
"emit_research_card", - 964
&input - 965
)); - 966
} - 967
- 968
#[test] - 969
fn a_directive_with_only_stopwords_is_never_gated() { - 970
let input = serde_json::json!({"payload": {"label": "anything at all"}}); - 971
assert!(card_shares_a_topic_with( - 972
"what is this now", - 973
"emit_metric_card", - 974
&input - 975
)); - 976
} - 977
- 978
#[test] - 979
fn a_correctly_derived_card_that_renames_the_topic_still_passes() { - 980
// The false-positive risk of a literal word check: a card that is - 981
// genuinely right but titled from what the search actually returned - 982
// ("OpenAI announces GPT-6"), not from the user's own words ("AI - 983
// news"), must not be gated just because it paraphrased. Checked - 984
// against the directive PLUS this turn's evidence together — the - 985
// call site does this by widening the context string before calling - 986
// this function, which is what this test exercises directly. - 987
let input = serde_json::json!({ - 988
"payload": {"title": "OpenAI announces GPT-6", "summary": "a major model release"} - 989
}); - 990
let directive_plus_evidence = - 991
"what is the current top news in AI OpenAI today unveiled GPT-6, its newest model"; - 992
assert!(card_shares_a_topic_with( - 993
directive_plus_evidence, - 994
"emit_entity_card", - 995
&input - 996
)); - 997
} - 998
- 999
#[test] - 1000
fn recency_words_alone_never_establish_a_shared_topic() { - 1001
// Both directives use "current"/"now"/"right"; without stripping - 1002
// them as stopwords, this unrelated pair would look related. - 1003
let input = serde_json::json!({"payload": {"label": "Noida Weather"}}); - 1004
assert!(!card_shares_a_topic_with( - 1005
"what is currently happening right now in politics", - 1006
"emit_metric_card", - 1007
&input - 1008
)); - 1009
} - 1010
} - 1011
- 1012
pub struct AutoApprove; - 1013
- 1014
#[async_trait::async_trait] - 1015
impl Approver for AutoApprove { - 1016
async fn approve(&self, _tool: &str, _args_json: &str, _reason: &str) -> bool { - 1017
true - 1018
} - 1019
} - 1020
- 1021
pub struct AutoDeny; - 1022
- 1023
#[async_trait::async_trait] - 1024
impl Approver for AutoDeny { - 1025
async fn approve(&self, _tool: &str, _args_json: &str, _reason: &str) -> bool { - 1026
false - 1027
} - 1028
- 1029
fn answerable(&self) -> bool { - 1030
false - 1031
} - 1032
} - 1033
- 1034
/// Compact JSON preview of a tool input; capped so UI surfaces never - 1035
/// absorb unbounded payloads. - 1036
fn args_preview(input: &Value) -> String { - 1037
let json = serde_json::to_string(input).unwrap_or_else(|_| "{}".into()); - 1038
truncate_chars(&json, ARGS_PREVIEW_LIMIT) - 1039
} - 1040
- 1041
fn result_preview(content: &str) -> Option<String> { - 1042
if content.is_empty() { - 1043
None - 1044
} else { - 1045
Some(truncate_chars(content, RESULT_PREVIEW_LIMIT)) - 1046
} - 1047
} - 1048
- 1049
fn truncate_chars(s: &str, max: usize) -> String { - 1050
if s.chars().count() <= max { - 1051
return s.to_string(); - 1052
} - 1053
let mut out: String = s.chars().take(max).collect(); - 1054
out.push('…'); - 1055
out - 1056
} - 1057
- 1058
const ARGS_PREVIEW_LIMIT: usize = 400; - 1059
const RESULT_PREVIEW_LIMIT: usize = 2000; - 1060
- 1061
/// What one tool call produced beyond the text its result block carries: the - 1062
/// whole result when the block carries only a window of it - 1063
/// (`vak_tools::window`), and the cards a run it delegated to showed. Filled - 1064
/// where the call executes, drained where the batch's results are recorded. - 1065
#[derive(Debug, Default)] - 1066
struct CallYield { - 1067
body: Option<String>, - 1068
delegated: Option<vak_tools::DelegatedCards>, - 1069
} - 1070
- 1071
type CallYields = Arc<StdMutex<HashMap<String, CallYield>>>; - 1072
- 1073
/// The text a result block carries for `content`: the content itself, or a - 1074
/// window of it when it is over-long, with the whole kept aside for the - 1075
/// ledger so `recall` returns it. - 1076
fn windowed_result(id: &str, content: String, yields: &CallYields) -> String { - 1077
match vak_tools::window(&content, Some(id), 1) { - 1078
Some(window) => { - 1079
yields - 1080
.lock() - 1081
.unwrap_or_else(std::sync::PoisonError::into_inner) - 1082
.entry(id.to_string()) - 1083
.or_default() - 1084
.body = Some(content); - 1085
window - 1086
} - 1087
None => content, - 1088
} - 1089
} - 1090
- 1091
#[derive(Clone)]
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.