- 1
//! vak-tools: built-in agent tools behind one trait. - 2
//! - 3
//! Contract: tools never panic and never return Err; failures are - 4
//! ToolOutput::error values fed back to the model for self-correction. - 5
- 6
pub mod artifact; - 7
pub mod bash; - 8
pub mod broker; - 9
pub mod context; - 10
pub mod contract; - 11
pub mod doc_read; - 12
pub mod edit; - 13
pub mod find_tools; - 14
pub mod glob; - 15
pub mod grep; - 16
#[cfg(target_os = "linux")] - 17
pub mod landlock; - 18
pub mod office_apply; - 19
pub mod read; - 20
pub mod recall; - 21
pub mod retired; - 22
pub mod sandbox; - 23
pub mod sandbox_events; - 24
pub mod webbrowse; - 25
pub mod webfetch; - 26
pub mod window; - 27
pub mod write; - 28
- 29
use async_trait::async_trait; - 30
use serde_json::Value; - 31
- 32
pub use context::ToolContext; - 33
pub use contract::validate_input; - 34
pub use find_tools::FindToolsTool; - 35
pub use recall::{RecallRequest, RecallTool, apply_range, parse_recall_args}; - 36
pub use sandbox_events::{SandboxEvent, SandboxEventSink}; - 37
pub use webbrowse::WebBrowseTool; - 38
pub use webfetch::WebFetchTool; - 39
pub use window::{LINE_WINDOW_CHARS, RESULT_WINDOW_CHARS, bounded, window}; - 40
- 41
/// Machine classification of a tool failure, so the agent loop and the output - 42
/// gate can reason about it instead of re-running keyword matches against - 43
/// free-text errors. `ToolErrorKind::classify` is the single authority that - 44
/// drives both the recovery hint and the repair budget — there is no parallel - 45
/// classification in the agent. - 46
#[derive(Debug, Clone, Copy, PartialEq, Eq)] - 47
pub enum ToolErrorKind { - 48
/// The call shape was wrong (missing/invalid argument, schema mismatch, - 49
/// unknown capability, oversized payload, or partial/malformed transport - 50
/// data). Plausibly repairable by re-issuing with corrected arguments. - 51
Correctable, - 52
/// A transient transport fault; the runtime may retry, the model cannot - 53
/// repair it by editing arguments. - 54
Transient, - 55
/// The tool is not admitted this turn (not in the frozen capability set). - 56
NotAdmitted, - 57
/// Policy / permission / capability-surface denial. - 58
Denied, - 59
/// Authentication / authorization failure. - 60
Auth, - 61
/// Provider rate limiting. - 62
RateLimited, - 63
/// User-initiated cancellation. - 64
Cancelled, - 65
/// Unclassified. - 66
Unknown, - 67
} - 68
- 69
impl ToolErrorKind { - 70
/// Classify a tool-error content string. Order matters: non-repairable - 71
/// categories are matched before `Correctable`, so an auth error that - 72
/// happens to contain "invalid" is never misread as a repairable - 73
/// argument fault. This subsumes and extends the agent's old keyword - 74
/// list — including the previously-uncovered payload/size faults (e.g. - 75
/// a body exceeding the fetch byte cap) so they get classified instead - 76
/// of falling through to `Unknown`. - 77
pub fn classify(error: &str) -> Self { - 78
let lower = error.to_ascii_lowercase(); - 79
if lower.contains("cancel") || lower.contains("aborted") { - 80
return Self::Cancelled; - 81
} - 82
if lower.contains("rate limit") || lower.contains("429") || lower.contains("retry-after") { - 83
return Self::RateLimited; - 84
} - 85
if lower.contains("unauthorized") - 86
|| lower.contains("forbidden") - 87
|| lower.contains("credential") - 88
|| lower.contains("token") - 89
|| lower.contains("authentication") - 90
|| lower.contains("unauthenticated") - 91
{ - 92
return Self::Auth; - 93
} - 94
if lower.contains("denied") - 95
|| lower.contains("revoked") - 96
|| lower.contains("not allowed") - 97
|| lower.contains("permission") - 98
|| lower.contains("approval") - 99
|| lower.contains("policy") - 100
{ - 101
return Self::Denied; - 102
} - 103
if lower.contains("not admitted") { - 104
return Self::NotAdmitted; - 105
} - 106
// Correctable: the caller can plausibly fix this by re-issuing with - 107
// corrected arguments. Covers the original hint set plus the - 108
// payload/size and generic argument faults that used to be missed. - 109
// `unknown_capability` is correctable — the model can switch to an - 110
// admitted broker name instead. - 111
if lower.contains("unknown_capability") - 112
|| lower.contains("invalid") - 113
|| lower.contains("missing") - 114
|| lower.contains("parameter") - 115
|| lower.contains("argument") - 116
|| lower.contains("expected") - 117
|| lower.contains("not a valid") - 118
|| lower.contains("schema") - 119
|| lower.contains("malformed") - 120
|| lower.contains("truncated") - 121
|| lower.contains("partial") - 122
|| lower.contains("parse") - 123
|| lower.contains("exceeds") - 124
|| lower.contains("too large") - 125
|| lower.contains("payload") - 126
|| lower.contains("byte") - 127
|| lower.contains("cap") - 128
|| lower.contains("size") - 129
|| lower.contains("input too") - 130
|| lower.contains("timed out") - 131
|| lower.contains("connection closed") - 132
|| lower.contains("spawn failed") - 133
|| lower.contains("protocol error") - 134
|| lower.contains("tool task failed") - 135
{ - 136
return Self::Correctable; - 137
} - 138
Self::Unknown - 139
} - 140
- 141
/// Whether the failure class carries a model recovery hint today. - 142
/// `Transient`, `Auth`, `RateLimited`, `Denied`, `Cancelled`, - 143
/// `NotAdmitted`, and `Unknown` deliberately do **not** — matching the - 144
/// original contract that only requires an external state change or a - 145
/// model-side argument fix. - 146
pub fn is_correctable(self) -> bool { - 147
matches!(self, Self::Correctable) - 148
} - 149
} - 150
- 151
/// A validated card, ready to become a `Presentation` ledger entry - 152
/// (docs/design/68-context-engine.md §10): the canonical payload plus the - 153
/// schema-driven title and identity digest, and the skill that owns its type. - 154
#[derive(Debug, Clone, PartialEq)] - 155
pub struct PresentationCard { - 156
pub semantic_type: String, - 157
pub skill_id: String, - 158
pub skill_version: String, - 159
pub schema_version: u32, - 160
/// Canonical (key-sorted) payload. - 161
pub payload: Value, - 162
pub title: String, - 163
pub identity_digest: String, - 164
} - 165
- 166
/// What a tool returns. `content` is the whole result: a tool never shortens - 167
/// its own output, because the calling loop records it in full and decides - 168
/// how much a request shows (`window`). - 169
#[derive(Debug, Clone, Default)] - 170
pub struct ToolOutput { - 171
pub content: String, - 172
pub is_error: bool, - 173
/// The cards a run this call delegated to showed (a `task` worker's). - 174
/// The calling loop records each as a presentation of this call, so the - 175
/// user sees it and the delegating agent can recall it. In-process only: - 176
/// a brokered worker's reply cannot fill it. - 177
pub delegated: Option<DelegatedCards>, - 178
} - 179
- 180
/// The cards a delegated run validated and showed, and the session that ran - 181
/// it, whose ledger holds each card's original call. - 182
#[derive(Debug, Clone, PartialEq)] - 183
pub struct DelegatedCards { - 184
pub session_id: String, - 185
pub cards: Vec<PresentationCard>, - 186
} - 187
- 188
impl ToolOutput { - 189
pub fn ok(content: impl Into<String>) -> Self { - 190
ToolOutput { - 191
content: content.into(), - 192
is_error: false, - 193
delegated: None, - 194
} - 195
} - 196
- 197
pub fn error(content: impl Into<String>) -> Self { - 198
ToolOutput { - 199
content: content.into(), - 200
is_error: true, - 201
delegated: None, - 202
} - 203
} - 204
- 205
/// Classify this output's failure kind, or `Unknown` for a success. - 206
pub fn classify(&self) -> ToolErrorKind { - 207
if !self.is_error { - 208
return ToolErrorKind::Unknown; - 209
} - 210
ToolErrorKind::classify(&self.content) - 211
} - 212
} - 213
- 214
/// Workspace-relative directory where files a person sends (on a channel or - 215
/// dropped in a client) are saved, so every file tool reaches them under - 216
/// invariant 10 (docs/design/72, "File in"). - 217
pub const INBOX_DIR: &str = "inbox"; - 218
- 219
#[async_trait] - 220
pub trait Tool: Send + Sync { - 221
fn name(&self) -> &str; - 222
fn description(&self) -> &str; - 223
fn schema(&self) -> Value; - 224
- 225
async fn execute(&self, args: &Value, ctx: &ToolContext) -> ToolOutput; - 226
- 227
/// What this call needs from the world while it runs. The default is - 228
/// unclaimed: schedulers may run it alongside anything. - 229
fn claims(&self, _args: &Value) -> ResourceClaims { - 230
ResourceClaims::default() - 231
} - 232
- 233
/// Whether a successful call *is* the answer being presented to the user - 234
/// (it shows a card) rather than a step toward one. The agent loop reads - 235
/// this to know a card was emitted; it does not infer it from the name. - 236
fn presents_cards(&self) -> bool { - 237
false - 238
} - 239
- 240
/// The capability domains this tool serves (`filesystem`, `web`, …; the - 241
/// vocabulary is `vak_core::capability::Domain`). The tool classifies - 242
/// itself so the harness never keeps a name table. Empty means - 243
/// undeclared, and an undeclared tool is always loaded: deferring is a - 244
/// context saving, never a policy, so an unknown tool fails open. - 245
fn serves(&self) -> &'static [&'static str] { - 246
&[] - 247
} - 248
- 249
/// Loaded into every turn's schemas regardless of the turn's reading — - 250
/// the orientation and discovery primitives a model needs to operate at - 251
/// all. Everything else is loaded when the reading predicts it and is - 252
/// otherwise one `find_tools` call away. - 253
fn always_loaded(&self) -> bool { - 254
false - 255
} - 256
- 257
/// The workspace path a successful call with these arguments delivers - 258
/// for the person to review through its own card (an Office draft with - 259
/// Review). The answer need not present it again: the presentation check - 260
/// stands down for the run, an identical call is answered with the first - 261
/// one's result, and a card previewing that path is withheld. Unlike - 262
/// `presents_cards`, this has no bearing on permission. - 263
fn delivered_file(&self, _args: &Value) -> Option<String> { - 264
None - 265
} - 266
- 267
/// A reason this call cannot succeed, decided from its arguments alone - 268
/// before permission is evaluated, so a person is never asked to approve - 269
/// a call that would only be refused (a text edit of a Word file). It - 270
/// can only refuse: `None` means "evaluate as usual", never "allowed". - 271
fn refusal(&self, _args: &Value) -> Option<String> { - 272
None - 273
} - 274
} - 275
- 276
/// Declared concurrent-execution constraints for one tool invocation. - 277
#[derive(Debug, Clone, PartialEq, Eq, Default)] - 278
pub struct ResourceClaims { - 279
/// Conflicts with every other claimed call (unknown or whole-world scope). - 280
pub exclusive: bool, - 281
/// Never conflicts; safe to fan out freely. - 282
pub read_only: bool, - 283
/// Path scopes the call will touch (globs; `src/**` style). - 284
pub paths: Vec<String>, - 285
} - 286
- 287
impl ResourceClaims { - 288
pub fn is_unclaimed(&self) -> bool { - 289
!self.exclusive && !self.read_only && self.paths.is_empty() - 290
} - 291
- 292
/// Conservative prefix test after normalization: two write scopes - 293
/// conflict when one contains the other. May over-serialize; never - 294
/// under-serializes. - 295
pub fn conflicts(&self, other: &ResourceClaims) -> bool { - 296
if self.is_unclaimed() || other.is_unclaimed() || self.read_only || other.read_only { - 297
return false; - 298
} - 299
if self.exclusive || other.exclusive { - 300
return true; - 301
} - 302
for a in &self.paths { - 303
let na = normalize_scope(a); - 304
for b in &other.paths { - 305
let nb = normalize_scope(b); - 306
if na.starts_with(&nb) || nb.starts_with(&na) { - 307
return true; - 308
} - 309
} - 310
} - 311
false - 312
} - 313
} - 314
- 315
fn normalize_scope(scope: &str) -> String { - 316
let mut s = scope.trim().trim_end_matches(['/', '*']).to_string(); - 317
while s.ends_with('/') { - 318
s.pop(); - 319
} - 320
if !s.ends_with('/') { - 321
s.push('/'); - 322
} - 323
s - 324
} - 325
- 326
pub fn default_tools() -> Vec<std::sync::Arc<dyn Tool>> { - 327
vec![ - 328
std::sync::Arc::new(read::ReadTool), - 329
std::sync::Arc::new(write::WriteTool), - 330
std::sync::Arc::new(edit::EditTool), - 331
std::sync::Arc::new(bash::BashTool), - 332
std::sync::Arc::new(glob::GlobTool), - 333
std::sync::Arc::new(grep::GrepTool), - 334
std::sync::Arc::new(doc_read::DocReadTool), - 335
std::sync::Arc::new(office_apply::OfficeApplyTool), - 336
] - 337
} - 338
- 339
/// Read/glob/grep/doc_read subset for explore-style workers. - 340
pub fn read_only_tools() -> Vec<std::sync::Arc<dyn Tool>> { - 341
vec![ - 342
std::sync::Arc::new(read::ReadTool), - 343
std::sync::Arc::new(glob::GlobTool), - 344
std::sync::Arc::new(grep::GrepTool), - 345
std::sync::Arc::new(doc_read::DocReadTool), - 346
] - 347
} - 348
- 349
pub fn brokered_default_tools(worker_exe: std::path::PathBuf) -> Vec<std::sync::Arc<dyn Tool>> { - 350
brokered_tools(worker_exe, &[]) - 351
} - 352
- 353
/// The default tools behind the broker, each told which Office files are - 354
/// new to the workspace a task copy was made from (see - 355
/// [`ToolContext::new_documents`]). - 356
pub fn brokered_tools( - 357
worker_exe: std::path::PathBuf, - 358
new_documents: &[String], - 359
) -> Vec<std::sync::Arc<dyn Tool>> { - 360
default_tools() - 361
.into_iter() - 362
.map(|tool| { - 363
std::sync::Arc::new(broker::BrokeredTool::new( - 364
tool, - 365
worker_exe.clone(), - 366
new_documents.to_vec(), - 367
)) as std::sync::Arc<dyn Tool> - 368
}) - 369
.collect() - 370
} - 371
- 372
pub fn brokered_read_only_tools(worker_exe: std::path::PathBuf) -> Vec<std::sync::Arc<dyn Tool>> { - 373
read_only_tools() - 374
.into_iter() - 375
.map(|tool| { - 376
std::sync::Arc::new(broker::BrokeredTool::new( - 377
tool, - 378
worker_exe.clone(), - 379
Vec::new(), - 380
)) as std::sync::Arc<dyn Tool> - 381
}) - 382
.collect() - 383
} - 384
- 385
pub fn definitions(tools: &[std::sync::Arc<dyn Tool>]) -> Vec<vak_llm::ToolDefinition> { - 386
tools - 387
.iter() - 388
.map(|t| vak_llm::ToolDefinition::new(t.name(), t.description(), t.schema())) - 389
.collect() - 390
} - 391
- 392
/// Canonical tool name resolution for aliases and common model hallucinations. - 393
/// Also remaps retired tool names (e.g. `python_eval`) to their replacement - 394
/// (`bash`), so a model that still reaches for a retired name is guided to - 395
/// the correct tool rather than producing an `unknown_capability` failure. - 396
pub fn canonical_tool_name(name: &str) -> &str { - 397
let trimmed = name.trim(); - 398
if trimmed.eq_ignore_ascii_case("session-search") { - 399
return "session_search"; - 400
} - 401
if trimmed.eq_ignore_ascii_case("propose-skill") { - 402
return "propose_skill"; - 403
} - 404
if trimmed.eq_ignore_ascii_case("read-file") || trimmed.eq_ignore_ascii_case("read_file") { - 405
return "read"; - 406
} - 407
if trimmed.eq_ignore_ascii_case("write-file") || trimmed.eq_ignore_ascii_case("write_file") { - 408
return "write"; - 409
} - 410
if trimmed.eq_ignore_ascii_case("edit-file") || trimmed.eq_ignore_ascii_case("edit_file") { - 411
return "edit"; - 412
} - 413
if trimmed.eq_ignore_ascii_case("shell") - 414
|| trimmed.eq_ignore_ascii_case("sh") - 415
|| trimmed.eq_ignore_ascii_case("sandbox") - 416
|| trimmed.eq_ignore_ascii_case("sandbox_exec") - 417
|| trimmed.eq_ignore_ascii_case("terminal") - 418
|| trimmed.eq_ignore_ascii_case("exec") - 419
{ - 420
return "bash"; - 421
} - 422
// Retired tool names: redirect to their replacement (currently `bash`). - 423
for tool in retired::RETIRED_TOOLS { - 424
if tool.name.eq_ignore_ascii_case(trimmed) { - 425
return tool - 426
.replacement - 427
.split(" — ") - 428
.next() - 429
.unwrap_or("bash") - 430
.trim(); - 431
} - 432
} - 433
name - 434
} - 435
- 436
#[cfg(test)] - 437
mod tests { - 438
use super::*; - 439
- 440
#[test] - 441
fn classify_correctable_covers_arg_shape_and_payload_faults() { - 442
// The previously-uncovered webfetch byte-cap case must now classify - 443
// as correctable instead of falling through to Unknown (which meant - 444
// it got no recovery hint at all in the agent). - 445
assert_eq!( - 446
ToolErrorKind::classify("response body exceeds the 524288 byte cap"), - 447
ToolErrorKind::Correctable, - 448
); - 449
assert_eq!( - 450
ToolErrorKind::classify("missing required parameter: server"), - 451
ToolErrorKind::Correctable, - 452
); - 453
assert_eq!( - 454
ToolErrorKind::classify("invalid tool arguments: expected object"), - 455
ToolErrorKind::Correctable, - 456
); - 457
assert_eq!( - 458
ToolErrorKind::classify(r#"{"type":"unknown_capability","name":"tavily_search"}"#), - 459
ToolErrorKind::Correctable, - 460
); - 461
assert_eq!( - 462
ToolErrorKind::classify("mcp protocol error: invalid argument"), - 463
ToolErrorKind::Correctable, - 464
); - 465
} - 466
- 467
#[test] - 468
fn classify_non_repairable_classes_get_no_hint() { - 469
assert_eq!( - 470
ToolErrorKind::classify("cancelled"), - 471
ToolErrorKind::Cancelled - 472
); - 473
assert_eq!( - 474
ToolErrorKind::classify("429 rate limit exceeded"), - 475
ToolErrorKind::RateLimited, - 476
); - 477
assert_eq!( - 478
ToolErrorKind::classify("capability denied by channel policy"), - 479
ToolErrorKind::Denied, - 480
); - 481
assert_eq!( - 482
ToolErrorKind::classify("unauthorized: bad token"), - 483
ToolErrorKind::Auth, - 484
); - 485
assert_eq!( - 486
ToolErrorKind::classify("tool not admitted on this turn"), - 487
ToolErrorKind::NotAdmitted, - 488
); - 489
assert!(!ToolErrorKind::classify("cancelled").is_correctable()); - 490
assert!(!ToolErrorKind::classify("429 rate limit").is_correctable()); - 491
assert!(ToolErrorKind::classify("missing required parameter: server").is_correctable()); - 492
} - 493
- 494
#[test] - 495
fn success_classifies_unknown() { - 496
let ok = ToolOutput::ok("done"); - 497
assert_eq!(ok.classify(), ToolErrorKind::Unknown); - 498
assert!(!ok.classify().is_correctable()); - 499
} - 500
- 501
#[test] - 502
fn canonical_tool_name_resolves_common_hallucinations_and_aliases() { - 503
assert_eq!(canonical_tool_name("read_file"), "read"); - 504
assert_eq!(canonical_tool_name("write_file"), "write"); - 505
assert_eq!(canonical_tool_name("edit_file"), "edit"); - 506
assert_eq!(canonical_tool_name("session-search"), "session_search"); - 507
assert_eq!(canonical_tool_name("unknown_tool"), "unknown_tool"); - 508
} - 509
- 510
#[test] - 511
fn canonical_tool_name_redirects_retired_tools_to_bash() { - 512
// python_eval and react_preview were retired in 3.0.21; the model - 513
// may still reach for them. They must resolve to `bash`. - 514
assert_eq!(canonical_tool_name("python_eval"), "bash"); - 515
assert_eq!(canonical_tool_name("react_preview"), "bash"); - 516
assert_eq!(canonical_tool_name("PYTHON_EVAL"), "bash"); - 517
} - 518
} - 519
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.