- 1
//! `emit_*_card` tools: function-calling based presentation card emission. - 2
//! - 3
//! Measured root cause (2026-09-18, against the real local model this app - 4
//! ships with, `gemma4:e2b-mlx` via Ollama's OpenAI-compatible endpoint at - 5
//! `http://localhost:11434/v1`, the exact endpoint `vak-llm`'s "ollama" - 6
//! provider uses): - 7
//! - 8
//! - Free-text `vak` fences embedded in prose: 1/5 syntactically valid JSON. - 9
//! - Tool-calling with a precise, per-shape JSON Schema as the function's - 10
//! `parameters`: 5/5 valid AND exact shape match, repeated across - 11
//! multiple categories. - 12
//! - Tool-calling with one generic/loose schema: worse than fences in one - 13
//! dimension (the model sometimes emits no tool call and no text at - 14
//! all) and the payload shape still drifted. Precision in the schema is - 15
//! what buys the reliability, not tool-calling by itself. - 16
//! - 17
//! So: one tool per real payload *shape* (not per `semantic_type` — many - 18
//! semantic types share an identical shape, e.g. every timeline-flavored - 19
//! type), each with a schema precise enough to match what the client - 20
//! renderer actually reads. These are the same 12 shape categories already - 21
//! used to build the client-side render harness - 22
//! (`crates/vak-client-ui/src/harness/fixtures.ts` `CATEGORY_FIXTURES`) — - 23
//! kept in sync deliberately, since both are describing the same - 24
//! `build*Spec` functions in `GenericSpecRenderer.tsx`. - 25
//! - 26
//! Call and response, not echo: the model's `emit_*_card` call carries the - 27
//! card in its arguments (schema-constrained, and recorded untruncated in the - 28
//! ledger). `execute()` validates them against the real `SkillRegistry` and - 29
//! answers with a short ack (now carrying the id of the `Presentation` - 30
//! ledger entry written at validation) — or, if the card is invalid, a tool - 31
//! error the model can repair in the same turn. `vak-server`'s projection - 32
//! reads that ledger entry directly (docs/design/68-context-engine.md - 33
//! §10); nothing rebuilds the card from call arguments at display time any - 34
//! more. The card is never carried in the result text: the tool framework - 35
//! line-truncates results at - 36
//! ~2000 characters, which silently destroyed any larger card (found live with - 37
//! a research card), and echoing the data back also invited the model to - 38
//! restate it as a duplicate fence. - 39
- 40
use async_trait::async_trait; - 41
use serde_json::Value; - 42
use vak_tools::context::ToolContext; - 43
use vak_tools::{Tool, ToolOutput}; - 44
- 45
struct CardShape { - 46
/// Tool name, e.g. `emit_chart_card`. - 47
name: &'static str, - 48
description: &'static str, - 49
/// Every `semantic_type` this shape can produce. The model picks one - 50
/// via the `semantic_type` enum in the schema; `SkillRegistry` (the - 51
/// real, current type list) re-validates it server-side regardless of - 52
/// what the model sends, so this list drifting from skills.rs over - 53
/// time fails closed (rejected, not silently accepted) rather than - 54
/// open. - 55
semantic_types: &'static [&'static str], - 56
/// JSON Schema for the `payload` field specifically (the tool's - 57
/// `parameters` wraps this as `{semantic_type: enum, payload: <this>}`). - 58
payload_schema: fn() -> Value, - 59
} - 60
- 61
fn universal_card_payload_schema() -> Value { - 62
serde_json::json!({ - 63
"type": "object", - 64
"properties": { - 65
"title": {"type": "string"}, - 66
"summary": {"type": "string"} - 67
}, - 68
"additionalProperties": true, - 69
"description": "Free-form key/value fields beyond title/summary are shown as a details list." - 70
}) - 71
} - 72
- 73
fn research_payload_schema() -> Value { - 74
serde_json::json!({ - 75
"type": "object", - 76
"properties": { - 77
"title": {"type": "string"}, - 78
"sources": { - 79
"type": "array", - 80
"items": { - 81
"type": "object", - 82
"properties": { - 83
"title": {"type": "string", "description": "Copy the source title from the retrieved result."}, - 84
"url": {"type": "string", "description": "Required: copy the exact source URL from retrieved evidence, not a guessed homepage or search URL."}, - 85
"snippet": {"type": "string", "description": "Use only text supported by the retrieved source."}, - 86
"source_name": {"type": "string", "description": "Optional; include only if the retrieved result identifies this publisher."}, - 87
"published_at": {"type": "string", "description": "Optional; include only when the retrieved result supplies a publication date."} - 88
}, - 89
"required": ["title", "url"] - 90
} - 91
}, - 92
"takeaways": { - 93
"type": "array", - 94
"items": { - 95
"type": "object", - 96
"properties": { - 97
"text": {"type": "string"}, - 98
"citation_indices": { - 99
"type": "array", - 100
"items": {"type": "integer"}, - 101
"minItems": 1, - 102
"description": "1-based indices into `sources`; every takeaway must cite at least one source." - 103
} - 104
}, - 105
"required": ["text", "citation_indices"] - 106
} - 107
} - 108
}, - 109
"required": ["sources", "takeaways"] - 110
}) - 111
} - 112
- 113
fn diff_payload_schema() -> Value { - 114
serde_json::json!({ - 115
"type": "object", - 116
"properties": { - 117
"files": { - 118
"type": "array", - 119
"items": { - 120
"type": "object", - 121
"properties": { - 122
"filename": {"type": "string"}, - 123
"additions": {"type": "integer", "minimum": 0}, - 124
"deletions": {"type": "integer", "minimum": 0}, - 125
"hunks": {"type": "string", "description": "Unified diff hunk text for this file"} - 126
}, - 127
"required": ["filename", "hunks", "additions", "deletions"] - 128
} - 129
} - 130
}, - 131
"required": ["files"] - 132
}) - 133
} - 134
- 135
fn test_matrix_payload_schema() -> Value { - 136
serde_json::json!({ - 137
"type": "object", - 138
"properties": { - 139
"suite_name": {"type": "string"}, - 140
"total": {"type": "integer"}, - 141
"passed": {"type": "integer"}, - 142
"failed": {"type": "integer"}, - 143
"skipped": {"type": "integer"}, - 144
"tests": { - 145
"type": "array", - 146
"items": { - 147
"type": "object", - 148
"properties": { - 149
"name": {"type": "string"}, - 150
"status": {"type": "string", "enum": ["passed", "failed", "skipped"]}, - 151
"duration_ms": {"type": "integer"}, - 152
"message": {"type": "string"} - 153
}, - 154
"required": ["name", "status"] - 155
} - 156
} - 157
}, - 158
"required": ["tests"] - 159
}) - 160
} - 161
- 162
fn terminal_payload_schema() -> Value { - 163
serde_json::json!({ - 164
"type": "object", - 165
"properties": { - 166
"command": {"type": "string"}, - 167
"output": {"type": "string"}, - 168
"exit_code": {"type": "integer"}, - 169
"duration_ms": {"type": "integer"} - 170
}, - 171
"required": ["output"] - 172
}) - 173
} - 174
- 175
fn table_payload_schema() -> Value { - 176
serde_json::json!({ - 177
"type": "object", - 178
"properties": { - 179
"title": {"type": "string"}, - 180
"columns": { - 181
"type": "array", - 182
"items": { - 183
"type": "object", - 184
"properties": {"key": {"type": "string"}, "label": {"type": "string"}, "isNumeric": {"type": "boolean"}}, - 185
"required": ["key", "label"] - 186
} - 187
}, - 188
"rows": { - 189
"type": "array", - 190
"items": {"type": "object", "additionalProperties": true} - 191
} - 192
}, - 193
"required": ["columns", "rows"] - 194
}) - 195
} - 196
- 197
fn timeline_payload_schema() -> Value { - 198
serde_json::json!({ - 199
"type": "object", - 200
"properties": { - 201
"title": {"type": "string"}, - 202
"items": { - 203
"type": "array", - 204
"items": { - 205
"type": "object", - 206
"properties": { - 207
"label": {"type": "string"}, - 208
"detail": {"type": "string"}, - 209
"status": {"type": "string"}, - 210
"time": {"type": "string", "description": "When it happens, e.g. 1:00–4:00 pm"}, - 211
"options": { - 212
"type": "array", - 213
"description": "Alternatives for this step, one of which the person chooses", - 214
"items": { - 215
"type": "object", - 216
"properties": { - 217
"label": {"type": "string"}, - 218
"detail": {"type": "string"}, - 219
"facts": {"type": "array", "items": {"type": "string"}, "description": "Short facts, e.g. \"20 min away\""} - 220
}, - 221
"required": ["label"] - 222
} - 223
} - 224
}, - 225
"required": ["label"] - 226
} - 227
} - 228
}, - 229
"required": ["items"] - 230
}) - 231
} - 232
- 233
fn recipe_payload_schema() -> Value { - 234
serde_json::json!({ - 235
"type": "object", - 236
"properties": { - 237
"title": {"type": "string"}, - 238
"servings": {"type": "integer"}, - 239
"prep_time_minutes": {"type": "integer"}, - 240
"cook_time_minutes": {"type": "integer"}, - 241
"ingredients": { - 242
"type": "array", - 243
"items": { - 244
"type": "object", - 245
"properties": {"name": {"type": "string"}, "amount": {"type": "number"}, "unit": {"type": "string"}}, - 246
"required": ["name"] - 247
} - 248
}, - 249
"steps": { - 250
"type": "array", - 251
"items": { - 252
"type": "object", - 253
"properties": { - 254
"text": {"type": "string"}, - 255
"timer_seconds": {"type": "integer", "minimum": 1, "maximum": 86400} - 256
}, - 257
"required": ["text"] - 258
} - 259
} - 260
}, - 261
"required": ["title", "ingredients", "steps"] - 262
}) - 263
} - 264
- 265
fn ui_preview_payload_schema() -> Value { - 266
serde_json::json!({ - 267
"type": "object", - 268
"properties": { - 269
"status": {"type": "string"}, - 270
"title": {"type": "string"}, - 271
"artifact_path": {"type": "string"}, - 272
"html": {"type": "string"} - 273
} - 274
}) - 275
} - 276
- 277
fn chart_payload_schema() -> Value { - 278
serde_json::json!({ - 279
"type": "object", - 280
"properties": { - 281
"title": {"type": "string"}, - 282
"chart_type": {"type": "string", "enum": ["line", "bar", "area"]}, - 283
"x_label": {"type": "string"}, - 284
"y_label": {"type": "string"}, - 285
"accessible_summary": {"type": "string"}, - 286
"series": { - 287
"type": "array", - 288
"items": { - 289
"type": "object", - 290
"properties": { - 291
"name": {"type": "string"}, - 292
"points": { - 293
"type": "array", - 294
"items": { - 295
"type": "object", - 296
"properties": { - 297
"x": {"type": ["string", "number"]}, - 298
"y": {"type": "number"} - 299
}, - 300
"required": ["x", "y"] - 301
} - 302
} - 303
}, - 304
"required": ["name", "points"] - 305
} - 306
} - 307
}, - 308
"required": ["chart_type", "series", "accessible_summary"] - 309
}) - 310
} - 311
- 312
fn media_payload_schema() -> Value { - 313
serde_json::json!({ - 314
"type": "object", - 315
"description": "For semantic_type `link.preview`, use the first shape (url+title). For `media.image`/`media.video`/`media.audio`, use the second shape (source+media_type+alt).", - 316
"oneOf": [ - 317
{ - 318
"properties": { - 319
"url": {"type": "string"}, - 320
"title": {"type": "string"}, - 321
"image_url": {"type": "string"}, - 322
"description": {"type": "string"}, - 323
"site_name": {"type": "string"} - 324
}, - 325
"required": ["url", "title"] - 326
}, - 327
{ - 328
"properties": { - 329
"source": {"type": "string"}, - 330
"media_type": {"type": "string", "enum": ["image", "video", "audio"]}, - 331
"alt": {"type": "string"}, - 332
"title": {"type": "string"} - 333
}, - 334
"required": ["source", "media_type", "alt"] - 335
} - 336
] - 337
}) - 338
} - 339
- 340
fn metric_payload_schema() -> Value { - 341
serde_json::json!({ - 342
"type": "object", - 343
"properties": { - 344
"label": {"type": "string"}, - 345
"value": {"type": ["string", "number"]}, - 346
"unit": {"type": "string"}, - 347
"location": {"type": "string", "description": "Optional grid title when reporting multiple metrics at once"} - 348
}, - 349
"additionalProperties": {"type": ["string", "number"]}, - 350
"description": "For a single metric, set label/value/unit. For several at once (a grid of current readings), use additional key/value fields instead." - 351
}) - 352
} - 353
- 354
const SHAPES: &[CardShape] = &[ - 355
CardShape { - 356
name: "emit_universal_card", - 357
description: "Emit a static general-purpose card (map, calendar, board, entity, document, graph, form, alert, and similar) with a title/summary and free-form key/value fields. This card has no row-selection control; for choices the user can select, use emit_table_card with semantic_type travel_options and one row per option.", - 358
semantic_types: &[ - 359
"map", - 360
"route_map", - 361
"calendar", - 362
"availability", - 363
"board", - 364
"entity", - 365
"search_results", - 366
"coding.search", - 367
"evidence", - 368
"document", - 369
"graph", - 370
"form", - 371
"action", - 372
"transaction", - 373
"alert", - 374
"conversation", - 375
"progress_dashboard", - 376
"simulation", - 377
], - 378
payload_schema: universal_card_payload_schema, - 379
}, - 380
CardShape { - 381
name: "emit_research_card", - 382
description: "Emit a research/news synthesis card: several distinct findings drawn from multiple cited sources, each takeaway traceable to a source. Every source needs its exact retrieved title and URL; omit publication dates or publisher names the evidence did not provide. Choose it by the shape of the answer, not because you searched: a single measurement or fact (a temperature, a price, a score) belongs on the metric card and a comparison on the table card, with the source named in your sentence.", - 383
semantic_types: &["research.synthesis", "research_brief", "news"], - 384
payload_schema: research_payload_schema, - 385
}, - 386
CardShape { - 387
name: "emit_diff_card", - 388
description: "Emit a code-diff card for changes you made.", - 389
semantic_types: &["coding.diff"], - 390
payload_schema: diff_payload_schema, - 391
}, - 392
CardShape { - 393
name: "emit_test_report_card", - 394
description: "Emit a test-results card summarizing a test run you actually executed.", - 395
semantic_types: &["test.report"], - 396
payload_schema: test_matrix_payload_schema, - 397
}, - 398
CardShape { - 399
name: "emit_terminal_card", - 400
description: "Emit a card showing a command you ran and its real captured output.", - 401
semantic_types: &["terminal.view"], - 402
payload_schema: terminal_payload_schema, - 403
}, - 404
CardShape { - 405
name: "emit_table_card", - 406
description: "Emit a data table / comparison / budget / inventory card with explicit columns and rows. For selectable travel or outing choices, use semantic_type travel_options; make the first column Option (or Choice) and put one choice in each row so the user can select it.", - 407
semantic_types: &[ - 408
"coding.benchmark", - 409
"coding.dependencies", - 410
"data.grid", - 411
"table", - 412
"dataframe", - 413
"comparison", - 414
"comparison_table", - 415
"pros_cons", - 416
"inventory", - 417
"scorecard", - 418
"budget", - 419
"finance_summary", - 420
"invoice_summary", - 421
"travel_options", - 422
"decision_matrix", - 423
"criteria_matrix", - 424
"tradeoff_analysis", - 425
], - 426
payload_schema: table_payload_schema, - 427
}, - 428
CardShape { - 429
name: "emit_timeline_card", - 430
description: "Emit a timeline/plan/checklist/schedule card: an ordered or grouped list of steps, milestones, or items. When a step offers alternatives to choose between, give that one step (for example \"After lunch\") an options list holding each alternative, rather than listing the alternatives as separate steps.", - 431
semantic_types: &[ - 432
"coding.deployment", - 433
"coding.incident", - 434
"coding.architecture", - 435
"coding.release", - 436
"plan.timeline", - 437
"timeline", - 438
"itinerary", - 439
"checklist", - 440
"schedule", - 441
"agenda", - 442
"milestones", - 443
"progress", - 444
"status", - 445
"steps", - 446
"overview", - 447
"summary", - 448
"detail", - 449
"notes", - 450
"follow_up", - 451
"reminder", - 452
"shopping_list", - 453
"lesson", - 454
"reading_list", - 455
"habit_plan", - 456
"project_plan", - 457
"meeting_notes", - 458
"contact_log", - 459
"home_project", - 460
"care_plan", - 461
"event_plan", - 462
"media_list", - 463
"collection", - 464
"faq", - 465
"decision", - 466
"decision_analysis", - 467
"meal_plan", - 468
], - 469
payload_schema: timeline_payload_schema, - 470
}, - 471
CardShape { - 472
name: "emit_recipe_card", - 473
description: "Emit a recipe card with ingredients and steps.", - 474
semantic_types: &[ - 475
"recipe.card", - 476
"recipe", - 477
"recipe_summary", - 478
"lifestyle.recipe", - 479
"lifestyle.culinary_recipe", - 480
], - 481
payload_schema: recipe_payload_schema, - 482
}, - 483
CardShape { - 484
name: "emit_ui_preview_card", - 485
description: "Emit a preview card for an HTML/UI artifact you wrote to the workspace.", - 486
semantic_types: &["ui.preview"], - 487
payload_schema: ui_preview_payload_schema, - 488
}, - 489
CardShape { - 490
name: "emit_chart_card", - 491
description: "Emit a chart card for a numeric series over time or categories.", - 492
semantic_types: &[ - 493
"chart", - 494
"trend", - 495
"timeseries", - 496
"bar_chart", - 497
"metric_chart", - 498
"comparison_chart", - 499
"telemetry.chart", - 500
], - 501
payload_schema: chart_payload_schema, - 502
}, - 503
CardShape { - 504
name: "emit_media_card", - 505
description: "Emit a link preview or media (image/video/audio) card.", - 506
semantic_types: &["link.preview", "media.image", "media.video", "media.audio"], - 507
payload_schema: media_payload_schema, - 508
}, - 509
CardShape { - 510
name: "emit_metric_card", - 511
description: "Emit a metric card: a single current measurement or a small grid of them (a reading, a price, a KPI, a benchmark number). Prefer it whenever the answer is one value, even if you searched the web to get it.", - 512
semantic_types: &["metric", "telemetry.metric", "weather"], - 513
payload_schema: metric_payload_schema, - 514
}, - 515
]; - 516
- 517
/// Repair payload shapes that `SkillRegistry::validate` checks strictly but - 518
/// that a shared per-*shape* schema can't fully pin down on its own (a - 519
/// handful of the ~84 semantic_types have stricter per-field-name or - 520
/// derived-value requirements than their sibling types in the same shape - 521
/// category). Fixing these here — deterministically, from data the model - 522
/// already gave us — is more reliable than asking a small local model to - 523
/// track field-name synonyms or keep derived counts consistent by hand. - 524
fn normalize_payload(semantic_type: &str, mut payload: Value) -> Value { - 525
match semantic_type { - 526
// `itinerary` validates each item's `title`; the shared timeline - 527
// schema asks the model for `label`. Same data, two field names. - 528
"itinerary" => { - 529
if let Some(items) = payload.get_mut("items").and_then(Value::as_array_mut) { - 530
for item in items { - 531
if item.get("title").is_none() - 532
&& let Some(label) = item.get("label").cloned() - 533
{ - 534
item["title"] = label; - 535
} - 536
} - 537
} - 538
} - 539
// `plan.timeline` validates a top-level `title`; not required by the - 540
// shared schema since most sibling timeline types don't need one. - 541
"plan.timeline" => { - 542
if payload.get("title").and_then(Value::as_str).is_none() { - 543
payload["title"] = Value::String("Plan".into()); - 544
} - 545
} - 546
// `test.report` validates that any `total`/`passed`/`failed`/`skipped` - 547
// counts the model supplies match the actual `tests` array — so - 548
// derive them instead of trusting the model to keep them in sync. - 549
"test.report" => { - 550
if let Some(tests) = payload.get("tests").and_then(Value::as_array).cloned() { - 551
let count_where = |status: &str| { - 552
tests - 553
.iter() - 554
.filter(|t| t.get("status").and_then(Value::as_str) == Some(status)) - 555
.count() as u64 - 556
}; - 557
payload["total"] = Value::from(tests.len() as u64); - 558
payload["passed"] = Value::from(count_where("passed")); - 559
payload["failed"] = Value::from(count_where("failed")); - 560
payload["skipped"] = Value::from(count_where("skipped")); - 561
} - 562
} - 563
_ => {} - 564
} - 565
payload - 566
} - 567
- 568
/// One representative-but-minimal fixture payload per shape, built to - 569
/// satisfy that shape's `payload_schema` (and, where the schema alone - 570
/// isn't enough, the stricter per-type validators in - 571
/// `vak_delivery::skills::validate_payload`). Every `semantic_type` this - 572
/// shape's tool can emit is then executed with the SAME fixture, to - 573
/// prove the shared schema (plus `normalize_payload` for the couple of - 574
/// known type-specific exceptions) genuinely renders for every type the - 575
/// tool claims to support — not just one hand-picked example. - 576
#[allow(clippy::panic)] - 577
fn fixture_for(shape_name: &str) -> Value { - 578
match shape_name { - 579
"emit_universal_card" => serde_json::json!({"title": "T", "summary": "S"}), - 580
"emit_research_card" => serde_json::json!({ - 581
"sources": [{"title": "Src", "url": "https://example.com"}], - 582
"takeaways": [{"text": "Point", "citation_indices": [1]}] - 583
}), - 584
"emit_diff_card" => serde_json::json!({ - 585
"files": [{"filename": "a.rs", "hunks": "@@ -1 +1 @@", "additions": 1, "deletions": 0}] - 586
}), - 587
"emit_test_report_card" => serde_json::json!({ - 588
"tests": [{"name": "it_works", "status": "passed"}] - 589
}), - 590
"emit_terminal_card" => serde_json::json!({"command": "ls", "output": "a.rs"}), - 591
"emit_table_card" => serde_json::json!({ - 592
"columns": [{"key": "name", "label": "Name"}], - 593
"rows": [{"name": "Alice"}] - 594
}), - 595
"emit_timeline_card" => serde_json::json!({ - 596
"title": "T", - 597
"items": [{"label": "Step 1", "detail": "d"}] - 598
}), - 599
"emit_recipe_card" => serde_json::json!({ - 600
"title": "Soup", - 601
"ingredients": [{"name": "Water"}], - 602
"steps": [{"text": "Boil"}] - 603
}), - 604
"emit_ui_preview_card" => serde_json::json!({"title": "Preview"}), - 605
"emit_chart_card" => serde_json::json!({ - 606
"chart_type": "line", - 607
"accessible_summary": "flat", - 608
"series": [{"name": "s1", "points": [{"x": 1, "y": 2.0}]}] - 609
}), - 610
"emit_media_card" => serde_json::json!({"url": "https://example.com", "title": "Link"}), - 611
"emit_metric_card" => { - 612
serde_json::json!({"label": "Uptime", "value": 99.9, "unit": "%"}) - 613
} - 614
other => panic!("no fixture defined for shape {other} — add one"), - 615
} - 616
} - 617
- 618
/// A shape's schema can be a `oneOf` covering several distinct payload - 619
/// shapes for different semantic_types within it (e.g. `emit_media_card`: - 620
/// `link.preview` wants url+title, `media.*` wants source+media_type+alt). - 621
/// Override the shared fixture for those specific types. - 622
fn fixture_override(semantic_type: &str) -> Option<Value> { - 623
match semantic_type { - 624
"media.image" => Some( - 625
serde_json::json!({"source": "https://example.com/a.png", "media_type": "image", "alt": "a"}), - 626
), - 627
"media.video" => Some( - 628
serde_json::json!({"source": "https://example.com/a.mp4", "media_type": "video", "alt": "a"}), - 629
), - 630
"media.audio" => Some( - 631
serde_json::json!({"source": "https://example.com/a.mp3", "media_type": "audio", "alt": "a"}), - 632
), - 633
_ => None, - 634
} - 635
} - 636
- 637
/// Every `(tool, semantic_type, payload)` the tools claim to support, with a - 638
/// schema-valid payload — the single source for conformance tests here and in - 639
/// vak-server (which checks the full call → ledger → projection path). - 640
#[doc(hidden)] - 641
pub fn conformance_cases() -> Vec<(&'static str, &'static str, Value)> { - 642
let mut out = Vec::new(); - 643
for shape in SHAPES { - 644
for &semantic_type in shape.semantic_types { - 645
let payload = - 646
fixture_override(semantic_type).unwrap_or_else(|| fixture_for(shape.name)); - 647
out.push((shape.name, semantic_type, payload)); - 648
} - 649
} - 650
out - 651
} - 652
- 653
pub struct EmitCardTool { - 654
shape: &'static CardShape, - 655
} - 656
- 657
impl EmitCardTool { - 658
/// One `EmitCardTool` per registered shape category — call this once - 659
/// per entry in `SHAPES` when assembling the tool list for a turn. - 660
pub fn all() -> Vec<Self> { - 661
SHAPES.iter().map(|shape| EmitCardTool { shape }).collect() - 662
} - 663
} - 664
- 665
#[async_trait] - 666
impl Tool for EmitCardTool { - 667
fn name(&self) -> &str { - 668
self.shape.name - 669
} - 670
- 671
fn description(&self) -> &str { - 672
self.shape.description - 673
} - 674
- 675
fn schema(&self) -> Value { - 676
serde_json::json!({ - 677
"type": "object", - 678
"properties": { - 679
"semantic_type": { - 680
"type": "string", - 681
"enum": self.shape.semantic_types, - 682
"description": "Which of this shape's card types this is." - 683
}, - 684
"payload": (self.shape.payload_schema)() - 685
}, - 686
"required": ["semantic_type", "payload"] - 687
}) - 688
} - 689
- 690
fn presents_cards(&self) -> bool { - 691
true - 692
} - 693
- 694
async fn execute(&self, args: &Value, _ctx: &ToolContext) -> ToolOutput { - 695
match validate_call(self.shape, args, &vak_delivery::built_in_skill_registry()) { - 696
Ok(output) => ToolOutput::ok(format!( - 697
"Card displayed to the user ({}). It is already on screen. Leave final text empty \ - 698
if the card answers fully. Only additional information will be shown: begin it \ - 699
with `Note:` and do not repeat card data or write a `vak` fence.", - 700
output.semantic_type - 701
)), - 702
Err(reason) => ToolOutput::error(format!( - 703
"Card not displayed: {reason}. Fix the arguments and call {} again.", - 704
self.shape.name - 705
)), - 706
} - 707
} - 708
} - 709
- 710
fn validate_call( - 711
shape: &CardShape, - 712
args: &Value, - 713
skills: &vak_delivery::SkillRegistry, - 714
) -> Result<vak_delivery::StructuredOutput, String> { - 715
let Some(semantic_type) = args.get("semantic_type").and_then(Value::as_str) else { - 716
return Err("missing or non-string `semantic_type`".into()); - 717
}; - 718
if !shape.semantic_types.contains(&semantic_type) { - 719
return Err(format!( - 720
"`{semantic_type}` is not one of this tool's types {:?}; use the matching emit_*_card tool", - 721
shape.semantic_types - 722
)); - 723
} - 724
let Some(payload) = args.get("payload") else { - 725
return Err("missing `payload`".into()); - 726
}; - 727
let envelope = serde_json::json!({ - 728
"semantic_type": semantic_type, - 729
"payload": normalize_payload(semantic_type, payload.clone()), - 730
}); - 731
vak_delivery::parse_fragment_with(&envelope.to_string(), skills).map_err(|e| e.to_string()) - 732
} - 733
- 734
/// The `emit_*_card` tool that carries `semantic_type`, if any. - 735
pub fn emit_tool_for(semantic_type: &str) -> Option<&'static str> { - 736
SHAPES - 737
.iter() - 738
.find(|shape| shape.semantic_types.contains(&semantic_type)) - 739
.map(|shape| shape.name) - 740
} - 741
- 742
/// The one-shot nudge for an answer that reads as something the app presents - 743
/// as a card but was written as prose. Driven entirely by the app's own signal - 744
/// and recipe detection (`signals_from_text` → `RecipeCatalog::intended_outputs`) - 745
/// — no per-type rules here — and only names a tool that was actually offered. - 746
pub fn presentation_check_nudge( - 747
text: &str, - 748
offered_tools: &[String], - 749
recipes: &vak_delivery::RecipeCatalog, - 750
) -> Option<vak_agent::PresentationNudge> { - 751
let signals = vak_delivery::signals_from_text(text); - 752
let intended = recipes.intended_outputs(&signals, "desktop")?; - 753
let (semantic_type, tool) = intended.primary_types.iter().find_map(|semantic_type| { - 754
let tool = emit_tool_for(semantic_type)?; - 755
offered_tools - 756
.iter() - 757
.any(|offered| offered == tool) - 758
.then_some((semantic_type.as_str(), tool)) - 759
})?; - 760
Some(vak_agent::PresentationNudge { - 761
tool: tool.to_string(), - 762
text: format!( - 763
"[presentation-check]: Your answer reads as `{}` (signals: {}), which the app presents \ - 764
as a card, but no card was emitted. If a card fits, call `{tool}` with \ - 765
semantic_type `{semantic_type}` and this content, and do not restate the data as text \ - 766
(any text after it is shown only if it begins with `Note:`). If a card genuinely does \ - 767
not fit, resend your answer unchanged.", - 768
intended.recipe_id, - 769
intended.matched_signals.join(", ") - 770
), - 771
}) - 772
} - 773
- 774
/// The card tools a request itself reads as, from the app's own signal and - 775
/// recipe detection over the request text — the same detection the - 776
/// presentation check runs over the answer. These are loaded for the turn; - 777
/// every other card tool is deferred until the check or `find_tools` asks. - 778
pub fn predicted_card_tools( - 779
request: &str, - 780
recipes: &vak_delivery::RecipeCatalog, - 781
) -> std::collections::BTreeSet<String> { - 782
let signals = vak_delivery::signals_from_text(request); - 783
recipes - 784
.intended_outputs(&signals, "desktop") - 785
.map(|intended| { - 786
intended - 787
.primary_types - 788
.iter() - 789
.filter_map(|semantic_type| emit_tool_for(semantic_type)) - 790
.map(str::to_string) - 791
.collect() - 792
}) - 793
.unwrap_or_default() - 794
} - 795
- 796
/// Names of every tool that declares `presents_cards()`, for the permission - 797
/// engine: a card is Vak's own display channel and needs no approval. - 798
pub fn presenting_tool_names() -> Vec<String> { - 799
EmitCardTool::all() - 800
.iter() - 801
.filter(|tool| tool.presents_cards()) - 802
.map(|tool| tool.name().to_string()) - 803
.collect() - 804
} - 805
- 806
/// Whether `name` is one of the `emit_*_card` tools. - 807
pub fn is_card_tool(name: &str) -> bool { - 808
SHAPES.iter().any(|shape| shape.name == name) - 809
} - 810
- 811
/// Rebuilds an `emit_*_card` call's validated output from the call's own - 812
/// arguments — the ledger records these untruncated, so nothing depends on - 813
/// the tool result text (which the tool framework line-truncates at ~2000 - 814
/// characters, silently destroying any larger card's JSON). Private: the - 815
/// only consumers are this module's own conformance tests and - 816
/// `presentation_info`, which turns this into a `Presentation` ledger entry - 817
/// at the moment a card validates (docs/design/68-context-engine.md §10). - 818
/// `vak-server`'s projection used to call a public version of this - 819
/// (`card_output_from_call`) to rebuild the card for display on every - 820
/// snapshot; it now reads the written `Presentation` entry instead, so - 821
/// nothing outside this crate needs to re-validate a call's arguments. - 822
fn rebuild_call( - 823
name: &str, - 824
input: &Value, - 825
skills: &vak_delivery::SkillRegistry, - 826
) -> Option<vak_delivery::StructuredOutput> { - 827
let shape = SHAPES.iter().find(|shape| shape.name == name)?; - 828
validate_call(shape, input, skills).ok() - 829
} - 830
- 831
/// Everything needed to write a `PresentationRecord` for a call that just - 832
/// validated: the canonical payload, the schema-driven title and identity - 833
/// digest, and which skill/version/schema owns the type. `vak-agent`'s - 834
/// tool-execution path has no session-log access (AGENTS.md invariant 14: - 835
/// tools cross a broker boundary), so this is exposed through - 836
/// `AgentConfig::presentation_rebuild`, a closure `Core` installs — the - 837
/// agent loop stays free of card-shape knowledge and calls this indirectly. - 838
pub struct PresentationInfo { - 839
pub semantic_type: String, - 840
pub skill_id: String, - 841
pub skill_version: String, - 842
pub schema_version: u32, - 843
/// Canonical (key-sorted) form — see `vak_session::types::canonicalize_json`. - 844
pub payload: Value, - 845
pub title: String, - 846
pub identity_digest: String, - 847
} - 848
- 849
/// Validates an `emit_*_card` call and returns everything needed to write - 850
/// its `Presentation` ledger entry. `None` when the call does not validate - 851
/// (the tool's own `execute()` already rejected it in that case, so this is - 852
/// only ever called for a call that already succeeded — see - 853
/// `AgentConfig::presentation_rebuild`'s call site in `Core`). - 854
pub fn presentation_info( - 855
name: &str, - 856
input: &Value, - 857
skills: &vak_delivery::SkillRegistry, - 858
) -> Option<PresentationInfo> { - 859
let output = rebuild_call(name, input, skills)?; - 860
let payload = vak_session::types::canonicalize_json(&output.payload); - 861
let title = title_for(&output.semantic_type, &payload); - 862
let identity_digest = identity_digest(&output.semantic_type, &payload); - 863
Some(PresentationInfo { - 864
semantic_type: output.semantic_type, - 865
skill_id: output.skill_id, - 866
skill_version: output.skill_version, - 867
schema_version: u32::from(output.schema_version), - 868
payload, - 869
title, - 870
identity_digest, - 871
}) - 872
} - 873
- 874
/// Title fallback for a card whose payload has no `title` field (only the - 875
/// metric shape lacks one; every other shape's schema asks the model for - 876
/// one). - 877
fn title_for(semantic_type: &str, payload: &Value) -> String { - 878
if let Some(title) = payload.get("title").and_then(Value::as_str) - 879
&& !title.trim().is_empty() - 880
{ - 881
return title.to_string(); - 882
} - 883
payload - 884
.get("label") - 885
.and_then(Value::as_str) - 886
.or_else(|| payload.get("location").and_then(Value::as_str)) - 887
.unwrap_or(semantic_type) - 888
.to_string() - 889
} - 890
- 891
fn compact_scalar(value: &Value) -> String { - 892
match value { - 893
Value::String(s) => s.clone(), - 894
other => other.to_string(), - 895
} - 896
} - 897
- 898
fn canonical_compact(payload: &Value) -> String { - 899
let canonical = vak_session::types::canonicalize_json(payload); - 900
serde_json::to_string(&canonical).unwrap_or_default() - 901
} - 902
- 903
fn research_digest(payload: &Value) -> String { - 904
let takeaways: Vec<String> = payload - 905
.get("takeaways") - 906
.and_then(Value::as_array) - 907
.map(|items| { - 908
items - 909
.iter() - 910
.filter_map(|t| t.get("text").and_then(Value::as_str)) - 911
.map(str::to_string) - 912
.collect() - 913
}) - 914
.unwrap_or_default(); - 915
let sources: Vec<String> = payload - 916
.get("sources") - 917
.and_then(Value::as_array) - 918
.map(|items| { - 919
items - 920
.iter() - 921
.map(|s| { - 922
let title = s.get("title").and_then(Value::as_str).unwrap_or(""); - 923
let url = s.get("url").and_then(Value::as_str).unwrap_or(""); - 924
format!("{title} ({url})") - 925
}) - 926
.collect() - 927
}) - 928
.unwrap_or_default(); - 929
format!( - 930
"takeaways: {} | sources: {}", - 931
takeaways.join(" ~ "), - 932
sources.join(", ") - 933
) - 934
} - 935
- 936
fn table_digest(payload: &Value) -> String { - 937
let title = payload.get("title").and_then(Value::as_str).unwrap_or(""); - 938
let columns: Vec<String> = payload - 939
.get("columns") - 940
.and_then(Value::as_array) - 941
.map(|items| { - 942
items - 943
.iter() - 944
.filter_map(|c| c.get("key").and_then(Value::as_str)) - 945
.map(str::to_string) - 946
.collect() - 947
}) - 948
.unwrap_or_default(); - 949
let rows = payload.get("rows").and_then(Value::as_array); - 950
let row_count = rows.map(Vec::len).unwrap_or(0); - 951
let first_row = rows.and_then(|r| r.first()).cloned().unwrap_or(Value::Null); - 952
format!( - 953
"title: {title} | columns: {} | rows: {row_count} | first: {}", - 954
columns.join(","), - 955
canonical_compact(&first_row) - 956
) - 957
} - 958
- 959
fn chart_digest(payload: &Value) -> String { - 960
let title = payload.get("title").and_then(Value::as_str).unwrap_or(""); - 961
let summary = payload - 962
.get("accessible_summary") - 963
.and_then(Value::as_str) - 964
.unwrap_or(""); - 965
let series: Vec<String> = payload - 966
.get("series") - 967
.and_then(Value::as_array) - 968
.map(|items| { - 969
items - 970
.iter() - 971
.map(|s| { - 972
let name = s.get("name").and_then(Value::as_str).unwrap_or(""); - 973
let points = s - 974
.get("points") - 975
.and_then(Value::as_array) - 976
.map(Vec::len) - 977
.unwrap_or(0); - 978
format!("{name}({points})") - 979
}) - 980
.collect() - 981
}) - 982
.unwrap_or_default(); - 983
format!( - 984
"title: {title} | series: {} | summary: {summary}", - 985
series.join(",") - 986
) - 987
} - 988
- 989
fn entity_digest(payload: &Value) -> String { - 990
let title = payload.get("title").and_then(Value::as_str).unwrap_or(""); - 991
let entity_type = payload.get("type").and_then(Value::as_str).unwrap_or(""); - 992
let mut fields: Vec<String> = payload - 993
.as_object() - 994
.map(|obj| { - 995
obj.iter() - 996
.filter(|(key, _)| key.as_str() != "title" && key.as_str() != "type") - 997
.map(|(key, value)| format!("{key}={}", compact_scalar(value))) - 998
.collect() - 999
}) - 1000
.unwrap_or_default();
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.