Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .repository-projection.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
2 changes: 2 additions & 0 deletions vendor/dex-loop/src/compaction.rs
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,7 @@ mod tests {
client_tools: vec![],
authorized_tools: vec![],
model_binding: None,
voice: None,
approval_mode: ApprovalMode::Interactive,
}
}
Expand Down Expand Up @@ -697,6 +698,7 @@ mod cut_tests {
client_tools: Vec::new(),
authorized_tools: Vec::new(),
model_binding: None,
voice: None,
approval_mode: ApprovalMode::Interactive,
}
}
Expand Down
13 changes: 13 additions & 0 deletions vendor/dex-loop/src/context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,7 @@ pub struct Context {
authorized_tools: Vec<ToolName>,
approval_mode: ApprovalMode,
model_binding: Option<crate::ManagedInferenceProviderBinding>,
voice: Option<crate::TurnVoice>,
/// 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<ServedBy>,
Expand Down Expand Up @@ -260,6 +261,7 @@ struct PendingTurn {
authorized_tools: Vec<ToolName>,
approval_mode: ApprovalMode,
model_binding: Option<crate::ManagedInferenceProviderBinding>,
voice: Option<crate::TurnVoice>,
}

impl Context {
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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> {
Expand Down Expand Up @@ -601,6 +610,7 @@ impl Context {
authorized_tools,
approval_mode,
model_binding,
voice,
} => {
let next = PendingTurn {
turn: turn.clone(),
Expand All @@ -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
Expand Down Expand Up @@ -1116,6 +1127,7 @@ impl Context {
authorized_tools,
approval_mode,
model_binding,
voice,
} = next;
self.turn = Some(turn.clone());
self.acting = Some(principal.clone());
Expand All @@ -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(
Expand Down
1 change: 1 addition & 0 deletions vendor/dex-loop/src/context/evidence_contract.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
);
Expand Down
43 changes: 43 additions & 0 deletions vendor/dex-loop/src/event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -561,6 +561,11 @@
/// never credential values, and grants no Gateway authority.
#[serde(default, skip_serializing_if = "Option::is_none")]
model_binding: Option<crate::ManagedInferenceProviderBinding>,
/// 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<crate::TurnVoice>,

Check failure on line 568 in vendor/dex-loop/src/event.rs

View workflow job for this annotation

GitHub Actions / unresolved-review-threads / unresolved-review-threads

unresolved P1 review thread

Check failure on line 568 in vendor/dex-loop/src/event.rs

View workflow job for this annotation

GitHub Actions / unresolved-review-threads / unresolved-review-threads

unresolved P1 review thread

Comment on lines +564 to +568

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Render the admitted voice in model requests

When a UserMessage carries this field, it is persisted and rehydrated, but the in-repo dex_loop::Model implementation never reads Context::voice(): packages/dex-host-rs/src/model.rs builds the request from ctx.history() at lines 518-519, and its RequestConfig only uses self.system at lines 103-116. A repository-wide search finds no other voice() consumer, so policy guidance, required/forbidden terms, citation requirements, and tone have no effect on AiRsModel responses. Include the resolved voice in the model prompt/request before treating it as an admitted turn policy.

Useful? React with 👍 / 👎.

},
/// Control: becomes a user message from `principal` before the next model
/// call. Calls the model then proposes act under `principal`.
Expand Down Expand Up @@ -884,6 +889,41 @@
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]
Expand Down Expand Up @@ -923,6 +963,7 @@
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"),
Expand Down Expand Up @@ -1036,6 +1077,7 @@
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");
Expand Down Expand Up @@ -1065,6 +1107,7 @@
client_tools: vec![],
authorized_tools: Vec::new(),
model_binding: None,
voice: None,
approval_mode: ApprovalMode::Interactive,
}
);
Expand Down
2 changes: 2 additions & 0 deletions vendor/dex-loop/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ mod event;
mod ports;
mod rehydrate;
mod sanitize;
mod voice;

pub use budget::{Budget, BudgetAxis, RemainingBudget};
pub use compaction::{
Expand Down Expand Up @@ -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;
126 changes: 126 additions & 0 deletions vendor/dex-loop/src/voice.rs
Original file line number Diff line number Diff line change
@@ -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<TurnContentPolicy>,
/// Short adjustments the sender asked for on top of the voice.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tone: Vec<ToneAdjustment>,
}

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<String>,
#[serde(default)]
pub forbidden_terms: Vec<String>,
#[serde(default)]
pub require_citations: bool,
#[serde(default)]
pub allowed_citation_domains: Vec<String>,
#[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::<TurnVoice>(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"}));
}
}
1 change: 1 addition & 0 deletions vendor/dex-loop/tests/action_confirmation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions vendor/dex-loop/tests/authorized_tools.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
}
Expand Down
1 change: 1 addition & 0 deletions vendor/dex-loop/tests/client_tool_replay.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions vendor/dex-loop/tests/codemode_workflows.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading
Loading