Connect account profile constitutions to Engine turn admission

Capture the authoritative account profile at admission and render it through
the existing constitution renderer. Persist the complete guidance snapshot
through history repair, retries and compaction; internal follow-ups and RLM
children keep the captured parent guidance. Signed-in defaults take precedence
over host-local preferences, with account identity and schema validation.

Carry the snapshot through HTTP runtime, relay and durable replay admission.
Add authenticated Engine preview and label CLI profile precedence. Backend
and app source are connected separately; this is unreleased source. The app
still requires a qualified Engine release and bundle pin update. Plugin packs,
additional override scopes and full resume/provider acceptance remain open.

Validation: npm test passed 1,289 tests, 0 failed, 7 skipped (101 package,
19 SDK, 399 extension, 770 web). npm run check:web passed docs/facts/lint,
types and production build. Final targeted Rust constitution run passed
47 tests, 0 failed, with 15,267 filtered out, including provider request capture,
replay, history repair, compaction, account switching and internal continuation.
No deployment or paid provider call. Staged diff whitespace check passed.
Concurrent foreign edits remain outside this commit.
This commit is contained in:
CodeWhale Bot committed 2026-10-06 11:34:57 -07:00
1 parent 2b8618603a
commit 2b660bcf99
47 files changed
+642 -22

No files matched your search

