- 280
impl DeliveryProfile { - 281
pub fn plain(surface: impl Into<String>) -> Self { - 282
Self { - 283
surface: surface.into(), - 284
markup: Markup::Plain, - 285
max_chars: None, - 286
supports_tables: false, - 287
supports_code_blocks: true, - 288
supports_links: true, - 289
supports_actions: false, - 290
template: None, - 291
posture: DeliveryPosture::default(), - 292
} - 293
} - 294
- 295
fn validate(&self) -> Result<(), DeliveryError> { - 296
if self.surface.trim().is_empty() { - 297
return Err(DeliveryError::InvalidProfile("surface is empty".into())); - 298
} - 299
if self.max_chars == Some(0) { - 300
return Err(DeliveryError::InvalidProfile( - 301
"max_chars must be greater than zero".into(), - 302
)); - 303
} - 304
Ok(()) - 305
} - 306
- 307
/// Semantic capabilities consumed by native and constrained projectors. - 308
/// Existing profile fields remain the compatibility wire contract. - 309
pub fn capabilities(&self) -> SurfaceCapabilities { - 310
let native = matches!(self.surface.as_str(), "desktop" | "tui" | "admin"); - 311
SurfaceCapabilities { - 312
structured_blocks: matches!(self.markup, Markup::Json) || native, - 313
tables: self.supports_tables, - 314
code: self.supports_code_blocks, - 315
links: self.supports_links, - 316
media: native || matches!(self.markup, Markup::Json), - 317
file_references: native || matches!(self.markup, Markup::Json), - 318
actions: self.supports_actions, - 319
color: native, - 320
interactive: self.supports_actions || native, - 321
accessible_plain: matches!(self.markup, Markup::Plain), - 322
max_chars: self.max_chars, - 323
} - 324
} - 325
} - 326
- 327
/// User-replaceable presentation layout. Templates contain no executable - 328
/// code, network access, filesystem access, or arbitrary markup injection. - 329
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] - 330
pub struct TemplateSpec { - 331
pub id: String, - 332
pub revision: u32, - 333
pub origin: TemplateOrigin, - 334
#[serde(default)] - 335
pub activation: TemplateActivation, - 336
#[serde(default)] - 337
pub nodes: Vec<TemplateNode>, - 338
} - 339
- 340
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] - 341
#[serde(rename_all = "snake_case")] - 342
pub enum TemplateOrigin { - 343
BuiltIn, - 344
User, - 345
Project, - 346
AgentProposal, - 347
} - 348
- 349
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] - 350
#[serde(rename_all = "snake_case")] - 351
pub enum TemplateActivation { - 352
#[default] - 353
Active, - 354
Proposed, - 355
} - 356
- 357
/// Resolves replaceable templates without allowing an agent proposal to - 358
/// silently become active. User/project templates override built-ins by - 359
/// origin priority, then by revision. - 360
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] - 361
pub struct TemplateRegistry { - 362
pub templates: Vec<TemplateSpec>, - 363
} - 364
- 365
impl TemplateRegistry { - 366
pub fn upsert(&mut self, template: TemplateSpec) -> Result<(), DeliveryError> { - 367
template.validate()?; - 368
if let Some(existing) = self - 369
.templates - 370
.iter_mut() - 371
.find(|candidate| candidate.id == template.id && candidate.origin == template.origin) - 372
{ - 373
if template.revision < existing.revision { - 374
return Err(DeliveryError::InvalidTemplate(format!( - 375
"template {} revision {} is older than stored revision {}", - 376
template.id, template.revision, existing.revision - 377
))); - 378
} - 379
*existing = template; - 380
} else { - 381
self.templates.push(template); - 382
} - 383
Ok(()) - 384
} - 385
- 386
pub fn resolve(&self, id: &str) -> Option<&TemplateSpec> { - 387
self.templates - 388
.iter() - 389
.filter(|template| { - 390
template.id == id && template.activation == TemplateActivation::Active - 391
}) - 392
.max_by_key(|template| (origin_priority(template.origin), template.revision)) - 393
} - 394
- 395
pub fn activate_proposal(&mut self, id: &str, revision: u32) -> bool { - 396
self.templates.iter_mut().any(|template| { - 397
if template.id == id - 398
&& template.revision == revision - 399
&& template.origin == TemplateOrigin::AgentProposal - 400
{ - 401
template.activation = TemplateActivation::Active; - 402
true - 403
} else { - 404
false - 405
} - 406
}) - 407
} - 408
} - 409
- 410
fn origin_priority(origin: TemplateOrigin) -> u8 { - 411
match origin { - 412
TemplateOrigin::BuiltIn => 1, - 413
TemplateOrigin::AgentProposal => 2, - 414
TemplateOrigin::Project => 3, - 415
TemplateOrigin::User => 4, - 416
} - 417
} - 418
- 419
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] - 420
#[serde(tag = "type", rename_all = "snake_case")] - 421
pub enum TemplateNode { - 422
Literal { - 423
text: String, - 424
}, - 425
Slot { - 426
slot: TemplateSlot, - 427
}, - 428
IfPresent { - 429
slot: TemplateSlot, - 430
then_nodes: Vec<TemplateNode>, - 431
else_nodes: Vec<TemplateNode>, - 432
}, - 433
} - 434
- 435
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] - 436
#[serde(rename_all = "snake_case")] - 437
pub enum TemplateSlot { - 438
Title, - 439
Body, - 440
SourceMarkdown, - 441
BlockCount, - 442
Metadata { key: String }, - 443
} - 444
- 445
impl TemplateSpec { - 446
pub fn validate(&self) -> Result<(), DeliveryError> { - 447
if self.id.trim().is_empty() { - 448
return Err(DeliveryError::InvalidTemplate("id is empty".into())); - 449
} - 450
if self.nodes.len() > 128 { - 451
return Err(DeliveryError::InvalidTemplate( - 452
"template has more than 128 top-level nodes".into(), - 453
)); - 454
} - 455
validate_nodes(&self.nodes, 0) - 456
} - 457
- 458
fn render( - 459
&self, - 460
answer: &AnswerDraft, - 461
project_structured: bool, - 462
skills: &SkillRegistry, - 463
) -> Result<String, DeliveryError> { - 464
self.validate()?; - 465
render_nodes(&self.nodes, answer, 0, project_structured, skills) - 466
} - 467
} - 468
- 469
fn validate_nodes(nodes: &[TemplateNode], depth: usize) -> Result<(), DeliveryError> { - 470
if depth > 8 { - 471
return Err(DeliveryError::InvalidTemplate( - 472
"template nesting exceeds 8 levels".into(), - 473
)); - 474
} - 475
for node in nodes { - 476
match node { - 477
TemplateNode::Literal { text } if text.chars().count() > 16_384 => { - 478
return Err(DeliveryError::InvalidTemplate( - 479
"template literal exceeds 16384 characters".into(), - 480
)); - 481
} - 482
TemplateNode::Literal { .. } | TemplateNode::Slot { .. } => {} - 483
TemplateNode::IfPresent { - 484
then_nodes, - 485
else_nodes, - 486
.. - 487
} => { - 488
validate_nodes(then_nodes, depth + 1)?; - 489
validate_nodes(else_nodes, depth + 1)?; - 490
} - 491
} - 492
} - 493
Ok(()) - 494
} - 495
- 496
fn render_nodes( - 497
nodes: &[TemplateNode], - 498
answer: &AnswerDraft, - 499
depth: usize, - 500
project_structured: bool, - 501
skills: &SkillRegistry, - 502
) -> Result<String, DeliveryError> { - 503
if depth > 8 { - 504
return Err(DeliveryError::InvalidTemplate( - 505
"template nesting exceeds 8 levels".into(), - 506
)); - 507
} - 508
let mut out = String::new(); - 509
for node in nodes { - 510
match node { - 511
TemplateNode::Literal { text } => out.push_str(text), - 512
TemplateNode::Slot { slot } => { - 513
out.push_str(&slot_value(slot, answer, project_structured, skills)) - 514
} - 515
TemplateNode::IfPresent { - 516
slot, - 517
then_nodes, - 518
else_nodes, - 519
} => { - 520
let selected = if slot_value(slot, answer, project_structured, skills).is_empty() { - 521
else_nodes - 522
} else { - 523
then_nodes - 524
}; - 525
out.push_str(&render_nodes( - 526
selected, - 527
answer, - 528
depth + 1, - 529
project_structured, - 530
skills, - 531
)?); - 532
} - 533
} - 534
} - 535
Ok(out) - 536
} - 537
- 538
fn slot_value( - 539
slot: &TemplateSlot, - 540
answer: &AnswerDraft, - 541
project_structured: bool, - 542
skills: &SkillRegistry, - 543
) -> String { - 544
match slot { - 545
TemplateSlot::Title => answer - 546
.blocks - 547
.iter() - 548
.find_map(|block| match block { - 549
Block::Heading { text, .. } => Some(text.clone()), - 550
_ => None, - 551
}) - 552
.or_else(|| answer.metadata.get("title").cloned()) - 553
.unwrap_or_default(), - 554
TemplateSlot::Body => render_plain(answer, project_structured, skills), - 555
TemplateSlot::SourceMarkdown => answer.source_markdown.clone(), - 556
TemplateSlot::BlockCount => answer.blocks.len().to_string(), - 557
TemplateSlot::Metadata { key } => answer.metadata.get(key).cloned().unwrap_or_default(), - 558
} - 559
} - 560
- 561
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] - 562
pub struct DeliveryJob { - 563
pub job_id: String, - 564
pub target: String, - 565
pub kind: DeliveryKind, - 566
pub content: DeliveryContent, - 567
pub profile: DeliveryProfile, - 568
/// Plugin-merged skill registry for structured-fence validation. When - 569
/// `None`, the worker falls back to `built_in_skill_registry()`. The Core - 570
/// populates this from its capability snapshot so that plugin-declared - 571
/// semantic types are recognized during rendering without the worker - 572
/// having filesystem or network access. - 573
#[serde(default, skip_serializing_if = "Option::is_none")] - 574
pub skill_registry: Option<SkillRegistry>, - 575
} - 576
- 577
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] - 578
pub struct DeliveryPacket { - 579
pub schema_version: u16, - 580
pub job_id: String, - 581
pub target: String, - 582
pub surface: String, - 583
pub kind: DeliveryKind, - 584
pub payload: DeliveryPayload, - 585
/// Exact source is always available for export, audit, and fallback. - 586
pub fallback_markdown: String, - 587
pub chunks: Vec<String>, - 588
pub actions: Vec<DeliveryAction>, - 589
pub coverage: Vec<Coverage>, - 590
pub diagnostics: Vec<String>, - 591
/// Native semantic projection. Text-only consumers can ignore this and - 592
/// continue using `chunks` or `fallback_markdown`. - 593
#[serde(default, skip_serializing_if = "Option::is_none")] - 594
pub presentation: Option<OutputTimeline>, - 595
} - 596
- 597
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] - 598
#[serde(tag = "type", content = "value", rename_all = "snake_case")] - 599
#[allow(clippy::large_enum_variant)] - 600
pub enum DeliveryPayload { - 601
Text(String), - 602
Structured(DeliveryContent), - 603
} - 604
- 605
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] - 606
pub struct Coverage { - 607
pub block_id: String, - 608
pub disposition: CoverageDisposition, - 609
} - 610
- 611
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] - 612
#[serde(tag = "type", rename_all = "snake_case")] - 613
pub enum CoverageDisposition { - 614
Rendered, - 615
PreservedInFallback { reason: String }, - 616
} - 617
- 618
#[derive(Debug, Clone, PartialEq, Eq)] - 619
pub enum DeliveryError { - 620
InvalidProfile(String), - 621
InvalidTemplate(String), - 622
InvalidMessage(String), - 623
UnsupportedSchema(u16), - 624
} - 625
- 626
impl std::fmt::Display for DeliveryError { - 627
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - 628
match self { - 629
Self::InvalidProfile(message) => write!(f, "invalid delivery profile: {message}"), - 630
Self::InvalidTemplate(message) => write!(f, "invalid delivery template: {message}"), - 631
Self::InvalidMessage(message) => write!(f, "invalid delivery message: {message}"), - 632
Self::UnsupportedSchema(version) => { - 633
write!(f, "unsupported delivery schema version: {version}") - 634
} - 635
} - 636
} - 637
} - 638
- 639
impl std::error::Error for DeliveryError {} - 640
- 641
pub fn render(job: &DeliveryJob) -> Result<DeliveryPacket, DeliveryError> { - 642
job.profile.validate()?; - 643
if !job.kind.is_external() { - 644
return Err(DeliveryError::InvalidMessage(format!( - 645
"{} messages are control-plane data and must use the event stream", - 646
format_kind(job.kind) - 647
))); - 648
} - 649
- 650
if !content_matches_kind(job.kind, &job.content) { - 651
return Err(DeliveryError::InvalidMessage( - 652
"message kind and content type do not match".into(), - 653
)); - 654
} - 655
if let DeliveryContent::Answer(answer) = &job.content - 656
&& answer.schema_version != DELIVERY_SCHEMA_VERSION - 657
{ - 658
return Err(DeliveryError::UnsupportedSchema(answer.schema_version)); - 659
} - 660
if job.profile.template.is_some() && !job.kind.allows_template() { - 661
return Err(DeliveryError::InvalidTemplate(format!( - 662
"templates are not allowed for {} messages", - 663
format_kind(job.kind) - 664
))); - 665
} - 666
if let Some(template) = job.profile.template.as_ref() - 667
&& template.activation != TemplateActivation::Active - 668
{ - 669
return Err(DeliveryError::InvalidTemplate( - 670
"agent template proposals require explicit activation".into(), - 671
)); - 672
} - 673
- 674
let (rendered, fallback_markdown, actions, coverage) = render_content(job)?; - 675
let chunks = match job.profile.markup { - 676
Markup::TelegramHtml => telegram::split_html_chunks(&rendered, job.profile.max_chars), - 677
Markup::SlackMrkdwn | Markup::DiscordMarkdown => { - 678
chunk_markdown_preserving_fences(&rendered, job.profile.max_chars) - 679
} - 680
_ => chunk_text(&rendered, job.profile.max_chars), - 681
}; - 682
let payload = match job.profile.markup { - 683
Markup::Json => DeliveryPayload::Structured(job.content.clone()), - 684
_ => DeliveryPayload::Text(rendered), - 685
}; - 686
let mut diagnostics = Vec::new(); - 687
if !actions.is_empty() && !job.profile.supports_actions { - 688
diagnostics.push( - 689
"actions preserved separately; target does not declare interactive action support" - 690
.into(), - 691
); - 692
} - 693
- 694
let presentation = presentation_for_job(job); - 695
Ok(DeliveryPacket { - 696
schema_version: DELIVERY_SCHEMA_VERSION, - 697
job_id: job.job_id.clone(), - 698
target: job.target.clone(), - 699
surface: job.profile.surface.clone(), - 700
kind: job.kind, - 701
payload, - 702
fallback_markdown, - 703
chunks, - 704
actions, - 705
coverage, - 706
diagnostics, - 707
presentation, - 708
}) - 709
} - 710
- 711
fn presentation_for_job(job: &DeliveryJob) -> Option<OutputTimeline> { - 712
let DeliveryContent::Answer(answer) = &job.content else { - 713
return None; - 714
}; - 715
let default_status = match ( - 716
answer - 717
.metadata - 718
.get("outcome_completion") - 719
.map(String::as_str), - 720
answer - 721
.metadata - 722
.get("outcome_evidence_state") - 723
.map(String::as_str), - 724
) { - 725
(Some("complete"), None | Some("fresh")) | (None, None) => OutputStatus::Succeeded, - 726
_ => OutputStatus::Partial, - 727
}; - 728
let default_status = if answer - 729
.metadata - 730
.get("outcome_human_review") - 731
.is_some_and(|value| !value.trim().is_empty() && value != "not_required") - 732
{ - 733
OutputStatus::Partial - 734
} else { - 735
default_status - 736
}; - 737
let legacy_document = answer.document.clone(); - 738
let has_typed_results = !answer.results.is_empty(); - 739
let results = if answer.results.is_empty() { - 740
vec![AnswerResult { - 741
id: "answer".into(), - 742
source_markdown: answer.source_markdown.clone(), - 743
outcome: metadata_outcome(&answer.metadata, default_status), - 744
}] - 745
} else { - 746
answer.results.clone() - 747
}; - 748
let mut diagnostics = Vec::new(); - 749
let items = results - 750
.into_iter() - 751
.map(|result| { - 752
let outcome = result.outcome; - 753
let status = outcome.as_ref().map(|value| value.status).unwrap_or(default_status); - 754
if status == OutputStatus::Partial { - 755
diagnostics.push(format!( - 756
"result '{}' is incomplete or unverified; inspect evidence before relying on it", - 757
result.id - 758
)); - 759
} - 760
OutputItem { - 761
id: format!("{}/{}", job.job_id, result.id), - 762
timestamp: String::new(), - 763
turn_id: job.job_id.clone(), - 764
role: OutputRole::Assistant, - 765
kind: match job.kind { - 766
DeliveryKind::Assistant | DeliveryKind::TaskSummary | DeliveryKind::Alert => OutputKind::Outcome, - 767
_ => OutputKind::Message, - 768
}, - 769
status, - 770
outcome, - 771
content: OutputContent::Document { - 772
document: if !has_typed_results - 773
&& result.id == "answer" - 774
&& legacy_document.source_markdown == result.source_markdown - 775
{ - 776
legacy_document.clone() - 777
} else { - 778
compile_markdown(result.source_markdown.clone()) - 779
}, - 780
}, - 781
provenance: None, - 782
actions: Vec::new(), - 783
fallback_text: result.source_markdown, - 784
} - 785
}) - 786
.collect(); - 787
Some(OutputTimeline { - 788
schema_version: presentation::PRESENTATION_SCHEMA_VERSION, - 789
session_id: String::new(), - 790
cursor: None, - 791
items, - 792
diagnostics, - 793
goal: None, - 794
}) - 795
} - 796
- 797
fn metadata_outcome( - 798
metadata: &BTreeMap<String, String>, - 799
status: OutputStatus, - 800
) -> Option<ResultOutcome> { - 801
let completion = metadata.get("outcome_completion").cloned(); - 802
let evidence_state = metadata.get("outcome_evidence_state").cloned(); - 803
let human_review = metadata.get("outcome_human_review").cloned(); - 804
(completion.is_some() || evidence_state.is_some() || human_review.is_some()).then(|| { - 805
ResultOutcome { - 806
result_id: "answer".into(), - 807
status, - 808
completion, - 809
evidence_state, - 810
requirement_ids: Vec::new(), - 811
evidence_receipt_ids: Vec::new(), - 812
evidence: Vec::new(), - 813
human_review, - 814
} - 815
}) - 816
} - 817
- 818
/// Whether the markup type cannot render semantic AST blocks natively and - 819
/// therefore needs ```vak fences projected to readable text before markup - 820
/// conversion. Native surfaces (desktop, tui, admin) and JSON payloads handle - 821
/// structured blocks through the semantic path; all chat and plain-text - 822
/// surfaces need projection. - 823
fn needs_structured_projection(markup: Markup) -> bool { - 824
!matches!(markup, Markup::Json) - 825
} - 826
- 827
fn render_content( - 828
job: &DeliveryJob, - 829
) -> Result<(String, String, Vec<DeliveryAction>, Vec<Coverage>), DeliveryError> { - 830
let project_structured = needs_structured_projection(job.profile.markup); - 831
let skills = job - 832
.skill_registry - 833
.as_ref() - 834
.map(std::borrow::Cow::Borrowed) - 835
.unwrap_or_else(|| std::borrow::Cow::Owned(built_in_skill_registry())); - 836
let skills_ref = &*skills; - 837
match &job.content { - 838
DeliveryContent::Answer(answer) => { - 839
let projected_answer = answer.channel_projection(); - 840
let rendered = match job.profile.template.as_ref() { - 841
Some(template) => { - 842
let text = - 843
template.render(&projected_answer, project_structured, skills_ref)?; - 844
let text = if project_structured { - 845
project_structured_fences_with(&text, skills_ref) - 846
} else { - 847
text - 848
}; - 849
render_text(&text, job.profile.markup) - 850
} - 851
None => render_answer( - 852
&projected_answer, - 853
job.profile.markup, - 854
project_structured, - 855
skills_ref, - 856
), - 857
}; - 858
let coverage = projected_answer - 859
.blocks - 860
.iter() - 861
.map(|block| Coverage { - 862
block_id: block.id().to_string(), - 863
disposition: CoverageDisposition::Rendered, - 864
}) - 865
.collect(); - 866
Ok(( - 867
rendered, - 868
projected_answer.source_markdown, - 869
Vec::new(), - 870
coverage, - 871
)) - 872
} - 873
DeliveryContent::Approval(approval) => Ok(( - 874
render_approval(approval, job.profile.markup), - 875
approval.detail.clone(), - 876
approval.actions.clone(), - 877
Vec::new(), - 878
)), - 879
DeliveryContent::Progress(progress) => Ok(( - 880
render_progress(progress, job.profile.markup), - 881
format!("{}: {}", progress.label, progress.state), - 882
Vec::new(), - 883
Vec::new(), - 884
)), - 885
DeliveryContent::ToolResult(result) => Ok(( - 886
render_tool_result(result, job.profile.markup, project_structured, skills_ref), - 887
result.output.clone(), - 888
Vec::new(), - 889
Vec::new(), - 890
)), - 891
DeliveryContent::Text { markdown } => { - 892
let projected = if project_structured { - 893
project_structured_fences_with(markdown, skills_ref) - 894
} else { - 895
markdown.clone() - 896
}; - 897
Ok(( - 898
render_text(&projected, job.profile.markup), - 899
markdown.clone(), - 900
Vec::new(), - 901
Vec::new(), - 902
)) - 903
} - 904
} - 905
} - 906
- 907
fn content_matches_kind(kind: DeliveryKind, content: &DeliveryContent) -> bool { - 908
matches!( - 909
(kind, content), - 910
( - 911
DeliveryKind::Assistant | DeliveryKind::TaskSummary | DeliveryKind::Alert, - 912
DeliveryContent::Answer(_) - 913
) | (DeliveryKind::User, DeliveryContent::Answer(_)) - 914
| (DeliveryKind::Approval, DeliveryContent::Approval(_)) - 915
| (DeliveryKind::Progress, DeliveryContent::Progress(_)) - 916
| (DeliveryKind::ToolResult, DeliveryContent::ToolResult(_)) - 917
| (_, DeliveryContent::Text { .. }) - 918
) - 919
} - 920
- 921
fn format_kind(kind: DeliveryKind) -> &'static str { - 922
match kind { - 923
DeliveryKind::Assistant => "assistant", - 924
DeliveryKind::TaskSummary => "task_summary", - 925
DeliveryKind::Alert => "alert", - 926
DeliveryKind::User => "user", - 927
DeliveryKind::Approval => "approval", - 928
DeliveryKind::Progress => "progress", - 929
DeliveryKind::ToolResult => "tool_result", - 930
DeliveryKind::System => "system", - 931
DeliveryKind::Developer => "developer", - 932
DeliveryKind::ToolCall => "tool_call", - 933
DeliveryKind::Steering => "steering", - 934
DeliveryKind::Internal => "internal", - 935
} - 936
} - 937
- 938
fn render_answer( - 939
answer: &AnswerDraft, - 940
markup: Markup, - 941
project_structured: bool, - 942
skills: &SkillRegistry, - 943
) -> String { - 944
let metadata_notice = match ( - 945
answer - 946
.metadata - 947
.get("outcome_completion") - 948
.map(String::as_str), - 949
answer - 950
.metadata - 951
.get("outcome_evidence_state") - 952
.map(String::as_str), - 953
) { - 954
(Some("complete"), None | Some("fresh")) | (None, None) => None, - 955
_ => Some( - 956
"⚠️ Outcome incomplete or unverified — review the evidence before relying on this result.\n\n" - 957
.into(), - 958
), - 959
}; - 960
let result_notice = answer.results.iter().find_map(|result| { - 961
let outcome = result.outcome.as_ref()?; - 962
(outcome.status != OutputStatus::Succeeded - 963
|| outcome.evidence_state.as_deref().is_some_and(|state| state != "fresh") - 964
|| outcome.human_review.is_some()) - 965
.then_some(format!( - 966
"⚠️ Result '{}' is incomplete, unverified, or requires review — inspect its evidence before relying on it.\n\n", - 967
result.id - 968
)) - 969
}); - 970
let notice = result_notice.or(metadata_notice); - 971
let raw_source = if project_structured { - 972
project_structured_fences_with(&answer.source_markdown, skills) - 973
} else { - 974
answer.source_markdown.clone() - 975
}; - 976
let source = clean_scaffolding(&raw_source); - 977
match markup { - 978
Markup::Plain => format!( - 979
"{}{}", - 980
notice.unwrap_or_default(), - 981
render_plain(answer, project_structured, skills) - 982
), - 983
Markup::Markdown => format!("{}{}", notice.unwrap_or_default(), source), - 984
Markup::TelegramHtml => { - 985
telegram::markdown_to_html(&format!("{}{}", notice.unwrap_or_default(), source)) - 986
} - 987
Markup::SlackMrkdwn => { - 988
slack::markdown_to_mrkdwn(&format!("{}{}", notice.unwrap_or_default(), source)) - 989
} - 990
Markup::DiscordMarkdown => { - 991
discord::markdown_to_discord(&format!("{}{}", notice.unwrap_or_default(), source)) - 992
} - 993
Markup::Json => String::new(), - 994
} - 995
} - 996
- 997
/// Markup conversion only — does NOT project structured fences. Callers must - 998
/// project first via `project_structured_fences` when the surface doesn't - 999
/// support structured blocks natively. - 1000
fn render_text(markdown: &str, markup: Markup) -> String { - 1001
match markup { - 1002
Markup::TelegramHtml => telegram::markdown_to_html(markdown), - 1003
Markup::SlackMrkdwn => slack::markdown_to_mrkdwn(markdown), - 1004
Markup::DiscordMarkdown => discord::markdown_to_discord(markdown), - 1005
Markup::Plain | Markup::Markdown | Markup::Json => markdown.to_string(), - 1006
} - 1007
} - 1008
- 1009
fn render_progress(progress: &ProgressPayload, markup: Markup) -> String { - 1010
let text = format!("{}: {}", progress.label, progress.state); - 1011
if matches!(markup, Markup::TelegramHtml) { - 1012
escape_html(&text) - 1013
} else { - 1014
text - 1015
} - 1016
} - 1017
- 1018
fn render_tool_result( - 1019
result: &ToolResultPayload, - 1020
markup: Markup, - 1021
project_structured: bool, - 1022
skills: &SkillRegistry, - 1023
) -> String { - 1024
let prefix = if result.is_error { - 1025
"Tool error" - 1026
} else { - 1027
"Tool result" - 1028
}; - 1029
let formatted = format!("{prefix} ({}\n\n{}", result.tool, result.output); - 1030
let text = if project_structured { - 1031
project_structured_fences_with(&formatted, skills) - 1032
} else { - 1033
formatted - 1034
}; - 1035
if matches!(markup, Markup::TelegramHtml) { - 1036
escape_html(&text) - 1037
} else { - 1038
text - 1039
} - 1040
} - 1041
- 1042
fn render_approval(approval: &ApprovalPayload, markup: Markup) -> String { - 1043
let mut text = format!("{}\n\n{}", approval.title, approval.detail); - 1044
if let Some(expires_at) = &approval.expires_at { - 1045
text.push_str(&format!("\n\nExpires: {expires_at}")); - 1046
} - 1047
if !approval.actions.is_empty() { - 1048
text.push_str("\n\nActions: "); - 1049
text.push_str( - 1050
&approval - 1051
.actions - 1052
.iter() - 1053
.map(|action| action.label.as_str()) - 1054
.collect::<Vec<_>>() - 1055
.join(" / "), - 1056
); - 1057
} - 1058
if matches!(markup, Markup::TelegramHtml) { - 1059
escape_html(&text) - 1060
} else { - 1061
text - 1062
} - 1063
} - 1064
- 1065
fn parse_blocks(source: &str) -> Vec<Block> { - 1066
let lines: Vec<&str> = source.lines().collect(); - 1067
let mut blocks = Vec::new(); - 1068
let mut index = 0; - 1069
let mut block_number = 0; - 1070
- 1071
while index < lines.len() { - 1072
if lines[index].trim().is_empty() { - 1073
index += 1; - 1074
continue; - 1075
} - 1076
let id = || format!("block-{block_number}"); - 1077
let line = lines[index]; - 1078
let trimmed = line.trim_start(); - 1079
- 1080
if let Some(fence) = trimmed.strip_prefix("```") { - 1081
let language = (!fence.trim().is_empty()).then(|| fence.trim().to_string()); - 1082
let start = index + 1; - 1083
index = start; - 1084
while index < lines.len() && !lines[index].trim_start().starts_with("```") { - 1085
index += 1; - 1086
} - 1087
blocks.push(Block::Code { - 1088
id: id(), - 1089
language, - 1090
content: lines[start..index].join("\n"), - 1091
}); - 1092
block_number += 1; - 1093
index += usize::from(index < lines.len()); - 1094
continue; - 1095
} - 1096
- 1097
if let Some((level, text)) = heading(line) { - 1098
blocks.push(Block::Heading { - 1099
id: id(), - 1100
level, - 1101
text: text.to_string(), - 1102
}); - 1103
block_number += 1; - 1104
index += 1; - 1105
continue; - 1106
} - 1107
- 1108
if let Some((ordered, item)) = list_item(line) { - 1109
let mut items = vec![item.to_string()]; - 1110
index += 1; - 1111
while index < lines.len() { - 1112
let Some((same_order, next)) = list_item(lines[index]) else { - 1113
break; - 1114
}; - 1115
if same_order != ordered { - 1116
break; - 1117
} - 1118
items.push(next.to_string()); - 1119
index += 1; - 1120
} - 1121
blocks.push(Block::List { - 1122
id: id(), - 1123
ordered, - 1124
items, - 1125
}); - 1126
block_number += 1; - 1127
continue; - 1128
} - 1129
- 1130
if let Some(text) = trimmed.strip_prefix("> ") { - 1131
blocks.push(Block::Quote { - 1132
id: id(), - 1133
text: text.to_string(), - 1134
}); - 1135
block_number += 1; - 1136
index += 1; - 1137
continue; - 1138
} - 1139
- 1140
if trimmed.starts_with('|') && trimmed.ends_with('|') { - 1141
let start = index; - 1142
index += 1; - 1143
while index < lines.len() - 1144
&& lines[index].trim_start().starts_with('|') - 1145
&& lines[index].trim_end().ends_with('|') - 1146
{ - 1147
index += 1; - 1148
} - 1149
blocks.push(Block::RawMarkdown { - 1150
id: id(), - 1151
markdown: lines[start..index].join("\n"), - 1152
reason: "table semantics require a channel capability".into(), - 1153
}); - 1154
block_number += 1; - 1155
continue; - 1156
} - 1157
- 1158
let start = index; - 1159
index += 1; - 1160
while index < lines.len() - 1161
&& !lines[index].trim().is_empty() - 1162
&& heading(lines[index]).is_none() - 1163
&& list_item(lines[index]).is_none() - 1164
&& !lines[index].trim_start().starts_with("> ") - 1165
&& !lines[index].trim_start().starts_with("```") - 1166
{ - 1167
index += 1; - 1168
} - 1169
blocks.push(Block::Paragraph { - 1170
id: id(), - 1171
text: lines[start..index].join("\n"), - 1172
}); - 1173
block_number += 1; - 1174
} - 1175
blocks - 1176
} - 1177
- 1178
fn heading(line: &str) -> Option<(u8, &str)> { - 1179
let trimmed = line.trim_start(); - 1180
let count = trimmed.chars().take_while(|c| *c == '#').count(); - 1181
if (1..=6).contains(&count) && trimmed.as_bytes().get(count) == Some(&b' ') { - 1182
Some((count as u8, trimmed[count + 1..].trim())) - 1183
} else { - 1184
None - 1185
} - 1186
} - 1187
- 1188
fn list_item(line: &str) -> Option<(bool, &str)> { - 1189
let trimmed = line.trim_start(); - 1190
if let Some(item) = trimmed - 1191
.strip_prefix("- ") - 1192
.or_else(|| trimmed.strip_prefix("* ")) - 1193
.or_else(|| trimmed.strip_prefix("+ ")) - 1194
{ - 1195
return Some((false, item)); - 1196
} - 1197
let digit_count = trimmed.chars().take_while(|c| c.is_ascii_digit()).count(); - 1198
if digit_count > 0 && trimmed.as_bytes().get(digit_count..digit_count + 2) == Some(b". ") { - 1199
return Some((true, &trimmed[digit_count + 2..])); - 1200
} - 1201
None - 1202
} - 1203
- 1204
fn render_plain(answer: &AnswerDraft, project_structured: bool, skills: &SkillRegistry) -> String { - 1205
answer - 1206
.blocks - 1207
.iter() - 1208
.map(|block| match block { - 1209
Block::Heading { text, .. } => text.clone(), - 1210
Block::Paragraph { text, .. } => text.clone(), - 1211
Block::List { ordered, items, .. } => items - 1212
.iter() - 1213
.enumerate() - 1214
.map(|(index, item)| { - 1215
if *ordered { - 1216
format!("{}. {item}", index + 1) - 1217
} else { - 1218
format!("- {item}") - 1219
} - 1220
}) - 1221
.collect::<Vec<_>>() - 1222
.join("\n"), - 1223
Block::Quote { text, .. } => format!("> {text}"), - 1224
Block::Code { - 1225
content, language, .. - 1226
} if project_structured && language.as_deref() == Some("vak") => { - 1227
parse_fragment_with(content, skills) - 1228
.map(|output| structured_markdown(&output)) - 1229
.unwrap_or_else(|_| content.clone()) - 1230
} - 1231
Block::Code { content, .. } - 1232
| Block::RawMarkdown { - 1233
markdown: content, .. - 1234
} => content.clone(), - 1235
}) - 1236
.collect::<Vec<_>>() - 1237
.join("\n\n") - 1238
} - 1239
- 1240
fn escape_html(value: &str) -> String { - 1241
value - 1242
.replace('&', "&") - 1243
.replace('<', "<") - 1244
.replace('>', ">") - 1245
} - 1246
- 1247
fn chunk_text(text: &str, max_chars: Option<usize>) -> Vec<String> { - 1248
let Some(max_chars) = max_chars else { - 1249
return vec![text.to_string()]; - 1250
}; - 1251
if text.is_empty() { - 1252
return vec![String::new()]; - 1253
} - 1254
let chars: Vec<char> = text.chars().collect(); - 1255
let mut chunks = Vec::new(); - 1256
let mut start = 0; - 1257
while start < chars.len() { - 1258
let hard_end = (start + max_chars).min(chars.len()); - 1259
let end = chars[start..hard_end] - 1260
.iter() - 1261
.rposition(|c| *c == '\n') - 1262
.map(|offset| start + offset) - 1263
.filter(|candidate| *candidate > start) - 1264
.unwrap_or(hard_end); - 1265
chunks.push(chars[start..end].iter().collect()); - 1266
start = end; - 1267
while start < chars.len() && chars[start] == '\n' { - 1268
start += 1; - 1269
} - 1270
} - 1271
chunks - 1272
} - 1273
- 1274
/// Chunk a Slack/Discord `mrkdwn`-ish message on line boundaries like - 1275
/// [`chunk_text`], but close and reopen a fenced code block (```` ``` ````) - 1276
/// at any boundary that falls inside one, so a split never leaves a - 1277
/// dangling fence that swallows the rest of the message as code. - 1278
fn chunk_markdown_preserving_fences(text: &str, max_chars: Option<usize>) -> Vec<String> { - 1279
let Some(max_chars) = max_chars else {
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.