- 1
//! Deriving an engagement from a reading and the authority in force. - 2
//! - 3
//! An [`Engagement`] has two halves and the split is load-bearing: - 4
//! - 5
//! * [`Limits`] carries authority implications, forms a meet semilattice, and - 6
//! is only ever composed downward. Invariant 1 lives there. - 7
//! * [`Posture`] carries selections that imply no authority — which renderer, - 8
//! how chatty, whether to state an assumption. Getting one wrong is a - 9
//! quality bug, not a safety bug. - 10
//! - 11
//! Keeping them apart means the safety review only has to read one small type. - 12
- 13
use std::collections::BTreeSet; - 14
- 15
use serde::{Deserialize, Serialize}; - 16
- 17
use crate::authority::{Authority, Autonomy, GateFallback}; - 18
use crate::axes::{Act, Attendance, Clarity, EpistemicStance, Evidence, Horizon, Modality, Stakes}; - 19
use crate::limits::Limits; - 20
use crate::reading::Reading; - 21
- 22
/// How a human is kept in the loop for this work. - 23
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] - 24
#[serde(rename_all = "kebab-case")] - 25
pub enum HilMode { - 26
/// Block and ask now. Someone is here and the decision cannot wait. - 27
Interrupt, - 28
/// Proceed inside the granted envelope; escalate outside it. - 29
Envelope, - 30
/// Do it, show the diff, offer a reversal. Only for work a checkpoint can - 31
/// undo — asking permission for something instantly reversible spends - 32
/// the user's attention for nothing. - 33
Review, - 34
/// Nobody can answer now, so suspend the commitment and put the question - 35
/// in the inbox rather than failing the work outright. - 36
Defer, - 37
} - 38
- 39
impl HilMode { - 40
/// How much a human is pulled in, least first. `meet` takes the larger. - 41
pub fn caution_rank(self) -> u8 { - 42
match self { - 43
HilMode::Review => 0, - 44
HilMode::Envelope => 1, - 45
HilMode::Defer => 2, - 46
HilMode::Interrupt => 3, - 47
} - 48
} - 49
- 50
pub fn as_str(self) -> &'static str { - 51
match self { - 52
HilMode::Interrupt => "interrupt", - 53
HilMode::Envelope => "envelope", - 54
HilMode::Review => "review", - 55
HilMode::Defer => "defer", - 56
} - 57
} - 58
} - 59
- 60
/// What to do about a request that is not fully specified. - 61
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] - 62
#[serde(rename_all = "kebab-case")] - 63
pub enum ClarifyPolicy { - 64
/// Nothing missing; get on with it. - 65
Proceed, - 66
/// Choose a reading, say which one out loud, and continue. The right - 67
/// default for low-stakes ambiguity: a question costs more than a stated - 68
/// assumption that turns out wrong and is corrected. - 69
StateAssumption, - 70
/// Stop and ask. Reserved for ambiguity that could cause harm. - 71
Ask, - 72
} - 73
- 74
impl ClarifyPolicy { - 75
pub fn caution_rank(self) -> u8 { - 76
match self { - 77
ClarifyPolicy::Proceed => 0, - 78
ClarifyPolicy::StateAssumption => 1, - 79
ClarifyPolicy::Ask => 2, - 80
} - 81
} - 82
- 83
pub fn as_str(self) -> &'static str { - 84
match self { - 85
ClarifyPolicy::Proceed => "proceed", - 86
ClarifyPolicy::StateAssumption => "state-assumption", - 87
ClarifyPolicy::Ask => "ask", - 88
} - 89
} - 90
} - 91
- 92
/// The shape the result should take when rendered. - 93
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] - 94
#[serde(rename_all = "kebab-case")] - 95
pub enum OutputShape { - 96
/// Ordinary prose. - 97
Prose, - 98
/// A claim with the sources that back it. - 99
Sources, - 100
/// A ranked list of locations. - 101
Findings, - 102
/// A change set. - 103
Diff, - 104
/// Pass/fail per check. - 105
Matrix, - 106
/// Rows and columns. - 107
Table, - 108
/// A produced file or document. - 109
Artifact, - 110
/// What was done, for after-the-fact reading. - 111
Report, - 112
} - 113
- 114
impl OutputShape { - 115
pub fn as_str(self) -> &'static str { - 116
match self { - 117
OutputShape::Prose => "prose", - 118
OutputShape::Sources => "sources", - 119
OutputShape::Findings => "findings", - 120
OutputShape::Diff => "diff", - 121
OutputShape::Matrix => "matrix", - 122
OutputShape::Table => "table", - 123
OutputShape::Artifact => "artifact", - 124
OutputShape::Report => "report", - 125
} - 126
} - 127
} - 128
- 129
/// When results reach the human. - 130
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] - 131
#[serde(rename_all = "kebab-case")] - 132
pub enum Cadence { - 133
/// Stream as it happens. - 134
Live, - 135
/// One delivery when the unit of work finishes. - 136
OnCompletion, - 137
/// Roll up into the next digest. What overnight work should do rather - 138
/// than sending forty notifications nobody reads. - 139
Digest, - 140
} - 141
- 142
impl Cadence { - 143
/// Least batched first. Composition takes the *least* batched, because - 144
/// a held packet is the one that can be lost. - 145
pub fn immediacy_rank(self) -> u8 { - 146
match self { - 147
Cadence::Digest => 0, - 148
Cadence::OnCompletion => 1, - 149
Cadence::Live => 2, - 150
} - 151
} - 152
- 153
pub fn as_str(self) -> &'static str { - 154
match self { - 155
Cadence::Live => "live", - 156
Cadence::OnCompletion => "on-completion", - 157
Cadence::Digest => "digest", - 158
} - 159
} - 160
} - 161
- 162
/// How hard a delivery may push for attention. - 163
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] - 164
#[serde(rename_all = "kebab-case")] - 165
pub enum Urgency { - 166
/// Break through: this needs somebody now. - 167
Interrupt, - 168
/// Normal notification. - 169
Notify, - 170
/// Silent; it will be read when the human looks. - 171
Quiet, - 172
} - 173
- 174
impl Urgency { - 175
pub fn rank(self) -> u8 { - 176
match self { - 177
Urgency::Quiet => 0, - 178
Urgency::Notify => 1, - 179
Urgency::Interrupt => 2, - 180
} - 181
} - 182
- 183
pub fn as_str(self) -> &'static str { - 184
match self { - 185
Urgency::Interrupt => "interrupt", - 186
Urgency::Notify => "notify", - 187
Urgency::Quiet => "quiet", - 188
} - 189
} - 190
} - 191
- 192
/// How results should be delivered. - 193
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] - 194
pub struct DeliveryPosture { - 195
pub shape: OutputShape, - 196
pub cadence: Cadence, - 197
pub urgency: Urgency, - 198
} - 199
- 200
/// Facts about this turn's difficulty, for the route ladder's demand scoring. - 201
/// - 202
/// These are the fields `vak_llm::DemandInput` has always had and that - 203
/// `plan_route_ladder` has always passed as zeros, which is why objective - 204
/// selection has been effectively constant. Supplying them honestly is what - 205
/// turns the existing router on. - 206
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] - 207
pub struct DemandHint { - 208
pub reasoning_required: bool, - 209
pub evidence_required: bool, - 210
pub structured_output: bool, - 211
} - 212
- 213
/// When the loop may stop. - 214
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] - 215
#[serde(rename_all = "kebab-case")] - 216
pub enum StopProfile { - 217
/// One message is a complete answer. - 218
#[default] - 219
Message, - 220
/// Stopping is fine once something was looked at. - 221
Inspection, - 222
/// Stopping requires an observable effect. An effectful act that produced - 223
/// no effect did not finish, whatever the model says about it. - 224
Effect, - 225
/// Stopping requires a criterion to have been evaluated. - 226
Verification, - 227
} - 228
- 229
impl StopProfile { - 230
/// Strictest last. Composition takes the strictest. - 231
pub fn rank(self) -> u8 { - 232
match self { - 233
StopProfile::Message => 0, - 234
StopProfile::Inspection => 1, - 235
StopProfile::Effect => 2, - 236
StopProfile::Verification => 3, - 237
} - 238
} - 239
- 240
pub fn as_str(self) -> &'static str { - 241
match self { - 242
StopProfile::Message => "message", - 243
StopProfile::Inspection => "inspection", - 244
StopProfile::Effect => "effect", - 245
StopProfile::Verification => "verification", - 246
} - 247
} - 248
} - 249
- 250
/// How much history and recall this turn wants. - 251
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] - 252
#[serde(rename_all = "kebab-case")] - 253
pub enum ContextProfile { - 254
/// Just the conversation. No retrieval, no workspace scan. - 255
Minimal, - 256
/// Conversation plus recall of prior sessions and memory. - 257
Recall, - 258
/// Recall plus the workspace delta since the run started. - 259
Working, - 260
/// Everything, rendered from the commitment ledger. - 261
Full, - 262
} - 263
- 264
impl ContextProfile { - 265
/// Widest last. Composition takes the widest, since a strand that needs - 266
/// the workspace delta needs it whatever its neighbours need. - 267
pub fn rank(self) -> u8 { - 268
match self { - 269
ContextProfile::Minimal => 0, - 270
ContextProfile::Recall => 1, - 271
ContextProfile::Working => 2, - 272
ContextProfile::Full => 3, - 273
} - 274
} - 275
- 276
pub fn as_str(self) -> &'static str { - 277
match self { - 278
ContextProfile::Minimal => "minimal", - 279
ContextProfile::Recall => "recall", - 280
ContextProfile::Working => "working", - 281
ContextProfile::Full => "full", - 282
} - 283
} - 284
} - 285
- 286
/// The non-authority half of an engagement. - 287
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] - 288
pub struct Posture { - 289
/// Run under managed-work admission rather than a direct turn. - 290
pub managed: bool, - 291
/// Open a durable commitment for this work. - 292
pub open_commitment: bool, - 293
/// Take a checkpoint before the first effect. - 294
pub checkpoint_before_effect: bool, - 295
pub hil: HilMode, - 296
pub gate_fallback: GateFallback, - 297
pub clarify: ClarifyPolicy, - 298
pub delivery: DeliveryPosture, - 299
pub demand: DemandHint, - 300
pub stop: StopProfile, - 301
pub context: ContextProfile, - 302
/// The epistemic cognitive stance for this turn. - 303
#[serde(default)] - 304
pub epistemic_stance: EpistemicStance, - 305
/// The code-owned block this engagement contributes to the prompt. - 306
/// `None` when there is nothing worth spending tokens to say. - 307
#[serde(default, skip_serializing_if = "Option::is_none")] - 308
pub note: Option<String>, - 309
} - 310
- 311
/// What the runtime will do about a reading. - 312
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] - 313
pub struct Engagement { - 314
pub limits: Limits, - 315
pub posture: Posture, - 316
} - 317
- 318
impl Engagement { - 319
/// The engagement that changes nothing — vak's pre-kernel behaviour. - 320
pub fn general() -> Self { - 321
Engagement { - 322
limits: Limits::unrestricted(), - 323
posture: Posture { - 324
managed: false, - 325
open_commitment: false, - 326
checkpoint_before_effect: false, - 327
hil: HilMode::Interrupt, - 328
gate_fallback: GateFallback::Deny, - 329
clarify: ClarifyPolicy::Proceed, - 330
delivery: DeliveryPosture { - 331
shape: OutputShape::Prose, - 332
cadence: Cadence::Live, - 333
urgency: Urgency::Notify, - 334
}, - 335
demand: DemandHint { - 336
reasoning_required: false, - 337
evidence_required: false, - 338
structured_output: false, - 339
}, - 340
stop: StopProfile::Message, - 341
context: ContextProfile::Recall, - 342
epistemic_stance: EpistemicStance::DirectAnswer, - 343
note: None, - 344
}, - 345
} - 346
} - 347
- 348
/// The engagement for a request the kernel could not read confidently. - 349
/// - 350
/// Posture is the general one, but the tool surface is narrowed to the - 351
/// orientation floor (docs/design/68-context-engine.md, Principle 6: - 352
/// "when a decision cannot be made confidently, send less and give the - 353
/// model a way to ask for more"). This is an *explicit* decision rather - 354
/// than an ambiguous top element: `Limits::unrestricted` keeps meaning - 355
/// "everything", which is what a disabled kernel produces. - 356
pub fn orienting() -> Self { - 357
let mut engagement = Engagement::general(); - 358
engagement.limits.required_domains = - 359
crate::limits::DomainSet::only(FLOOR_DOMAINS.iter().copied()); - 360
engagement - 361
} - 362
- 363
/// Compose with another engagement, narrowing only: limits meet, and the - 364
/// posture takes the more cautious of each selection ([`Posture::meet`]). - 365
pub fn meet(&self, other: &Engagement) -> Engagement { - 366
Engagement { - 367
limits: self.limits.meet(&other.limits), - 368
posture: self.posture.meet(&other.posture), - 369
} - 370
} - 371
- 372
/// The engagement for a turn made of several strands. - 373
/// - 374
/// Everything authority-bearing meets: the strictest strand governs - 375
/// approval, permission, spend, closure evidence, modalities, and the - 376
/// posture. The domain requirement is the turn's *capacity* rather than - 377
/// its authority and takes the **union** — a turn that is "search the web, - 378
/// then run the tests" needs both toolsets. It is still at most the - 379
/// unrestricted baseline, which is the invariant that matters. - 380
pub fn compose(strands: &[Engagement]) -> Engagement { - 381
let Some((first, rest)) = strands.split_first() else { - 382
return Engagement::general(); - 383
}; - 384
let mut out = first.clone(); - 385
let mut domains = first.limits.required_domains.clone(); - 386
for next in rest { - 387
domains = domains.union(&next.limits.required_domains); - 388
out = out.meet(next); - 389
} - 390
out.limits.required_domains = domains; - 391
out - 392
} - 393
} - 394
- 395
impl Posture { - 396
/// The more cautious of two postures, field by field, under an explicit - 397
/// per-field order, so the operation is commutative and never less - 398
/// cautious than either operand. The delivery shape and the stance have - 399
/// no order and are `self`'s. Notes are kept from both sides: a - 400
/// model-visible instruction one strand needed is not dropped because - 401
/// another had none. - 402
pub fn meet(&self, other: &Posture) -> Posture { - 403
let a = self; - 404
let b = other; - 405
{ - 406
Posture { - 407
managed: a.managed || b.managed, - 408
open_commitment: a.open_commitment || b.open_commitment, - 409
checkpoint_before_effect: a.checkpoint_before_effect || b.checkpoint_before_effect, - 410
hil: if b.hil.caution_rank() > a.hil.caution_rank() { - 411
b.hil - 412
} else { - 413
a.hil - 414
}, - 415
gate_fallback: if a.gate_fallback == GateFallback::Deny - 416
|| b.gate_fallback == GateFallback::Deny - 417
{ - 418
GateFallback::Deny - 419
} else { - 420
GateFallback::Defer - 421
}, - 422
clarify: if b.clarify.caution_rank() > a.clarify.caution_rank() { - 423
b.clarify - 424
} else { - 425
a.clarify - 426
}, - 427
delivery: DeliveryPosture { - 428
// The primary strand's shape; shapes have no order. - 429
shape: a.delivery.shape, - 430
cadence: if b.delivery.cadence.immediacy_rank() - 431
> a.delivery.cadence.immediacy_rank() - 432
{ - 433
b.delivery.cadence - 434
} else { - 435
a.delivery.cadence - 436
}, - 437
urgency: if b.delivery.urgency.rank() > a.delivery.urgency.rank() { - 438
b.delivery.urgency - 439
} else { - 440
a.delivery.urgency - 441
}, - 442
}, - 443
demand: DemandHint { - 444
reasoning_required: a.demand.reasoning_required || b.demand.reasoning_required, - 445
evidence_required: a.demand.evidence_required || b.demand.evidence_required, - 446
structured_output: a.demand.structured_output || b.demand.structured_output, - 447
}, - 448
stop: if b.stop.rank() > a.stop.rank() { - 449
b.stop - 450
} else { - 451
a.stop - 452
}, - 453
context: if b.context.rank() > a.context.rank() { - 454
b.context - 455
} else { - 456
a.context - 457
}, - 458
// The primary strand's stance. Stances have no order either; - 459
// the strand-aware note carries the rest. - 460
epistemic_stance: a.epistemic_stance, - 461
note: match (&a.note, &b.note) { - 462
(None, None) => None, - 463
(Some(n), None) | (None, Some(n)) => Some(n.clone()), - 464
(Some(x), Some(y)) if x == y => Some(x.clone()), - 465
(Some(x), Some(y)) => Some(format!("{x}\n{y}")), - 466
}, - 467
} - 468
} - 469
} - 470
} - 471
- 472
// ------------------------------------------------------ capability slice --- - 473
- 474
/// Kinds of work this act plausibly needs, as domain names. - 475
/// - 476
/// This replaces a table that listed built-in *tool names* per act - 477
/// (`Act::Answer => ["webfetch", "mcp"]`). That shape could not answer the - 478
/// only question that matters — which of the capabilities this user actually - 479
/// installed could serve this request — because installed capabilities were - 480
/// never in it. Its failure mode was silent: a live-data question about a - 481
/// specific place read as `Answer`, sliced to six file tools, and came back - 482
/// "I do not have access to real-time information" with a configured, - 483
/// connected search server sitting right there. Naming the server in the - 484
/// next turn did not help either, because `Locate` had no way to reach one. - 485
/// - 486
/// Domains are matched against what each capability declares it serves, so - 487
/// adding an integration never edits this function. A capability that - 488
/// declares nothing is never narrowed away, which keeps the common case - 489
/// working with no configuration at all. - 490
fn act_domains(act: Act) -> &'static [&'static str] { - 491
match act { - 492
// A greeting needs nothing beyond the orientation floor. This is - 493
// where slicing pays for itself most obviously. - 494
Act::Converse => &[], - 495
// The acts that exist to produce a fact must be able to go and get - 496
// one. Lookup is read-only, and a missing capability costs a failed - 497
// task while an extra one costs a little context — so this trade is - 498
// the one this table has always claimed to want to make. - 499
Act::Answer => &["live-data", "web"], - 500
Act::Locate => &["live-data", "web", "vcs"], - 501
Act::Analyze => &["live-data", "web", "code-exec", "vcs"], - 502
Act::Author => &["documents", "web"], - 503
Act::Modify => &["documents", "code-exec", "vcs"], - 504
// Operating reaches outside the workspace and legitimately needs the - 505
// broad set; the narrowing that matters for this act is the approval - 506
// floor, not the toolbox. - 507
Act::Operate => &[ - 508
"code-exec", - 509
"web", - 510
"live-data", - 511
"messaging", - 512
"documents", - 513
"orchestration", - 514
], - 515
Act::Verify => &["code-exec", "observability"], - 516
Act::Orchestrate => &["orchestration", "code-exec"], - 517
Act::Govern => &["memory", "orchestration", "documents", "observability"], - 518
} - 519
} - 520
- 521
/// The domains every turn gets regardless of act: enough to look at what is - 522
/// in front of it and recall what it already knows. - 523
/// - 524
/// An agent that cannot look at anything cannot correct a misread of its own - 525
/// task, so this floor is what makes slicing safe to attempt at all. - 526
pub const FLOOR_DOMAINS: &[&str] = &["filesystem", "memory"]; - 527
- 528
/// Every domain a capability can declare it serves — the shared vocabulary - 529
/// readings are matched against (`vak_core::capability::Domain` mirrors it, - 530
/// and a test there keeps the two equal). A tier-2/3 classifier is told to - 531
/// choose from exactly these, because a free-form subject tag ("weather") - 532
/// matches no capability and would key behaviour on a topic. - 533
pub const DOMAIN_VOCABULARY: &[&str] = &[ - 534
"live-data", - 535
"web", - 536
"filesystem", - 537
"code-exec", - 538
"memory", - 539
"messaging", - 540
"documents", - 541
"orchestration", - 542
"vcs", - 543
"observability", - 544
]; - 545
- 546
// ----------------------------------------------------------- derivation --- - 547
- 548
/// Turn a reading plus the authority in force into an engagement. - 549
/// - 550
/// `slice_capabilities` is separate from the reading's own confidence because - 551
/// the two failure modes are not symmetric: raising an approval floor on a - 552
/// misread is a small annoyance, while removing a tool the task needed looks - 553
/// to the user like the agent is broken. Callers therefore gate slicing on a - 554
/// higher bar, and low confidence still tightens risk. - 555
pub fn derive(reading: &Reading, authority: &Authority, slice_capabilities: bool) -> Engagement { - 556
let mut limits = Limits::unrestricted(); - 557
- 558
// --- capabilities -------------------------------------------------- - 559
if slice_capabilities { - 560
// Union over every act the reading covers. A request that is - 561
// genuinely both a modification and a verification needs both - 562
// toolsets, and resolving the tie by argmax would silently remove - 563
// half of what it needs. - 564
let mut domains: BTreeSet<String> = FLOOR_DOMAINS - 565
.iter() - 566
.map(|domain| (*domain).to_string()) - 567
.collect(); - 568
for act in reading.acts() { - 569
domains.extend(act_domains(act).iter().map(|d| (*d).to_string())); - 570
} - 571
// Evidence the reading demands has to come from somewhere. A turn - 572
// required to cite cannot satisfy that from memory, so requiring - 573
// citation implies the ability to reach a source — whatever the act - 574
// was read as. This is the general form of the live-data failure: it - 575
// was never specific to `Answer`. - 576
if reading.evidence.rank() >= Evidence::Cited.rank() { - 577
domains.insert("live-data".into()); - 578
domains.insert("web".into()); - 579
} - 580
limits.required_domains = crate::limits::DomainSet::only(domains); - 581
} else { - 582
// Not confident enough to say what the turn needs: send the - 583
// orientation floor and let `find_tools` reach the rest (design 68, - 584
// Principle 6). Stated explicitly so `DomainSet::All` keeps its one - 585
// meaning — "everything", the disabled-kernel behaviour. - 586
limits.required_domains = crate::limits::DomainSet::only(FLOOR_DOMAINS.iter().copied()); - 587
} - 588
- 589
// --- modality ------------------------------------------------------ - 590
// A leg that cannot see is not a valid fallback for a vision turn. - 591
limits.required_modalities = reading.required_modalities(); - 592
- 593
// A reading never caps the route ladder, the turn count or the workers. - 594
// Those caps only ever removed capacity from requests the reader got - 595
// wrong — a 20-character "fix the failing test" lost its fallback legs - 596
// and gave its workers two steps — and a reading decides what is loaded, - 597
// never what is possible (invariant 32). - 598
- 599
// --- closure ------------------------------------------------------- - 600
limits.min_satisfaction = reading.evidence.min_satisfaction(); - 601
- 602
// --- posture ------------------------------------------------------- - 603
let hil = derive_hil(reading, authority); - 604
let delivery = derive_delivery(reading); - 605
let posture = Posture { - 606
// Multi-step work runs under a plan; only work that outlives the - 607
// session earns a durable commitment. - 608
managed: reading.horizon.rank() >= Horizon::Session.rank(), - 609
open_commitment: reading.horizon.opens_commitment(), - 610
// Any act the reading covers, not just the primary one. "migrate … - 611
// and verify … before deploying" resolves `verify` as primary, and - 612
// gating on that alone skipped the checkpoint for a turn that plainly - 613
// modifies and deploys. `Author` is deliberately not one of the acts - 614
// `requires_execution` covers (see `Act::requires_execution`): most - 615
// authoring never touches the workspace, and only the request text — - 616
// which this `Reading`-only derivation cannot see — says whether it - 617
// names a file. `OutcomeSpec::requires_execution` covers that case - 618
// once the objective text is known; a caller deciding whether to - 619
// checkpoint a file-saving authoring turn needs that check too. - 620
checkpoint_before_effect: reading.acts().iter().any(|act| act.requires_execution()) - 621
&& reading.stakes.wants_checkpoint(), - 622
hil, - 623
gate_fallback: GateFallback::Deny, - 624
clarify: derive_clarify(reading), - 625
delivery, - 626
demand: DemandHint { - 627
reasoning_required: matches!( - 628
reading.act, - 629
Act::Analyze | Act::Author | Act::Modify | Act::Orchestrate - 630
) || reading.horizon.rank() >= Horizon::Session.rank(), - 631
evidence_required: reading.evidence.rank() >= Evidence::Cited.rank(), - 632
structured_output: matches!( - 633
delivery.shape, - 634
OutputShape::Matrix | OutputShape::Table | OutputShape::Diff - 635
), - 636
}, - 637
stop: derive_stop(reading), - 638
context: derive_context(reading), - 639
epistemic_stance: derive_epistemic_stance(reading), - 640
note: derive_note(reading, hil), - 641
}; - 642
- 643
let mut engagement = Engagement { limits, posture }; - 644
apply_authority(&mut engagement, reading, authority); - 645
engagement - 646
} - 647
- 648
/// Apply the authority in force to an engagement: the approval ceiling for - 649
/// the reading's stakes, and what happens to a gate nobody can answer. - 650
/// - 651
/// One place for every path — a confident reading, a provisional one, and a - 652
/// part the free tiers could not read at all — because authority does not - 653
/// depend on how well the request was read: `manual` means "ask" whether or - 654
/// not the reader understood the words. - 655
/// - 656
/// A grant on a commitment is applied separately, once the strands serving - 657
/// it are known (`apply_envelopes`), and covers actions one at a time at the - 658
/// approval gate — an engagement is computed before anyone knows which paths - 659
/// a turn will touch. - 660
pub fn apply_authority(engagement: &mut Engagement, reading: &Reading, authority: &Authority) { - 661
engagement.limits.approval_ceiling = authority.approval_ceiling(reading.stakes); - 662
engagement.posture.gate_fallback = authority.gate_fallback(reading.horizon); - 663
} - 664
- 665
/// Derive the appropriate epistemic cognitive stance from the reading. - 666
/// - 667
/// Ensures the model adopts the right operational and intellectual posture - 668
/// across any domain of work without domain-specific stereotyping. - 669
pub fn derive_epistemic_stance(reading: &Reading) -> EpistemicStance { - 670
match reading.act { - 671
Act::Converse => EpistemicStance::Conversational, - 672
Act::Answer => { - 673
if reading.evidence.rank() >= Evidence::Cited.rank() { - 674
EpistemicStance::Analytical - 675
} else { - 676
EpistemicStance::DirectAnswer - 677
} - 678
} - 679
Act::Locate => EpistemicStance::Exploratory, - 680
Act::Analyze => EpistemicStance::Analytical, - 681
Act::Author => EpistemicStance::Generative, - 682
Act::Modify | Act::Operate | Act::Govern => EpistemicStance::Operational, - 683
Act::Verify => EpistemicStance::Diagnostic, - 684
Act::Orchestrate => { - 685
if reading.acts().iter().any(|act| act.is_effectful()) { - 686
EpistemicStance::Operational - 687
} else { - 688
EpistemicStance::Exploratory - 689
} - 690
} - 691
} - 692
} - 693
- 694
fn derive_hil(reading: &Reading, authority: &Authority) -> HilMode { - 695
let needs_a_human = reading.stakes.rank() >= Stakes::Costly.rank(); - 696
// Nobody to ask and somewhere to park the question: wait rather than - 697
// fail. This covers irreversible work too — an irreversible step with - 698
// nobody present must reach the inbox, not raise a gate that can only - 699
// time out. "Interrupt" is what happens when someone is here to be - 700
// interrupted. - 701
if needs_a_human && authority.gate_fallback(reading.horizon) == GateFallback::Defer { - 702
return HilMode::Defer; - 703
} - 704
// Irreversible stakes always interrupt whatever was delegated. - 705
if reading.stakes == Stakes::Irreversible { - 706
return HilMode::Interrupt; - 707
} - 708
// Manual autonomy is "propose only": nothing is done first and shown - 709
// afterwards, so `Review` is never the right mode for it. - 710
if authority.autonomy == Autonomy::Manual { - 711
return HilMode::Interrupt; - 712
} - 713
// `Envelope` is set by `apply_envelopes` on a strand whose commitment - 714
// carries a live grant; delegation without one is not a boundary. - 715
if authority.autonomy == Autonomy::Autonomous { - 716
return HilMode::Review; - 717
} - 718
// Reversible work under a checkpoint is better reviewed than pre-approved. - 719
if reading.stakes.rank() <= Stakes::Reversible.rank() - 720
&& reading.acts().iter().any(|act| act.is_effectful()) - 721
{ - 722
return HilMode::Review; - 723
} - 724
HilMode::Interrupt - 725
} - 726
- 727
fn derive_clarify(reading: &Reading) -> ClarifyPolicy { - 728
// Ambiguity is only worth interrupting for when being wrong would hurt. - 729
let costly = reading.stakes.rank() >= Stakes::Costly.rank(); - 730
match reading.clarity { - 731
Clarity::Clear => ClarifyPolicy::Proceed, - 732
Clarity::Underspecified | Clarity::Ambiguous if costly => ClarifyPolicy::Ask, - 733
Clarity::Underspecified | Clarity::Ambiguous => ClarifyPolicy::StateAssumption, - 734
} - 735
} - 736
- 737
fn derive_delivery(reading: &Reading) -> DeliveryPosture { - 738
let shape = match reading.act { - 739
Act::Converse | Act::Answer => { - 740
if reading.evidence.rank() >= Evidence::Cited.rank() { - 741
OutputShape::Sources - 742
} else { - 743
OutputShape::Prose - 744
} - 745
} - 746
Act::Locate => OutputShape::Findings, - 747
Act::Analyze => { - 748
if reading.evidence.rank() >= Evidence::Cited.rank() { - 749
OutputShape::Sources - 750
} else { - 751
OutputShape::Prose - 752
} - 753
} - 754
Act::Author => OutputShape::Artifact, - 755
Act::Modify => OutputShape::Diff, - 756
Act::Operate | Act::Govern => OutputShape::Report, - 757
Act::Verify => OutputShape::Matrix, - 758
Act::Orchestrate => OutputShape::Report, - 759
}; - 760
let cadence = match reading.attendance { - 761
Attendance::Interactive => Cadence::Live, - 762
Attendance::Supervised => Cadence::OnCompletion, - 763
// Overnight work sends one roll-up, not a stream nobody is reading. - 764
Attendance::Unattended => Cadence::Digest, - 765
}; - 766
let urgency = match (reading.stakes, reading.attendance) { - 767
(Stakes::Irreversible, _) => Urgency::Interrupt, - 768
(Stakes::Costly, Attendance::Unattended) => Urgency::Notify, - 769
(_, Attendance::Unattended) => Urgency::Quiet, - 770
_ => Urgency::Notify, - 771
}; - 772
DeliveryPosture { - 773
shape, - 774
cadence, - 775
urgency, - 776
} - 777
} - 778
- 779
fn derive_stop(reading: &Reading) -> StopProfile { - 780
if reading.evidence.rank() >= Evidence::Verified.rank() { - 781
return StopProfile::Verification; - 782
} - 783
// The *primary* act, not every contender: a contender is a noun that - 784
// happens to be a verb somewhere ("what do we know about deploys?" - 785
// carries an `operate` contender), and gating the stop on it demanded - 786
// an effect from a question. A request that genuinely has an effectful - 787
// part is a strand of its own, and the composite stop is the strictest - 788
// strand's. If the act changes something, an episode that changed - 789
// nothing did not finish, whatever the model says about it. - 790
if reading.act.is_effectful() { - 791
return StopProfile::Effect; - 792
} - 793
match reading.act { - 794
Act::Converse | Act::Answer => StopProfile::Message, - 795
_ => StopProfile::Inspection, - 796
} - 797
} - 798
- 799
fn derive_context(reading: &Reading) -> ContextProfile { - 800
if reading.horizon.opens_commitment() { - 801
return ContextProfile::Full; - 802
} - 803
if reading - 804
.acts() - 805
.iter() - 806
.any(|act| matches!(act, Act::Modify | Act::Verify | Act::Operate)) - 807
{ - 808
return ContextProfile::Working; - 809
} - 810
match reading.act { - 811
// A greeting does not need last Tuesday's session searched. - 812
Act::Converse => ContextProfile::Minimal, - 813
_ => ContextProfile::Recall, - 814
} - 815
} - 816
- 817
/// The code-owned prompt block, when there is something worth saying. - 818
/// - 819
/// Deliberately terse and factual. This is not a place to re-explain the - 820
/// agent's job; it states the few decisions the model cannot otherwise see, - 821
/// and stays silent when there are none. - 822
fn derive_note(reading: &Reading, hil: HilMode) -> Option<String> { - 823
let mut lines = Vec::new(); - 824
match derive_clarify(reading) { - 825
ClarifyPolicy::StateAssumption => lines.push( - 826
"This request is under-specified. Choose the most reasonable reading, \ - 827
state the assumption you made in one sentence, and proceed." - 828
.to_string(), - 829
), - 830
ClarifyPolicy::Ask => lines.push( - 831
"This request is ambiguous and the stakes are high enough that guessing \ - 832
is not acceptable. Ask one specific question before acting." - 833
.to_string(), - 834
), - 835
ClarifyPolicy::Proceed => {} - 836
} - 837
if reading.evidence.rank() >= Evidence::Verified.rank() { - 838
lines.push(format!( - 839
"Completion requires {} evidence: the runtime, not you, decides whether \ - 840
this is done. Produce a checkable result and do not claim success \ - 841
without one.", - 842
reading.evidence.min_satisfaction().as_str() - 843
)); - 844
} else if reading.evidence == Evidence::Cited { - 845
lines.push("Cite the sources behind any factual claim.".to_string()); - 846
} - 847
if hil == HilMode::Defer { - 848
lines.push( - 849
"Nobody is available to answer right now. If you need a decision, say so \ - 850
plainly and stop; the question will be queued rather than guessed." - 851
.to_string(), - 852
); - 853
} - 854
if reading.stakes == Stakes::Irreversible { - 855
lines.push(if hil == HilMode::Defer { - 856
"At least one step here cannot be undone and nobody is available to \ - 857
confirm it. Do everything up to that step, then stop and say what \ - 858
needs confirming." - 859
.to_string() - 860
} else { - 861
"At least one step here cannot be undone. Confirm before that step, \ - 862
not after." - 863
.to_string() - 864
}); - 865
} - 866
if lines.is_empty() { - 867
None - 868
} else { - 869
Some(lines.join("\n")) - 870
} - 871
} - 872
- 873
/// Modalities a reading needs, exposed for the ladder filter. - 874
pub fn required_modalities(reading: &Reading) -> BTreeSet<Modality> { - 875
reading.required_modalities() - 876
} - 877
- 878
#[cfg(test)] - 879
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] - 880
mod tests { - 881
use super::*; - 882
use crate::axes::{Act, Attendance, Clarity, Evidence, Horizon, Stakes}; - 883
- 884
fn reading(act: Act, horizon: Horizon, stakes: Stakes, evidence: Evidence) -> Reading { - 885
Reading { - 886
act, - 887
horizon, - 888
stakes, - 889
evidence, - 890
clarity: Clarity::Clear, - 891
attendance: Attendance::Interactive, - 892
confidence: 0.9, - 893
..Reading::general() - 894
} - 895
} - 896
- 897
/// Invariant 1 across the entire reachable space of readings. If this ever - 898
/// fails, some derivation is granting rather than restricting. - 899
#[test] - 900
fn no_derived_engagement_ever_widens_the_baseline() { - 901
let baseline = Limits::unrestricted(); - 902
for act in Act::ALL { - 903
for horizon in Horizon::ALL { - 904
for stakes in Stakes::ALL { - 905
for evidence in Evidence::ALL { - 906
for clarity in Clarity::ALL { - 907
for attendance in Attendance::ALL { - 908
for autonomy in crate::Autonomy::ALL { - 909
for slice in [true, false] { - 910
let mut r = reading(act, horizon, stakes, evidence); - 911
r.clarity = clarity; - 912
r.attendance = attendance; - 913
let authority = Authority { - 914
autonomy, - 915
attendance, - 916
}; - 917
let engagement = derive(&r, &authority, slice); - 918
assert!( - 919
engagement.limits.is_at_most(&baseline), - 920
"widened: {act:?}/{horizon:?}/{stakes:?}/\ - 921
{evidence:?}/{autonomy:?} slice={slice}" - 922
); - 923
} - 924
} - 925
} - 926
} - 927
} - 928
} - 929
} - 930
} - 931
} - 932
- 933
#[test] - 934
fn a_greeting_gets_no_tools_and_minimal_context() { - 935
let r = reading( - 936
Act::Converse, - 937
Horizon::Immediate, - 938
Stakes::Inert, - 939
Evidence::None, - 940
); - 941
let engagement = derive(&r, &Authority::default(), true); - 942
// A greeting asks for nothing beyond the floor that lets it look at - 943
// what is in front of it. - 944
assert_eq!( - 945
engagement.limits.required_domains, - 946
crate::limits::DomainSet::only(FLOOR_DOMAINS.iter().copied()) - 947
); - 948
assert_eq!(engagement.posture.context, ContextProfile::Minimal); - 949
assert!(!engagement.posture.open_commitment); - 950
assert_eq!(engagement.posture.note, None); - 951
} - 952
- 953
#[test] - 954
fn every_act_keeps_the_orientation_floor() { - 955
for act in Act::ALL { - 956
let r = reading(act, Horizon::Turn, Stakes::Reversible, Evidence::None); - 957
let engagement = derive(&r, &Authority::default(), true); - 958
for domain in FLOOR_DOMAINS { - 959
assert!( - 960
engagement.limits.required_domains.contains(domain), - 961
"{act:?} lost `{domain}` and cannot orient itself" - 962
); - 963
} - 964
} - 965
} - 966
- 967
#[test] - 968
fn asking_and_searching_can_still_reach_a_live_source() { - 969
// Regression: `answer` and `locate` were sliced to the orientation - 970
// floor and `webfetch`. A configured search MCP server (Tavily) was - 971
// therefore unreachable for exactly the two acts that ask for a - 972
// fact, and "how is the weather today" came back as "I do not have - 973
// access to real-time information" with the tool sitting right - 974
// there, connected and admitted. - 975
for act in [Act::Answer, Act::Locate] { - 976
let r = reading(act, Horizon::Turn, Stakes::Inert, Evidence::None); - 977
let engagement = derive(&r, &Authority::default(), true); - 978
assert!( - 979
engagement.limits.required_domains.contains("live-data"), - 980
"{act:?} cannot reach a live source" - 981
); - 982
} - 983
} - 984
- 985
#[test] - 986
fn required_citation_implies_reaching_a_source_whatever_the_act() { - 987
// The general form of the weather failure: it was never specific to - 988
// `Answer`. A turn obliged to cite cannot satisfy that from memory. - 989
let r = reading(Act::Verify, Horizon::Turn, Stakes::Inert, Evidence::Cited); - 990
let engagement = derive(&r, &Authority::default(), true); - 991
assert!(engagement.limits.required_domains.contains("live-data")); - 992
} - 993
- 994
#[test] - 995
fn domain_requirements_narrow_by_intersection() { - 996
// More domains admits more capabilities, so composing two limits - 997
// must take the intersection or `meet` would widen. - 998
let wide = Limits { - 999
required_domains: crate::limits::DomainSet::only(["web", "live-data", "code-exec"]), - 1000
..Limits::unrestricted()
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.