- 1
//! Durable channel delivery and the transport adapter boundary. - 2
- 3
use async_trait::async_trait; - 4
use serde_json::Value; - 5
use std::collections::{BTreeMap, BTreeSet, HashMap}; - 6
use std::sync::{Arc, Mutex, OnceLock}; - 7
use std::time::Duration; - 8
use vak_core::Core; - 9
use vak_delivery::client::WorkerClient; - 10
use vak_delivery::outbox::{Outbox, OutboxRecord}; - 11
use vak_delivery::templates::{ChannelPreference, load_layers}; - 12
use vak_delivery::{ - 13
AnswerDraft, DeliveryAction, DeliveryContent, DeliveryJob, DeliveryKind, DeliveryPacket, - 14
DeliveryProfile, Markup, - 15
}; - 16
- 17
const WORKER_TIMEOUT: Duration = Duration::from_secs(5); - 18
const MAX_CHANNEL_CHARS: usize = 100_000; - 19
- 20
/// Build a plugin-merged `PresentationPlanner` (skills + recipes) from all - 21
/// enabled plugins across the Core's capability roots. Starts with built-in - 22
/// skills and recipes and layers in any `PresentationSkillManifest` and - 23
/// `PresentationRecipe` files that plugins declare in their - 24
/// `components.presentation` list. - 25
pub(crate) fn merged_presentation_planner(core: &Core) -> vak_delivery::PresentationPlanner { - 26
let mut skills = vak_delivery::built_in_skill_registry(); - 27
let mut recipes = vak_delivery::built_in_recipes(); - 28
let mut revoked_skills = BTreeSet::new(); - 29
for root in core.capability_roots() { - 30
let Ok(plugins) = vak_plugin::PluginStore::new(&root.path).enabled() else { - 31
continue; - 32
}; - 33
for plugin in plugins { - 34
for relative in &plugin.capabilities.presentation { - 35
let path = plugin.package_path.join(relative); - 36
let Ok(bytes) = std::fs::read(&path) else { - 37
continue; - 38
}; - 39
if let Ok(json) = std::str::from_utf8(&bytes) { - 40
if let Ok(manifest) = - 41
serde_json::from_str::<vak_delivery::PresentationSkillManifest>(json) - 42
{ - 43
if !core.capability_revoked( - 44
vak_session::types::CapabilityKind::Skill, - 45
&manifest.id, - 46
) { - 47
let _ = skills.register(manifest); - 48
} else { - 49
revoked_skills.insert(manifest.id); - 50
} - 51
} else if let Ok(recipe) = - 52
serde_json::from_str::<vak_delivery::PresentationRecipe>(json) - 53
{ - 54
let _ = recipes.register(recipe); - 55
} - 56
} - 57
} - 58
} - 59
} - 60
recipes.remove_revoked_skills(&revoked_skills); - 61
vak_delivery::PresentationPlanner { skills, recipes } - 62
} - 63
- 64
/// Build a plugin-merged `SkillRegistry` for the worker's structured-fence - 65
/// projection. Equivalent to `merged_presentation_planner(core).skills` - 66
/// but avoids constructing a full planner. - 67
fn merged_presentation_skills(core: &Core) -> vak_delivery::SkillRegistry { - 68
merged_presentation_planner(core).skills - 69
} - 70
- 71
#[derive(Debug, Clone, Default, serde::Deserialize, serde::Serialize)] - 72
#[serde(default, deny_unknown_fields)] - 73
pub struct RequestedCapabilities { - 74
pub markup: Option<Markup>, - 75
pub max_chars: Option<usize>, - 76
pub supports_tables: Option<bool>, - 77
pub supports_code_blocks: Option<bool>, - 78
pub supports_links: Option<bool>, - 79
pub supports_actions: Option<bool>, - 80
/// The bridge sends files back to the chat: a turn's Office drafts are - 81
/// returned as documents (docs/design/72, P5). - 82
pub accepts_files: Option<bool>, - 83
} - 84
- 85
#[async_trait] - 86
trait ChannelAdapter: Send + Sync { - 87
fn scheme(&self) -> &'static str; - 88
fn profile(&self) -> DeliveryProfile; - 89
async fn send(&self, core: &Core, packet: &DeliveryPacket) -> Result<(), String>; - 90
} - 91
- 92
struct AdapterRegistry { - 93
/// Legacy per-surface fallback, used for a two-part target - 94
/// (`surface:address`, no bot id) exactly as before multi-bot-per- - 95
/// channel existed. - 96
adapters: HashMap<&'static str, Arc<dyn ChannelAdapter>>, - 97
/// One adapter per configured bot, keyed by (surface, bot id) — used - 98
/// for a three-part target (`surface:address:bot_id`), so a reply goes - 99
/// out with *that* bot's own token rather than whichever token happens - 100
/// to be registered first for the surface (docs/design/34 Phase 5 - 101
/// "known limitation", now fixed: the delivery target carries the bot - 102
/// id, so this map can pick the exact adapter instead of guessing). - 103
bot_adapters: HashMap<(String, String), Arc<dyn ChannelAdapter>>, - 104
} - 105
- 106
impl AdapterRegistry { - 107
/// Every configured bot on `surface` with a token actually set, as - 108
/// (bot id, token) pairs. Each gets its own adapter: collapsing them - 109
/// into one per surface is how a reply goes out under the wrong bot's - 110
/// identity (AGENTS.md invariant 24). - 111
fn all_bot_tokens(sessions_home: &std::path::Path, surface: &str) -> Vec<(String, String)> { - 112
let Ok(raw) = std::fs::read_to_string(sessions_home.join("gateway").join("bots.json")) - 113
else { - 114
return Vec::new(); - 115
}; - 116
let Ok(file) = serde_json::from_str::<serde_json::Value>(&raw) else { - 117
return Vec::new(); - 118
}; - 119
file.get("bots") - 120
.and_then(|b| b.as_array()) - 121
.map(|bots| { - 122
bots.iter() - 123
.filter_map(|b| { - 124
if b.get("surface")?.as_str()? != surface { - 125
return None; - 126
} - 127
let id = b.get("id")?.as_str()?.to_string(); - 128
let env_var = b.get("token_env")?.as_str()?; - 129
let token = vak_config::get_var(env_var)?; - 130
Some((id, token)) - 131
}) - 132
.collect() - 133
}) - 134
.unwrap_or_default() - 135
} - 136
- 137
fn built_in(sessions_home: &std::path::Path) -> Self { - 138
let mut registry = Self { - 139
adapters: HashMap::new(), - 140
bot_adapters: HashMap::new(), - 141
}; - 142
registry.register(LogAdapter); - 143
registry.register(WebhookAdapter); - 144
// No per-surface chat adapter is registered. A chat credential - 145
// belongs to a bot (invariant 23), so every chat surface resolves - 146
// through the (surface, bot_id) map below. Log and webhook stay - 147
// surface-level because neither is a bot. - 148
let telegram_api_base = vak_config::get_var("TELEGRAM_API_BASE") - 149
.unwrap_or_else(|| "https://api.telegram.org".into()); - 150
for (id, bot_token) in Self::all_bot_tokens(sessions_home, "telegram") { - 151
registry.register_bot( - 152
"telegram", - 153
id, - 154
TelegramAdapter { - 155
bot_token, - 156
api_base: telegram_api_base.clone(), - 157
}, - 158
); - 159
} - 160
let discord_api_base = vak_config::get_var("DISCORD_API_BASE") - 161
.unwrap_or_else(|| "https://discord.com/api/v10".into()); - 162
for (id, bot_token) in Self::all_bot_tokens(sessions_home, "discord") { - 163
registry.register_bot( - 164
"discord", - 165
id, - 166
DiscordAdapter { - 167
bot_token, - 168
api_base: discord_api_base.clone(), - 169
}, - 170
); - 171
} - 172
let slack_api_base = - 173
vak_config::get_var("SLACK_API_BASE").unwrap_or_else(|| "https://slack.com/api".into()); - 174
for (id, bot_token) in Self::all_bot_tokens(sessions_home, "slack") { - 175
registry.register_bot( - 176
"slack", - 177
id, - 178
SlackAdapter { - 179
bot_token, - 180
api_base: slack_api_base.clone(), - 181
}, - 182
); - 183
} - 184
registry - 185
} - 186
- 187
fn register(&mut self, adapter: impl ChannelAdapter + 'static) { - 188
self.adapters.insert(adapter.scheme(), Arc::new(adapter)); - 189
} - 190
- 191
fn register_bot( - 192
&mut self, - 193
surface: &str, - 194
bot_id: String, - 195
adapter: impl ChannelAdapter + 'static, - 196
) { - 197
self.bot_adapters - 198
.insert((surface.to_string(), bot_id), Arc::new(adapter)); - 199
} - 200
- 201
/// A delivery target is `surface:address:bot_id` for a chat surface, - 202
/// or `surface:address` for `log` and `webhook`, which are not bots. - 203
/// - 204
/// A chat target with no bot id is refused rather than resolved: there - 205
/// is no per-surface fallback any more, because picking "some bot on - 206
/// this surface" replies under an identity the chat was never bound to - 207
/// (AGENTS.md invariant 24). - 208
fn resolve(&self, target: &str) -> Result<(Arc<dyn ChannelAdapter>, String), String> { - 209
let mut parts = target.splitn(3, ':'); - 210
let scheme = parts.next().filter(|s| !s.is_empty()).ok_or_else(|| { - 211
format!("invalid delivery target '{target}': expected '<surface>:<address>'") - 212
})?; - 213
let address = parts.next().ok_or_else(|| { - 214
format!("invalid delivery target '{target}': expected '<surface>:<address>'") - 215
})?; - 216
if address.trim().is_empty() { - 217
return Err(format!("delivery target '{target}' has an empty address")); - 218
} - 219
if let Some(bot_id) = parts.next().filter(|b| !b.trim().is_empty()) { - 220
return self - 221
.bot_adapters - 222
.get(&(scheme.to_string(), bot_id.to_string())) - 223
.cloned() - 224
.map(|adapter| (adapter, address.to_string())) - 225
// Named but not (yet) configured with a token: fail loudly - 226
// rather than silently falling back to a different bot's - 227
// token, which would reply under the wrong identity. - 228
.ok_or_else(|| { - 229
format!("delivery target '{target}' names bot '{bot_id}', which has no token configured") - 230
}); - 231
} - 232
if vak_core::Core::is_surface(scheme) { - 233
return Err(format!( - 234
"delivery target '{target}' names no bot; a {scheme} target must be \ - 235
'<surface>:<address>:<bot_id>'" - 236
)); - 237
} - 238
self.adapters - 239
.get(scheme) - 240
.cloned() - 241
.map(|adapter| (adapter, address.to_string())) - 242
.ok_or_else(|| format!("unsupported gateway surface '{scheme}'")) - 243
} - 244
} - 245
- 246
struct DeliveryRuntime { - 247
outbox: Outbox, - 248
worker: Option<WorkerClient>, - 249
adapters: AdapterRegistry, - 250
serial: tokio::sync::Mutex<()>, - 251
} - 252
- 253
impl DeliveryRuntime { - 254
fn new(core: &Core) -> Self { - 255
let worker = std::env::current_exe() - 256
.ok() - 257
.filter(|path| { - 258
path.parent() - 259
.and_then(|parent| parent.file_name()) - 260
.is_none_or(|name| name != "deps") - 261
}) - 262
.map(|path| WorkerClient::new(path, WORKER_TIMEOUT)); - 263
Self { - 264
outbox: Outbox::new(core.shared_data_home().join("delivery").join("jobs")), - 265
worker, - 266
adapters: AdapterRegistry::built_in(&core.shared_data_home()), - 267
serial: tokio::sync::Mutex::new(()), - 268
} - 269
} - 270
- 271
async fn render(&self, job: &DeliveryJob) -> Result<DeliveryPacket, String> { - 272
if let Some(worker) = &self.worker { - 273
match worker.render(job).await { - 274
Ok(packet) => return Ok(packet), - 275
Err(error) => { - 276
eprintln!( - 277
"[delivery] isolated renderer unavailable for {}: {error}; using safe fallback", - 278
job.job_id - 279
); - 280
} - 281
} - 282
} - 283
let mut packet = vak_delivery::render(job).map_err(|error| error.to_string())?; - 284
packet - 285
.diagnostics - 286
.push("isolated renderer unavailable; used deterministic in-process fallback".into()); - 287
Ok(packet) - 288
} - 289
- 290
async fn deliver_record( - 291
&self, - 292
core: &Core, - 293
record: OutboxRecord, - 294
) -> Result<DeliveryPacket, String> { - 295
let mut packet = self.render(&record.job).await?; - 296
let (adapter, address) = self.adapters.resolve(&record.job.target)?; - 297
// Each adapter's own `send` re-derives its address from - 298
// `packet.target` via a plain `surface:address` split — normalize - 299
// away a three-part bot-scoped target (`surface:address:bot_id`) - 300
// here, once, rather than teaching every adapter about the bot id - 301
// segment it has no use for once the right adapter is already - 302
// picked. - 303
packet.target = format!("{}:{address}", adapter.scheme()); - 304
adapter.send(core, &packet).await?; - 305
self.outbox - 306
.mark_delivered(&record.job.job_id, packet.clone()) - 307
.map_err(|error| error.to_string())?; - 308
Ok(packet) - 309
} - 310
} - 311
- 312
fn runtime(core: &Core) -> Arc<DeliveryRuntime> { - 313
static RUNTIMES: OnceLock<Mutex<BTreeMap<String, Arc<DeliveryRuntime>>>> = OnceLock::new(); - 314
let key = core.sessions_home().to_string_lossy().into_owned(); - 315
let runtimes = RUNTIMES.get_or_init(|| Mutex::new(BTreeMap::new())); - 316
let mut runtimes = runtimes - 317
.lock() - 318
.unwrap_or_else(std::sync::PoisonError::into_inner); - 319
if let Some(existing) = runtimes.get(&key) { - 320
return existing.clone(); - 321
} - 322
let created = Arc::new(DeliveryRuntime::new(core)); - 323
runtimes.insert(key, created.clone()); - 324
created - 325
} - 326
- 327
pub(crate) fn profile_for_surface( - 328
core: &Core, - 329
surface: &str, - 330
requested: Option<&RequestedCapabilities>, - 331
) -> DeliveryProfile { - 332
let mut profile = built_in_surface_profile(surface); - 333
if let Some(requested) = requested { - 334
if let Some(markup) = requested.markup { - 335
profile.markup = markup; - 336
} - 337
if let Some(max_chars) = requested.max_chars.filter(|value| *value > 0) { - 338
profile.max_chars = Some(max_chars.min(MAX_CHANNEL_CHARS)); - 339
} - 340
profile.supports_tables = requested.supports_tables.unwrap_or(profile.supports_tables); - 341
profile.supports_code_blocks = requested - 342
.supports_code_blocks - 343
.unwrap_or(profile.supports_code_blocks); - 344
profile.supports_links = requested.supports_links.unwrap_or(profile.supports_links); - 345
profile.supports_actions = requested - 346
.supports_actions - 347
.unwrap_or(profile.supports_actions); - 348
} - 349
apply_preferences(core, profile) - 350
} - 351
- 352
fn built_in_surface_profile(surface: &str) -> DeliveryProfile { - 353
match surface { - 354
"telegram" => DeliveryProfile { - 355
surface: surface.into(), - 356
markup: Markup::TelegramHtml, - 357
max_chars: Some(4000), - 358
supports_tables: false, - 359
supports_code_blocks: true, - 360
supports_links: true, - 361
supports_actions: false, - 362
template: None, - 363
posture: vak_delivery::DeliveryPosture::default(), - 364
}, - 365
"discord" => DeliveryProfile { - 366
surface: surface.into(), - 367
markup: Markup::DiscordMarkdown, - 368
max_chars: Some(1900), - 369
supports_tables: false, - 370
supports_code_blocks: true, - 371
supports_links: true, - 372
supports_actions: false, - 373
template: None, - 374
posture: vak_delivery::DeliveryPosture::default(), - 375
}, - 376
"slack" => DeliveryProfile { - 377
surface: surface.into(), - 378
markup: Markup::SlackMrkdwn, - 379
max_chars: Some(3900), - 380
supports_tables: false, - 381
supports_code_blocks: true, - 382
supports_links: true, - 383
supports_actions: false, - 384
template: None, - 385
posture: vak_delivery::DeliveryPosture::default(), - 386
}, - 387
"desktop" | "tui" => DeliveryProfile { - 388
surface: surface.into(), - 389
markup: Markup::Markdown, - 390
max_chars: None, - 391
supports_tables: true, - 392
supports_code_blocks: true, - 393
supports_links: true, - 394
supports_actions: true, - 395
template: None, - 396
posture: vak_delivery::DeliveryPosture::default(), - 397
}, - 398
"background" => DeliveryProfile { - 399
surface: surface.into(), - 400
markup: Markup::Plain, - 401
max_chars: Some(4000), - 402
supports_tables: false, - 403
supports_code_blocks: false, - 404
supports_links: true, - 405
supports_actions: false, - 406
template: None, - 407
posture: vak_delivery::DeliveryPosture { - 408
cadence: vak_delivery::Cadence::Digest, - 409
urgency: vak_delivery::Urgency::Quiet, - 410
}, - 411
}, - 412
_ => DeliveryProfile { - 413
surface: surface.into(), - 414
markup: Markup::Plain, - 415
max_chars: Some(4000), - 416
supports_tables: false, - 417
supports_code_blocks: false, - 418
supports_links: false, - 419
supports_actions: false, - 420
template: None, - 421
posture: vak_delivery::DeliveryPosture::default(), - 422
}, - 423
} - 424
} - 425
- 426
fn apply_preferences(core: &Core, mut profile: DeliveryProfile) -> DeliveryProfile { - 427
let loaded = load_layers( - 428
&core.sessions_home().join("output.toml"), - 429
&core.cwd().join(".vak").join("output.toml"), - 430
core.project_config_trusted(), - 431
); - 432
for warning in loaded.warnings { - 433
eprintln!("[delivery] {warning}"); - 434
} - 435
if let Some(preference) = loaded.channels.get(&profile.surface) { - 436
apply_preference(&mut profile, preference, &loaded.registry); - 437
} - 438
profile - 439
} - 440
- 441
fn apply_preference( - 442
profile: &mut DeliveryProfile, - 443
preference: &ChannelPreference, - 444
templates: &vak_delivery::TemplateRegistry, - 445
) { - 446
if let Some(max_chars) = preference.max_chars.filter(|value| *value > 0) { - 447
profile.max_chars = Some( - 448
profile - 449
.max_chars - 450
.map_or(max_chars, |hard_limit| hard_limit.min(max_chars)), - 451
); - 452
} - 453
if let Some(markup) = preference.markup - 454
&& markup == profile.markup - 455
{ - 456
profile.markup = markup; - 457
} - 458
if !matches!(profile.markup, Markup::Json) - 459
&& let Some(template) = preference - 460
.template - 461
.as_deref() - 462
.and_then(|id| templates.resolve(id)) - 463
{ - 464
profile.template = Some(template.clone()); - 465
} - 466
} - 467
- 468
#[allow(clippy::too_many_arguments)] - 469
pub(crate) async fn render_response( - 470
core: &Core, - 471
surface: &str, - 472
chat: &str, - 473
markdown: String, - 474
requested: Option<&RequestedCapabilities>, - 475
outcome_metadata: Option<std::collections::BTreeMap<String, String>>, - 476
provenance: Option<std::collections::BTreeMap<String, String>>, - 477
bot_id: Option<&str>, - 478
session_id: Option<&str>, - 479
intent_posture: Option<vak_intent::DeliveryPosture>, - 480
) -> Result<DeliveryPacket, String> { - 481
let runtime = runtime(core); - 482
let mut profile = profile_for_surface(core, surface, requested); - 483
// The engagement decides *when* a packet goes out (docs/design/47, - 484
// delivery posture): an unattended run rolls up into the digest, an - 485
// irreversible step's confirmation breaks through. - 486
if let Some(posture) = intent_posture { - 487
profile.posture = vak_delivery::DeliveryPosture::from_intent_labels( - 488
posture.cadence.as_str(), - 489
posture.urgency.as_str(), - 490
); - 491
} - 492
let job = DeliveryJob { - 493
job_id: uuid::Uuid::now_v7().to_string(), - 494
target: bot_id - 495
.filter(|id| !id.trim().is_empty()) - 496
.map(|id| format!("{surface}:{chat}:{id}")) - 497
.unwrap_or_else(|| format!("{surface}:{chat}")), - 498
kind: DeliveryKind::Assistant, - 499
content: DeliveryContent::Answer({ - 500
let artifact_suffix = session_id - 501
.and_then(|id| { - 502
crate::projection::sandbox_artifact_markdown(&core.sessions_home(), id) - 503
}) - 504
.unwrap_or_default(); - 505
let cleaned_markdown = crate::projection::clean_scaffolding(&markdown); - 506
let mut answer = - 507
AnswerDraft::from_markdown(format!("{cleaned_markdown}{artifact_suffix}")); - 508
if let Some(metadata) = outcome_metadata { - 509
answer.metadata.extend(metadata.clone()); - 510
answer.document.metadata.extend(metadata); - 511
} - 512
if let Some(provenance) = provenance { - 513
answer.metadata.extend(provenance.clone()); - 514
answer.document.metadata.extend(provenance); - 515
} - 516
answer - 517
}), - 518
profile, - 519
skill_registry: Some(merged_presentation_skills(core)), - 520
}; - 521
runtime.render(&job).await - 522
} - 523
- 524
pub(crate) async fn deliver( - 525
core: &Core, - 526
target: &str, - 527
kind: DeliveryKind, - 528
content: DeliveryContent, - 529
) -> Result<DeliveryPacket, String> { - 530
let runtime = runtime(core); - 531
let _serial = runtime.serial.lock().await; - 532
let (adapter, _) = runtime.adapters.resolve(target)?; - 533
let profile = apply_preferences(core, adapter.profile()); - 534
let content = enrich_provenance(core, content); - 535
// Posture decides WHEN a packet goes out, never what it says. - 536
// Held packets (HoldUntilComplete / HoldForDigest) are enqueued to the - 537
// outbox and delivered later by the replay loop or turn-completion flush. - 538
let disposition = profile.posture.disposition(kind); - 539
let job = DeliveryJob { - 540
job_id: uuid::Uuid::now_v7().to_string(), - 541
target: target.into(), - 542
kind, - 543
content, - 544
profile: profile.clone(), - 545
skill_registry: Some(merged_presentation_skills(core)), - 546
}; - 547
let record = runtime - 548
.outbox - 549
.enqueue(job) - 550
.map_err(|error| error.to_string())?; - 551
if disposition == vak_delivery::Disposition::Send { - 552
match runtime.deliver_record(core, record.clone()).await { - 553
Ok(packet) => Ok(packet), - 554
Err(error) => { - 555
let _ = runtime.outbox.mark_failed(&record.job.job_id, &error); - 556
Err(error) - 557
} - 558
} - 559
} else { - 560
Ok(vak_delivery::DeliveryPacket { - 561
schema_version: vak_delivery::DELIVERY_SCHEMA_VERSION, - 562
job_id: record.job.job_id.clone(), - 563
target: record.job.target.clone(), - 564
surface: record.job.profile.surface.clone(), - 565
kind: record.job.kind, - 566
payload: vak_delivery::DeliveryPayload::Text(String::new()), - 567
fallback_markdown: String::new(), - 568
chunks: Vec::new(), - 569
actions: Vec::new(), - 570
coverage: Vec::new(), - 571
diagnostics: vec![format!( - 572
"delivery held: disposition={:?}, will retry via outbox", - 573
disposition - 574
)], - 575
presentation: None, - 576
}) - 577
} - 578
} - 579
- 580
/// Attach the immutable ownership envelope before a generic task, schedule, or - 581
/// approval packet enters the durable outbox. Gateway rendering supplies a - 582
/// request id as well; this common path guarantees that packets emitted by - 583
/// internal machinery still identify the Agent and authorized audience. - 584
fn enrich_provenance(core: &Core, mut content: DeliveryContent) -> DeliveryContent { - 585
let DeliveryContent::Answer(answer) = &mut content else { - 586
return content; - 587
}; - 588
if let Some(agent) = core.agent_identity() { - 589
for (key, value) in [ - 590
("agent_id", agent.id.clone()), - 591
("agent_name", agent.name.clone()), - 592
("agent_revision", agent.revision.to_string()), - 593
("agent_character", agent.character.clone()), - 594
("agent_animation", agent.animation.clone()), - 595
("agent_voice", agent.voice.clone()), - 596
] { - 597
answer.metadata.insert(key.into(), value.clone()); - 598
answer.document.metadata.insert(key.into(), value); - 599
} - 600
} - 601
if let Some(context) = core.conversation_context() { - 602
for (key, value) in [ - 603
("audience_id", context.audience_id.clone()), - 604
("conversation_id", context.conversation_id.clone()), - 605
] { - 606
answer.metadata.insert(key.into(), value.clone()); - 607
answer.document.metadata.insert(key.into(), value); - 608
} - 609
if let Some(origin) = &context.origin { - 610
answer - 611
.metadata - 612
.insert("origin_surface".into(), origin.surface.clone()); - 613
answer - 614
.document - 615
.metadata - 616
.insert("origin_surface".into(), origin.surface.clone()); - 617
answer - 618
.metadata - 619
.insert("origin_address".into(), origin.address.clone()); - 620
answer - 621
.document - 622
.metadata - 623
.insert("origin_address".into(), origin.address.clone()); - 624
if let Some(bot_id) = &origin.bot_id { - 625
answer.metadata.insert("bot_id".into(), bot_id.clone()); - 626
answer - 627
.document - 628
.metadata - 629
.insert("bot_id".into(), bot_id.clone()); - 630
} - 631
} - 632
} - 633
content - 634
} - 635
- 636
pub(crate) async fn deliver_feed_intents( - 637
core: &Core, - 638
intents: &[serde_json::Value], - 639
) -> Result<usize, String> { - 640
let mut delivered = 0; - 641
for intent in intents { - 642
let target = intent - 643
.get("deliver_to") - 644
.and_then(serde_json::Value::as_str) - 645
.filter(|value| !value.is_empty()) - 646
.ok_or_else(|| "feed alert delivery intent has no target".to_string())?; - 647
let alert_name = intent - 648
.get("alert_name") - 649
.and_then(serde_json::Value::as_str) - 650
.unwrap_or("Feed alert"); - 651
let item = intent.get("item").cloned().unwrap_or_default(); - 652
let title = item.get("title").and_then(Value::as_str).unwrap_or(""); - 653
let source = item - 654
.get("source_name") - 655
.and_then(Value::as_str) - 656
.unwrap_or(""); - 657
let url = item.get("url").and_then(Value::as_str).unwrap_or(""); - 658
let reasons = intent - 659
.get("match") - 660
.and_then(|value| value.get("reasons")) - 661
.and_then(Value::as_array) - 662
.map(|values| { - 663
values - 664
.iter() - 665
.filter_map(Value::as_str) - 666
.collect::<Vec<_>>() - 667
.join(", ") - 668
}) - 669
.unwrap_or_default(); - 670
let markdown = format!( - 671
"**Feed Alert: {alert_name}**\n\n**{title}**\nSource: {source}\nURL: {url}\nReasons: {reasons}" - 672
); - 673
deliver( - 674
core, - 675
target, - 676
DeliveryKind::Alert, - 677
DeliveryContent::Text { markdown }, - 678
) - 679
.await?; - 680
delivered += 1; - 681
} - 682
Ok(delivered) - 683
} - 684
- 685
pub(crate) fn start_replay(core: &Core) { - 686
let core = core.clone(); - 687
tokio::spawn(async move { - 688
let runtime = runtime(&core); - 689
let mut interval = tokio::time::interval(Duration::from_secs(30)); - 690
loop { - 691
interval.tick().await; - 692
let _serial = runtime.serial.lock().await; - 693
let records = match runtime.outbox.pending() { - 694
Ok(records) => records, - 695
Err(error) => { - 696
eprintln!("[delivery] outbox replay scan failed: {error}"); - 697
continue; - 698
} - 699
}; - 700
for record in records.into_iter().take(100) { - 701
if record.attempts >= 10 { - 702
let _ = runtime - 703
.outbox - 704
.mark_dead_letter(&record.job.job_id, "delivery retry budget exhausted"); - 705
eprintln!( - 706
"[delivery] {} moved to dead letter after {} attempts", - 707
record.job.job_id, record.attempts - 708
); - 709
continue; - 710
} - 711
// Respect held postures: a packet held for completion or - 712
// digest stays in the outbox until its posture changes. - 713
let disposition = record.job.profile.posture.disposition(record.job.kind); - 714
if disposition != vak_delivery::Disposition::Send { - 715
eprintln!( - 716
"[delivery] {} held (disposition={:?}); skipping replay", - 717
record.job.job_id, disposition - 718
); - 719
continue; - 720
} - 721
if let Err(error) = runtime.deliver_record(&core, record.clone()).await { - 722
let _ = runtime.outbox.mark_failed(&record.job.job_id, &error); - 723
eprintln!("[delivery] replay {} failed: {error}", record.job.job_id); - 724
} - 725
} - 726
} - 727
}); - 728
} - 729
- 730
/// Read the durable outbox for the operations surfaces. Records are returned - 731
/// as-is so the console can distinguish pending, delivered and dead-lettered - 732
/// work without inventing a second status store. - 733
pub(crate) fn outbox_records(core: &Core) -> Result<Vec<OutboxRecord>, String> { - 734
runtime(core) - 735
.outbox - 736
.list() - 737
.map_err(|error| error.to_string()) - 738
} - 739
- 740
/// Replay one pending or dead-lettered job through the same serialized - 741
/// renderer/adapter path as the background worker. A missing job is surfaced - 742
/// as an error; no new delivery target or capability is inferred here. - 743
pub(crate) async fn replay_outbox_job(core: &Core, job_id: &str) -> Result<(), String> { - 744
let runtime = runtime(core); - 745
let _serial = runtime.serial.lock().await; - 746
let record = runtime - 747
.outbox - 748
.get(job_id) - 749
.map_err(|error| error.to_string())?; - 750
if record.state == vak_delivery::outbox::OutboxState::Delivered { - 751
return Err("delivery job is already delivered".into()); - 752
} - 753
match runtime.deliver_record(core, record.clone()).await { - 754
Ok(_) => Ok(()), - 755
Err(error) => { - 756
let _ = runtime.outbox.mark_failed(job_id, &error); - 757
Err(error) - 758
} - 759
} - 760
} - 761
- 762
struct LogAdapter; - 763
- 764
#[async_trait] - 765
impl ChannelAdapter for LogAdapter { - 766
fn scheme(&self) -> &'static str { - 767
"log" - 768
} - 769
- 770
fn profile(&self) -> DeliveryProfile { - 771
DeliveryProfile { - 772
surface: "log".into(), - 773
markup: Markup::Markdown, - 774
max_chars: None, - 775
supports_tables: true, - 776
supports_code_blocks: true, - 777
supports_links: true, - 778
supports_actions: false, - 779
template: None, - 780
posture: vak_delivery::DeliveryPosture::default(), - 781
} - 782
} - 783
- 784
async fn send(&self, core: &Core, packet: &DeliveryPacket) -> Result<(), String> { - 785
let path = core - 786
.shared_data_home() - 787
.join("gateway") - 788
.join("deliveries.jsonl"); - 789
if let Some(parent) = path.parent() { - 790
std::fs::create_dir_all(parent) - 791
.map_err(|error| format!("create deliveries directory: {error}"))?; - 792
} - 793
let line = serde_json::json!({ - 794
"ts": chrono::Utc::now().to_rfc3339(), - 795
"target": packet.target, - 796
"text": packet.fallback_markdown, - 797
"job_id": packet.job_id, - 798
"delivery": packet, - 799
}); - 800
let mut buffer = line.to_string(); - 801
buffer.push('\n'); - 802
use std::io::Write; - 803
let mut file = std::fs::OpenOptions::new() - 804
.create(true) - 805
.append(true) - 806
.open(path) - 807
.map_err(|error| format!("open deliveries log: {error}"))?; - 808
file.write_all(buffer.as_bytes()) - 809
.map_err(|error| format!("append deliveries log: {error}")) - 810
} - 811
} - 812
- 813
struct WebhookAdapter; - 814
- 815
#[async_trait] - 816
impl ChannelAdapter for WebhookAdapter { - 817
fn scheme(&self) -> &'static str { - 818
"webhook" - 819
} - 820
- 821
fn profile(&self) -> DeliveryProfile { - 822
DeliveryProfile { - 823
surface: "webhook".into(), - 824
markup: Markup::Json, - 825
max_chars: None, - 826
supports_tables: true, - 827
supports_code_blocks: true, - 828
supports_links: true, - 829
supports_actions: true, - 830
template: None, - 831
posture: vak_delivery::DeliveryPosture::default(), - 832
} - 833
} - 834
- 835
async fn send(&self, core: &Core, packet: &DeliveryPacket) -> Result<(), String> { - 836
let (_, name) = packet - 837
.target - 838
.split_once(':') - 839
.ok_or_else(|| "webhook target has no name".to_string())?; - 840
super::gateway::deliver_webhook_packet(core, name, packet).await - 841
} - 842
} - 843
- 844
/// Telegram's `reply_markup.inline_keyboard`: one row, one button per - 845
/// action. `callback_data` is `"<verb>:<request_id>"` — the bridge's - 846
/// `handle_callback` splits on the first `:` and maps `verb` straight onto - 847
/// the same "yes"/"no" verdict text a typed chat reply would produce. - 848
fn inline_keyboard_markup(actions: &[DeliveryAction]) -> serde_json::Value { - 849
let buttons: Vec<serde_json::Value> = actions - 850
.iter() - 851
.map(|action| { - 852
let request_id = action - 853
.data - 854
.get("request_id") - 855
.map(String::as_str) - 856
.unwrap_or_default(); - 857
serde_json::json!({ - 858
"text": action.label, - 859
"callback_data": format!("{}:{request_id}", action.verb), - 860
}) - 861
}) - 862
.collect(); - 863
serde_json::json!({ "inline_keyboard": [buttons] }) - 864
} - 865
- 866
/// Proactive push to a Telegram chat: the async counterpart to the - 867
/// bridge's own `sendMessage` reply. Used for anything that isn't a direct - 868
/// reply to the message currently in flight — chiefly forwarded approval - 869
/// gates (`[gateway] approver = "telegram:<chat>"`), which can open while - 870
/// the approver chat isn't the one that triggered the turn. - 871
struct TelegramAdapter { - 872
bot_token: String, - 873
api_base: String, - 874
} - 875
- 876
#[async_trait] - 877
impl ChannelAdapter for TelegramAdapter { - 878
fn scheme(&self) -> &'static str { - 879
"telegram" - 880
} - 881
- 882
fn profile(&self) -> DeliveryProfile { - 883
DeliveryProfile { - 884
surface: "telegram".into(), - 885
markup: Markup::TelegramHtml, - 886
max_chars: Some(4000), - 887
supports_tables: false, - 888
supports_code_blocks: true, - 889
supports_links: true, - 890
// Unlike the synchronous per-turn reply profile, this adapter - 891
// renders `packet.actions` as inline-keyboard buttons below. - 892
supports_actions: true, - 893
template: None, - 894
posture: vak_delivery::DeliveryPosture::default(), - 895
} - 896
} - 897
- 898
async fn send(&self, _core: &Core, packet: &DeliveryPacket) -> Result<(), String> { - 899
let (_, chat_id) = packet - 900
.target - 901
.split_once(':') - 902
.ok_or_else(|| "telegram target has no chat id".to_string())?; - 903
let chunks: Vec<&str> = if packet.chunks.is_empty() { - 904
vec![packet.fallback_markdown.as_str()] - 905
} else { - 906
packet.chunks.iter().map(String::as_str).collect() - 907
}; - 908
let client = reqwest::Client::new(); - 909
let last = chunks.len().saturating_sub(1); - 910
for (i, chunk) in chunks.iter().enumerate() { - 911
let mut body = serde_json::json!({ - 912
"chat_id": chat_id, - 913
"text": chunk, - 914
"parse_mode": "HTML", - 915
"link_preview_options": { "is_disabled": true }, - 916
}); - 917
// Buttons ride on the last chunk so they land under the final - 918
// line of text, matching where a human reader expects them. - 919
if i == last && !packet.actions.is_empty() { - 920
body["reply_markup"] = inline_keyboard_markup(&packet.actions); - 921
} - 922
let resp = client - 923
.post(format!( - 924
"{}/bot{}/sendMessage", - 925
self.api_base, self.bot_token - 926
)) - 927
.json(&body) - 928
.send() - 929
.await - 930
.map_err(|error| format!("telegram sendMessage: {error}"))?; - 931
if !resp.status().is_success() { - 932
return Err(format!("telegram sendMessage returned {}", resp.status())); - 933
} - 934
} - 935
Ok(()) - 936
} - 937
} - 938
- 939
/// Forwarded-approval actions rendered as a typed-verdict prompt, for the - 940
/// surfaces that do not (yet) get interactive components here. This is the - 941
/// same fallback Telegram used before its inline keyboard: the reply text - 942
/// it asks for is exactly what `parse_verdict` in `gateway.rs` already - 943
/// understands, so approvals resolve through the one existing path. - 944
fn typed_verdict_prompt(actions: &[DeliveryAction]) -> Option<String> { - 945
let request_id = actions - 946
.iter() - 947
.find_map(|action| action.data.get("request_id"))?; - 948
Some(format!( - 949
"\n\nReply `yes {request_id}` to approve or `no {request_id}` to deny." - 950
)) - 951
} - 952
- 953
/// Proactive push to a Discord channel (docs/design/34 Phase 3): the async - 954
/// counterpart to the bridge's own reply, chiefly forwarded approval - 955
/// gates, which can open while the approver channel is not the one that - 956
/// triggered the turn. - 957
struct DiscordAdapter { - 958
bot_token: String, - 959
api_base: String, - 960
} - 961
- 962
#[async_trait] - 963
impl ChannelAdapter for DiscordAdapter { - 964
fn scheme(&self) -> &'static str { - 965
"discord" - 966
} - 967
- 968
fn profile(&self) -> DeliveryProfile { - 969
built_in_surface_profile("discord") - 970
} - 971
- 972
async fn send(&self, _core: &Core, packet: &DeliveryPacket) -> Result<(), String> { - 973
let (_, channel_id) = packet - 974
.target - 975
.split_once(':') - 976
.ok_or_else(|| "discord target has no channel id".to_string())?; - 977
post_chunks( - 978
packet, - 979
|chunk, last| { - 980
let mut content = chunk.to_string(); - 981
if last && let Some(prompt) = typed_verdict_prompt(&packet.actions) { - 982
content.push_str(&prompt); - 983
} - 984
( - 985
format!("{}/channels/{channel_id}/messages", self.api_base), - 986
serde_json::json!({ "content": content }), - 987
) - 988
}, - 989
|request| request.header("Authorization", format!("Bot {}", self.bot_token)), - 990
"discord createMessage", - 991
) - 992
.await - 993
} - 994
} - 995
- 996
/// Proactive push to a Slack channel/DM via `chat.postMessage`. - 997
struct SlackAdapter { - 998
bot_token: String, - 999
api_base: String, - 1000
}
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.