+98
View File
@@ -109,6 +109,104 @@ pub const MAX_ITEM_LEN: usize = 280;
/// (generous for BCP-47; blocks prose smuggled into a metadata field).
pub const MAX_LANGUAGE_LEN: usize = 35;
/// Account-profile controls. This is data, not another prompt authority: the
/// conversion below reuses the accepted-clause renderer. Model suggestions
/// must stay drafts until the existing account or local edit action accepts them.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct ProfileConstitution {
pub schema_version: u32,
pub detail: ResponseDetail,
pub initiative: InitiativePreference,
pub collaboration: CollaborationPreference,
pub notes: String,
}
/// Immutable account preference admitted with one turn. The transport supplies
/// data and provenance; only the Engine renders its model-facing instructions.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct ProfileConstitutionSnapshot {
pub account_id: String,
pub revision: u64,
pub constitution: ProfileConstitution,
}
impl ProfileConstitutionSnapshot {
pub fn validate(&self) -> Result<()> {
anyhow::ensure!(!self.account_id.is_empty() && self.account_id.len() <= 240
&& self.account_id.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.')),
"Invalid constitution account identity");
anyhow::ensure!(self.revision <= 9_007_199_254_740_991, "Invalid constitution revision");
self.constitution.validate()
}
pub fn render(&self) -> Result<String> {
self.validate()?;
Ok(format!("Account profile constitution, settings revision {}.\n{}",
self.revision, self.constitution.as_user_constitution()?.render_body()))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ResponseDetail { Brief, Balanced, Detailed }
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum InitiativePreference { Check, Judgment, Moving }
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CollaborationPreference { Direct, Critical, Coach }
impl Default for ProfileConstitution {
fn default() -> Self {
Self { schema_version: 1, detail: ResponseDetail::Balanced,
initiative: InitiativePreference::Judgment,
collaboration: CollaborationPreference::Direct, notes: String::new() }
}
}
impl ProfileConstitution {
/// Never use the legacy bounding operation to silently truncate a saved
/// profile. The exact accepted document must survive a round trip.
pub fn validate(&self) -> Result<()> {
anyhow::ensure!(self.schema_version == 1, "Unsupported profile constitution version");
anyhow::ensure!(self.notes.chars().count() <= MAX_NOTES_LEN,
"Constitution notes cannot exceed {MAX_NOTES_LEN} characters");
anyhow::ensure!(!self.notes.chars().any(|c| c.is_control() && !matches!(c, '\n' | '\r' | '\t')),
"Constitution notes contain unsupported control characters");
Ok(())
}
pub fn as_user_constitution(&self) -> Result<UserConstitution> {
self.validate()?;
let detail = match self.detail {
ResponseDetail::Brief => "Lead with the result. Keep routine explanations brief; include important evidence and failed checks.",
ResponseDetail::Balanced => "Lead with the result. Give enough explanation to assess the work without narrating every step.",
ResponseDetail::Detailed => "Explain significant decisions and tradeoffs with concrete examples. Keep failures and uncertainty visible.",
};
let initiative = match self.initiative {
InitiativePreference::Check => "Check with the user about nontrivial approach decisions before implementation. Continue independently useful authorized work.",
InitiativePreference::Judgment => "Act on clear, reversible work within the request. Ask when ambiguity would materially change the outcome.",
InitiativePreference::Moving => "Keep clear, reversible work moving within the user's request. Batch routine decisions and surface consequential choices.",
};
let collaboration = match self.collaboration {
CollaborationPreference::Direct => "Offer a clear recommendation and the evidence behind it.",
CollaborationPreference::Critical => "Test material assumptions and distinguish supporting evidence from uncertainty. Avoid contrarianism for its own sake.",
CollaborationPreference::Coach => "Explain a useful decision in plain language. Offer a learning opportunity without withholding completion when the user asks for it.",
};
Ok(UserConstitution {
clauses: vec![ConstitutionClause::accepted("profile.detail", detail),
ConstitutionClause::accepted("profile.initiative", initiative),
ConstitutionClause::accepted("profile.collaboration", collaboration)],
notes: (!self.notes.is_empty()).then(|| self.notes.clone()),
..UserConstitution::default()
})
}
}
/// Model-facing autonomy preference. **Guidance only** — it may recommend a
/// runtime posture but never applies one.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
+3
View File
@@ -109,6 +109,8 @@ fn default_capability_state() -> String {
/// byte-identical: `{"kind":"send_message", ...fields}`.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct TurnSpec {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub profile_constitution: Option<serde_json::Value>,
#[serde(
default,
rename = "maxOutputTokens",
@@ -538,6 +540,7 @@ pub fn headless_send_message_op(thread_id: ThreadId, content: impl Into<String>)
thread_id: thread_id.clone(),
session_id: SessionId::new(),
op: Op::SendMessage(TurnSpec {
profile_constitution: None,
max_output_tokens: None,
content: content.into(),
images: Vec::new(),
+4
View File
@@ -74,6 +74,9 @@ pub struct RuntimeCapabilities {
/// Per-turn maxOutputTokens is validated and intersected with the route ceiling.
#[serde(default)]
pub turn_output_token_limit: bool,
/// Account profile snapshots and Engine-owned constitution preview.
#[serde(default)]
pub profile_constitution: bool,
pub turn_steer: bool,
pub turn_interrupt: bool,
pub event_replay: bool,
@@ -425,6 +428,7 @@ mod tests {
fn runtime_capabilities_serializes_expected_shape() {
let caps = RuntimeCapabilities {
turn_output_token_limit: false,
profile_constitution: false,
account_session: true,
threads: true,
thread_shell_consent: true,
@@ -116,7 +116,10 @@ fn open_review(app: &mut App) {
fn open_preview(app: &mut App) {
let locale = app.ui_locale;
let text = preview_text(locale);
let text = format!("{}\n\n{}", match locale {
Locale::ZhHans => "这是本机宪章预览。已登录账户的宪章(包括默认值)优先;请在账户设置中预览。",
_ => "This previews the local constitution. The signed-in profile constitution, including its defaults, takes precedence; preview it in account settings.",
}, preview_text(locale));
open_pager(app, rendered_title(locale), &text);
}
@@ -383,6 +386,10 @@ fn format_status(app: &App, locale: Locale) -> String {
let copy = ConstitutionManagerCopy::for_locale(locale);
let _ = writeln!(out, "{}", copy.manager_header);
out.push_str(match locale {
Locale::ZhHans => "\n下方为本机宪章设置。已登录账户的宪章(包括默认值)优先于本机设置,由引擎在每轮开始时加载。在应用的设置 → 账户 → 宪章中编辑;更改从下一轮生效。\n",
_ => "\nLocal constitution settings follow. The signed-in profile constitution, including its defaults, takes precedence and is loaded by the Engine at each turn. Edit it in Settings → Accounts → Constitution; changes apply from the next turn.\n",
});
out.push('\n');
let _ = writeln!(out, "{}", copy.active_stack_header);
let _ = writeln!(out, "- {}", copy.bundled_active);
+57
View File
@@ -282,6 +282,12 @@ pub(super) fn replacement_messages(
});
retained.insert(0, snapshot.clone());
}
if let Some(snapshot) = messages.iter().rev().find(|message| {
crate::runtime_handoff::constitution_display(message).is_some()
}) {
retained.retain(|message| crate::runtime_handoff::constitution_display(message).is_none());
retained.insert(0, snapshot.clone());
}
retained
}
@@ -752,6 +758,57 @@ mod tests {
}
}
/// The handoff header tells the next turn what survived. It must match
/// what the replacement history keeps: only the last steps of a long
/// round, with long tool output shortened and marked.
#[test]
fn profile_constitution_compaction_keeps_only_the_complete_latest_snapshot() {
use crate::runtime_handoff::{
constitution_display, constitution_runtime_message,
};
let old = constitution_runtime_message(Some("old instructions"));
let current_text = "current instructions ".repeat(400);
for current in [
constitution_runtime_message(Some(&current_text)),
constitution_runtime_message(None),
] {
let quoted = msg(
"user",
&user_text_of(&current).expect("runtime snapshot has text"),
);
let original = vec![
old.clone(),
quoted.clone(),
current.clone(),
msg("user", "Continue this task."),
tool_use("first", "Read", json!({"path": "first"})),
tool_result("first", "first output"),
tool_use("second", "Read", json!({"path": "second"})),
tool_result("second", "second output"),
tool_use("third", "Read", json!({"path": "third"})),
tool_result("third", "third output"),
];
let kept = replacement_messages(&original, 20_000);
let snapshots: Vec<_> = kept
.iter()
.filter(|message| constitution_display(message).is_some())
.collect();
assert_eq!(snapshots, [&current]);
assert!(
kept.contains(&quoted),
"a person's quote is ordinary user text"
);
assert_eq!(
replacement_messages(&kept, 20_000)
.iter()
.filter(|message| constitution_display(message).is_some())
.count(),
1,
"repeated compaction must not accumulate snapshots"
);
}
}
/// The handoff header tells the next turn what survived. It must match
/// what the replacement history keeps: only the last steps of a long
/// round, with long tool output shortened and marked.
+5
View File
@@ -2198,6 +2198,8 @@ pub(crate) struct AccountModelAccess {
/// Resolved CLI configuration, including defaults and environment overrides.
#[derive(Debug, Clone, Default, Deserialize)]
pub struct Config {
#[serde(skip)]
pub(crate) account_profile: Option<String>,
/// Diagnostic clones must never refresh or mutate plugin OAuth credentials.
#[serde(skip)]
pub(crate) plugin_oauth_read_only: bool,
@@ -4328,6 +4330,7 @@ impl Config {
})?;
let legacy_root = parsed.legacy_root.clone();
let mut config = apply_profile(parsed, profile)?;
config.account_profile = profile.map(str::to_owned);
config.legacy_root = legacy_root;
Ok(config)
}
@@ -4360,6 +4363,7 @@ impl Config {
};
// Scope and profile choices outrank device startup memory. Environment
config.account_profile = profile.map(str::to_owned);
// and managed values are applied afterwards, so their models win too.
if profile.is_none() && path.as_deref().is_some_and(is_home_config_path) {
if let Ok(settings) =
@@ -10477,6 +10481,7 @@ fn merge_config(base: Config, override_cfg: Config) -> Config {
legacy_root: base.legacy_root,
legacy_root_custom_generation: base.legacy_root_custom_generation,
account_model_access: base.account_model_access,
account_profile: override_cfg.account_profile.or(base.account_profile),
plugin_oauth_read_only: base.plugin_oauth_read_only || override_cfg.plugin_oauth_read_only,
runtime_chat_isolated: override_cfg.runtime_chat_isolated || base.runtime_chat_isolated,
runtime_thread_inference_unrelated: override_cfg.runtime_thread_inference_unrelated
+1
View File
@@ -207,6 +207,7 @@ pub(super) fn send_message_op(case: &Value, config: &Config) -> Op {
)
.expect("resolve conformance route");
Op::SendMessage(TurnSpec {
profile_constitution: None,
max_output_tokens: None,
submission_id: None,
content: case["user_message"]
@@ -74,6 +74,7 @@ fn fixture_compaction() -> CompactionConfig {
fn turn_op(content: &str, route: &ResolvedRuntimeRoute) -> Op {
let compaction = fixture_compaction();
Op::SendMessage(TurnSpec {
profile_constitution: None,
max_output_tokens: None,
content: content.to_string(),
images: Vec::new(),
+46
View File
@@ -958,6 +958,7 @@ pub struct Engine {
/// Immutable contribution snapshot for the running turn. Re-delivery after
/// compaction uses these same bytes, never a mid-turn host re-sampling.
extension_prompt_block: Option<String>,
constitution_block: Option<String>,
api_provider: ProviderKind,
/// One captured admitted route. Presentation snapshots derive strings from
/// it; a changed table cannot be blessed by reinterpreting those strings.
@@ -2235,6 +2236,7 @@ impl Engine {
plugin_registry,
extension_host,
extension_prompt_block: None,
constitution_block: None,
api_provider,
api_provider_identity,
active_route_limits,
@@ -3314,6 +3316,7 @@ impl Engine {
.await;
let _ = self
.handle_send_message(TurnSpec {
profile_constitution: None,
content:
"[runtime] A background shell task finished; its completion evidence follows."
.to_string(),
@@ -3513,6 +3516,7 @@ impl Engine {
let _ = self
.handle_send_message(TurnSpec {
profile_constitution: None,
content,
mode: self.current_mode,
route: Box::new(route),
@@ -4133,6 +4137,7 @@ impl Engine {
let mode = self.current_mode;
let outcome = self
.handle_send_message(TurnSpec {
profile_constitution: None,
content: new_message.clone(),
mode,
route: Box::new(route),
@@ -4853,6 +4858,7 @@ impl Engine {
let outcome = self
.handle_send_message(TurnSpec {
profile_constitution: None,
content,
mode: self.current_mode,
route: Box::new(route),
@@ -5823,6 +5829,7 @@ impl Engine {
autonomous: bool,
) -> SendMessageOutcome {
let TurnSpec {
profile_constitution,
max_output_tokens,
content,
images,
@@ -5897,6 +5904,27 @@ impl Engine {
// any provider dispatch), so its admission waits for capacity instead
// of refusing on cancellation. An interactive queued cancellation
// keeps no lifecycle.
// Internal follow-ups belong to the already-admitted work. They must
// not replace its account preferences with the host operator's profile.
let constitution_block = if self.rlm_host.is_some()
|| (!provenance.can_authorize_work() && profile_constitution.is_none())
{
self.constitution_block.clone()
} else { match crate::profile_constitution::capture(
self.api_config.account_profile.as_deref(), profile_constitution,
).await {
Ok(block) => block,
Err(error) => {
crate::cost_status::report_runtime_usage_batch(
crate::cost_status::scope_token(), initial_usage_owner.as_deref(), &initial_routed_usage,
);
let _ = self.send_event(Event::error(ErrorEnvelope::new(
ErrorCategory::InvalidInput, ErrorSeverity::Error, true,
"profile_constitution_unavailable", error.to_string(),
))).await;
return SendMessageOutcome::NotStarted { error: Some(error.to_string()) };
}
} };
let admission_cancel = (!self.host_managed_turns()).then_some(&self.cancel_token);
let admission = async {
let terminal = streaming::reserve_event_capacity(
@@ -6389,6 +6417,8 @@ impl Engine {
} else {
None
};
self.constitution_block = constitution_block;
self.record_current_constitution().await;
self.record_current_extension_prompt_contributions().await;
// Compose from the immutable values accepted for this turn. Preview
@@ -8346,6 +8376,22 @@ impl Engine {
.await;
}
async fn record_current_constitution(&mut self) {
let previous = self.session.messages.iter().rev().find(|message| {
crate::runtime_handoff::constitution_display(message).is_some()
});
if self.constitution_block.is_none() && previous.is_none() { return; }
let message = crate::runtime_handoff::constitution_runtime_message(self.constitution_block.as_deref());
if previous == Some(&message) { return; }
self.add_session_message(message).await;
let receipt = match self.constitution_block.as_deref() {
Some(block) if block.starts_with("Account profile constitution,") => block.lines().next().unwrap_or("Profile constitution applied."),
Some(_) => "Local constitution applied to this turn.",
None => "Personal constitution withdrawn for this turn.",
};
let _ = self.send_event(Event::status(receipt.to_owned())).await;
}
async fn record_extension_prompt_contributions(&mut self, block: Option<&str>) {
let previous = self.session.messages.iter().rev().find(|message| {
crate::runtime_handoff::extension_prompt_contributions_display(message).is_some()
+1
View File
@@ -147,6 +147,7 @@ impl Engine {
.validate_context(&state.authority.context())?;
let posture = self.runtime_authority_snapshot();
Ok(TurnSpec {
profile_constitution: None,
content,
images: Vec::new(),
mode: posture.mode,
@@ -486,6 +486,7 @@ mod tests {
engine.config.features.disable(Feature::Mcp);
let message = |content: &str| {
Op::SendMessage(TurnSpec {
profile_constitution: None,
max_output_tokens: None,
content: content.into(),
images: Vec::new(),
+6
View File
@@ -608,6 +608,12 @@ impl Engine {
compaction: &crate::compaction::CompactionConfig,
) -> Vec<&'static str> {
let mut reasons = Vec::new();
if crate::profile_constitution::account_is_present(self.api_config.account_profile.as_deref()).unwrap_or(true)
|| self.constitution_block.as_deref().is_some_and(|block| block.starts_with("Account profile constitution,")) {
reasons.push("the account constitution is resolved at next-turn admission; preview its guidance in account settings");
} else if crate::prompts::load_user_constitution_block() != self.constitution_block {
reasons.push("the local constitution changed and will be recorded at next-turn admission");
}
if !self.pending_lsp_blocks.is_empty() {
reasons.push("pending LSP diagnostics would be injected as a synthetic message");
@@ -962,6 +962,7 @@ async fn assert_preview_matches_first_wire_body(
let _ = engine
.handle_send_message(TurnSpec {
profile_constitution: None,
content: prompt.to_string(),
mode: AppMode::Agent,
route: Box::new(production_route),
@@ -1924,6 +1925,7 @@ async fn provider_reported_usage_is_unavailable_until_a_response_reports_it() {
let _ = engine
.handle_send_message(TurnSpec {
profile_constitution: None,
content: prompt.to_string(),
mode: AppMode::Agent,
route: Box::new(production_route),
+4
View File
@@ -41,6 +41,7 @@ pub(crate) struct CapturedRlmCaller {
config: EngineConfig,
system: SystemPrompt,
extension_prompt_block: Option<String>,
constitution_block: Option<String>,
approval_store: Result<ApprovalReceiptStore, String>,
review_policy: Arc<AutoReviewPolicy>,
deadline: tokio::time::Instant,
@@ -142,6 +143,7 @@ impl CapturedRlmCaller {
ToolError::not_available("the Core policy prompt is unavailable")
})?,
extension_prompt_block: engine.extension_prompt_block.clone(),
constitution_block: engine.constitution_block.clone(),
approval_store: engine.approval_receipt_store.clone(),
review_policy: Arc::clone(&engine.shared_auto_review_policy),
deadline,
@@ -442,6 +444,7 @@ impl Engine {
handle.client_preflight_required = false;
engine.repl_kernel = kernel;
engine.extension_prompt_block = caller.extension_prompt_block.clone();
engine.constitution_block = caller.constitution_block.clone();
Ok((engine, handle))
}
@@ -464,6 +467,7 @@ impl Engine {
crate::rlm::turn::metadata_text(&state.prompt, 0, None, None)
};
Ok(TurnSpec {
profile_constitution: None,
content,
images: Vec::new(),
mode: state.caller.authority.mode,
+2
View File
@@ -27,6 +27,8 @@ use tempfile::tempdir;
mod extension_hooks;
#[path = "tests/extension_prompts.rs"]
mod extension_prompts;
#[path = "tests/profile_constitution.rs"]
mod profile_constitution;
#[path = "tests/child_host.rs"]
mod child_host;
@@ -0,0 +1,45 @@
use super::*;
use codewhale_config::user_constitution::{ProfileConstitution, ProfileConstitutionSnapshot};
#[tokio::test]
async fn profile_constitution_reaches_real_engine_requests_and_survives_history_repair() {
use crate::llm_client::mock::{MockLlmClient, canned};
let _home = crate::test_support::SealedHome::new();
let workspace = tempfile::tempdir().unwrap();
let config = Config::default();
let mock = Arc::new(MockLlmClient::new(vec![canned::simple_text_turn("Done"), canned::simple_text_turn("Done again"), canned::simple_text_turn("Follow-up done")]));
let (mut engine, handle) = Engine::new_with_model_client(deterministic_engine_config(workspace.path()), &config, mock.clone());
let drainer = tokio::spawn(async move { while handle.rx_event.write().await.recv().await.is_some() {} });
for (revision, note) in [(1, "Profile alpha preference"), (2, "Profile beta preference")] {
let Op::SendMessage(mut spec) = external_user_message_op("Say hello", AppMode::Agent, &config) else { unreachable!() };
spec.profile_constitution = Some(ProfileConstitutionSnapshot { account_id:"acct_fixture".into(), revision,
constitution:ProfileConstitution { notes:note.into(), ..ProfileConstitution::default() } });
let outcome = engine.handle_send_message(spec).await;
assert!(!matches!(outcome, SendMessageOutcome::NotStarted { .. }));
let requests = mock.captured_requests();
let request = serde_json::to_string(requests.last().unwrap()).unwrap();
assert!(request.contains(note));
assert!(request.contains("replaces all earlier personal constitution snapshots"));
assert!(request.contains("do not change permissions"));
}
let latest = engine.session.messages.iter().rev().find(|message| crate::runtime_handoff::constitution_display(message).is_some()).unwrap().clone();
assert!(crate::runtime_handoff::constitution_display(&latest).unwrap().contains("revision 2"));
let Op::SendMessage(mut continuation) = external_user_message_op("A background task finished", AppMode::Agent, &config) else { unreachable!() };
continuation.provenance = UserInputProvenance::Runtime;
// A host can admit a runtime follow-up itself, so this must not depend on
// the Engine's autonomous scheduling flag.
let outcome = engine.handle_admitted_message(continuation, false).await;
assert!(!matches!(outcome, SendMessageOutcome::NotStarted { .. }));
assert!(serde_json::to_string(mock.captured_requests().last().unwrap()).unwrap().contains("Profile beta preference"));
assert_eq!(engine.session.messages.iter().rev().find(|message| crate::runtime_handoff::constitution_display(message).is_some()), Some(&latest));
engine.session.messages.clear();
engine.record_current_constitution().await;
assert_eq!(engine.session.messages.last(), Some(&latest));
let count = engine.session.messages.len();
engine.record_current_constitution().await;
assert_eq!(engine.session.messages.len(), count);
engine.constitution_block = None;
engine.record_current_constitution().await;
assert!(crate::runtime_handoff::constitution_display(engine.session.messages.last().unwrap()).unwrap().contains("withdrawn"));
drainer.abort();
}
@@ -591,6 +591,7 @@ async fn exact_turn_snapshot_restores_custom_endpoint_and_turn_receipt_after_bui
let run_task = tokio::spawn(engine.run());
handle
.send(Op::SendMessage(TurnSpec {
profile_constitution: None,
max_output_tokens: None,
content: "verify exact route".to_string(),
images: Vec::new(),
@@ -768,6 +769,7 @@ async fn main_turn_dispatch_freezes_declared_custom_model_rate() {
let run_task = tokio::spawn(engine.run());
handle
.send(Op::SendMessage(TurnSpec {
profile_constitution: None,
max_output_tokens: None,
content: "price this turn".to_string(),
images: Vec::new(),
@@ -1087,6 +1089,7 @@ async fn goal_continuation_preserves_goal_and_resolves_updated_authoritative_rou
handle
.send(Op::SendMessage(TurnSpec {
profile_constitution: None,
max_output_tokens: None,
content: "first turn".to_string(),
images: Vec::new(),
@@ -1370,6 +1373,7 @@ async fn saturated_mailbox_does_not_deadlock_goal_continuation_self_dispatch() {
handle
.send(Op::SendMessage(TurnSpec {
profile_constitution: None,
max_output_tokens: None,
content: "start the saturated goal turn".to_string(),
images: Vec::new(),
@@ -1503,6 +1507,7 @@ async fn queued_ordinary_turn_does_not_multiply_engine_goal_continuations() {
let run_task = tokio::spawn(engine.run());
let send_message = |content: &str| {
Op::SendMessage(TurnSpec {
profile_constitution: None,
max_output_tokens: None,
content: content.to_string(),
images: Vec::new(),
@@ -1210,6 +1210,7 @@ async fn cross_turn_token_budget_exhaustion_does_not_pause_goal() {
handle
.send(Op::SendMessage(TurnSpec {
profile_constitution: None,
max_output_tokens: None,
content: "start budgeted goal".to_string(),
images: Vec::new(),
@@ -150,6 +150,7 @@ async fn ordinary_prose_never_activates_a_goal() {
handle
.send(Op::SendMessage(TurnSpec {
profile_constitution: None,
max_output_tokens: None,
content: "hello - take over and make it your /goal to solve navier stokes".to_string(),
images: Vec::new(),
@@ -251,6 +252,7 @@ async fn operate_goal_probe(mode: AppMode, prompt: &str) -> (Option<String>, boo
handle
.send(Op::SendMessage(TurnSpec {
profile_constitution: None,
max_output_tokens: None,
content: prompt.to_string(),
images: Vec::new(),
@@ -437,6 +439,7 @@ async fn operate_contract_is_appended_once_and_an_existing_goal_is_never_replace
let send = |content: &str, goal_objective: Option<String>, goal_status| {
Op::SendMessage(TurnSpec {
profile_constitution: None,
max_output_tokens: None,
content: content.to_string(),
images: Vec::new(),
@@ -1267,6 +1270,7 @@ async fn host_managed_engine_does_not_self_dispatch_goal_continuation() {
handle
.send(Op::SendMessage(TurnSpec {
profile_constitution: None,
max_output_tokens: None,
content: "one host-owned turn".to_string(),
images: Vec::new(),
@@ -65,6 +65,7 @@ async fn host_managed_engine_defers_idle_subagent_completion_to_explicit_turn()
handle
.send(Op::SendMessage(TurnSpec {
profile_constitution: None,
max_output_tokens: None,
content: "claim the next turn".to_string(),
images: Vec::new(),
@@ -676,6 +676,7 @@ fn active_goal_message_op(
token_budget: Option<u32>,
) -> Op {
Op::SendMessage(TurnSpec {
profile_constitution: None,
max_output_tokens: None,
content: content.to_string(),
images: Vec::new(),
@@ -716,6 +717,7 @@ fn system_prompt_text(prompt: SystemPrompt) -> String {
fn external_user_message_op(content: &str, mode: AppMode, config: &Config) -> Op {
Op::SendMessage(TurnSpec {
profile_constitution: None,
max_output_tokens: None,
content: content.to_string(),
images: Vec::new(),
@@ -745,6 +747,7 @@ fn external_user_message_op(content: &str, mode: AppMode, config: &Config) -> Op
fn auto_review_message_op(content: &str, config: &Config) -> Op {
Op::SendMessage(TurnSpec {
profile_constitution: None,
max_output_tokens: None,
content: content.to_string(),
images: Vec::new(),
@@ -1292,6 +1292,7 @@ async fn deferred_tool_first_use_does_not_emit_a_retry_status() {
let run_task = tokio::spawn(engine.run());
handle
.send(Op::SendMessage(TurnSpec {
profile_constitution: None,
max_output_tokens: None,
content: "Map this project".to_string(),
images: Vec::new(),
@@ -538,6 +538,7 @@ async fn operate_model_shell_uses_normal_approval_and_workspace_sandbox() {
handle
.send(Op::SendMessage(TurnSpec {
profile_constitution: None,
max_output_tokens: None,
content: "Write the requested local fixture to the workspace".to_string(),
images: Vec::new(),
@@ -694,6 +695,7 @@ async fn posture_change_during_approval_wait(
let run_task = tokio::spawn(engine.run());
handle
.send(Op::SendMessage(TurnSpec {
profile_constitution: None,
max_output_tokens: None,
content: "Record the approval fixture in the workspace".to_string(),
images: Vec::new(),
@@ -909,6 +911,7 @@ async fn full_access_subagent_handoff_keeps_model_shell_free_of_approval_prompts
handle
.send(Op::SendMessage(TurnSpec {
profile_constitution: None,
max_output_tokens: None,
content: "continue from the completed child".to_string(),
images: Vec::new(),
@@ -1048,6 +1051,7 @@ async fn assert_full_access_model_tool_batch_is_blocked(
handle
.send(Op::SendMessage(TurnSpec {
profile_constitution: None,
max_output_tokens: None,
content: "exercise the Full Access execution boundary".to_string(),
images: Vec::new(),
@@ -1256,6 +1260,7 @@ async fn assert_full_access_model_tool_batch_runs(
handle
.send(Op::SendMessage(TurnSpec {
profile_constitution: None,
max_output_tokens: None,
content: "exercise the Full Access auto-approval boundary".to_string(),
images: Vec::new(),
@@ -92,6 +92,7 @@ async fn auto_review_asks_the_user_and_returns_the_answer() {
handle
.send(Op::SendMessage(TurnSpec {
profile_constitution: None,
max_output_tokens: None,
content: "continue autonomously".to_string(),
images: Vec::new(),
@@ -279,6 +280,7 @@ async fn full_access_permission_allow_cannot_bypass_background_catastrophic_floo
handle
.send(Op::SendMessage(TurnSpec {
profile_constitution: None,
max_output_tokens: None,
content: "please run a background shell".to_string(),
images: Vec::new(),
@@ -422,6 +424,7 @@ async fn yolo_mode_does_not_prompt_for_background_shell() {
handle
.send(Op::SendMessage(TurnSpec {
profile_constitution: None,
max_output_tokens: None,
content: "please run a background shell".to_string(),
images: Vec::new(),
@@ -561,6 +564,7 @@ async fn yolo_mode_executes_publish_like_shell_without_prompt() {
handle
.send(Op::SendMessage(TurnSpec {
profile_constitution: None,
max_output_tokens: None,
content: "please publish this crate".to_string(),
images: Vec::new(),
@@ -704,6 +708,7 @@ async fn yolo_mode_does_not_prompt_for_mcp_action() {
handle
.send(Op::SendMessage(TurnSpec {
profile_constitution: None,
max_output_tokens: None,
content: "please open the PR".to_string(),
images: Vec::new(),
@@ -545,6 +545,7 @@ async fn run_headless_turn_with_flaky_network(
handle
.send(Op::SendMessage(TurnSpec {
profile_constitution: None,
max_output_tokens: None,
content: "solve the task".to_string(),
images: Vec::new(),
@@ -896,6 +897,7 @@ async fn terminal_output_limit_followed_by_stream_error_is_charged_and_not_retri
handle
.send(Op::SendMessage(TurnSpec {
profile_constitution: None,
max_output_tokens: None,
content: "solve the task".to_string(),
images: Vec::new(),
@@ -989,6 +991,7 @@ async fn error_frame_turn_events(turns: Vec<Vec<StreamEvent>>) -> (Vec<Event>, u
let run_task = tokio::spawn(engine.run());
handle
.send(Op::SendMessage(TurnSpec {
profile_constitution: None,
max_output_tokens: None,
content: "solve the task".to_string(),
images: Vec::new(),
@@ -1194,6 +1197,7 @@ async fn midstream_error_frame_stops_the_stream_and_drops_trailing_deltas() {
handle
.send(Op::SendMessage(TurnSpec {
profile_constitution: None,
max_output_tokens: None,
content: "solve the task".to_string(),
images: Vec::new(),
@@ -1447,6 +1451,7 @@ async fn run_interactive_turn_with_flaky_network(
handle
.send(Op::SendMessage(TurnSpec {
profile_constitution: None,
max_output_tokens: None,
content: "solve the task".to_string(),
images: Vec::new(),
@@ -21,6 +21,7 @@ async fn interactive_thinking_only_drop_preserves_nothing_and_never_claims_it_di
handle
.send(Op::SendMessage(TurnSpec {
profile_constitution: None,
max_output_tokens: None,
content: "solve the task".to_string(),
images: Vec::new(),
@@ -255,6 +256,7 @@ async fn run_reasoning_only_turn_with_reprompts(
let run_task = tokio::spawn(engine.run());
handle
.send(Op::SendMessage(TurnSpec {
profile_constitution: None,
max_output_tokens: None,
content: "solve the task".to_string(),
images: Vec::new(),
@@ -35,6 +35,7 @@ impl Engine {
.await;
self.record_mcp_server_instructions(&progress.tool_catalog)
.await;
self.record_current_constitution().await;
self.record_current_extension_prompt_contributions().await;
// R1: the cumulative per-turn wall-clock budget. Checked at the
@@ -742,6 +743,7 @@ impl Engine {
)),
));
}
self.record_current_constitution().await;
self.record_current_extension_prompt_contributions().await;
let estimated = turn
.live_input_tokens_for_compaction(
+1
View File
@@ -170,6 +170,7 @@ impl UserInputProvenance {
/// variant; the serializable twin is `codewhale_protocol::op::TurnSpec`.
#[derive(Debug)]
pub struct TurnSpec {
pub profile_constitution: Option<codewhale_config::user_constitution::ProfileConstitutionSnapshot>,
/// Admitted allowance for this turn only; never changes session settings.
pub max_output_tokens: Option<std::num::NonZeroU32>,
pub content: String,
+1
View File
@@ -390,6 +390,7 @@ fn turn_spec_to_wire(spec: &crate::core::ops::TurnSpec) -> wire_op::TurnSpec {
// so wire submitters observe `TurnStarted.submission_id` always absent
// and cannot correlate submissions on that channel.
wire_op::TurnSpec {
profile_constitution: spec.profile_constitution.as_ref().map(|snapshot| serde_json::json!(snapshot)),
max_output_tokens: spec.max_output_tokens,
content: spec.content.clone(),
images: spec.images.clone(),
@@ -121,6 +121,7 @@ async fn approving_the_first_of_three_queued_calls_cancels_none_of_them() {
let run_task = tokio::spawn(engine.run());
handle
.send(Op::SendMessage(TurnSpec {
profile_constitution: None,
max_output_tokens: None,
content: "Record three approval fixtures in the workspace".to_string(),
images: Vec::new(),
+1
View File
@@ -783,6 +783,7 @@ pub(crate) async fn run_exec_agent(
engine_handle
.send(Op::SendMessage(TurnSpec {
profile_constitution: None,
max_output_tokens: None,
content: prompt.to_string(),
images: Vec::new(),
+1
View File
@@ -103,6 +103,7 @@ pub mod route_preferences;
mod route_receipt;
mod route_runtime;
mod runtime_api;
mod profile_constitution;
mod runtime_chat_relay;
mod runtime_handoff;
mod runtime_log;
+136
View File
@@ -0,0 +1,136 @@
//! Account preferences enter the existing Engine history once per turn.
use anyhow::{Context, Result, ensure};
use codewhale_config::user_constitution::ProfileConstitutionSnapshot;
use codewhale_secrets::account::{AccountSessionStore, secure_account_session_secrets};
/// No cached cross-account fallback: unavailable or malformed signed-in profiles
/// must be resolved before starting another turn. Signed-out/local use retains
/// the existing local constitution.
pub(crate) async fn load(profile: Option<&str>) -> Result<Option<ProfileConstitutionSnapshot>> {
let api_base = crate::runtime_api::runtime_account_api_base();
let store = AccountSessionStore::new(secure_account_session_secrets()?, profile, &api_base);
load_from(&store, &api_base).await
}
/// Offline preview cannot promise a request hash before account admission.
pub(crate) fn account_is_present(profile: Option<&str>) -> Result<bool> {
#[cfg(test)]
{ let _ = profile; Ok(false) }
#[cfg(not(test))]
{
let base = crate::runtime_api::runtime_account_api_base();
Ok(AccountSessionStore::new(secure_account_session_secrets()?, profile, &base).load()?.is_some())
}
}
async fn load_from(store: &AccountSessionStore, api_base: &str) -> Result<Option<ProfileConstitutionSnapshot>> {
let captured = store.snapshot()?;
let Some(auth) = captured.load()? else { return Ok(None); };
let account = auth.bundle.user.as_ref().map(|user| user.id.as_str())
.filter(|id| !id.is_empty()).context("Sign in again to load your profile constitution")?;
let client = reqwest::Client::builder()
.redirect(reqwest::redirect::Policy::none())
.timeout(std::time::Duration::from_secs(10)).build()?;
let response = client.get(format!("{api_base}/api/me"))
.bearer_auth(&auth.bundle.access_token).send().await
.context("Your profile could not be loaded; retry when the account service is reachable")?;
ensure!(response.status().is_success(), "Your profile could not be loaded (HTTP {}). Sign in again or retry", response.status().as_u16());
let bytes = crate::utils::read_response_body_capped(response, 256 * 1024).await?;
let value: serde_json::Value = serde_json::from_slice(&bytes).context("The account profile response is invalid")?;
ensure!(store.with_transaction(|current| Ok::<_, anyhow::Error>(current.matches(&captured)))?,
"The signed-in account changed while loading its constitution; retry");
snapshot_from_me(value, account)
}
pub(crate) fn snapshot_from_me(value: serde_json::Value, account: &str) -> Result<Option<ProfileConstitutionSnapshot>> {
let user = value.get("user").context("The account profile response is missing")?;
ensure!(user.get("id").and_then(|id| id.as_str()) == Some(account), "The profile belongs to a different account");
let document = user.get("preferences").and_then(|preferences| preferences.get("constitution"));
let snapshot = ProfileConstitutionSnapshot {
account_id: account.to_owned(),
revision: user.get("settingsRevision").and_then(|revision| revision.as_u64())
.context("The profile constitution has no valid revision")?,
constitution: document.map(|value| serde_json::from_value(value.clone())).transpose()
.context("The profile constitution is invalid; review it in account settings")?.unwrap_or_default(),
};
snapshot.validate()?;
Ok(Some(snapshot))
}
pub(crate) async fn capture(profile: Option<&str>, supplied: Option<ProfileConstitutionSnapshot>) -> Result<Option<String>> {
if let Some(snapshot) = supplied { return snapshot.render().map(Some); }
// Unit tests never read an operator's account credentials. Transport tests
// exercise load_from with an explicitly isolated store instead.
#[cfg(not(test))]
if let Some(snapshot) = load(profile).await? { return snapshot.render().map(Some); }
#[cfg(test)]
let _ = profile;
Ok(crate::prompts::load_user_constitution_block())
}
#[cfg(test)]
mod tests {
use super::*;
use codewhale_config::user_constitution::ProfileConstitution;
use codewhale_secrets::{Secrets, InMemoryKeyringStore};
use codewhale_secrets::account::{AccountAuthBundle, AccountSession, AccountUser};
use wiremock::{Mock, MockServer, ResponseTemplate, matchers::{method, path, header}};
fn fixture_store(base: &str) -> AccountSessionStore {
let _ = rustls::crypto::ring::default_provider().install_default();
let store = AccountSessionStore::new(Secrets::new(std::sync::Arc::new(InMemoryKeyringStore::new())), Some("work"), base);
store.save(AccountAuthBundle {
token_type:"Bearer".into(), access_token:"constitution-fixture-access".into(), refresh_token:"constitution-fixture-refresh".into(),
user:Some(AccountUser { id:"acct_fixture".into(), ..Default::default() }),
session:Some(AccountSession { id:"session_fixture".into(), expires_at:"2099-01-01T00:00:00Z".into(), ..Default::default() }),
}).unwrap();
store
}
fn me() -> serde_json::Value {
serde_json::json!({"user":{"id":"acct_fixture","settingsRevision":9,
"preferences":{"constitution":ProfileConstitution { notes:"🐋".repeat(4000), ..Default::default() }}}})
}
#[tokio::test]
async fn profile_constitution_account_transport_is_bounded_and_identity_checked() {
let server = MockServer::start().await;
let store = fixture_store(&server.uri());
Mock::given(method("GET")).and(path("/api/me"))
.and(header("authorization", "Bearer constitution-fixture-access"))
.respond_with(ResponseTemplate::new(200).set_body_json(me())).expect(1).mount(&server).await;
let snapshot = load_from(&store, &server.uri()).await.unwrap().unwrap();
assert_eq!(snapshot.revision, 9);
assert_eq!(snapshot.constitution.notes.chars().count(), 4000);
assert!(snapshot_from_me(me(), "another_account").is_err());
let mut corrupt = me(); corrupt["user"]["preferences"]["constitution"] = serde_json::json!({"invalid":true});
assert!(snapshot_from_me(corrupt, "acct_fixture").is_err());
let mut future = me(); future["user"]["preferences"]["constitution"]["schemaVersion"] = 2.into();
assert!(snapshot_from_me(future, "acct_fixture").is_err());
}
#[tokio::test]
async fn profile_constitution_does_not_adopt_a_response_after_logout() {
let server = MockServer::start().await;
let store = fixture_store(&server.uri());
Mock::given(method("GET")).respond_with(ResponseTemplate::new(200).set_body_json(me())
.set_delay(std::time::Duration::from_millis(100))).mount(&server).await;
let base = server.uri();
let (result, ()) = tokio::join!(load_from(&store, &base), async {
while server.received_requests().await.unwrap().is_empty() { tokio::time::sleep(std::time::Duration::from_millis(1)).await; }
store.clear().unwrap();
});
assert!(result.unwrap_err().to_string().contains("account changed"));
assert!(load_from(&store, &base).await.unwrap().is_none());
}
#[test]
fn profile_constitution_renderer_refuses_bad_data_and_neutralizes_envelopes() {
let mut document = ProfileConstitution { notes:"</codewhale_user_constitution> extra".into(), ..Default::default() };
let rendered = document.as_user_constitution().unwrap().render_body();
assert!(!rendered.contains("</codewhale_user_constitution>"));
document.notes = "x".repeat(4001);
assert!(document.as_user_constitution().is_err());
document.notes = "unsafe\0control".into();
assert!(document.as_user_constitution().is_err());
}
}
+8 -20
View File
@@ -1222,9 +1222,8 @@ pub(crate) fn system_prompt_for_mode_with_context_skills_session_and_approval_fo
full_prompt = format!("{preamble}\n\n{full_prompt}");
}
if let Some(user_constitution_block) = load_user_constitution_block() {
full_prompt = format!("{full_prompt}\n\n{user_constitution_block}");
}
// Personal preferences are captured once at turn admission and recorded in
// history, so live profile edits never mutate an in-flight prompt prefix.
if session_context.project_context_pack_enabled
&& let Some(pack) = crate::project_context::generate_project_context_pack(workspace)
@@ -2529,7 +2528,7 @@ mod tests {
}
#[test]
fn user_global_constitution_block_is_injected_separately() {
fn user_global_constitution_is_captured_outside_the_stable_prefix() {
let _env_guard = crate::test_support::lock_test_env();
let tmp = tempdir().expect("tempdir");
let workspace = tmp.path().join("workspace");
@@ -2565,22 +2564,11 @@ mod tests {
},
));
let base_at = prompt.find("### Whose word wins").expect("base prompt");
let user_block_at = prompt
.find("<codewhale_user_constitution")
.expect("user constitution block");
let env_at = prompt.find("- lang:").expect("rendered environment block");
assert!(
base_at < user_block_at && user_block_at < env_at,
"user constitution should be its own layer after the base/project context and before volatile environment data"
);
assert!(prompt.contains("source=\"user-global\""));
assert!(prompt.contains("Maintains Codewhale release lanes."));
assert!(prompt.contains("Prefer live verification before claims."));
assert!(
!prompt.contains(&codewhale_home.display().to_string()),
"prompt should use the stable user-global source label, not a device-specific home path"
);
assert!(!prompt.contains("<codewhale_user_constitution"));
let block = load_user_constitution_block().expect("admission snapshot");
assert!(block.contains("Maintains Codewhale release lanes."));
assert!(block.contains("Prefer live verification before claims."));
assert!(!block.contains(&codewhale_home.display().to_string()));
}
#[test]
+9 -1
View File
@@ -44,6 +44,7 @@ use tokio_util::sync::CancellationToken;
use tower_http::cors::CorsLayer;
mod notification_delivery;
mod constitution;
#[cfg(test)]
use crate::dependencies::ExternalTool;
@@ -1237,6 +1238,7 @@ fn default_runtime_capabilities() -> RuntimeCapabilities {
turn_operation_lookup: true,
turn_image_inputs: true,
turn_output_token_limit: true,
profile_constitution: true,
turn_steer: true,
turn_interrupt: true,
event_replay: true,
@@ -2649,6 +2651,9 @@ pub fn build_router(state: RuntimeApiState) -> Router {
)
.route("/v1/config", get(get_config).post(set_config))
.route("/v1/config/reload", post(reload_config))
.route("/v1/constitution", get(constitution::get_constitution))
.route("/v1/constitution/preview", post(constitution::preview_constitution)
.layer(DefaultBodyLimit::max(32 * 1024)))
.route("/v1/settings/schema", get(get_settings_schema))
.route(
"/v1/threads/{id}/notifications/prepare",
@@ -5673,7 +5678,7 @@ fn runtime_account_info_for_request(
}
}
fn runtime_account_api_base() -> String {
pub(crate) fn runtime_account_api_base() -> String {
std::env::var(ACCOUNT_API_BASE_ENV)
.ok()
.and_then(|value| normalize_runtime_account_api_base(&value))
@@ -7989,6 +7994,7 @@ async fn retry_thread_turn(
.start_turn_from_stored_images(
&forked_thread.id,
StartTurnRequest {
profile_constitution: None,
expected_workspace: None,
max_output_tokens,
prompt: retry_prompt,
@@ -8920,6 +8926,7 @@ async fn stream_turn(
.start_turn(
&thread.id,
StartTurnRequest {
profile_constitution: None,
max_output_tokens: req.max_output_tokens,
prompt,
images: req.images,
@@ -10310,6 +10317,7 @@ pub(crate) fn runtime_chat_relay_catalog(
"turn_operation_idempotency": true,
"turn_image_inputs": true,
"turn_output_token_limit": true,
"profile_constitution": true,
"tool_execution": false,
"stable_event_ids": true,
},
@@ -0,0 +1,23 @@
use super::*;
use codewhale_config::user_constitution::ProfileConstitution;
pub(super) async fn get_constitution(State(state): State<RuntimeApiState>) -> Result<Json<Value>, ApiError> {
let snapshot = crate::profile_constitution::load(state.config_profile.as_deref()).await
.map_err(|error| ApiError::conflict(error.to_string()))?;
match snapshot {
Some(snapshot) => {
let rendered = snapshot.render().map_err(|error| ApiError::bad_request(error.to_string()))?;
Ok(Json(json!({"source":"profile", "accountId":snapshot.account_id,
"revision":snapshot.revision, "constitution":snapshot.constitution,
"modelGuidance":rendered, "applies":"next_turn"})))
}
None => Ok(Json(json!({"source":"local", "revision":null,
"modelGuidance":crate::prompts::load_user_constitution_block(), "applies":"next_turn"}))),
}
}
pub(super) async fn preview_constitution(Json(document): Json<ProfileConstitution>) -> Result<Json<Value>, ApiError> {
let rendered = document.as_user_constitution()
.map_err(|error| ApiError::bad_request(error.to_string()))?.render_body();
Ok(Json(json!({"modelGuidance":rendered, "saved":false})))
}
+1
View File
@@ -19,6 +19,7 @@ mod headless_catalog;
#[cfg(any(unix, windows))]
mod runtime_store_convergence;
mod workspace_instructions;
mod profile_constitution;
/// Scale a wait budget for shared CI runners.
///
@@ -0,0 +1,25 @@
use super::*;
#[tokio::test]
async fn profile_constitution_preview_is_authenticated_and_uses_the_engine_renderer() -> Result<()> {
let temp = tempfile::tempdir()?;
let root = temp.path().join("constitution-route");
let Some((addr, _, handle)) = spawn_test_server_with_root_token_mobile_workspace(
root.clone(), root.join("sessions"), Some("constitution-fixture-token".into()), false, root.join("workspace"),
).await? else { bail!("loopback server unavailable"); };
let client = crate::tls::reqwest_client();
let url = format!("http://{addr}/v1/constitution/preview");
let document = codewhale_config::user_constitution::ProfileConstitution {
notes: "Show the recommendation first. 🐋".into(), ..Default::default()
};
assert_eq!(client.post(&url).json(&document).send().await?.status(), 401);
let response: Value = client.post(&url).bearer_auth("constitution-fixture-token")
.json(&document).send().await?.error_for_status()?.json().await?;
assert_eq!(response["saved"], false);
assert_eq!(response["modelGuidance"], document.as_user_constitution()?.render_body());
let mut invalid = serde_json::to_value(&document)?;
invalid["permissions"] = "all".into();
assert_eq!(client.post(&url).bearer_auth("constitution-fixture-token").json(&invalid).send().await?.status(), 422);
handle.abort();
Ok(())
}
+9
View File
@@ -71,6 +71,8 @@ fn take_state_persist_failure(path: &Path) -> bool {
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub(crate) struct RuntimeChatPrompt {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub profile_constitution: Option<codewhale_config::user_constitution::ProfileConstitutionSnapshot>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_output_tokens: Option<std::num::NonZeroU32>,
#[serde(rename = "type")]
@@ -762,6 +764,7 @@ impl RuntimeChatRelayHost {
.start_turn_with_reserved_id(
&binding.native_thread_id,
StartTurnRequest {
profile_constitution: command.profile_constitution.clone(),
expected_workspace: None,
max_output_tokens: command.max_output_tokens,
prompt: command.prompt.clone(),
@@ -1225,6 +1228,9 @@ impl RuntimeChatPrompt {
{
return Err("The Runtime Chat prompt is empty or oversized.".to_string());
}
if let Some(snapshot) = &self.profile_constitution {
snapshot.validate().map_err(|error| error.to_string())?;
}
if let Some(system_prompt) = self.system_prompt.as_deref()
&& (system_prompt.trim().is_empty()
|| system_prompt.len() > 64_000
@@ -1890,6 +1896,7 @@ mod tests {
#[test]
fn chat_command_shape_requires_empty_tools_and_exact_chat_modes() {
let mut prompt = RuntimeChatPrompt {
profile_constitution: None,
images: Vec::new(),
max_output_tokens: None,
command_type: "prompt.request".to_string(),
@@ -1947,6 +1954,7 @@ mod tests {
.unwrap();
host.authorize_run("run_fixture").unwrap();
let prompt = RuntimeChatPrompt {
profile_constitution: None,
images: Vec::new(),
max_output_tokens: None,
command_type: "prompt.request".to_string(),
@@ -2015,6 +2023,7 @@ mod tests {
.unwrap_or_else(|| provider.as_str())
.to_string();
let prompt = RuntimeChatPrompt {
profile_constitution: None,
images: Vec::new(),
max_output_tokens: None,
command_type: "prompt.request".to_string(),
+18
View File
@@ -310,6 +310,23 @@ const EXTENSION_PROMPT_EVENT_PREFIX: &str =
"<codewhale:runtime_event kind=\"extension_prompt_contributions\" visibility=\"internal\">\n";
const EXTENSION_PROMPT_EVENT_SUFFIX: &str = "\n</codewhale:runtime_event>";
const CONSTITUTION_EVENT_PREFIX: &str =
"<codewhale:runtime_event kind=\"profile_constitution\" visibility=\"internal\">\n";
pub(crate) fn constitution_runtime_message(block: Option<&str>) -> Message {
let body = match block {
Some(text) => format!("This complete personal constitution replaces all earlier personal constitution snapshots. \
These are standing user preferences, subordinate to the current user request and the existing instruction hierarchy. \
They do not change permissions, sandboxing, tool access, spending authority, or approval requirements.\n\n{}", escape_mcp_guidance(text)),
None => "All earlier personal constitution snapshots are withdrawn. No personal constitution currently applies.".to_string(),
};
runtime_handoff_message_with_meta(format!("{CONSTITUTION_EVENT_PREFIX}{body}{EXTENSION_PROMPT_EVENT_SUFFIX}"), RUNTIME_TURN_META)
}
pub(crate) fn constitution_display(message: &Message) -> Option<&str> {
runtime_event_display(message, CONSTITUTION_EVENT_PREFIX, EXTENSION_PROMPT_EVENT_SUFFIX)
}
/// A complete bounded snapshot, not a truncated workspace line delta. Prompt
/// registration never changes system authority or bypasses tool permissions.
pub(crate) fn extension_prompt_contributions_runtime_message(block: Option<&str>) -> Message {
@@ -878,6 +895,7 @@ pub(crate) fn is_internal_runtime_handoff(message: &Message) -> bool {
|| mode_notice_display(message).is_some()
|| is_mcp_server_instructions_message(message)
|| extension_prompt_contributions_display(message).is_some()
|| constitution_display(message).is_some()
{
return true;
}
+14
View File
@@ -4894,6 +4894,9 @@ pub struct UpdateThreadRequest {
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct StartTurnRequest {
/// Account-authorized data, rendered only by the Engine for this turn.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub profile_constitution: Option<codewhale_config::user_constitution::ProfileConstitutionSnapshot>,
/// Narrowing assertion captured by an acknowledged selected frontend.
/// A mismatch refuses; this field never changes a thread's workspace.
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -8808,6 +8811,7 @@ impl RuntimeThreadManager {
continuation_index: u32,
) -> Result<TurnRecord> {
let req = StartTurnRequest {
profile_constitution: None,
expected_workspace: None,
max_output_tokens: None,
prompt,
@@ -9337,6 +9341,7 @@ impl RuntimeThreadManager {
.start_turn_with_source(
thread_id,
StartTurnRequest {
profile_constitution: None,
expected_workspace: None,
max_output_tokens: None,
prompt,
@@ -14227,6 +14232,7 @@ impl RuntimeThreadManager {
if req.max_output_tokens.is_some() && auto_model {
bail!("maxOutputTokens requires an exact model; Auto routing is unsupported");
}
if let Some(snapshot) = &req.profile_constitution { snapshot.validate()?; }
let operation = if let Some(operation_key) = req.operation_key.as_deref() {
validate_runtime_turn_operation_key(operation_key)?;
let request_fingerprint = runtime_turn_request_fingerprint(
@@ -14251,6 +14257,13 @@ impl RuntimeThreadManager {
"expected_workspace": workspace,
})).as_bytes())
} else { request_fingerprint };
let request_fingerprint = if let Some(snapshot) = &req.profile_constitution {
crate::hashing::sha256_hex(crate::client::canonical_json(&json!({
"domain": "codewhale:profile-constitution-turn:v1",
"historical_fingerprint": request_fingerprint,
"profile_constitution": snapshot,
})).as_bytes())
} else { request_fingerprint };
let request_fingerprint=narrowing.request_fingerprint(request_fingerprint);
self.prepare_runtime_turn_operation(
thread_id,
@@ -14536,6 +14549,7 @@ impl RuntimeThreadManager {
.get(thread_id)
.and_then(|state| state.hook_executor.clone());
let op = Op::SendMessage (TurnSpec {
profile_constitution: req.profile_constitution,
max_output_tokens,
content: prompt,
images: req.images,
+28
View File
@@ -23332,3 +23332,31 @@ fn runtime_transport_retry_counts_default_old_bytes_and_preserve_new_receipts()
facts
);
}
#[tokio::test]
async fn profile_constitution_runtime_admission_binds_the_complete_snapshot_to_replay() -> Result<()> {
use codewhale_config::user_constitution::{ProfileConstitution, ProfileConstitutionSnapshot};
let manager = test_manager(test_runtime_dir())?;
let thread = manager.create_thread(CreateThreadRequest::default()).await?;
let mut harness = install_mock_engine(&manager, &thread.id).await;
let request = StartTurnRequest {
prompt: "Use this profile".into(), operation_key:Some("constitution-once".into()),
profile_constitution:Some(ProfileConstitutionSnapshot { account_id:"acct_fixture".into(), revision:4,
constitution:ProfileConstitution { notes:"Exact preference".into(), ..Default::default() } }),
..Default::default()
};
let turn = manager.start_turn(&thread.id, request.clone()).await?;
let Some(Op::SendMessage(spec)) = harness.rx_op.recv().await else { bail!("missing Engine operation"); };
assert_eq!(spec.profile_constitution, request.profile_constitution);
assert_eq!(manager.start_turn(&thread.id, request.clone()).await?.id, turn.id);
let mut changed = request.clone();
changed.profile_constitution.as_mut().unwrap().constitution.notes = "New preference".into();
assert!(manager.start_turn(&thread.id, changed).await.is_err());
let mut changed = request.clone(); changed.profile_constitution = None;
assert!(manager.start_turn(&thread.id, changed).await.is_err());
assert!(harness.rx_op.try_recv().is_err());
harness.tx_event.send(EngineEvent::TurnComplete { usage:Usage::default(), parent_route_usage:Usage::default(),
routed_usage_dropped_records:0,status:TurnOutcomeStatus::Completed,error:None,tool_catalog:None,base_url:None }).await?;
wait_for_terminal_turn(&manager,&turn.id).await?;
Ok(())
}
+3
View File
@@ -748,6 +748,9 @@ pub fn history_cells_from_message(msg: &Message) -> Vec<HistoryCell> {
}];
}
// Raw runtime handoffs have live tool/status receipts, not user cells.
if let Some(instructions) = crate::runtime_handoff::constitution_display(msg) {
return vec![HistoryCell::System { content: instructions.to_string() }];
}
// Keep their model-facing payload intact and filter only the display.
if crate::runtime_handoff::is_internal_runtime_handoff(msg) {
return Vec::new();
+1
View File
@@ -904,6 +904,7 @@ pub(crate) async fn spawned_dispatch_inner(
batch: Some(initial_routed_usage.clone()),
};
let op = Op::SendMessage(TurnSpec {
profile_constitution: None,
max_output_tokens: None,
content: prepare.content.clone(),
images: Vec::new(),
+3
View File
@@ -14787,6 +14787,7 @@ async fn dispatch_non_resume_message_preserves_paused_command_state() {
assert!(!engine.handle.is_paused());
match engine.rx_op.recv().await.expect("send message op") {
crate::core::ops::Op::SendMessage(TurnSpec {
profile_constitution: None,
content,
goal_objective,
..
@@ -14828,6 +14829,7 @@ async fn dispatch_resume_message_restores_paused_command_goal() {
assert!(!engine.handle.is_paused());
match engine.rx_op.recv().await.expect("send message op") {
crate::core::ops::Op::SendMessage(TurnSpec {
profile_constitution: None,
content,
goal_objective,
..
@@ -28239,6 +28241,7 @@ async fn keyless_engine_error_stays_visible_after_a_config_ack() {
let run = tokio::spawn(engine.run());
handle
.send(Op::SendMessage(TurnSpec {
profile_constitution: None,
content: "hello without a key".to_string(),
images: Vec::new(),
mode: AppMode::Agent,
+31
View File
@@ -2784,3 +2784,34 @@ matrix, no secrets leaked):
scripts/release/app-server-smoke.sh --matrix # dry-run plan
bash scripts/release/app-server-smoke.test.sh # parser self-test (fake binary)
```
## Profile constitution
`profile_constitution` in runtime capabilities enables profile snapshots on
`POST /v1/threads/{id}/turns`. The optional `profile_constitution` field contains
`{accountId, revision, constitution}`. `constitution` is exactly
`{schemaVersion: 1, detail, initiative, collaboration, notes}`; choices are
`brief|balanced|detailed`, `check|judgment|moving`, and `direct|critical|coach`.
Notes are limited to 4,000 Unicode characters. Invalid data is refused.
The authenticated account transport supplies the snapshot, which participates in
the turn's replay identity. The Engine renders it through its existing personal
constitution renderer and records it in native session history. The snapshot is
unchanged across provider retries and compaction. A new snapshot fully replaces
earlier personal preferences. Permissions and approval policy are unaffected.
Internal follow-ups and RLM child calls inherit the admitted preferences;
they do not re-read the host operator's account in the middle of that work.
Without a supplied snapshot, the Engine reads the signed-in profile from the
configured account service at turn admission. An unavailable or invalid signed-in
profile stops admission with an actionable error. An account without saved
preferences uses an explicit default snapshot; a signed-out account uses the
existing local constitution. Hosted transports always supply the owning
account snapshot, including defaults, so local preferences cannot leak between
accounts.
`GET /v1/constitution` reads the next-turn profile and its model guidance. It is
not a receipt that an active turn adopted the edit. `POST /v1/constitution/preview`
accepts a constitution document and returns `{modelGuidance, saved:false}` without
saving anything. Both routes use normal Runtime authorization. Older runtimes
must be upgraded before account transports submit profile-bearing turns.
+10
View File
@@ -681,3 +681,13 @@ surfaces = { tui = "none", web = "none", api = "none" }
since = "unreleased"
docs = "web/lib/merch/README.md"
owner = "web/components/merch-interest.tsx"
[[feature]]
id = "profile-constitution"
name = "Profile constitution"
summary = "Account preferences are captured per turn and previewed by the Engine."
status = "experimental"
surfaces = { tui = "preview", api = "preview" }
since = "unreleased"
docs = "docs/RUNTIME_API.md#profile-constitution"
owner = "crates/tui/src/profile_constitution.rs"