- 1
//! The `commitments` tool: the agent can read its own portfolio. - 2
//! - 3
//! # Why a tool rather than a chat command - 4
//! - 5
//! "What are you working on?", "what's blocked?", "did that ever finish?" are - 6
//! ordinary questions, and they arrive on every surface — a Telegram message, - 7
//! a desktop turn, a cron check-in. Building a slash-command layer in the - 8
//! gateway would answer them on exactly one surface and create a second - 9
//! dispatch path beside the tool broker. - 10
//! - 11
//! As a capability it reaches all of them at once, crosses the same permission - 12
//! boundary as everything else, and appears in the ledger like any other call. - 13
//! - 14
//! Strictly read-only. Opening, advancing and closing a commitment are effects - 15
//! the runtime performs; the model may propose criteria but never marks one - 16
//! passed (`AGENTS.md` invariant 33), so there is deliberately no write verb - 17
//! here for it to reach for. - 18
- 19
use serde_json::{Value, json}; - 20
- 21
use vak_commit::{CommitmentLedger, SchedulerContext, rank}; - 22
- 23
pub struct CommitmentsTool { - 24
/// The Agent's own home: commitments are held by the Agent that took - 25
/// them on (docs/design/64-agent-owned-platform.md). - 26
pub sessions_home: std::path::PathBuf, - 27
/// The conversation audience asking. When set, only commitments that - 28
/// audience asked for are visible: an Agent serving several chats never - 29
/// shows one chat what another asked of it. - 30
pub audience_id: Option<String>, - 31
} - 32
- 33
impl CommitmentsTool { - 34
fn visible(&self, commitment: &vak_commit::Commitment) -> bool { - 35
self.audience_id - 36
.as_deref() - 37
.is_none_or(|audience| commitment.spec.audience_id.as_deref() == Some(audience)) - 38
} - 39
} - 40
- 41
#[async_trait::async_trait] - 42
impl vak_tools::Tool for CommitmentsTool { - 43
fn name(&self) -> &str { - 44
"commitments" - 45
} - 46
- 47
fn serves(&self) -> &'static [&'static str] { - 48
&["memory", "orchestration"] - 49
} - 50
- 51
fn always_loaded(&self) -> bool { - 52
true - 53
} - 54
- 55
fn description(&self) -> &str { - 56
"Read the durable commitments this agent holds: long-running work that \ - 57
outlives a single conversation, what evidence each one still needs \ - 58
before it can be called done, what is waiting on a person, and what \ - 59
already closed and how. Use when the user asks what you are working \ - 60
on, what is blocked or outstanding, whether something ever finished, \ - 61
or what you owe them. Read-only." - 62
} - 63
- 64
fn schema(&self) -> Value { - 65
json!({ - 66
"type": "object", - 67
"properties": { - 68
"include_closed": { - 69
"type": "boolean", - 70
"description": "Include commitments that have already closed (default false)" - 71
}, - 72
"id": { - 73
"type": "string", - 74
"description": "Full id or unique prefix of one commitment to read in detail" - 75
} - 76
} - 77
}) - 78
} - 79
- 80
async fn execute(&self, args: &Value, _ctx: &vak_tools::ToolContext) -> vak_tools::ToolOutput { - 81
let ledger = CommitmentLedger::new(&self.sessions_home); - 82
let include_closed = args - 83
.get("include_closed") - 84
.and_then(Value::as_bool) - 85
.unwrap_or(false); - 86
- 87
if let Some(id) = args.get("id").and_then(Value::as_str) { - 88
let all = ledger.all(); - 89
let matches: Vec<_> = all - 90
.iter() - 91
.filter(|c| self.visible(c)) - 92
.filter(|c| c.commitment_id == id || c.commitment_id.starts_with(id)) - 93
.collect(); - 94
return match matches.as_slice() { - 95
[] => vak_tools::ToolOutput::error(format!("no commitment matching '{id}'")), - 96
// An ambiguous prefix names nothing rather than whichever row - 97
// happened to sort first. - 98
[_, _, ..] => vak_tools::ToolOutput::error(format!( - 99
"'{id}' matches {} commitments; use a longer prefix", - 100
matches.len() - 101
)), - 102
[one] => vak_tools::ToolOutput::ok(detail(one)), - 103
}; - 104
} - 105
- 106
let commitments: Vec<_> = if include_closed { - 107
ledger.all() - 108
} else { - 109
ledger.open() - 110
} - 111
.into_iter() - 112
.filter(|commitment| self.visible(commitment)) - 113
.collect(); - 114
if commitments.is_empty() { - 115
return vak_tools::ToolOutput::ok( - 116
"No commitments. Nothing asked of this agent so far has needed to \ - 117
outlive its session.", - 118
); - 119
} - 120
// Ranked by the same scheduler the runtime uses, so what the model - 121
// reports as "next" is what would actually be worked next. - 122
let ranked = rank(&commitments, &SchedulerContext::default()); - 123
let mut out = String::new(); - 124
for priority in ranked { - 125
let Some(commitment) = commitments - 126
.iter() - 127
.find(|c| c.commitment_id == priority.commitment_id) - 128
else { - 129
continue; - 130
}; - 131
let short = &commitment.commitment_id[..8.min(commitment.commitment_id.len())]; - 132
out.push_str(&format!("[{short}] {}\n", commitment.summary())); - 133
match &priority.withheld { - 134
Some(reason) => out.push_str(&format!(" held: {reason}\n")), - 135
None => out.push_str(&format!(" priority {:.1}\n", priority.score)), - 136
} - 137
// The evidence gap is the fact worth volunteering: it is the - 138
// difference between work that is finished and work that merely - 139
// looks finished. - 140
if commitment.closure.is_none() { - 141
let achieved = commitment.achieved_strength(); - 142
let required = commitment.spec.min_satisfaction; - 143
if !achieved.satisfies(required) { - 144
out.push_str(&format!( - 145
" needs {} evidence to close; has {}\n", - 146
required.as_str(), - 147
achieved.as_str() - 148
)); - 149
} - 150
} - 151
} - 152
out.push_str( - 153
"\nYou may report and discuss these. You cannot mark a criterion passed — \ - 154
the runtime evaluates them.", - 155
); - 156
vak_tools::ToolOutput::ok(out) - 157
} - 158
} - 159
- 160
fn detail(commitment: &vak_commit::Commitment) -> String { - 161
let mut out = format!( - 162
"{}\n id {}\n phase {}\n opened {}\n requires {} evidence; has {}\n spend ${:.4}\n", - 163
commitment.spec.objective, - 164
commitment.commitment_id, - 165
commitment.phase.as_str(), - 166
commitment.opened_at.to_rfc3339(), - 167
commitment.spec.min_satisfaction.as_str(), - 168
commitment.achieved_strength().as_str(), - 169
commitment.spend_usd, - 170
); - 171
if let Some(suspension) = &commitment.suspension { - 172
out.push_str(&format!(" waiting: {}\n", suspension.describe())); - 173
} - 174
if let Some(blocker) = &commitment.blocker { - 175
out.push_str(&format!(" blocked: {blocker}\n")); - 176
} - 177
if commitment.is_stalled() { - 178
out.push_str(&format!( - 179
" stalled: {} consecutive episodes made no progress\n", - 180
commitment.consecutive_stalls - 181
)); - 182
} - 183
if !commitment.criteria.is_empty() { - 184
out.push_str(" criteria:\n"); - 185
for criterion in &commitment.criteria { - 186
out.push_str(&format!( - 187
" [{}] {} ({})\n", - 188
if criterion.passed() { "pass" } else { "open" }, - 189
criterion.statement, - 190
criterion - 191
.strength - 192
.map(|s| s.as_str()) - 193
.unwrap_or("not evaluated"), - 194
)); - 195
} - 196
} - 197
if let Some(closure) = &commitment.closure { - 198
out.push_str(&format!( - 199
" closed {} on {} evidence — {}\n", - 200
closure.verdict.as_str(), - 201
closure.strength.as_str(), - 202
closure.note - 203
)); - 204
} - 205
out - 206
} - 207
- 208
#[cfg(test)] - 209
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] - 210
mod tests { - 211
use super::*; - 212
use vak_commit::{Economics, Event, EventKind}; - 213
use vak_intent::{Evidence, Horizon, Reading, Satisfaction}; - 214
use vak_session::types::{CriterionKind, CriterionResult, WorkCriterion}; - 215
use vak_tools::Tool; - 216
- 217
fn ctx() -> vak_tools::ToolContext { - 218
vak_tools::ToolContext::new(std::path::PathBuf::from("/tmp")) - 219
} - 220
- 221
fn open(dir: &std::path::Path, evidence: Evidence) -> String { - 222
let reading = Reading { - 223
horizon: Horizon::Durable, - 224
evidence, - 225
..Reading::general() - 226
}; - 227
CommitmentLedger::new(dir) - 228
.open_commitment(vak_commit::spec_from_reading( - 229
"migrate the billing schema", - 230
reading, - 231
vec![WorkCriterion { - 232
criterion_id: "tests".into(), - 233
statement: "cargo test passes".into(), - 234
kind: CriterionKind::Shell { - 235
command: "cargo test".into(), - 236
}, - 237
required: true, - 238
}], - 239
dir.to_path_buf(), - 240
Economics::default(), - 241
)) - 242
.unwrap() - 243
} - 244
- 245
#[tokio::test] - 246
async fn an_empty_portfolio_says_so_plainly() { - 247
let dir = tempfile::tempdir().unwrap(); - 248
let tool = CommitmentsTool { - 249
sessions_home: dir.path().to_path_buf(), - 250
audience_id: None, - 251
}; - 252
let out = tool.execute(&json!({}), &ctx()).await; - 253
assert!(!out.is_error); - 254
assert!(out.content.contains("No commitments")); - 255
} - 256
- 257
/// The evidence gap is the fact worth volunteering: it separates work that - 258
/// is finished from work that merely looks finished. - 259
#[tokio::test] - 260
async fn the_listing_names_the_evidence_a_commitment_still_needs() { - 261
let dir = tempfile::tempdir().unwrap(); - 262
open(dir.path(), Evidence::Verified); - 263
let tool = CommitmentsTool { - 264
sessions_home: dir.path().to_path_buf(), - 265
audience_id: None, - 266
}; - 267
let out = tool.execute(&json!({}), &ctx()).await; - 268
assert!( - 269
out.content.contains("needs observed evidence"), - 270
"{}", - 271
out.content - 272
); - 273
// And it tells the model, in the result, that it cannot close this - 274
// itself — the separation of powers is stated where it is relevant. - 275
assert!(out.content.contains("cannot mark a criterion passed")); - 276
} - 277
- 278
#[tokio::test] - 279
async fn detail_reports_criteria_and_their_strength() { - 280
let dir = tempfile::tempdir().unwrap(); - 281
let id = open(dir.path(), Evidence::Verified); - 282
CommitmentLedger::new(dir.path()) - 283
.append(&Event::new( - 284
&id, - 285
EventKind::CriterionEvaluated { - 286
criterion_id: "tests".into(), - 287
result: CriterionResult::Passed { - 288
evidence: "exit 0".into(), - 289
}, - 290
strength: Satisfaction::Observed, - 291
}, - 292
)) - 293
.unwrap(); - 294
let tool = CommitmentsTool { - 295
sessions_home: dir.path().to_path_buf(), - 296
audience_id: None, - 297
}; - 298
let out = tool.execute(&json!({ "id": &id[..8] }), &ctx()).await; - 299
assert!(out.content.contains("cargo test passes")); - 300
assert!(out.content.contains("observed")); - 301
} - 302
- 303
#[tokio::test] - 304
async fn an_ambiguous_prefix_names_nothing_rather_than_guessing() { - 305
let dir = tempfile::tempdir().unwrap(); - 306
open(dir.path(), Evidence::None); - 307
open(dir.path(), Evidence::None); - 308
let tool = CommitmentsTool { - 309
sessions_home: dir.path().to_path_buf(), - 310
audience_id: None, - 311
}; - 312
// uuid v7 ids share a time-ordered prefix, so a one-character prefix - 313
// is genuinely ambiguous here. - 314
let out = tool.execute(&json!({ "id": "0" }), &ctx()).await; - 315
assert!(out.is_error); - 316
assert!(out.content.contains("longer prefix")); - 317
} - 318
- 319
/// An Agent serving two chats never shows one what the other asked of it, - 320
/// by listing or by id. - 321
#[tokio::test] - 322
async fn one_audience_never_sees_anothers_commitments() { - 323
let dir = tempfile::tempdir().unwrap(); - 324
let ledger = CommitmentLedger::new(dir.path()); - 325
let mut spec = vak_commit::spec_from_reading( - 326
"renew the lease for the other chat", - 327
Reading { - 328
horizon: Horizon::Durable, - 329
..Reading::general() - 330
}, - 331
Vec::new(), - 332
dir.path().to_path_buf(), - 333
Economics::default(), - 334
); - 335
spec.audience_id = Some("telegram:other".into()); - 336
let theirs = ledger.open_commitment(spec).unwrap(); - 337
let tool = CommitmentsTool { - 338
sessions_home: dir.path().to_path_buf(), - 339
audience_id: Some("telegram:mine".into()), - 340
}; - 341
let listed = tool.execute(&json!({}), &ctx()).await; - 342
assert!( - 343
listed.content.contains("No commitments"), - 344
"{}", - 345
listed.content - 346
); - 347
let by_id = tool.execute(&json!({ "id": theirs }), &ctx()).await; - 348
assert!(by_id.is_error); - 349
- 350
let owner = CommitmentsTool { - 351
sessions_home: dir.path().to_path_buf(), - 352
audience_id: Some("telegram:other".into()), - 353
}; - 354
let listed = owner.execute(&json!({}), &ctx()).await; - 355
assert!( - 356
listed.content.contains("renew the lease"), - 357
"{}", - 358
listed.content - 359
); - 360
} - 361
- 362
#[tokio::test] - 363
async fn an_unknown_id_is_an_error_value_not_a_panic() { - 364
let dir = tempfile::tempdir().unwrap(); - 365
let tool = CommitmentsTool { - 366
sessions_home: dir.path().to_path_buf(), - 367
audience_id: None, - 368
}; - 369
let out = tool.execute(&json!({ "id": "nope" }), &ctx()).await; - 370
assert!(out.is_error); - 371
} - 372
} - 373
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.