From fe7e7afac6fc023d15a0f8e57399b4cc023be7db Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 5 Oct 2026 05:55:34 +0000 Subject: [PATCH] chore: sync public mirror from internal --- .repository-projection.json | 6 +- vendor/dex-loop/src/content_policy.rs | 571 +++++++++++++++++++++++- vendor/dex-loop/src/context.rs | 21 + vendor/dex-loop/src/engine.rs | 18 +- vendor/dex-loop/src/event.rs | 6 + vendor/dex-loop/src/lib.rs | 9 +- vendor/dex-loop/src/voice.rs | 35 ++ vendor/dex-loop/tests/content_policy.rs | 108 ++++- vendor/dex-loop/tests/sim/invariants.rs | 1 + vendor/dex-loop/tests/support/mod.rs | 1 + 10 files changed, 757 insertions(+), 19 deletions(-) diff --git a/.repository-projection.json b/.repository-projection.json index 531416c18..b7032a333 100644 --- a/.repository-projection.json +++ b/.repository-projection.json @@ -3,11 +3,11 @@ "projection": "deixic-code", "projectionSchemaVersion": 1, "sourceRepository": "dx-corp/mono", - "sourceSha": "9c83e14d4c0144c2b6d5830d63b06b925937fa5f", + "sourceSha": "880d24b8d9656db0b8d3c859644bb7fcee1d410c", "destinationRepository": "dx-corp/code", - "priorProjectedBase": "3718b097b301f748fe93e4bdcd2228617ae00e09", + "priorProjectedBase": "4ee737816a77f0f27974d1aa3dea0465de0e299c", "definitionDigest": "cb9d429542ebb0a2de9b42a7aad60d9d8696a648ceba47c30f05c0b285ca0db7", "toolDigest": "f8cb071b0f27267120ccf45a00d0982f45113bd23535bef6a1555b4933f99f13", - "contentDigest": "5a6e6c32494ea9e417f86956469d637a6b26c80d514398ff85d733f70e6f0448", + "contentDigest": "9f55a740a2489f9028851b0a74b3042b3ce8d15f9486fd7db0cc10bd8d2a1285", "publicationEligible": true } diff --git a/vendor/dex-loop/src/content_policy.rs b/vendor/dex-loop/src/content_policy.rs index 1137b9633..dbddcf0e7 100644 --- a/vendor/dex-loop/src/content_policy.rs +++ b/vendor/dex-loop/src/content_policy.rs @@ -1,6 +1,6 @@ //! Pure checks over authored content, independent of tools, transport and storage. -use crate::TurnContentPolicy; +use crate::{HeadingCase, TurnContentPolicy, WritingRules}; /// Prose and citation targets have different meanings for writing rules. #[derive(Clone, Debug, Default, PartialEq, Eq)] @@ -8,11 +8,33 @@ pub struct AuthoredContent { prose: String, pub citation_urls: Vec, word_count: usize, + pub fields: Vec, + pub is_presentation: bool, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum AuthoredFieldKind { + #[default] + Body, + Heading, + Bullet, + List, + Slide, + Citation, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AuthoredField { + pub path: String, + pub prose: String, + pub kind: AuthoredFieldKind, + pub items: usize, + pub citation_urls: Vec, } impl AuthoredContent { /// Preserve visible labels, but never treat a URL target as authored prose. - pub fn from_text(text: &str) -> Self { + fn plain_text(text: &str) -> Self { let mut content = Self { word_count: text.split_whitespace().count(), ..Self::default() @@ -45,6 +67,164 @@ impl AuthoredContent { } content } + + pub fn from_text(text: &str) -> Self { + let mut content = Self::plain_text(text); + let mut bullets = 0; + let normalized_lines = text.replace("\r\n", "\n"); + for (index, paragraph) in normalized_lines.split("\n\n").enumerate() { + let lines: Vec<_> = paragraph.lines().collect(); + if lines + .iter() + .any(|line| markdown_kind(line) != AuthoredFieldKind::Body) + { + let mut pending: Option<(usize, AuthoredFieldKind, String)> = None; + for (line_index, line) in lines.into_iter().enumerate() { + let kind = markdown_kind(line); + if matches!(kind, AuthoredFieldKind::Heading | AuthoredFieldKind::Bullet) { + if let Some((start, previous_kind, text)) = pending.take() { + content.fields.push(Self::field( + format!("response.paragraphs[{index}].lines[{start}]"), + previous_kind, + &text, + )); + } + bullets += usize::from(kind == AuthoredFieldKind::Bullet); + let prose = markdown_prose(line, kind); + if kind == AuthoredFieldKind::Heading { + content.fields.push(Self::field( + format!("response.paragraphs[{index}].lines[{line_index}]"), + kind, + prose, + )); + } else { + pending = Some((line_index, kind, prose.into())); + } + } else if let Some((_, _, text)) = &mut pending { + text.push('\n'); + text.push_str(line.trim()); + } else { + pending = Some((line_index, kind, line.into())); + } + } + if let Some((start, kind, text)) = pending { + content.fields.push(Self::field( + format!("response.paragraphs[{index}].lines[{start}]"), + kind, + &text, + )); + } + } else { + content.fields.push(Self::field( + format!("response.paragraphs[{index}]"), + AuthoredFieldKind::Body, + paragraph, + )); + } + } + content.fields.push(AuthoredField { + path: "response.bullets".into(), + prose: String::new(), + kind: AuthoredFieldKind::List, + items: bullets, + citation_urls: Vec::new(), + }); + content + } + + fn field(path: String, kind: AuthoredFieldKind, text: &str) -> AuthoredField { + let plain = Self::plain_text(text); + AuthoredField { + path, + prose: plain.prose, + kind, + items: 0, + citation_urls: plain.citation_urls, + } + } + + /// Paths are constructed by the typed adapter, never copied from authored prose or IDs. + pub fn push_field(&mut self, path: String, kind: AuthoredFieldKind, text: &str) { + let plain = Self::plain_text(text); + self.word_count += plain.word_count; + self.prose.push_str(&plain.prose); + self.prose.push('\n'); + self.citation_urls + .extend(plain.citation_urls.iter().cloned()); + self.fields.push(AuthoredField { + path, + prose: plain.prose, + kind, + items: 0, + citation_urls: plain.citation_urls, + }); + } + + /// Aggregate checks do not duplicate prose in the artifact's term/citation projection. + pub fn push_group( + &mut self, + path: String, + kind: AuthoredFieldKind, + prose: String, + items: usize, + ) { + self.fields.push(AuthoredField { + path, + kind, + prose, + items, + citation_urls: Vec::new(), + }); + } + + pub fn push_citation(&mut self, path: String, url: &str) { + self.citation_urls.push(url.into()); + self.fields.push(AuthoredField { + path, + prose: String::new(), + kind: AuthoredFieldKind::Citation, + items: 0, + citation_urls: vec![url.into()], + }); + } +} + +fn markdown_prose(line: &str, kind: AuthoredFieldKind) -> &str { + let line = line.trim_start(); + match kind { + AuthoredFieldKind::Heading => line.trim_start_matches('#').trim_start(), + AuthoredFieldKind::Bullet => { + if let Some(text) = ordered_list_prose(line) { + text + } else { + &line[2..] + } + } + _ => line, + } +} + +fn ordered_list_prose(line: &str) -> Option<&str> { + [". ", ") "].into_iter().find_map(|marker| { + line.split_once(marker).and_then(|(prefix, text)| { + (!prefix.is_empty() && prefix.chars().all(|c| c.is_ascii_digit())).then_some(text) + }) + }) +} + +fn markdown_kind(line: &str) -> AuthoredFieldKind { + let line = line.trim_start(); + if line.starts_with('#') && line.trim_start_matches('#').starts_with(' ') { + return AuthoredFieldKind::Heading; + } + if ["- ", "* ", "+ "] + .iter() + .any(|prefix| line.starts_with(prefix)) + || ordered_list_prose(line).is_some() + { + return AuthoredFieldKind::Bullet; + } + AuthoredFieldKind::Body } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -57,6 +237,8 @@ pub enum ContentPolicyScope { #[derive(Clone, Debug, PartialEq, Eq)] pub struct ContentPolicyViolation { pub code: &'static str, + pub rule_id: String, + pub field_path: String, /// Safe feedback: never quotes rejected prose or URLs. pub message: String, } @@ -74,6 +256,9 @@ impl TurnContentPolicy { || self.require_citations || !self.allowed_citation_domains.is_empty() || self.max_response_words > 0 + || !self.response_rules.is_empty() + || !self.document_rules.is_empty() + || !self.presentation_rules.is_empty() } } @@ -96,6 +281,13 @@ pub fn evaluate_content_policy( let mut reject = |code, message: &str| { evaluation.violations.push(ContentPolicyViolation { code, + rule_id: code.into(), + field_path: if scope == ContentPolicyScope::Artifact { + "artifact" + } else { + "response" + } + .into(), message: message.into(), }) }; @@ -154,13 +346,388 @@ pub fn evaluate_content_policy( "Response exceeds the workspace word limit.", ); } + let mut missing_terms = policy + .required_terms + .iter() + .enumerate() + .filter(|(_, term)| !normalized.contains(&term.to_ascii_lowercase())) + .map(|(index, _)| index); + let mut forbidden_matches = policy + .forbidden_terms + .iter() + .enumerate() + .filter(|(_, term)| !term.is_empty() && normalized.contains(&term.to_ascii_lowercase())); + for violation in &mut evaluation.violations { + if violation.code == "artifact_style_guide_required_term_missing" { + if let Some(index) = missing_terms.next() { + violation.rule_id = format!("required_terms[{index}]"); + } + } else if violation.code == "artifact_style_guide_forbidden_term" { + if let Some((index, term)) = forbidden_matches.next() { + violation.rule_id = format!("forbidden_terms[{index}]"); + if let Some(field) = content.fields.iter().find(|field| { + field + .prose + .to_ascii_lowercase() + .contains(&term.to_ascii_lowercase()) + }) { + violation.field_path.clone_from(&field.path); + } + } + } else if violation.code == "content_policy_citation_domain_forbidden" + && let Some(field) = content.fields.iter().find(|field| { + field + .citation_urls + .iter() + .filter_map(|url| http_url(url)) + .any(|url| !allowed_host(policy, &url)) + }) + { + violation.field_path.clone_from(&field.path); + } + } + let (rules, scope_name) = if scope == ContentPolicyScope::Artifact { + if content.is_presentation { + (&policy.presentation_rules, "presentation_rules") + } else { + (&policy.document_rules, "document_rules") + } + } else { + (&policy.response_rules, "response_rules") + }; + evaluate_writing_rules(rules, scope_name, content, &mut evaluation.violations); + evaluation.violations.truncate(32); evaluation } +fn allowed_host(policy: &TurnContentPolicy, url: &url::Url) -> bool { + url.host_str().is_some_and(|host| { + policy.allowed_citation_domains.iter().any(|allowed| { + host.eq_ignore_ascii_case(allowed) + || host + .to_ascii_lowercase() + .ends_with(&format!(".{}", allowed.to_ascii_lowercase())) + }) + }) +} + +fn evaluate_writing_rules( + rules: &WritingRules, + scope: &str, + content: &AuthoredContent, + violations: &mut Vec, +) { + for field in &content.fields { + let mut check = |code: &'static str, name: &str, count: usize, limit: u32| { + if limit > 0 && count > limit as usize { + violations.push(ContentPolicyViolation { + code, + rule_id: format!("{scope}.{name}"), + field_path: field.path.clone(), + message: format!("{name} limit is {limit}; this field has {count}."), + }); + } + }; + let words = |text: &str| { + text.split_whitespace() + .filter(|word| { + *word != "#" && !word.chars().all(|c| matches!(c, '#' | '-' | '*' | '+')) + }) + .count() + }; + match field.kind { + AuthoredFieldKind::Slide => check( + "writing_rule_slide_words", + "max_slide_words", + words(&field.prose), + rules.max_slide_words, + ), + AuthoredFieldKind::List => check( + "writing_rule_bullet_count", + "max_bullets", + field.items, + rules.max_bullets, + ), + AuthoredFieldKind::Citation => {} + kind => { + if kind == AuthoredFieldKind::Heading { + check( + "writing_rule_heading_words", + "max_heading_words", + words(&field.prose), + rules.max_heading_words, + ); + } + if kind == AuthoredFieldKind::Bullet { + check( + "writing_rule_bullet_words", + "max_bullet_words", + words(&field.prose), + rules.max_bullet_words, + ); + } + let normalized_lines = field.prose.replace("\r\n", "\n"); + for paragraph in normalized_lines.split("\n\n") { + check( + "writing_rule_paragraph_words", + "max_paragraph_words", + words(paragraph), + rules.max_paragraph_words, + ); + } + if kind != AuthoredFieldKind::Heading { + for sentence in field.prose.split(['.', '!', '?', '\n']) { + check( + "writing_rule_sentence_words", + "max_sentence_words", + words(sentence), + rules.max_sentence_words, + ); + } + } + if kind == AuthoredFieldKind::Heading + && !heading_case_matches(&field.prose, rules.heading_case) + { + violations.push(ContentPolicyViolation { + code: "writing_rule_heading_case", + rule_id: format!("{scope}.heading_case"), + field_path: field.path.clone(), + message: "Heading does not follow the configured case convention.".into(), + }); + } + } + } + // Bound feedback even for a large artifact with many violating fields. + if violations.len() >= 32 { + violations.truncate(32); + break; + } + } +} + +fn heading_case_matches(text: &str, case: HeadingCase) -> bool { + let words: Vec<_> = text + .split_whitespace() + .map(|word| word.trim_matches(|c: char| !c.is_alphabetic())) + .filter(|word| !word.is_empty()) + .collect(); + words.iter().enumerate().all(|(index, word)| { + if case == HeadingCase::Unspecified + || word + .chars() + .filter(|c| c.is_alphabetic()) + .all(|c| c.is_uppercase()) + { + return true; + } + let first = word.chars().next().unwrap_or(' '); + match case { + HeadingCase::Unspecified => true, + HeadingCase::Sentence => { + if index == 0 { + first.is_uppercase() + } else { + first.is_lowercase() + } + } + HeadingCase::Title => { + if index > 0 + && [ + "a", "an", "and", "as", "at", "by", "for", "in", "of", "on", "or", "the", + "to", "with", + ] + .contains(&word.to_ascii_lowercase().as_str()) + { + first.is_lowercase() + } else { + first.is_uppercase() + } + } + } + }) +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn scoped_rules_check_markdown_heading_sentence_paragraph_and_bullets() { + let policy: TurnContentPolicy = serde_json::from_value(serde_json::json!({ + "guide_version":0, "response_rules": {"max_sentence_words": 3, "max_heading_words": 2, "max_bullet_words": 2, "max_bullets": 1, "heading_case": "sentence"} + })).unwrap(); + let result = evaluate_content_policy( + &policy, + &AuthoredContent::from_text( + "# A Heading With Words\n\n- three bullet words\n- another item\n\nThis sentence has many words.", + ), + ContentPolicyScope::Response, + ); + assert!( + result + .violations + .iter() + .any(|v| v.code == "writing_rule_sentence_words") + ); + assert!( + result + .violations + .iter() + .any(|v| v.code == "writing_rule_heading_words") + ); + assert!( + result + .violations + .iter() + .any(|v| v.code == "writing_rule_bullet_words") + ); + assert!( + result + .violations + .iter() + .any(|v| v.code == "writing_rule_bullet_count") + ); + assert!( + result + .violations + .iter() + .any(|v| v.code == "writing_rule_heading_case") + ); + } + + #[test] + fn markdown_markers_do_not_count_as_authored_words() { + let policy = TurnContentPolicy { + response_rules: WritingRules { + max_bullet_words: 3, + max_paragraph_words: 3, + max_heading_words: 3, + ..Default::default() + }, + ..Default::default() + }; + for text in [ + "1. Keep it brief", + "12. Keep it brief", + "1) Keep it brief", + "- Keep it brief", + "## Keep it brief", + ] { + assert!( + evaluate_content_policy( + &policy, + &AuthoredContent::from_text(text), + ContentPolicyScope::Response + ) + .violations + .is_empty(), + "{text}" + ); + } + assert!( + !evaluate_content_policy( + &policy, + &AuthoredContent::from_text("1. Please keep it brief"), + ContentPolicyScope::Response + ) + .violations + .is_empty() + ); + } + + #[test] + fn markdown_continuations_preserve_paragraph_and_bullet_limits() { + for (text, rules, expected) in [ + ( + "# Heading\none two\nthree four", + WritingRules { + max_paragraph_words: 3, + ..Default::default() + }, + "writing_rule_paragraph_words", + ), + ( + "- one\n two three four", + WritingRules { + max_bullet_words: 3, + ..Default::default() + }, + "writing_rule_bullet_words", + ), + ] { + let policy = TurnContentPolicy { + response_rules: rules, + ..Default::default() + }; + assert!( + evaluate_content_policy( + &policy, + &AuthoredContent::from_text(text), + ContentPolicyScope::Response + ) + .violations + .iter() + .any(|violation| violation.code == expected) + ); + } + } + + #[test] + fn ordered_parenthesis_markers_cannot_bypass_bullet_rules() { + let policy = TurnContentPolicy { + response_rules: WritingRules { + max_bullet_words: 3, + max_bullets: 1, + ..Default::default() + }, + ..Default::default() + }; + let evaluation = evaluate_content_policy( + &policy, + &AuthoredContent::from_text("1) one two three four\n2) five"), + ContentPolicyScope::Response, + ); + for code in ["writing_rule_bullet_words", "writing_rule_bullet_count"] { + assert!( + evaluation + .violations + .iter() + .any(|violation| violation.code == code) + ); + } + } + + #[test] + fn crlf_blank_lines_preserve_response_and_artifact_paragraph_boundaries() { + let rules = WritingRules { + max_paragraph_words: 3, + ..Default::default() + }; + let policy = TurnContentPolicy { + response_rules: rules.clone(), + document_rules: rules, + ..Default::default() + }; + let text = "one two\r\n\r\nthree four"; + let response = AuthoredContent::from_text(text); + let mut artifact = AuthoredContent::default(); + artifact.push_field( + "documentBlocks[0].body".into(), + AuthoredFieldKind::Body, + text, + ); + for (content, scope) in [ + (response, ContentPolicyScope::Response), + (artifact, ContentPolicyScope::Artifact), + ] { + assert!( + evaluate_content_policy(&policy, &content, scope) + .violations + .is_empty() + ); + } + } + #[test] fn url_targets_are_not_prose_but_link_labels_are() { let policy = TurnContentPolicy { diff --git a/vendor/dex-loop/src/context.rs b/vendor/dex-loop/src/context.rs index cae63bd89..4736fdb4e 100644 --- a/vendor/dex-loop/src/context.rs +++ b/vendor/dex-loop/src/context.rs @@ -247,6 +247,9 @@ pub struct Context { scratch: crate::codemode_state::Scratch, model_usage_unresolved: bool, specialist_usage_recorded: bool, + // Turn-local safe feedback and cap survive history compaction/recovery. + content_policy_repairs: u32, + content_policy_feedback: Option, } #[derive(Clone, Debug, PartialEq)] @@ -306,6 +309,8 @@ impl Context { scratch: Default::default(), model_usage_unresolved: false, specialist_usage_recorded: false, + content_policy_repairs: 0, + content_policy_feedback: None, } } @@ -559,6 +564,15 @@ impl Context { /// Whether a turn is running (or a queued one just began). A host that /// finished a turn checks this before doing work between turns. + pub fn content_policy_repairs(&self) -> u32 { + self.content_policy_repairs + } + + /// Latest safe validation feedback, kept outside compactable history. + pub fn content_policy_repair_guidance(&self) -> Option { + self.content_policy_feedback.as_ref().map(|feedback| format!("The prior draft was rejected before publication or tool execution. Repair attempt {}/2. Correct these writing-rule violations using the admitted policy and the original request. Do not retry an accepted or uncertain write. Do not invent sources. {feedback}", self.content_policy_repairs)) + } + pub fn turn_running(&self) -> bool { self.status == Status::Running } @@ -789,6 +803,11 @@ impl Context { }); } self.pre_started.clear(); + self.content_policy_feedback = None; + } + Event::ContentPolicyRepairRequested { feedback, .. } => { + self.content_policy_repairs = self.content_policy_repairs.saturating_add(1); + self.content_policy_feedback = Some(feedback.clone()); } Event::ModelAttemptAbandoned { .. } => { self.attempt = None; @@ -1146,6 +1165,8 @@ impl Context { self.usage = Usage::default(); self.model_usage_unresolved = false; self.specialist_usage_recorded = false; + self.content_policy_repairs = 0; + self.content_policy_feedback = None; self.attempt = None; self.interrupt_requested = false; self.exposed.clear(); diff --git a/vendor/dex-loop/src/engine.rs b/vendor/dex-loop/src/engine.rs index 23eecdb7f..b2a331faf 100644 --- a/vendor/dex-loop/src/engine.rs +++ b/vendor/dex-loop/src/engine.rs @@ -559,6 +559,7 @@ where let mut calls = Vec::new(); let mut failure = None; let mut wall_exceeded = false; + let mut policy_feedback = None; // Usage arrives mid-stream but is never written on its own: it is // batched into whichever terminal event ends this attempt // (`ModelStepCompleted` or `ModelAttemptAbandoned`), so one attempt @@ -750,9 +751,14 @@ where .first() .or_else(|| response.violations.first()) { + let feedback = format!( + "{} at {} ({}): {}", + violation.rule_id, violation.field_path, violation.code, violation.message + ); + policy_feedback = Some(feedback.clone()); failure = Some(ModelError { class: crate::ErrorClass::Rejected, - message: format!("{}: {}", violation.code, violation.message), + message: feedback, }); } } @@ -812,6 +818,16 @@ where return Ok((!continues).then_some(Exit::Done)); } if let Some(error) = failure { + if let Some(feedback) = policy_feedback + && ctx.content_policy_repairs() < 2 + && !cancel.is_cancelled() + { + let mut events = pending_usage; + events.push(Event::ModelAttemptAbandoned { step }); + events.push(Event::ContentPolicyRepairRequested { step, feedback }); + self.emit(ctx, events).await?; + return Ok(None); + } let mut events = pending_usage; events.push(Event::ModelAttemptAbandoned { step }); events.push(Event::Error { diff --git a/vendor/dex-loop/src/event.rs b/vendor/dex-loop/src/event.rs index a8dc5ab37..03c411536 100644 --- a/vendor/dex-loop/src/event.rs +++ b/vendor/dex-loop/src/event.rs @@ -679,6 +679,12 @@ pub enum Event { ModelAttemptAbandoned { step: u32, }, + /// A rejected unpublished draft. Only safe, generated rule diagnostics are retained. + /// No proposed call from this attempt is eligible to run. + ContentPolicyRepairRequested { + step: u32, + feedback: String, + }, /// A model attempt on one route ended without a `ModelStepCompleted`. /// Debug data for staff: never model history, never rendered. Carries /// only the route's configured provider/model, dex-model's fixed error diff --git a/vendor/dex-loop/src/lib.rs b/vendor/dex-loop/src/lib.rs index 500148ef0..3263d8c25 100644 --- a/vendor/dex-loop/src/lib.rs +++ b/vendor/dex-loop/src/lib.rs @@ -37,8 +37,8 @@ pub use compaction::{ Compaction, CompactionPlan, Compactor, NoCompaction, Summarize, Summary, Threshold, }; pub use content_policy::{ - AuthoredContent, ContentPolicyEvaluation, ContentPolicyScope, ContentPolicyViolation, - evaluate_content_policy, + AuthoredContent, AuthoredField, AuthoredFieldKind, ContentPolicyEvaluation, ContentPolicyScope, + ContentPolicyViolation, evaluate_content_policy, }; pub use context::{ AttachmentInput, Context, Entry, MAX_CONTEXT_ATTACHMENTS, Message, TOOL_EVIDENCE_LIMIT, @@ -62,7 +62,10 @@ 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 voice::{ + HeadingCase, ToneAdjustment, TurnBrandVoice, TurnContentPolicy, TurnVoice, TurnVoiceChoice, + WritingRules, +}; 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 index 72e6939d5..4a97ab207 100644 --- a/vendor/dex-loop/src/voice.rs +++ b/vendor/dex-loop/src/voice.rs @@ -53,9 +53,44 @@ pub struct TurnContentPolicy { #[serde(default)] pub max_response_words: u32, #[serde(default)] + pub response_rules: WritingRules, + #[serde(default)] + pub document_rules: WritingRules, + #[serde(default)] + pub presentation_rules: WritingRules, + #[serde(default)] pub voice: TurnVoiceChoice, } +/// Explicit mechanical writing controls. Zero leaves a limit disabled. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct WritingRules { + pub max_sentence_words: u32, + pub max_paragraph_words: u32, + pub max_heading_words: u32, + pub max_bullet_words: u32, + pub max_bullets: u32, + pub max_slide_words: u32, + pub heading_case: HeadingCase, +} + +impl WritingRules { + pub fn is_empty(&self) -> bool { + self == &Self::default() + } +} + +/// English heading conventions; all-capital acronyms retain their case. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum HeadingCase { + #[default] + Unspecified, + Sentence, + Title, +} + /// Which brand voice the turn writes in. #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "kind", rename_all = "snake_case")] diff --git a/vendor/dex-loop/tests/content_policy.rs b/vendor/dex-loop/tests/content_policy.rs index 725d6cd98..af136cd28 100644 --- a/vendor/dex-loop/tests/content_policy.rs +++ b/vendor/dex-loop/tests/content_policy.rs @@ -44,15 +44,13 @@ fn start(log: &FakeLog, policy: TurnContentPolicy) -> Context { #[tokio::test] async fn prohibited_text_split_across_chunks_never_leaks_or_dispatches_a_mutation() { let log = FakeLog::default(); - let model = FakeModel::new(vec![ - vec![ - text("We fos"), - text("ter growth."), - call("update", json!({})), - usage(3, 4, 7), - ], - vec![text("Updated.")], - ]); + let rejected = vec![ + text("We fos"), + text("ter growth."), + call("update", json!({})), + usage(3, 4, 7), + ]; + let model = FakeModel::new(vec![rejected.clone(), rejected.clone(), rejected]); let tools = FakeTools::new(vec![write_tool("update")]); let mut ctx = start( &log, @@ -75,7 +73,7 @@ async fn prohibited_text_split_across_chunks_never_leaks_or_dispatches_a_mutatio tools.runs().is_empty(), "a rejected step dispatched its mutation" ); - assert_eq!(ctx.usage().cost_micros, 7); + assert_eq!(ctx.usage().cost_micros, 21); assert_eq!(ctx, log.rehydrate()); } @@ -352,3 +350,93 @@ async fn compliant_controlled_answer_and_progress_are_published_after_validation )); assert_eq!(ctx, log.rehydrate()); } + +#[tokio::test] +async fn rejected_draft_is_repaired_without_publishing_or_dispatching_rejected_calls() { + let log = FakeLog::default(); + let model = FakeModel::new(vec![ + vec![ + text("We foster growth."), + call("update", json!({})), + usage(3, 4, 7), + ], + vec![text("We support growth."), usage(5, 6, 11)], + ]); + let tools = FakeTools::new(vec![write_tool("update")]); + let mut ctx = start( + &log, + TurnContentPolicy { + forbidden_terms: vec!["foster".into()], + ..Default::default() + }, + ); + assert_eq!( + engine(&log, &model, &tools, budget()) + .run(&mut ctx, &CancellationToken::new()) + .await, + Ok(Exit::Done) + ); + assert_eq!(log.text_writes(), vec!["We support growth."]); + assert!(tools.runs().is_empty()); + assert_eq!(ctx.usage().cost_micros, 18); + assert_eq!(ctx, log.rehydrate()); +} + +#[tokio::test] +async fn scoped_sentence_rule_rejects_long_draft_and_accepts_short_repair() { + let log = FakeLog::default(); + let model = FakeModel::new(vec![ + vec![text("This sentence has too many words.")], + vec![text("Keep it brief.")], + ]); + let tools = FakeTools::new(vec![]); + let policy: TurnContentPolicy = serde_json::from_value( + json!({"guide_version":0, "response_rules":{"max_sentence_words":3}}), + ) + .unwrap(); + let mut ctx = start(&log, policy); + assert_eq!( + engine(&log, &model, &tools, budget()) + .run(&mut ctx, &CancellationToken::new()) + .await, + Ok(Exit::Done) + ); + assert_eq!(log.text_writes(), vec!["Keep it brief."]); +} + +#[tokio::test] +async fn repair_cap_survives_rehydration_and_never_restarts_the_allowance() { + let log = FakeLog::default(); + let mut ctx = start( + &log, + TurnContentPolicy { + forbidden_terms: vec!["foster".into()], + ..Default::default() + }, + ); + for step in [1, 2] { + log.host_append(Event::StepStarted { + step, + control_through: ctx.control_cursor(), + }); + log.host_append(Event::ModelAttemptAbandoned { step }); + log.host_append(Event::ContentPolicyRepairRequested { + step, + feedback: "forbidden_terms[0] at response".into(), + }); + ctx = log.rehydrate(); + } + assert_eq!(ctx.content_policy_repairs(), 2); + let model = FakeModel::new(vec![vec![text("foster"), call("update", json!({}))]]); + let tools = FakeTools::new(vec![write_tool("update")]); + assert_eq!( + engine(&log, &model, &tools, budget()) + .run(&mut ctx, &CancellationToken::new()) + .await, + Ok(Exit::Failed) + ); + assert_eq!(ctx.content_policy_repairs(), 2); + assert!(log.text_writes().is_empty()); + assert!(tools.runs().is_empty()); + assert_eq!(ctx, log.rehydrate()); +} diff --git a/vendor/dex-loop/tests/sim/invariants.rs b/vendor/dex-loop/tests/sim/invariants.rs index 7df5ea377..7c4178262 100644 --- a/vendor/dex-loop/tests/sim/invariants.rs +++ b/vendor/dex-loop/tests/sim/invariants.rs @@ -68,6 +68,7 @@ fn kind_str(event: &Event) -> &'static str { Event::ThinkingDelta { .. } => "thinking_delta", Event::Usage(_) => "usage", Event::ModelStepCompleted { .. } => "model_step_completed", + Event::ContentPolicyRepairRequested { .. } => "policy_repair", Event::ModelAttemptAbandoned { .. } => "model_attempt_abandoned", Event::ModelAttemptFailed { .. } => "model_attempt_failed", Event::CodeModeStorePrepared { .. } => "code_mode_store_prepared", diff --git a/vendor/dex-loop/tests/support/mod.rs b/vendor/dex-loop/tests/support/mod.rs index 42a31490d..cd50498a9 100644 --- a/vendor/dex-loop/tests/support/mod.rs +++ b/vendor/dex-loop/tests/support/mod.rs @@ -789,6 +789,7 @@ pub fn shape(event: &Event) -> String { format!("attempt_failed:{step}:{code}:{then:?}") } Event::CodeModeStorePrepared { parent, .. } => format!("store_prepared:{parent}"), + Event::ContentPolicyRepairRequested { step, .. } => format!("policy_repair:{step}"), Event::ModelUsageResolved { call } => format!("usage_resolved:{call}"), Event::ModelUsageUnresolved { call, .. } => format!("usage_unresolved:{call}"), Event::CodeModeCallsProposed { parent, calls } => {