diff --git a/.repository-projection.json b/.repository-projection.json index 7ef92da22..f558dfd5e 100644 --- a/.repository-projection.json +++ b/.repository-projection.json @@ -3,11 +3,11 @@ "projection": "deixic-code", "projectionSchemaVersion": 1, "sourceRepository": "dx-corp/mono", - "sourceSha": "8b32b94c6bf3d6850bea833b991b2fc6ee26ffdc", + "sourceSha": "e94970dc115671761bdb96f00e6f47d8e294dac5", "destinationRepository": "dx-corp/code", - "priorProjectedBase": "45779ed2d5271af92aacacae152b859a886531e4", + "priorProjectedBase": "5050b67e1cd94b20955093b1f754fbc8693ae917", "definitionDigest": "cb9d429542ebb0a2de9b42a7aad60d9d8696a648ceba47c30f05c0b285ca0db7", "toolDigest": "f8cb071b0f27267120ccf45a00d0982f45113bd23535bef6a1555b4933f99f13", - "contentDigest": "1ff896cc41aec4814fb80a4aa347bdc43672ac61d76472ec53da8c5a1263b14e", + "contentDigest": "eaa908b660a0258d88aec980f5423de6ccfeab1034307347aeac75cf47aae91f", "publicationEligible": true } diff --git a/vendor/dex-loop/src/compaction.rs b/vendor/dex-loop/src/compaction.rs index 2bc94d109..0fe9f1b4b 100644 --- a/vendor/dex-loop/src/compaction.rs +++ b/vendor/dex-loop/src/compaction.rs @@ -273,6 +273,7 @@ mod tests { client_tools: vec![], authorized_tools: vec![], model_binding: None, + voice: None, approval_mode: ApprovalMode::Interactive, } } @@ -697,6 +698,7 @@ mod cut_tests { client_tools: Vec::new(), authorized_tools: Vec::new(), model_binding: None, + voice: None, approval_mode: ApprovalMode::Interactive, } } diff --git a/vendor/dex-loop/src/context.rs b/vendor/dex-loop/src/context.rs index 6c5255b77..8d4024028 100644 --- a/vendor/dex-loop/src/context.rs +++ b/vendor/dex-loop/src/context.rs @@ -210,6 +210,7 @@ pub struct Context { authorized_tools: Vec, approval_mode: ApprovalMode, model_binding: Option, + voice: Option, /// Last completed serving route in this turn, derived from existing events. /// Kept outside compactable history so recovery does not retry a failed primary. last_served: Option, @@ -260,6 +261,7 @@ struct PendingTurn { authorized_tools: Vec, approval_mode: ApprovalMode, model_binding: Option, + voice: Option, } impl Context { @@ -287,6 +289,7 @@ impl Context { authorized_tools: Vec::new(), approval_mode: ApprovalMode::Interactive, model_binding: None, + voice: None, last_served: None, authorized_principal: None, uncertain_calls: Vec::new(), @@ -363,6 +366,12 @@ impl Context { self.model_binding.as_ref() } + /// The current turn's host-resolved writing policy and voice choice, as + /// logged on its `UserMessage`. Prompt data only. + pub fn voice(&self) -> Option<&crate::TurnVoice> { + self.voice.as_ref() + } + /// The last completed model step's route in the current turn. New turns /// reset it; compaction and replay preserve it without changing admission. pub fn last_served(&self) -> Option<&ServedBy> { @@ -601,6 +610,7 @@ impl Context { authorized_tools, approval_mode, model_binding, + voice, } => { let next = PendingTurn { turn: turn.clone(), @@ -612,6 +622,7 @@ impl Context { authorized_tools: authorized_tools.clone(), approval_mode: *approval_mode, model_binding: model_binding.clone(), + voice: voice.clone(), }; if self.status == Status::Running { // This message's cursor landed while the current turn was @@ -1116,6 +1127,7 @@ impl Context { authorized_tools, approval_mode, model_binding, + voice, } = next; self.turn = Some(turn.clone()); self.acting = Some(principal.clone()); @@ -1139,6 +1151,7 @@ impl Context { self.authorized_tools = authorized_tools; self.approval_mode = approval_mode; self.model_binding = model_binding; + self.voice = voice; self.last_served = None; self.authorized_principal = Some(principal.clone()); self.push( diff --git a/vendor/dex-loop/src/context/evidence_contract.rs b/vendor/dex-loop/src/context/evidence_contract.rs index b05b931a3..33bca268f 100644 --- a/vendor/dex-loop/src/context/evidence_contract.rs +++ b/vendor/dex-loop/src/context/evidence_contract.rs @@ -26,6 +26,7 @@ fn typed_owner_evidence_is_bounded_and_survives_compaction_and_full_replay() { client_tools: vec![], authorized_tools: vec![], model_binding: None, + voice: None, approval_mode: ApprovalMode::Interactive, }, ); diff --git a/vendor/dex-loop/src/event.rs b/vendor/dex-loop/src/event.rs index a6eb4bd1e..f50548e20 100644 --- a/vendor/dex-loop/src/event.rs +++ b/vendor/dex-loop/src/event.rs @@ -561,6 +561,11 @@ pub enum Event { /// never credential values, and grants no Gateway authority. #[serde(default, skip_serializing_if = "Option::is_none")] model_binding: Option, + /// The workspace writing policy and the sender's voice choice, + /// resolved by the authenticated host. Prompt data only; grants no + /// authority. Older turns carry none and render no voice. + #[serde(default, skip_serializing_if = "Option::is_none")] + voice: Option, }, /// Control: becomes a user message from `principal` before the next model /// call. Calls the model then proposes act under `principal`. @@ -884,6 +889,41 @@ mod tests { let event: Event = serde_json::from_value(input.clone()).expect("accepted message"); let replay = serde_json::to_value(event).expect("durable event"); assert_eq!(replay["model_binding"], input["model_binding"]); + assert!( + replay.get("voice").is_none(), + "older rows stay byte-identical" + ); + } + + #[test] + fn accepted_turn_voice_survives_event_round_trip() { + let input = serde_json::json!({ + "type": "user_message", "turn": "turn-1", "principal": "user-1", + "text": "hello", "attachments": [], + "voice": { + "policy": { + "guide_version": 3, + "voice": {"kind": "neutral"} + }, + "tone": ["formal"] + } + }); + let event: Event = serde_json::from_value(input.clone()).expect("accepted message"); + let Event::UserMessage { voice, .. } = &event else { + panic!("user message"); + }; + let voice = voice.as_ref().expect("voice"); + assert_eq!(voice.tone, vec![crate::ToneAdjustment::Formal]); + assert_eq!( + voice.policy.as_ref().map(|policy| &policy.voice), + Some(&crate::TurnVoiceChoice::Neutral) + ); + let replay = serde_json::to_value(event).expect("durable event"); + assert_eq!(replay["voice"]["tone"], input["voice"]["tone"]); + assert_eq!( + replay["voice"]["policy"]["voice"], + input["voice"]["policy"]["voice"] + ); } #[test] @@ -923,6 +963,7 @@ mod tests { attachments: vec![ArtifactRef::new("a1")], authorized_tools: Vec::new(), model_binding: None, + voice: None, approval_mode: ApprovalMode::Interactive, client_tools: vec![ClientToolSpec { name: ToolName::new("browser.read_tab"), @@ -1036,6 +1077,7 @@ mod tests { client_tools: vec![], authorized_tools: Vec::new(), model_binding: None, + voice: None, approval_mode: ApprovalMode::Interactive, }; let json = serde_json::to_string(&event).expect("serialize"); @@ -1065,6 +1107,7 @@ mod tests { client_tools: vec![], authorized_tools: Vec::new(), model_binding: None, + voice: None, approval_mode: ApprovalMode::Interactive, } ); diff --git a/vendor/dex-loop/src/lib.rs b/vendor/dex-loop/src/lib.rs index 79de4e869..2b5e5691b 100644 --- a/vendor/dex-loop/src/lib.rs +++ b/vendor/dex-loop/src/lib.rs @@ -29,6 +29,7 @@ mod event; mod ports; mod rehydrate; mod sanitize; +mod voice; pub use budget::{Budget, BudgetAxis, RemainingBudget}; pub use compaction::{ @@ -56,6 +57,7 @@ pub use ports::{ pub use rehydrate::rehydrate; pub use sanitize::{DeltaFilter, Lexicon, LexiconFilter, Sanitizer}; pub use tokio_util::sync::CancellationToken; +pub use voice::{ToneAdjustment, TurnBrandVoice, TurnContentPolicy, TurnVoice, TurnVoiceChoice}; pub use agent_codemode::{ModelBinding, ModelOperation, OutputBlock}; pub use codemode_output::validate_codemode_blocks; diff --git a/vendor/dex-loop/src/voice.rs b/vendor/dex-loop/src/voice.rs new file mode 100644 index 000000000..871e57b9d --- /dev/null +++ b/vendor/dex-loop/src/voice.rs @@ -0,0 +1,126 @@ +//! The writing policy one turn runs under: the workspace Content & AI policy +//! with the sender's voice choice already applied by the authenticated host. +//! +//! This is prompt data, never authority. It grants no tool, connector, or +//! model access, and the loop never reads it: the model port renders it into +//! the turn's stored context. Logged on the turn's `UserMessage`, so every +//! step, resume, and replica of the turn writes under the same policy even if +//! the workspace edits its style guide mid-turn. + +use serde::{Deserialize, Serialize}; + +/// One turn's resolved voice. Empty (no policy, no tone) renders nothing. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct TurnVoice { + /// The workspace Content & AI policy, present only when the workspace + /// has it enabled. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub policy: Option, + /// Short adjustments the sender asked for on top of the voice. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub tone: Vec, +} + +impl TurnVoice { + /// True when there is nothing to render for the turn. + pub fn is_empty(&self) -> bool { + self.policy.is_none() && self.tone.is_empty() + } +} + +/// The workspace Content & AI policy snapshot admitted for one turn. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct TurnContentPolicy { + pub guide_version: u64, + #[serde(default)] + pub response_guidance: String, + #[serde(default)] + pub document_guidance: String, + #[serde(default)] + pub presentation_guidance: String, + #[serde(default)] + pub required_terms: Vec, + #[serde(default)] + pub forbidden_terms: Vec, + #[serde(default)] + pub require_citations: bool, + #[serde(default)] + pub allowed_citation_domains: Vec, + #[serde(default)] + pub max_response_words: u32, + #[serde(default)] + pub voice: TurnVoiceChoice, +} + +/// Which brand voice the turn writes in. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum TurnVoiceChoice { + /// The workspace has no brand voice assigned. + #[default] + Unassigned, + /// The sender asked for no brand voice on this turn. The policy's + /// content rules still apply. + Neutral, + /// A workspace brand voice: the workspace default, or one the sender + /// picked. + Brand(TurnBrandVoice), +} + +/// One workspace brand voice, copied at the version admitted for the turn. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct TurnBrandVoice { + pub voice_id: String, + pub name: String, + pub guidance: String, + pub version: u64, + /// True when the sender picked this voice rather than inheriting the + /// workspace default. + #[serde(default)] + pub explicit: bool, +} + +/// A short tone adjustment the sender can add to one turn. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ToneAdjustment { + Concise, + Formal, + Warmer, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn empty_voice_serializes_to_an_empty_object_and_round_trips() { + let empty = TurnVoice::default(); + assert!(empty.is_empty()); + assert_eq!(serde_json::to_value(&empty).unwrap(), serde_json::json!({})); + let voice = TurnVoice { + policy: Some(TurnContentPolicy { + guide_version: 4, + voice: TurnVoiceChoice::Brand(TurnBrandVoice { + voice_id: "voice_exec".into(), + name: "Executive".into(), + guidance: "Lead with the decision.".into(), + version: 2, + explicit: true, + }), + ..TurnContentPolicy::default() + }), + tone: vec![ToneAdjustment::Concise, ToneAdjustment::Warmer], + }; + let json = serde_json::to_value(&voice).unwrap(); + assert_eq!(json["policy"]["voice"]["kind"], "brand"); + assert_eq!(json["tone"], serde_json::json!(["concise", "warmer"])); + assert_eq!(serde_json::from_value::(json).unwrap(), voice); + } + + #[test] + fn neutral_choice_has_no_voice_fields() { + let json = serde_json::to_value(TurnVoiceChoice::Neutral).unwrap(); + assert_eq!(json, serde_json::json!({"kind": "neutral"})); + } +} diff --git a/vendor/dex-loop/tests/action_confirmation.rs b/vendor/dex-loop/tests/action_confirmation.rs index 5c125ed2c..4cb19fadb 100644 --- a/vendor/dex-loop/tests/action_confirmation.rs +++ b/vendor/dex-loop/tests/action_confirmation.rs @@ -204,6 +204,7 @@ fn events(decision: ConfirmationDecision, principal: &str) -> Vec<(Cursor, Event client_tools: vec![], authorized_tools: vec![], model_binding: None, + voice: None, approval_mode: ApprovalMode::Interactive, }, Event::ModelStepCompleted { diff --git a/vendor/dex-loop/tests/authorized_tools.rs b/vendor/dex-loop/tests/authorized_tools.rs index 0b60eaa97..b27789b86 100644 --- a/vendor/dex-loop/tests/authorized_tools.rs +++ b/vendor/dex-loop/tests/authorized_tools.rs @@ -12,6 +12,7 @@ fn message(turn: &str, principal: &str, tools: &[&str]) -> Event { client_tools: vec![], authorized_tools: tools.iter().map(|name| ToolName::new(*name)).collect(), model_binding: None, + voice: None, approval_mode: dex_loop::ApprovalMode::Interactive, } } diff --git a/vendor/dex-loop/tests/client_tool_replay.rs b/vendor/dex-loop/tests/client_tool_replay.rs index 561c388f5..15402b2f3 100644 --- a/vendor/dex-loop/tests/client_tool_replay.rs +++ b/vendor/dex-loop/tests/client_tool_replay.rs @@ -55,6 +55,7 @@ fn log_up_to_model_step(log: &FakeLog, call: &ProposedCall) { client_tools: Vec::new(), authorized_tools: Vec::new(), model_binding: None, + voice: None, approval_mode: dex_loop::ApprovalMode::Interactive, }); log.host_append(Event::StepStarted { diff --git a/vendor/dex-loop/tests/codemode_workflows.rs b/vendor/dex-loop/tests/codemode_workflows.rs index 94d728852..51f93b957 100644 --- a/vendor/dex-loop/tests/codemode_workflows.rs +++ b/vendor/dex-loop/tests/codemode_workflows.rs @@ -170,6 +170,7 @@ async fn scratch_state_is_partitioned_by_the_accepted_principal() { client_tools: vec![], authorized_tools: vec![], model_binding: None, + voice: None, approval_mode: dex_loop::ApprovalMode::Interactive, }); ctx = log.rehydrate(); diff --git a/vendor/dex-loop/tests/scenarios.rs b/vendor/dex-loop/tests/scenarios.rs index 9275677d7..d8a0f8dd7 100644 --- a/vendor/dex-loop/tests/scenarios.rs +++ b/vendor/dex-loop/tests/scenarios.rs @@ -364,6 +364,7 @@ async fn a_legacy_parked_call_is_granted_on_rehydrate_and_the_step_continues() { client_tools: vec![], authorized_tools: Vec::new(), model_binding: None, + voice: None, approval_mode: dex_loop::ApprovalMode::Interactive, }, Event::StepStarted { @@ -961,6 +962,7 @@ async fn crash_mid_stream_abandons_the_attempt_and_reissues_it() { client_tools: vec![], authorized_tools: Vec::new(), model_binding: None, + voice: None, approval_mode: dex_loop::ApprovalMode::Interactive, }); log.host_append(Event::StepStarted { @@ -1180,6 +1182,7 @@ async fn same_turn_id_in_two_threads_dispatches_under_distinct_threads() { client_tools: vec![], authorized_tools: Vec::new(), model_binding: None, + voice: None, approval_mode: dex_loop::ApprovalMode::Interactive, }); let model = FakeModel::new(vec![vec![call("update", json!({}))], vec![text("done")]]); @@ -2220,6 +2223,7 @@ async fn a_stale_interrupt_excluded_from_the_rehydrated_suffix_must_not_kill_the client_tools: Vec::new(), authorized_tools: Vec::new(), model_binding: None, + voice: None, approval_mode: dex_loop::ApprovalMode::Interactive, }); // cursor 1 log.host_append(Event::Interrupt { principal: alice() }); // cursor 2 -- excluded from the suffix below @@ -2233,6 +2237,7 @@ async fn a_stale_interrupt_excluded_from_the_rehydrated_suffix_must_not_kill_the client_tools: Vec::new(), authorized_tools: Vec::new(), model_binding: None, + voice: None, approval_mode: dex_loop::ApprovalMode::Interactive, }); // cursor 4 @@ -2278,6 +2283,7 @@ async fn a_steer_from_before_the_rehydrate_point_is_not_carried_into_a_later_tur client_tools: Vec::new(), authorized_tools: Vec::new(), model_binding: None, + voice: None, approval_mode: dex_loop::ApprovalMode::Interactive, }); // cursor 1 -- excluded from the suffix below log.host_append(Event::Steer { @@ -2296,6 +2302,7 @@ async fn a_steer_from_before_the_rehydrate_point_is_not_carried_into_a_later_tur client_tools: Vec::new(), authorized_tools: Vec::new(), model_binding: None, + voice: None, approval_mode: dex_loop::ApprovalMode::Interactive, }); // cursor 4 diff --git a/vendor/dex-loop/tests/sim.rs b/vendor/dex-loop/tests/sim.rs index d923b317a..5ef665f85 100644 --- a/vendor/dex-loop/tests/sim.rs +++ b/vendor/dex-loop/tests/sim.rs @@ -195,6 +195,7 @@ fn stale_control_kinds_would_release_a_lease_with_unprocessed_control_event() { client_tools: vec![], authorized_tools: Vec::new(), model_binding: None, + voice: None, approval_mode: dex_loop::ApprovalMode::Interactive, }, ), @@ -308,6 +309,7 @@ async fn dst_two_replicas_racing_the_same_generation_dispatch_once() { client_tools: vec![], authorized_tools: Vec::new(), model_binding: None, + voice: None, approval_mode: dex_loop::ApprovalMode::Interactive, }); @@ -368,6 +370,7 @@ async fn dst_two_replica_lease_fencing() { client_tools: vec![], authorized_tools: Vec::new(), model_binding: None, + voice: None, approval_mode: dex_loop::ApprovalMode::Interactive, }); diff --git a/vendor/dex-loop/tests/sim/scenario.rs b/vendor/dex-loop/tests/sim/scenario.rs index 8be477880..f7643a92b 100644 --- a/vendor/dex-loop/tests/sim/scenario.rs +++ b/vendor/dex-loop/tests/sim/scenario.rs @@ -358,6 +358,7 @@ pub async fn run_actions(seed: u64, actions: &[Action]) -> Vec { client_tools: Vec::new(), authorized_tools: Vec::new(), model_binding: None, + voice: None, approval_mode: dex_loop::ApprovalMode::Interactive, }); } diff --git a/vendor/dex-loop/tests/support/mod.rs b/vendor/dex-loop/tests/support/mod.rs index 2b7448a75..5401ebaa1 100644 --- a/vendor/dex-loop/tests/support/mod.rs +++ b/vendor/dex-loop/tests/support/mod.rs @@ -126,6 +126,7 @@ impl FakeLog { client_tools, authorized_tools: Vec::new(), model_binding: None, + voice: None, approval_mode: dex_loop::ApprovalMode::Interactive, }); self.rehydrate() @@ -148,6 +149,7 @@ impl FakeLog { client_tools: Vec::new(), authorized_tools: Vec::new(), model_binding: None, + voice: None, approval_mode, }); self.rehydrate() @@ -880,6 +882,7 @@ pub fn crashed_after_start(log: &FakeLog, call: &ProposedCall) { client_tools: vec![], authorized_tools: Vec::new(), model_binding: None, + voice: None, approval_mode: dex_loop::ApprovalMode::Interactive, }, Event::StepStarted {