mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-09 09:59:45 -10:00
refactor(split): provider pre-cuts into codewhale-models and runtime (S22, CW-6129) [skip ci]
Into codewhale-models: `schema_sanitize` (production extent re-measured: old lines 1-448 + 982-1988, incl. `sanitize_for_kimi_parameters`), `schema_canonicalize`, `turn_classify` (wire-compaction checkpoint and prompt classifier closure; models, not core, because core cannot name Message), `image_window` (pure image helpers), `tool_projection` (S22 owns it, not S33) and `reasoning_effort_tier` (from work_graph). Into codewhale-runtime: `stall_report`, and `safe_receipt_field` / `sanitize_persisted_route_label` from cost_status (cuts provider_catalog_live -> cost_status). `client::redact_url_for_display` wrapper removed. Not done in this slice: image_attach decode helpers and the P6-2 back-edge cuts. Implemented on Boat VM vm4 by the split-s22-provider session. Series verification (S14 + S29/S30 + S22 integrated on0cf390f01, Linux x86_64 Boat VM vm3), then rebased cleanly onto25225ef051: - cargo check --workspace --all-targets --locked: exit 0, no warnings - codewhale-util 49, codewhale-models 106, codewhale-runtime 849, codewhale-execpolicy 237, codewhale-tools 26, codewhale-protocol 78, codewhale-agent-contract 14 passed, 0 failed; feature_registry 2 passed - codewhale-tui lib: 14381 passed, 16 failed, 22 ignored. 15 of the 16 fail identically on unmodified0cf390f01on Linux (MCP/extension-host tests needing bwrap on that VM, StepFun pricing and model-picker tests from7d2308ac81); the 16th (full_access_auto_approves_non_bypassable_registered_tools) passed 5/5 when rerun alone (timing under full load). Not run: macOS tui suite on this exact series, Windows, clippy. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
19616707f8
commit
c5cd7c87f0
75 files changed
+1003
-933
No files matched your search
@@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
- **`codewhale-util` crate:** shared utilities, confined workspace file access, link and file-identity checks, bundle file readers, the `.git` name rule and the owner file lock move out of `codewhale-tui` into a new crate; no behavior change. (CW-6129)
|
||||
- **Crate-split contract hygiene:** the `canonical_action` policy table now lives in `codewhale-execpolicy`, `required_action`, the file path-alias folding and the `apply_patch` preflight parser in `codewhale-tools`, and `DEFAULT_SPAWN_DEPTH`, `PermissionCeiling` and `ShellCeiling` in `codewhale_protocol::fleet`, so `codewhale-agent-contract` no longer depends on `codewhale-config` or `codewhale-workflow`. Approval and authority behavior is unchanged. (CW-6129)
|
||||
- **Provider pre-cuts (internal):** schema sanitizing, prompt-turn classification, image-window capping, tool-projection warning names and the reasoning-effort tier moved into `codewhale-models`; stall records and route-label sanitizing moved into `codewhale-runtime`. No behavior change. (CW-6129)
|
||||
- **Change mode and permissions while the agent works:** Tab, Shift+Tab, `/mode`, the hotbar and `/config mode` no longer refuse while a turn is running. A response that is already streaming is not restarted; the change applies at the agent's next step in the same turn (before its next tool batch, at every approval gate, and before its next model request, which carries the new mode notice). The status line says so until it applies. A call still waiting for approval goes back to the model under the new posture, and a call you already approved runs only if the new posture is equal or broader. Model, thinking level and provider stay locked until the turn ends. (CW-7552)
|
||||
- **Steering interrupts thinking:** a steer (Ctrl+Enter, or Enter twice after queueing) now closes the in-flight model request at once, whether the model is waiting on headers, reasoning, writing text or emitting a tool call, and continues the same turn with your message. Text already shown is kept as interrupted output; unfinished reasoning and partial tool calls are discarded, never run. (CW-7552)
|
||||
- **Switching provider lands on its current model:** `/provider stepfun` now selects Step 5 Preview instead of the older Step 3.7 Flash; a model you saved for a provider is still kept. `/model` now lists the current provider's own models (bundled and live, newest first, up to 20) instead of only the selected one. Search also matches ids typed without separators (`step5`). While you search, Shift+P and Shift+F type their letters rather than pinning or adding to Fleet, so `Step-5-Preview` can be typed in full; Ctrl+P and Ctrl+F pin or add the highlighted row at any time.
|
||||
|
||||
Generated
+2
@@ -1097,8 +1097,10 @@ version = "0.10.2"
|
||||
dependencies = [
|
||||
"codewhale-config",
|
||||
"codewhale-core",
|
||||
"codewhale-sanitize",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -17,5 +17,7 @@ workspace = true
|
||||
[dependencies]
|
||||
codewhale-config = { path = "../config", version = "0.10.2" }
|
||||
codewhale-core = { path = "../core", version = "0.10.2" }
|
||||
codewhale-sanitize = { path = "../sanitize", version = "0.10.2" }
|
||||
serde.workspace = true
|
||||
serde_json = { workspace = true, features = ["preserve_order"] }
|
||||
thiserror.workspace = true
|
||||
@@ -0,0 +1,134 @@
|
||||
use crate::{ContentBlock, Message};
|
||||
|
||||
/// Split a `data:<media-type>;base64,<payload>` URL.
|
||||
///
|
||||
/// Anthropic's Messages API models an image as a tagged `source` rather than a
|
||||
/// URL, so the native route has to take the data URL back apart. Returns
|
||||
/// `None` for `http(s)` URLs and for anything malformed, which the caller
|
||||
/// renders as a remote source or a visible degradation respectively.
|
||||
#[must_use]
|
||||
pub fn parse_data_url(url: &str) -> Option<(&str, &str)> {
|
||||
let rest = url.strip_prefix("data:")?;
|
||||
let (header, payload) = rest.split_once(',')?;
|
||||
let media_type = header.strip_suffix(";base64")?;
|
||||
if media_type.is_empty() || payload.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some((media_type, payload))
|
||||
}
|
||||
|
||||
/// Whether a URL is one a provider can fetch for itself.
|
||||
#[must_use]
|
||||
pub fn is_remote_image_url(url: &str) -> bool {
|
||||
url.starts_with("https://") || url.starts_with("http://")
|
||||
}
|
||||
|
||||
pub const ELIDED_IMAGE_NOTE: &str = "[Image not attached to this request: this model accepts only a limited number of images per request, so the oldest are left out. It remains in the session; do not guess what it showed.]";
|
||||
|
||||
pub const ELIDED_TOOL_IMAGE_NOTE: &str = "[Tool image not attached to this request: this model accepts only a limited number of images per request, so the oldest are left out; do not guess what it showed. Take a fresh observation (for example a new screenshot) if you need the current state; do not repeat an action to recover its image.]";
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub struct ImageWindow {
|
||||
pub total: usize,
|
||||
pub elided: usize,
|
||||
pub cap: usize,
|
||||
}
|
||||
|
||||
fn block_carries_image(block: &ContentBlock) -> bool {
|
||||
match block {
|
||||
ContentBlock::ImageUrl { .. } => true,
|
||||
ContentBlock::ToolResult { content_blocks, .. } => {
|
||||
content_blocks.as_ref().is_some_and(|blocks| {
|
||||
blocks.iter().any(|value| {
|
||||
value.get("type").and_then(serde_json::Value::as_str) == Some("image")
|
||||
})
|
||||
})
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn count_request_images(messages: &[Message]) -> usize {
|
||||
messages
|
||||
.iter()
|
||||
.flat_map(|message| &message.content)
|
||||
.filter(|block| block_carries_image(block))
|
||||
.count()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn images_to_elide(total: usize, cap: usize) -> usize {
|
||||
if total <= cap {
|
||||
return 0;
|
||||
}
|
||||
let chunk = (cap / 4).max(1);
|
||||
((total - cap).div_ceil(chunk) * chunk).min(total)
|
||||
}
|
||||
|
||||
pub fn cap_request_images(messages: &mut [Message], cap: usize) -> ImageWindow {
|
||||
let total = count_request_images(messages);
|
||||
let mut window = cap_request_images_with_notes(
|
||||
messages,
|
||||
total - images_to_elide(total, cap),
|
||||
ELIDED_IMAGE_NOTE,
|
||||
ELIDED_TOOL_IMAGE_NOTE,
|
||||
);
|
||||
window.cap = cap;
|
||||
window
|
||||
}
|
||||
|
||||
pub fn cap_request_images_with_notes(
|
||||
messages: &mut [Message],
|
||||
cap: usize,
|
||||
image_note: &str,
|
||||
tool_note: &str,
|
||||
) -> ImageWindow {
|
||||
let total = count_request_images(messages);
|
||||
let elided = total.saturating_sub(cap);
|
||||
let mut remaining = elided;
|
||||
for block in messages.iter_mut().flat_map(|message| &mut message.content) {
|
||||
if remaining == 0 {
|
||||
break;
|
||||
}
|
||||
if !block_carries_image(block) {
|
||||
continue;
|
||||
}
|
||||
match block {
|
||||
ContentBlock::ImageUrl { .. } => {
|
||||
*block = ContentBlock::Text {
|
||||
text: image_note.to_string(),
|
||||
cache_control: None,
|
||||
};
|
||||
}
|
||||
ContentBlock::ToolResult {
|
||||
content,
|
||||
content_blocks,
|
||||
..
|
||||
} => {
|
||||
if let Some(blocks) = content_blocks.as_mut() {
|
||||
blocks.retain(|value| {
|
||||
value.get("type").and_then(serde_json::Value::as_str) != Some("image")
|
||||
});
|
||||
if blocks.is_empty() {
|
||||
*content_blocks = None;
|
||||
}
|
||||
}
|
||||
*content = format!("{content}\n{tool_note}");
|
||||
}
|
||||
_ => continue,
|
||||
}
|
||||
remaining -= 1;
|
||||
}
|
||||
ImageWindow { total, elided, cap }
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn tool_result_text_with_omission(content: &str, omitted: usize) -> String {
|
||||
if omitted == 0 {
|
||||
return content.to_string();
|
||||
}
|
||||
format!(
|
||||
"{content}\n[{omitted} tool-result image block(s) omitted: invalid, unsupported, oversized, or additional.]"
|
||||
)
|
||||
}
|
||||
@@ -2,6 +2,13 @@
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub mod image_window;
|
||||
pub mod reasoning_effort_tier;
|
||||
pub mod schema_canonicalize;
|
||||
pub mod schema_sanitize;
|
||||
pub mod tool_projection;
|
||||
pub mod turn_classify;
|
||||
|
||||
/// Context window used only for legacy DeepSeek model IDs that do not name a
|
||||
/// newer V4 alias and do not carry an explicit `*k` suffix.
|
||||
pub const LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS: u32 = 128_000;
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Canonical reasoning-effort tiers recorded as configuration facts. This is
|
||||
/// deliberately an enum rather than free-form text so Work Graph activity can
|
||||
/// never become a side channel for model reasoning.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ReasoningEffortTier {
|
||||
Off,
|
||||
Minimal,
|
||||
Low,
|
||||
Medium,
|
||||
High,
|
||||
#[serde(rename = "xhigh")]
|
||||
XHigh,
|
||||
Ultra,
|
||||
Auto,
|
||||
Max,
|
||||
/// Thinking is enabled, but the provider route exposes no supported
|
||||
/// effort tiers. This is an effective receipt, never a requested setting.
|
||||
ThinkingEnabledGranularityUnavailable,
|
||||
/// The configured route exposes no verified reasoning-control contract.
|
||||
/// This is an effective receipt, never a requested setting.
|
||||
Unavailable,
|
||||
}
|
||||
File renamed without changes.
@@ -102,7 +102,7 @@ pub fn sanitize_for_responses(schema: &mut Value) -> Option<String> {
|
||||
combine_constraint_notes(constraint_note, dependent_note)
|
||||
}
|
||||
|
||||
pub(crate) fn strict_schema_supported(schema: &Value) -> bool {
|
||||
pub fn strict_schema_supported(schema: &Value) -> bool {
|
||||
let mut normalized = schema.clone();
|
||||
sanitize(&mut normalized);
|
||||
!has_strict_incompatible_composition(&normalized, true)
|
||||
@@ -916,30 +916,6 @@ mod tests {
|
||||
assert_eq!(note, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xai_sanitize_flattens_apply_patch_root_one_of() {
|
||||
// The exact shape that produced the live 400:
|
||||
// "apply_patch: tool parameter root must be an object type (root
|
||||
// schema is an anyOf/oneOf union with a non-object branch)".
|
||||
use crate::tools::spec::ToolSpec as _;
|
||||
let mut schema = crate::tools::apply_patch::ApplyPatchTool.input_schema();
|
||||
assert!(schema.get("oneOf").is_some(), "fixture must match the tool");
|
||||
|
||||
let note = sanitize_for_xai_parameters(&mut schema);
|
||||
|
||||
assert_eq!(schema["type"], "object");
|
||||
assert!(schema.get("oneOf").is_none());
|
||||
assert!(schema.get("anyOf").is_none());
|
||||
assert!(schema["properties"].get("patch").is_some());
|
||||
assert!(schema["properties"].get("changes").is_some());
|
||||
assert_eq!(
|
||||
note.as_deref(),
|
||||
Some(
|
||||
"Exactly one of these parameter groups must be provided: `changes` | `patch` | `replace`."
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn responses_sanitize_plain_object_has_no_constraint_note() {
|
||||
let mut schema = json!({
|
||||
@@ -1989,8 +1965,6 @@ fn validate_mfjs_expanded_depth(
|
||||
#[cfg(test)]
|
||||
mod kimi_tests {
|
||||
use super::*;
|
||||
use crate::tools::apply_patch::ApplyPatchTool;
|
||||
use crate::tools::spec::ToolSpec;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
@@ -2256,27 +2230,6 @@ mod kimi_tests {
|
||||
assert_eq!(schema["properties"]["name"]["type"], "string");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kimi_parameters_flattens_actual_apply_patch_root_and_returns_constraint_note() {
|
||||
let mut schema = ApplyPatchTool.input_schema();
|
||||
|
||||
let note = sanitize_for_kimi_parameters(&mut schema).unwrap();
|
||||
|
||||
assert_eq!(schema["type"], "object");
|
||||
assert!(schema.get("oneOf").is_none());
|
||||
assert!(schema.get("anyOf").is_none());
|
||||
assert!(schema.get("allOf").is_none());
|
||||
assert_eq!(schema["properties"]["patch"]["type"], "string");
|
||||
assert_eq!(schema["properties"]["replace"]["type"], "array");
|
||||
assert_eq!(schema["properties"]["changes"]["type"], "array");
|
||||
assert_eq!(
|
||||
note.as_deref(),
|
||||
Some(
|
||||
"Exactly one of these parameter groups must be provided: `changes` | `patch` | `replace`."
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kimi_parameters_preserves_nested_anyof_branches() {
|
||||
let mut schema = json!({
|
||||
@@ -0,0 +1,36 @@
|
||||
pub const TOOL_PROJECTION_WARNING_MAX_NAMES: usize = 8;
|
||||
pub const TOOL_PROJECTION_WARNING_MAX_NAME_CHARS: usize = 64;
|
||||
|
||||
/// Return a privacy-safe, display-bounded sample of omitted wire tool names.
|
||||
///
|
||||
/// MCP catalogs may contain thousands of tools and their names are supplied by
|
||||
/// external servers. Keep the exact count separately, but never let one route
|
||||
/// diagnostic flood a transcript, toast, or runtime record.
|
||||
#[must_use]
|
||||
pub fn bounded_tool_projection_warning_names(omitted_tool_names: &[String]) -> Vec<String> {
|
||||
omitted_tool_names
|
||||
.iter()
|
||||
.take(TOOL_PROJECTION_WARNING_MAX_NAMES)
|
||||
.map(|name| {
|
||||
let cleaned: String = name
|
||||
.chars()
|
||||
.filter(|c| !c.is_control() && !codewhale_sanitize::sanitize::is_bidi_control(*c))
|
||||
.collect();
|
||||
let collapsed = cleaned.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||
let collapsed = if collapsed.is_empty() {
|
||||
"<unnamed>".to_string()
|
||||
} else {
|
||||
collapsed
|
||||
};
|
||||
if collapsed.chars().count() <= TOOL_PROJECTION_WARNING_MAX_NAME_CHARS {
|
||||
return collapsed;
|
||||
}
|
||||
let mut out: String = collapsed
|
||||
.chars()
|
||||
.take(TOOL_PROJECTION_WARNING_MAX_NAME_CHARS - 1)
|
||||
.collect();
|
||||
out.push('…');
|
||||
out
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
use crate::{ContentBlock, Message, Role};
|
||||
|
||||
/// Detection marker for a new-format checkpoint: the opening words of
|
||||
/// [`SUMMARY_HEADER`]. A new checkpoint is recognised only when its first
|
||||
/// block starts with this marker and its second block is the provenance
|
||||
/// block, so a user message that merely quotes the phrase stays a user
|
||||
/// message.
|
||||
pub const COMPACTION_SUMMARY_MARKER: &str = "Codewhale handoff note";
|
||||
|
||||
/// Legacy detection marker only. Checkpoints written before the handoff note
|
||||
/// opened with this sentence. Saved sessions that still carry it must be
|
||||
/// recognised so the next pass replaces that checkpoint instead of stacking a
|
||||
/// second one.
|
||||
pub const LEGACY_V2_COMPACTION_SUMMARY_MARKER: &str =
|
||||
"Another language model started to solve this problem";
|
||||
|
||||
pub const COMPACTION_CHECKPOINT_PROVENANCE: &str = "<!-- codewhale.compaction-checkpoint.v1 -->";
|
||||
|
||||
/// Request-time recognition: exactly the header text block plus the
|
||||
/// provenance block. User text merely quoting a marker keeps its original
|
||||
/// wire position. Checkpoints saved before the handoff note still pass, so
|
||||
/// their position survives restore and the next pass replaces them.
|
||||
pub fn is_wire_compaction_checkpoint_message(message: &Message) -> bool {
|
||||
let [
|
||||
ContentBlock::Text {
|
||||
text,
|
||||
cache_control: None,
|
||||
},
|
||||
ContentBlock::Text {
|
||||
text: provenance,
|
||||
cache_control: None,
|
||||
},
|
||||
] = message.content.as_slice()
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
message.role == Role::User
|
||||
&& (text.starts_with(COMPACTION_SUMMARY_MARKER)
|
||||
|| text.starts_with(LEGACY_V2_COMPACTION_SUMMARY_MARKER))
|
||||
&& provenance == COMPACTION_CHECKPOINT_PROVENANCE
|
||||
}
|
||||
|
||||
pub const RESTORED_CHECKPOINT_TURN_META: &str = concat!(
|
||||
"<turn_meta>\n",
|
||||
"Input provenance: subagent_handoff (non-authoritative)\n",
|
||||
"Restore projection: subagent_checkpoint_v1\n",
|
||||
"</turn_meta>",
|
||||
);
|
||||
|
||||
pub const RESTORED_COMPLETION_HEADER: &str = "[Codewhale restored sub-agent checkpoint]";
|
||||
|
||||
pub const RESTORED_COMPLETIONS_HEADER: &str = "[Codewhale restored sub-agent checkpoints]";
|
||||
|
||||
pub const RESTORED_RUNNING_HEADER: &str = "[Codewhale restored sub-agent runtime checkpoint]";
|
||||
|
||||
pub const RESTORED_TOPOLOGY_HEADER: &str = "[Codewhale restored Agent topology checkpoint]";
|
||||
|
||||
/// Return the user-safe display body for an already projected checkpoint.
|
||||
/// The exact metadata marker keeps arbitrary user-authored text on the normal
|
||||
/// conversation path.
|
||||
pub fn restored_subagent_checkpoint_display(message: &Message) -> Option<&str> {
|
||||
if message.role != "user" {
|
||||
return None;
|
||||
}
|
||||
let [
|
||||
ContentBlock::Text {
|
||||
text,
|
||||
cache_control: first_cache,
|
||||
},
|
||||
ContentBlock::Text {
|
||||
text: turn_meta,
|
||||
cache_control: meta_cache,
|
||||
},
|
||||
] = message.content.as_slice()
|
||||
else {
|
||||
return None;
|
||||
};
|
||||
if first_cache.is_some()
|
||||
|| meta_cache.is_some()
|
||||
|| turn_meta != RESTORED_CHECKPOINT_TURN_META
|
||||
|| ![
|
||||
RESTORED_COMPLETION_HEADER,
|
||||
RESTORED_COMPLETIONS_HEADER,
|
||||
RESTORED_RUNNING_HEADER,
|
||||
RESTORED_TOPOLOGY_HEADER,
|
||||
]
|
||||
.iter()
|
||||
.any(|header| text.starts_with(header))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
Some(text)
|
||||
}
|
||||
|
||||
/// Classification used when locating a user-authored turn in the session log.
|
||||
///
|
||||
/// Runtime and tool messages are skipped because their provider-compatible
|
||||
/// `role = "user"` is not user authority. Unsupported user content is a real
|
||||
/// turn boundary, however, so callers must not skip it and edit an older turn.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum UserTurnPromptKind {
|
||||
/// Assistant messages, tool results, and runtime-owned control messages.
|
||||
NotPrompt,
|
||||
/// A genuine user turn containing editable text.
|
||||
Editable,
|
||||
/// A genuine user turn without editable text, such as an image-only turn.
|
||||
Unsupported,
|
||||
}
|
||||
|
||||
/// Classify `message` for edit-last-turn and admitted-display handling.
|
||||
#[must_use]
|
||||
pub fn classify_user_turn_prompt(message: &Message) -> UserTurnPromptKind {
|
||||
if message.role != Role::User {
|
||||
return UserTurnPromptKind::NotPrompt;
|
||||
}
|
||||
if message.content.iter().any(|block| {
|
||||
matches!(
|
||||
block,
|
||||
ContentBlock::ToolResult { .. }
|
||||
| ContentBlock::ToolSearchToolResult { .. }
|
||||
| ContentBlock::CodeExecutionToolResult { .. }
|
||||
)
|
||||
}) {
|
||||
return UserTurnPromptKind::NotPrompt;
|
||||
}
|
||||
if is_runtime_owned_user_message(message) || is_wire_compaction_checkpoint_message(message) {
|
||||
return UserTurnPromptKind::NotPrompt;
|
||||
}
|
||||
|
||||
let turn_metadata_index = turn_metadata_text(message).map(|(index, _)| index);
|
||||
if message.content.iter().enumerate().any(|(index, block)| {
|
||||
Some(index) != turn_metadata_index && matches!(block, ContentBlock::Text { .. })
|
||||
}) {
|
||||
UserTurnPromptKind::Editable
|
||||
} else {
|
||||
UserTurnPromptKind::Unsupported
|
||||
}
|
||||
}
|
||||
|
||||
/// True when a `role = "user"` message is runtime-owned rather than
|
||||
/// user-authored. Runtime authority is accepted only from the engine-owned
|
||||
/// structural `<turn_meta>` block, never from arbitrary user text that happens
|
||||
/// to resemble a runtime envelope or metadata marker.
|
||||
pub fn is_runtime_owned_user_message(message: &Message) -> bool {
|
||||
restored_subagent_checkpoint_display(message).is_some()
|
||||
|| has_non_authoritative_turn_provenance(message)
|
||||
}
|
||||
|
||||
/// Return engine-owned metadata in either the current trailing shape or the
|
||||
/// historical leading shape. Requiring a separate prompt block prevents a
|
||||
/// user who submits `<turn_meta>…</turn_meta>` as ordinary text from minting
|
||||
/// authority.
|
||||
pub fn turn_metadata_text(message: &Message) -> Option<(usize, &str)> {
|
||||
if message.content.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
if let Some(ContentBlock::Text {
|
||||
text,
|
||||
cache_control: None,
|
||||
}) = message.content.last()
|
||||
{
|
||||
let trimmed = text.trim();
|
||||
if is_complete_turn_metadata(trimmed) {
|
||||
return Some((message.content.len() - 1, trimmed));
|
||||
}
|
||||
}
|
||||
// Sessions written before the metadata-tail migration used
|
||||
// `[turn_meta, prompt, ...]`. Match the same conservative legacy shape as
|
||||
// the transcript renderer: a metadata envelope first and ordinary text
|
||||
// last. A single user-authored metadata example is never hidden.
|
||||
let ContentBlock::Text {
|
||||
text,
|
||||
cache_control: None,
|
||||
} = message.content.first()?
|
||||
else {
|
||||
return None;
|
||||
};
|
||||
let trimmed = text.trim();
|
||||
let trailing_text_is_ordinary = matches!(
|
||||
message.content.last(),
|
||||
Some(ContentBlock::Text { text, .. }) if !is_complete_turn_metadata(text.trim())
|
||||
);
|
||||
(is_complete_turn_metadata(trimmed) && trailing_text_is_ordinary).then_some((0, trimmed))
|
||||
}
|
||||
|
||||
fn is_complete_turn_metadata(text: &str) -> bool {
|
||||
text.starts_with("<turn_meta>") && text.ends_with("</turn_meta>")
|
||||
}
|
||||
|
||||
/// Recognize both the current condensed provenance line and the legacy
|
||||
/// provenance/authority pair. Any explicitly non-authoritative provenance is
|
||||
/// runtime-owned; this stays correct as new provenance variants are added.
|
||||
fn has_non_authoritative_turn_provenance(message: &Message) -> bool {
|
||||
let Some((_, metadata)) = turn_metadata_text(message) else {
|
||||
return false;
|
||||
};
|
||||
let mut has_provenance = false;
|
||||
let mut condensed_non_authoritative = false;
|
||||
let mut legacy_non_authoritative = false;
|
||||
for line in metadata.lines().map(str::trim) {
|
||||
if let Some(value) = line.strip_prefix("Input provenance: ") {
|
||||
has_provenance = true;
|
||||
condensed_non_authoritative |= value.ends_with(" (non-authoritative)");
|
||||
}
|
||||
legacy_non_authoritative |= line == "Input authority: non_authoritative";
|
||||
}
|
||||
condensed_non_authoritative || (has_provenance && legacy_non_authoritative)
|
||||
}
|
||||
@@ -42,6 +42,7 @@ pub mod safe_label;
|
||||
pub mod session_tree;
|
||||
pub mod skill_state;
|
||||
pub mod sleep_guard;
|
||||
pub mod stall_report;
|
||||
pub mod startup_trace;
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
pub mod test_support;
|
||||
|
||||
@@ -569,3 +569,163 @@ mod tests {
|
||||
assert!(!safe.contains('\n'));
|
||||
}
|
||||
}
|
||||
|
||||
const MAX_RECEIPT_FIELD_CHARS: usize = 96;
|
||||
|
||||
pub fn safe_receipt_field(raw: &str) -> String {
|
||||
let sanitized = sanitize_persisted_route_label(raw);
|
||||
let mut out = String::with_capacity(raw.len().min(MAX_RECEIPT_FIELD_CHARS));
|
||||
let mut previous_separator = false;
|
||||
for ch in sanitized.chars() {
|
||||
if out.chars().count() >= MAX_RECEIPT_FIELD_CHARS {
|
||||
break;
|
||||
}
|
||||
let safe = if ch.is_alphanumeric() || matches!(ch, '.' | '_' | '-' | '/' | ':' | '+') {
|
||||
ch
|
||||
} else {
|
||||
'_'
|
||||
};
|
||||
let separator = safe == '_';
|
||||
if separator && previous_separator {
|
||||
continue;
|
||||
}
|
||||
out.push(safe);
|
||||
previous_separator = separator;
|
||||
}
|
||||
if out.is_empty() { "-".to_string() } else { out }
|
||||
}
|
||||
|
||||
pub fn sanitize_persisted_route_label(raw: &str) -> String {
|
||||
const MAX_PERSISTED_ROUTE_LABEL_CHARS: usize = 256;
|
||||
let value = raw.trim();
|
||||
let lower = value.to_ascii_lowercase();
|
||||
|
||||
if value.is_empty() {
|
||||
return "-".to_string();
|
||||
}
|
||||
|
||||
// URLs are not route labels. Endpoints have a dedicated, validated hash
|
||||
// field; persisting a URL here risks leaking userinfo, query credentials,
|
||||
// or fragments through a custom provider/model name.
|
||||
if value.contains("://") {
|
||||
return "redacted-url".to_string();
|
||||
}
|
||||
|
||||
let authorization_value = ["bearer ", "basic ", "digest ", "token ", "apikey "]
|
||||
.iter()
|
||||
.any(|scheme| lower.starts_with(scheme))
|
||||
|| lower.contains("authorization:")
|
||||
|| lower.contains("proxy-authorization:");
|
||||
if authorization_value {
|
||||
return "redacted-credential".to_string();
|
||||
}
|
||||
|
||||
// Reject credential assignments regardless of common casing or separator:
|
||||
// FOO_API_KEY=..., access-token:..., password = ....
|
||||
for (index, ch) in value.char_indices() {
|
||||
if !matches!(ch, '=' | ':') {
|
||||
continue;
|
||||
}
|
||||
let name = lower[..index]
|
||||
.trim()
|
||||
.trim_matches(|ch: char| matches!(ch, '"' | '\'' | '{' | '[' | ','));
|
||||
let name = name.rsplit([' ', ',', ';']).next().unwrap_or(name);
|
||||
let normalized = name.replace('-', "_");
|
||||
if normalized.ends_with("api_key")
|
||||
|| normalized.ends_with("token")
|
||||
|| normalized.ends_with("secret")
|
||||
|| normalized.ends_with("password")
|
||||
|| normalized.ends_with("passwd")
|
||||
{
|
||||
return "redacted-credential".to_string();
|
||||
}
|
||||
}
|
||||
|
||||
// Common credential token prefixes. These are intentionally checked at
|
||||
// word boundaries so model ids containing an incidental "sk" survive.
|
||||
let credential_prefix = lower
|
||||
.split(|ch: char| ch.is_whitespace() || matches!(ch, '=' | ':' | ',' | ';' | '"' | '\''))
|
||||
.filter(|part| !part.is_empty())
|
||||
.any(|part| {
|
||||
[
|
||||
"sk-",
|
||||
"sk_",
|
||||
"rk-",
|
||||
"pk-",
|
||||
"ghp_",
|
||||
"gho_",
|
||||
"ghu_",
|
||||
"ghs_",
|
||||
"github_pat_",
|
||||
"hf_",
|
||||
"glpat-",
|
||||
"xoxb-",
|
||||
"xoxp-",
|
||||
"xoxa-",
|
||||
"akia",
|
||||
"aiza",
|
||||
"eyj",
|
||||
]
|
||||
.iter()
|
||||
.any(|prefix| part.starts_with(prefix))
|
||||
});
|
||||
if credential_prefix {
|
||||
return "redacted-credential".to_string();
|
||||
}
|
||||
|
||||
let windows_absolute = value.as_bytes().get(1) == Some(&b':')
|
||||
&& value
|
||||
.as_bytes()
|
||||
.get(2)
|
||||
.is_some_and(|separator| matches!(separator, b'/' | b'\\'));
|
||||
let contains_local_root = [
|
||||
"/users/",
|
||||
"/volumes/",
|
||||
"/home/",
|
||||
"/private/",
|
||||
"\\users\\",
|
||||
"file://",
|
||||
"/.ssh/",
|
||||
"\\.ssh\\",
|
||||
]
|
||||
.iter()
|
||||
.any(|needle| lower.contains(needle));
|
||||
let looks_like_relative_path = value.contains('\\')
|
||||
|| lower.starts_with(".ssh/")
|
||||
|| lower.starts_with(".ssh\\")
|
||||
|| lower.split('/').any(|segment| {
|
||||
matches!(
|
||||
segment,
|
||||
"." | ".."
|
||||
| ".ssh"
|
||||
| ".config"
|
||||
| "secrets"
|
||||
| "secret"
|
||||
| "credentials"
|
||||
| "credential"
|
||||
| "relative"
|
||||
| "workspace"
|
||||
| "tmp"
|
||||
)
|
||||
});
|
||||
if std::path::Path::new(value).is_absolute()
|
||||
|| windows_absolute
|
||||
|| value.starts_with("~/")
|
||||
|| value.starts_with("./")
|
||||
|| value.starts_with("../")
|
||||
|| contains_local_root
|
||||
|| looks_like_relative_path
|
||||
{
|
||||
return "redacted-local-path".to_string();
|
||||
}
|
||||
let bounded: String = value
|
||||
.chars()
|
||||
.filter(|ch| !ch.is_control())
|
||||
.take(MAX_PERSISTED_ROUTE_LABEL_CHARS)
|
||||
.collect();
|
||||
if bounded.is_empty() {
|
||||
"-".to_string()
|
||||
} else {
|
||||
bounded
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
||||
/// One detected stall episode.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct StallReport {
|
||||
/// Which watchdog saw it (`engine`, `ui`, `client`).
|
||||
pub source: &'static str,
|
||||
pub phase: String,
|
||||
pub detail: Option<String>,
|
||||
pub turn_id: Option<String>,
|
||||
/// Provider response/request id when the stream reported one, else the
|
||||
/// route label the request went to.
|
||||
pub provider_request: Option<String>,
|
||||
pub since_progress: Duration,
|
||||
pub bound: Option<Duration>,
|
||||
}
|
||||
|
||||
impl StallReport {
|
||||
/// One user-facing line: where it stalled and what to do.
|
||||
#[must_use]
|
||||
pub fn status_line(&self) -> String {
|
||||
let mut line = format!(
|
||||
"Turn stalled {} — no progress for {}s",
|
||||
self.phase,
|
||||
self.since_progress.as_secs()
|
||||
);
|
||||
if let Some(detail) = self.detail.as_deref().filter(|d| !d.is_empty()) {
|
||||
line.push_str(&format!(" ({detail})"));
|
||||
}
|
||||
line.push_str(". Press Esc to cancel and retry.");
|
||||
line
|
||||
}
|
||||
|
||||
fn record_body(&self) -> String {
|
||||
let timestamp = chrono::Utc::now().to_rfc3339();
|
||||
let bound = self.bound.map_or_else(
|
||||
|| "none (parked)".to_string(),
|
||||
|b| format!("{}s", b.as_secs()),
|
||||
);
|
||||
format!(
|
||||
"Kind: turn-stall\nSource: {source}\nTimestamp: {timestamp}\nPhase: {phase}\n\
|
||||
Detail: {detail}\nTurn: {turn}\nProvider request: {request}\n\
|
||||
No progress for: {since}s\nPhase bound: {bound}\n",
|
||||
source = self.source,
|
||||
phase = self.phase,
|
||||
detail = self.detail.as_deref().unwrap_or("-"),
|
||||
turn = self.turn_id.as_deref().unwrap_or("-"),
|
||||
request = self.provider_request.as_deref().unwrap_or("-"),
|
||||
since = self.since_progress.as_secs(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(test, all(feature = "test-thread-scoped-state", debug_assertions)))]
|
||||
thread_local! {
|
||||
static TEST_STALL_RECORD_DIR: std::cell::RefCell<Option<PathBuf>> =
|
||||
const { std::cell::RefCell::new(None) };
|
||||
}
|
||||
|
||||
/// Route stall records for the current test thread into `dir` (tests never
|
||||
/// write into the real `~/.codewhale/crashes`).
|
||||
#[cfg(any(test, all(feature = "test-thread-scoped-state", debug_assertions)))]
|
||||
pub fn set_test_stall_record_dir(dir: Option<PathBuf>) {
|
||||
TEST_STALL_RECORD_DIR.with(|slot| *slot.borrow_mut() = dir);
|
||||
}
|
||||
|
||||
/// The selected profile's crash directory, shared with panic dumps.
|
||||
pub fn stall_record_dir() -> Option<PathBuf> {
|
||||
#[cfg(any(test, all(feature = "test-thread-scoped-state", debug_assertions)))]
|
||||
{
|
||||
TEST_STALL_RECORD_DIR.with(|slot| slot.borrow().clone())
|
||||
}
|
||||
#[cfg(not(any(test, all(feature = "test-thread-scoped-state", debug_assertions))))]
|
||||
{
|
||||
codewhale_config::codewhale_home()
|
||||
.ok()
|
||||
.map(|home| home.join("crashes"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Log a stall and write its record to `crashes/<timestamp>-turn-stall-<source>.log`.
|
||||
/// Best effort; returns the record path when a record directory exists. The
|
||||
/// write runs on its own short-lived thread so no caller (engine task or UI
|
||||
/// event loop) blocks a runtime worker on disk I/O (#6149).
|
||||
pub fn report_stall(report: &StallReport) -> Option<PathBuf> {
|
||||
let path = stall_record_dir().map(|dir| {
|
||||
let stamp = chrono::Utc::now().format("%Y%m%dT%H%M%S%.3fZ");
|
||||
dir.join(format!("{stamp}-turn-stall-{}.log", report.source))
|
||||
});
|
||||
if let Some(path) = path.clone() {
|
||||
let body = report.record_body();
|
||||
let writer = std::thread::spawn(move || {
|
||||
if let Some(dir) = path.parent() {
|
||||
let _ = std::fs::create_dir_all(dir);
|
||||
}
|
||||
let _ = std::fs::write(&path, body);
|
||||
});
|
||||
// Tests read the record right after reporting.
|
||||
#[cfg(any(test, all(feature = "test-thread-scoped-state", debug_assertions)))]
|
||||
let _ = writer.join();
|
||||
#[cfg(not(any(test, all(feature = "test-thread-scoped-state", debug_assertions))))]
|
||||
drop(writer);
|
||||
}
|
||||
let message = format!(
|
||||
"turn stall ({source}): phase={phase} since_progress={since}s bound={bound:?} turn={turn} request={request} detail={detail} record={record}",
|
||||
source = report.source,
|
||||
phase = report.phase,
|
||||
since = report.since_progress.as_secs(),
|
||||
bound = report.bound.map(|b| b.as_secs()),
|
||||
turn = report.turn_id.as_deref().unwrap_or("-"),
|
||||
request = report.provider_request.as_deref().unwrap_or("-"),
|
||||
detail = report.detail.as_deref().unwrap_or("-"),
|
||||
record = path
|
||||
.as_deref()
|
||||
.map_or_else(|| "-".to_string(), |p| p.display().to_string()),
|
||||
);
|
||||
tracing::warn!(target: "turn_stall", "{message}");
|
||||
crate::logging::warn(&message);
|
||||
path
|
||||
}
|
||||
@@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
- **`codewhale-util` crate:** shared utilities, confined workspace file access, link and file-identity checks, bundle file readers, the `.git` name rule and the owner file lock move out of `codewhale-tui` into a new crate; no behavior change. (CW-6129)
|
||||
- **Crate-split contract hygiene:** the `canonical_action` policy table now lives in `codewhale-execpolicy`, `required_action`, the file path-alias folding and the `apply_patch` preflight parser in `codewhale-tools`, and `DEFAULT_SPAWN_DEPTH`, `PermissionCeiling` and `ShellCeiling` in `codewhale_protocol::fleet`, so `codewhale-agent-contract` no longer depends on `codewhale-config` or `codewhale-workflow`. Approval and authority behavior is unchanged. (CW-6129)
|
||||
- **Provider pre-cuts (internal):** schema sanitizing, prompt-turn classification, image-window capping, tool-projection warning names and the reasoning-effort tier moved into `codewhale-models`; stall records and route-label sanitizing moved into `codewhale-runtime`. No behavior change. (CW-6129)
|
||||
- **Change mode and permissions while the agent works:** Tab, Shift+Tab, `/mode`, the hotbar and `/config mode` no longer refuse while a turn is running. A response that is already streaming is not restarted; the change applies at the agent's next step in the same turn (before its next tool batch, at every approval gate, and before its next model request, which carries the new mode notice). The status line says so until it applies. A call still waiting for approval goes back to the model under the new posture, and a call you already approved runs only if the new posture is equal or broader. Model, thinking level and provider stay locked until the turn ends. (CW-7552)
|
||||
- **Steering interrupts thinking:** a steer (Ctrl+Enter, or Enter twice after queueing) now closes the in-flight model request at once, whether the model is waiting on headers, reasoning, writing text or emitting a tool call, and continues the same turn with your message. Text already shown is kept as interrupted output; unfinished reasoning and partial tool calls are discarded, never run. (CW-7552)
|
||||
- **Switching provider lands on its current model:** `/provider stepfun` now selects Step 5 Preview instead of the older Step 3.7 Flash; a model you saved for a provider is still kept. `/model` now lists the current provider's own models (bundled and live, newest first, up to 20) instead of only the selected one. Search also matches ids typed without separators (`step5`). While you search, Shift+P and Shift+F type their letters rather than pinning or adding to Fleet, so `Step-5-Preview` can be typed in full; Ctrl+P and Ctrl+F pin or add the highlighted row at any time.
|
||||
|
||||
@@ -538,7 +538,8 @@ impl AcpServer {
|
||||
};
|
||||
if let Some(kind) = kind
|
||||
&& !crate::runtime_handoff::is_internal_runtime_handoff(message)
|
||||
&& crate::runtime_handoff::restored_subagent_checkpoint_display(message).is_none()
|
||||
&& codewhale_models::turn_classify::restored_subagent_checkpoint_display(message)
|
||||
.is_none()
|
||||
{
|
||||
let mut chunks = Vec::<String>::new();
|
||||
let mut coalesce = false;
|
||||
|
||||
@@ -49,6 +49,7 @@ use codewhale_models::{
|
||||
ContentBlock, Message, MessageRequest, MessageResponse, SystemPrompt, Usage,
|
||||
};
|
||||
use codewhale_runtime::logging;
|
||||
use codewhale_secrets::sanitize::redact_url_for_display;
|
||||
|
||||
/// Every provider request that can feed the interactive TUI's attached CWC run
|
||||
/// takes a shared permit at this lowest common dispatch seam. Runtime Chat holds
|
||||
@@ -1274,15 +1275,6 @@ fn validate_base_url_security(base_url: &str, provider_allows_insecure_http: boo
|
||||
)
|
||||
}
|
||||
|
||||
/// Mask credentials in a URL for display.
|
||||
///
|
||||
/// Delegates to the single shared implementation in
|
||||
/// [`codewhale_secrets::sanitize`] (FEAT-025 D4) so command sanitization and
|
||||
/// client diagnostics cannot drift.
|
||||
pub(crate) fn redact_url_for_display(url: &str) -> String {
|
||||
codewhale_secrets::sanitize::redact_url_for_display(url)
|
||||
}
|
||||
|
||||
pub(super) fn versioned_base_url(base_url: &str) -> String {
|
||||
let trimmed = base_url.trim_end_matches('/');
|
||||
if base_url_has_version_suffix(trimmed) {
|
||||
@@ -3013,12 +3005,12 @@ impl CodewhaleClient {
|
||||
if original_bytes <= prepared::REQUEST_BODY_BYTE_TRIGGER {
|
||||
return Ok(prepared);
|
||||
}
|
||||
let total = crate::image_attach::count_request_images(&request.messages);
|
||||
let total = codewhale_models::image_window::count_request_images(&request.messages);
|
||||
if total == 0 {
|
||||
return Ok(prepared);
|
||||
}
|
||||
let mut candidate = request.clone();
|
||||
crate::image_attach::cap_request_images_with_notes(
|
||||
codewhale_models::image_window::cap_request_images_with_notes(
|
||||
&mut candidate.messages,
|
||||
0,
|
||||
prepared::REQUEST_BODY_IMAGE_NOTE,
|
||||
@@ -3032,7 +3024,7 @@ impl CodewhaleClient {
|
||||
while retained + 1 < rejected {
|
||||
let keep = retained + (rejected - retained) / 2;
|
||||
let mut candidate = request.clone();
|
||||
crate::image_attach::cap_request_images_with_notes(
|
||||
codewhale_models::image_window::cap_request_images_with_notes(
|
||||
&mut candidate.messages,
|
||||
keep,
|
||||
prepared::REQUEST_BODY_IMAGE_NOTE,
|
||||
@@ -3049,10 +3041,11 @@ impl CodewhaleClient {
|
||||
}
|
||||
}
|
||||
}
|
||||
let chunk_retained = total - crate::image_attach::images_to_elide(total, retained);
|
||||
let chunk_retained =
|
||||
total - codewhale_models::image_window::images_to_elide(total, retained);
|
||||
if chunk_retained < retained {
|
||||
let mut candidate = request.clone();
|
||||
crate::image_attach::cap_request_images_with_notes(
|
||||
codewhale_models::image_window::cap_request_images_with_notes(
|
||||
&mut candidate.messages,
|
||||
chunk_retained,
|
||||
prepared::REQUEST_BODY_IMAGE_NOTE,
|
||||
@@ -4863,7 +4856,7 @@ impl CodewhaleClient {
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
let window = crate::image_attach::cap_request_images_with_notes(
|
||||
let window = codewhale_models::image_window::cap_request_images_with_notes(
|
||||
&mut request.messages,
|
||||
0,
|
||||
prepared::REQUEST_BODY_413_IMAGE_NOTE,
|
||||
@@ -5041,7 +5034,7 @@ impl LlmClient for CodewhaleClient {
|
||||
let omitted_tool_count = prepared.omitted_tool_names.len();
|
||||
(
|
||||
prepared.endpoint.provider_display.clone(),
|
||||
crate::core::events::bounded_tool_projection_warning_names(
|
||||
codewhale_models::tool_projection::bounded_tool_projection_warning_names(
|
||||
&prepared.omitted_tool_names,
|
||||
),
|
||||
omitted_tool_count,
|
||||
|
||||
@@ -29,8 +29,8 @@ use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::llm_client::StreamEventBox;
|
||||
use crate::tools::schema_sanitize;
|
||||
use codewhale_config::ProviderKind;
|
||||
use codewhale_models::schema_sanitize;
|
||||
use codewhale_models::{ContentBlock, MessageRequest, MessageResponse, StreamEvent, Usage};
|
||||
use codewhale_runtime::config::wire_model_for_provider_route;
|
||||
use codewhale_runtime::logging;
|
||||
@@ -814,13 +814,13 @@ pub(super) fn message_to_anthropic(message: &codewhale_models::Message) -> Optio
|
||||
/// confusing failure this whole path exists to avoid, so the data URL is taken
|
||||
/// back apart into `{"type":"base64", media_type, data}`.
|
||||
fn anthropic_image_block(url: &str) -> Value {
|
||||
if let Some((media_type, data)) = crate::image_attach::parse_data_url(url) {
|
||||
if let Some((media_type, data)) = codewhale_models::image_window::parse_data_url(url) {
|
||||
return json!({
|
||||
"type": "image",
|
||||
"source": { "type": "base64", "media_type": media_type, "data": data },
|
||||
});
|
||||
}
|
||||
if crate::image_attach::is_remote_image_url(url) {
|
||||
if codewhale_models::image_window::is_remote_image_url(url) {
|
||||
return json!({
|
||||
"type": "image",
|
||||
"source": { "type": "url", "url": url },
|
||||
@@ -841,7 +841,7 @@ pub(super) fn anthropic_tool_result_content(
|
||||
content_blocks: Option<&[Value]>,
|
||||
) -> Value {
|
||||
let (image, omitted) = crate::image_attach::provider_tool_result_image_refs(content_blocks);
|
||||
let content = crate::image_attach::tool_result_text_with_omission(content, omitted);
|
||||
let content = codewhale_models::image_window::tool_result_text_with_omission(content, omitted);
|
||||
let Some((mime_type, data)) = image else {
|
||||
return json!(content);
|
||||
};
|
||||
@@ -2761,9 +2761,7 @@ mod tests {
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
crate::core::engine::turn_heartbeat::set_test_stall_record_dir(Some(
|
||||
dir.path().to_path_buf(),
|
||||
));
|
||||
codewhale_runtime::stall_report::set_test_stall_record_dir(Some(dir.path().to_path_buf()));
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.expect("bind");
|
||||
@@ -2815,7 +2813,7 @@ mod tests {
|
||||
assert_eq!(records.len(), 1, "{records:?}");
|
||||
assert!(records[0].contains("first byte"), "{}", records[0]);
|
||||
server.abort();
|
||||
crate::core::engine::turn_heartbeat::set_test_stall_record_dir(None);
|
||||
codewhale_runtime::stall_report::set_test_stall_record_dir(None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -1110,7 +1110,7 @@ fn sanitize_moonshot_chat_tools(chat_tools: &mut Vec<Value>) -> Vec<String> {
|
||||
let Some(parameters) = function.get_mut("parameters") else {
|
||||
return true;
|
||||
};
|
||||
match crate::tools::schema_sanitize::sanitize_for_kimi_parameters(parameters) {
|
||||
match codewhale_models::schema_sanitize::sanitize_for_kimi_parameters(parameters) {
|
||||
Ok(note) => {
|
||||
if let Some(note) = note {
|
||||
let description = function
|
||||
@@ -1244,7 +1244,7 @@ pub(crate) fn build_chat_wire_body(
|
||||
continue;
|
||||
};
|
||||
let note = function.get_mut("parameters").and_then(|parameters| {
|
||||
crate::tools::schema_sanitize::sanitize_for_xai_parameters(parameters)
|
||||
codewhale_models::schema_sanitize::sanitize_for_xai_parameters(parameters)
|
||||
});
|
||||
if let Some(note) = note
|
||||
&& let Some(description) = function
|
||||
@@ -2762,7 +2762,7 @@ fn build_chat_messages_with_reasoning(
|
||||
.rev()
|
||||
.find_map(|(index, message)| {
|
||||
(index > 0
|
||||
&& crate::compaction::is_wire_compaction_checkpoint_message(message)
|
||||
&& codewhale_models::turn_classify::is_wire_compaction_checkpoint_message(message)
|
||||
&& messages[index - 1]
|
||||
.content
|
||||
.iter()
|
||||
@@ -2773,8 +2773,8 @@ fn build_chat_messages_with_reasoning(
|
||||
messages[..summary_index]
|
||||
.iter()
|
||||
.rposition(|message| {
|
||||
crate::runtime_handoff::classify_user_turn_prompt(message)
|
||||
!= crate::runtime_handoff::UserTurnPromptKind::NotPrompt
|
||||
codewhale_models::turn_classify::classify_user_turn_prompt(message)
|
||||
!= codewhale_models::turn_classify::UserTurnPromptKind::NotPrompt
|
||||
})
|
||||
.or_else(|| {
|
||||
messages[..summary_index]
|
||||
@@ -2999,7 +2999,9 @@ fn build_chat_messages_with_reasoning(
|
||||
}
|
||||
if (Some(message_index) == summary_index
|
||||
|| Some(message_index) == summary_target
|
||||
|| crate::compaction::is_wire_compaction_checkpoint_message(message)
|
||||
|| codewhale_models::turn_classify::is_wire_compaction_checkpoint_message(
|
||||
message,
|
||||
)
|
||||
|| crate::runtime_handoff::is_agent_topology_checkpoint(message)
|
||||
|| crate::runtime_handoff::is_restored_agent_topology_checkpoint(message))
|
||||
&& let Some(previous) = out.last_mut()
|
||||
@@ -3044,7 +3046,9 @@ fn build_chat_messages_with_reasoning(
|
||||
Some(&content_blocks),
|
||||
);
|
||||
let content =
|
||||
crate::image_attach::tool_result_text_with_omission(&content, omitted);
|
||||
codewhale_models::image_window::tool_result_text_with_omission(
|
||||
&content, omitted,
|
||||
);
|
||||
let wire_result = compact_tool_result_for_wire(
|
||||
&tool_info.tool_name,
|
||||
&tool_info.input,
|
||||
@@ -6748,8 +6752,8 @@ mod image_block_wire_tests {
|
||||
fn quoted_compaction_marker_does_not_reorder_user_wire_messages() {
|
||||
// The current marker, and the legacy one older sessions still carry.
|
||||
for marker in [
|
||||
crate::compaction::COMPACTION_SUMMARY_MARKER,
|
||||
crate::compaction::LEGACY_V2_COMPACTION_SUMMARY_MARKER,
|
||||
codewhale_models::turn_classify::COMPACTION_SUMMARY_MARKER,
|
||||
codewhale_models::turn_classify::LEGACY_V2_COMPACTION_SUMMARY_MARKER,
|
||||
] {
|
||||
let quote = format!("Please explain: {marker}");
|
||||
let messages: Vec<Message> = serde_json::from_value(serde_json::json!([
|
||||
|
||||
@@ -114,7 +114,7 @@ impl RouteShape {
|
||||
/// Which endpoint this request would be POSTed to, as typed facts.
|
||||
///
|
||||
/// `url` is the real, unredacted target: production needs it to send. Every
|
||||
/// display surface must go through [`super::redact_url_for_display`] rather
|
||||
/// display surface must go through [`codewhale_secrets::sanitize::redact_url_for_display`] rather
|
||||
/// than printing it, and the manifest only ever publishes the redacted
|
||||
/// scheme/host and a fingerprint — never the path, which can itself carry a
|
||||
/// deployment secret.
|
||||
|
||||
@@ -11,8 +11,8 @@ use anyhow::{Context, Result};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::llm_client::StreamEventBox;
|
||||
use crate::tools::schema_sanitize;
|
||||
use codewhale_config::ProviderKind;
|
||||
use codewhale_models::schema_sanitize;
|
||||
use codewhale_models::{
|
||||
ContentBlock, ContentBlockStart, Delta, MessageDelta, MessageRequest, MessageResponse,
|
||||
OpaqueReasoningState, StreamEvent, Tool, Usage,
|
||||
@@ -790,7 +790,7 @@ impl CodewhaleClient {
|
||||
|
||||
pub(super) fn responses_tool_output(content: &str, content_blocks: Option<&[Value]>) -> Value {
|
||||
let (image, omitted) = crate::image_attach::provider_tool_result_image_refs(content_blocks);
|
||||
let content = crate::image_attach::tool_result_text_with_omission(content, omitted);
|
||||
let content = codewhale_models::image_window::tool_result_text_with_omission(content, omitted);
|
||||
let Some((mime_type, data)) = image else {
|
||||
return json!(content);
|
||||
};
|
||||
|
||||
@@ -91,17 +91,15 @@ pub(crate) fn body_timeout_message(
|
||||
} else {
|
||||
"waiting for the next stream chunk"
|
||||
};
|
||||
crate::core::engine::turn_heartbeat::report_stall(
|
||||
&crate::core::engine::turn_heartbeat::StallReport {
|
||||
source: "client",
|
||||
phase: format!("while {phase}"),
|
||||
detail: Some(provider.to_string()),
|
||||
turn_id: None,
|
||||
provider_request: None,
|
||||
since_progress: since_last_chunk,
|
||||
bound: Some(timeout),
|
||||
},
|
||||
);
|
||||
codewhale_runtime::stall_report::report_stall(&codewhale_runtime::stall_report::StallReport {
|
||||
source: "client",
|
||||
phase: format!("while {phase}"),
|
||||
detail: Some(provider.to_string()),
|
||||
turn_id: None,
|
||||
provider_request: None,
|
||||
since_progress: since_last_chunk,
|
||||
bound: Some(timeout),
|
||||
});
|
||||
message
|
||||
}
|
||||
|
||||
|
||||
@@ -435,7 +435,7 @@
|
||||
parameters["properties"]["query"].get("nullable").is_none(),
|
||||
"{parameters}"
|
||||
);
|
||||
crate::tools::schema_sanitize::validate_mfjs_parameters(parameters).unwrap();
|
||||
codewhale_models::schema_sanitize::validate_mfjs_parameters(parameters).unwrap();
|
||||
}
|
||||
|
||||
async fn assert_kimi_code_captures_exact_general_child_catalog() {
|
||||
@@ -463,7 +463,7 @@
|
||||
// production errors remain fixed and non-secret.
|
||||
for tool in &tools {
|
||||
let mut parameters = tool.input_schema.clone();
|
||||
crate::tools::schema_sanitize::sanitize_for_kimi_parameters(&mut parameters)
|
||||
codewhale_models::schema_sanitize::sanitize_for_kimi_parameters(&mut parameters)
|
||||
.unwrap_or_else(|error| panic!("General child tool {}: {error}", tool.name));
|
||||
}
|
||||
|
||||
@@ -504,7 +504,7 @@
|
||||
tool["function"]["name"]
|
||||
);
|
||||
}
|
||||
crate::tools::schema_sanitize::validate_mfjs_parameters(parameters).unwrap();
|
||||
codewhale_models::schema_sanitize::validate_mfjs_parameters(parameters).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -192,8 +192,8 @@ pub(in crate::commands) fn undo_conversation_for_engine(app: &mut App) -> DebugC
|
||||
let mut sync = session_sync_payload(app);
|
||||
if let Some(index) = sync.messages.iter().rposition(|message| {
|
||||
!matches!(
|
||||
crate::runtime_handoff::classify_user_turn_prompt(message),
|
||||
crate::runtime_handoff::UserTurnPromptKind::NotPrompt
|
||||
codewhale_models::turn_classify::classify_user_turn_prompt(message),
|
||||
codewhale_models::turn_classify::UserTurnPromptKind::NotPrompt
|
||||
)
|
||||
}) {
|
||||
sync.messages.truncate(index);
|
||||
@@ -229,13 +229,13 @@ fn request_prompts(app: &App) -> Vec<Option<String>> {
|
||||
.iter()
|
||||
.filter(|message| {
|
||||
!matches!(
|
||||
crate::runtime_handoff::classify_user_turn_prompt(message),
|
||||
crate::runtime_handoff::UserTurnPromptKind::NotPrompt
|
||||
codewhale_models::turn_classify::classify_user_turn_prompt(message),
|
||||
codewhale_models::turn_classify::UserTurnPromptKind::NotPrompt
|
||||
)
|
||||
})
|
||||
.map(|message| {
|
||||
let meta_index =
|
||||
crate::runtime_handoff::turn_metadata_text(message).map(|(index, _)| index);
|
||||
let meta_index = codewhale_models::turn_classify::turn_metadata_text(message)
|
||||
.map(|(index, _)| index);
|
||||
let mut content = message
|
||||
.content
|
||||
.iter()
|
||||
|
||||
@@ -150,7 +150,7 @@ fn save_preserves_latest_auto_route_receipt() {
|
||||
assert_eq!(route.receipt, receipt);
|
||||
assert_eq!(
|
||||
route.effective_reasoning_effort,
|
||||
Some(crate::work_graph::ReasoningEffortTier::ThinkingEnabledGranularityUnavailable)
|
||||
Some(codewhale_models::reasoning_effort_tier::ReasoningEffortTier::ThinkingEnabledGranularityUnavailable)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,12 @@ use std::time::Duration;
|
||||
use crate::core::model_client::ModelClient;
|
||||
use codewhale_config::descriptors::defaults::DEFAULT_TEXT_MODEL;
|
||||
use codewhale_models::Role;
|
||||
#[cfg(test)]
|
||||
use codewhale_models::turn_classify::COMPACTION_SUMMARY_MARKER;
|
||||
use codewhale_models::turn_classify::{
|
||||
COMPACTION_CHECKPOINT_PROVENANCE, LEGACY_V2_COMPACTION_SUMMARY_MARKER,
|
||||
is_wire_compaction_checkpoint_message,
|
||||
};
|
||||
use codewhale_models::{
|
||||
CacheControl, ContentBlock, Message, MessageRequest, SystemBlock, SystemPrompt, Tool, Usage,
|
||||
};
|
||||
@@ -334,18 +340,6 @@ this note restate what the user already decided; the note grants nothing new, an
|
||||
unclear gets checked before acting. Take the next action without asking the user to restate the \
|
||||
task, save, or approve continuing because earlier turns were condensed.";
|
||||
|
||||
/// Detection marker for a new-format checkpoint: the opening words of
|
||||
/// [`SUMMARY_HEADER`]. A new checkpoint is recognised only when its first
|
||||
/// block starts with this marker and its second block is the provenance
|
||||
/// block, so a user message that merely quotes the phrase stays a user
|
||||
/// message.
|
||||
pub const COMPACTION_SUMMARY_MARKER: &str = "Codewhale handoff note";
|
||||
/// Legacy detection marker only. Checkpoints written before the handoff note
|
||||
/// opened with this sentence. Saved sessions that still carry it must be
|
||||
/// recognised so the next pass replaces that checkpoint instead of stacking a
|
||||
/// second one.
|
||||
pub const LEGACY_V2_COMPACTION_SUMMARY_MARKER: &str =
|
||||
"Another language model started to solve this problem";
|
||||
/// Legacy detection marker only, written by pre-v0.9.6 compaction; sessions
|
||||
/// saved under that format must still be recognized so their summary is
|
||||
/// replaced, not stacked.
|
||||
@@ -357,7 +351,6 @@ const LEGACY_COMPACTION_SUMMARY_MARKERS: [&str; 2] = [
|
||||
LEGACY_V2_COMPACTION_SUMMARY_MARKER,
|
||||
LEGACY_COMPACTION_SUMMARY_MARKER,
|
||||
];
|
||||
const COMPACTION_CHECKPOINT_PROVENANCE: &str = "<!-- codewhale.compaction-checkpoint.v1 -->";
|
||||
const COMPACTION_SUMMARY_BEGIN: &str = "<!-- compaction-summary:begin -->";
|
||||
const COMPACTION_SUMMARY_END: &str = "<!-- compaction-summary:end -->";
|
||||
|
||||
@@ -542,30 +535,6 @@ pub(crate) fn is_compaction_checkpoint_message_in_era(
|
||||
}
|
||||
}
|
||||
|
||||
/// Request-time recognition: exactly the header text block plus the
|
||||
/// provenance block. User text merely quoting a marker keeps its original
|
||||
/// wire position. Checkpoints saved before the handoff note still pass, so
|
||||
/// their position survives restore and the next pass replaces them.
|
||||
pub(crate) fn is_wire_compaction_checkpoint_message(message: &Message) -> bool {
|
||||
let [
|
||||
ContentBlock::Text {
|
||||
text,
|
||||
cache_control: None,
|
||||
},
|
||||
ContentBlock::Text {
|
||||
text: provenance,
|
||||
cache_control: None,
|
||||
},
|
||||
] = message.content.as_slice()
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
message.role == Role::User
|
||||
&& (text.starts_with(COMPACTION_SUMMARY_MARKER)
|
||||
|| text.starts_with(LEGACY_V2_COMPACTION_SUMMARY_MARKER))
|
||||
&& provenance == COMPACTION_CHECKPOINT_PROVENANCE
|
||||
}
|
||||
|
||||
/// Keep the checkpoint at its original historical boundary on session load.
|
||||
/// Later user turns must remain after the saved compaction boundary.
|
||||
pub(crate) fn restore_compaction_checkpoint(
|
||||
@@ -1605,7 +1574,7 @@ pub(crate) fn retained_user_messages_in_era(
|
||||
break;
|
||||
}
|
||||
if msg.role != Role::User
|
||||
|| crate::runtime_handoff::is_runtime_owned_user_message(msg)
|
||||
|| codewhale_models::turn_classify::is_runtime_owned_user_message(msg)
|
||||
|| (user_text_of(msg).is_none()
|
||||
&& !msg
|
||||
.content
|
||||
@@ -1962,7 +1931,7 @@ async fn create_summary(
|
||||
));
|
||||
}
|
||||
if let Some(cap) = image_cap {
|
||||
let window = crate::image_attach::cap_request_images(&mut request_messages, cap);
|
||||
let window = codewhale_models::image_window::cap_request_images(&mut request_messages, cap);
|
||||
if window.elided > 0 {
|
||||
let message = format!(
|
||||
"Making room: the summary request sends the newest {} of {} images; {} older go as text notes (limit {} per request).",
|
||||
@@ -3602,8 +3571,8 @@ mod tests {
|
||||
let retained = retained_user_messages(std::slice::from_ref(&user), usize::MAX);
|
||||
assert_eq!(retained, vec![user]);
|
||||
assert_eq!(
|
||||
crate::runtime_handoff::classify_user_turn_prompt(&retained[0]),
|
||||
crate::runtime_handoff::UserTurnPromptKind::Editable,
|
||||
codewhale_models::turn_classify::classify_user_turn_prompt(&retained[0]),
|
||||
codewhale_models::turn_classify::UserTurnPromptKind::Editable,
|
||||
);
|
||||
assert_eq!(
|
||||
crate::session_manager::conversation_title_prompt(&retained),
|
||||
|
||||
@@ -129,7 +129,7 @@ pub fn pinned_anchors_text(workspace: Option<&std::path::Path>) -> Option<String
|
||||
|
||||
fn is_plain_user_text(post_provenance: bool, message: &Message) -> bool {
|
||||
!is_compaction_checkpoint_message_in_era(message, post_provenance)
|
||||
&& !crate::runtime_handoff::is_runtime_owned_user_message(message)
|
||||
&& !codewhale_models::turn_classify::is_runtime_owned_user_message(message)
|
||||
&& user_text_of(message).is_some()
|
||||
}
|
||||
|
||||
@@ -598,9 +598,9 @@ pub(super) fn build_replacement_history(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::compaction::{
|
||||
use crate::compaction::compaction_checkpoint_message;
|
||||
use codewhale_models::turn_classify::{
|
||||
COMPACTION_SUMMARY_MARKER, LEGACY_V2_COMPACTION_SUMMARY_MARKER,
|
||||
compaction_checkpoint_message,
|
||||
};
|
||||
use codewhale_models::{ContentBlock, Role};
|
||||
use serde_json::json;
|
||||
|
||||
@@ -4256,14 +4256,15 @@ mod released_provider_serde {
|
||||
model: String,
|
||||
receipt: crate::model_routing::AutoRouteReceipt,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
effective_reasoning_effort: Option<crate::work_graph::ReasoningEffortTier>,
|
||||
effective_reasoning_effort:
|
||||
Option<codewhale_models::reasoning_effort_tier::ReasoningEffortTier>,
|
||||
}
|
||||
#[derive(Serialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
enum LegacyActivity {
|
||||
ReasoningEffortChanged {
|
||||
requested: crate::work_graph::ReasoningEffortTier,
|
||||
effective: crate::work_graph::ReasoningEffortTier,
|
||||
requested: codewhale_models::reasoning_effort_tier::ReasoningEffortTier,
|
||||
effective: codewhale_models::reasoning_effort_tier::ReasoningEffortTier,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
provider_kind: Option<ApiProvider>,
|
||||
provider: String,
|
||||
@@ -4456,8 +4457,9 @@ mod released_provider_serde {
|
||||
AutoRouteDataPath, AutoRouteHeuristicReason, AutoRoutePair, AutoRouteReason,
|
||||
AutoRouteReceipt, AutoRouteScope, AutoRouteTier,
|
||||
};
|
||||
use crate::work_graph::{ReasoningEffortTier, WorkActivityEvent};
|
||||
use crate::work_graph::WorkActivityEvent;
|
||||
use codewhale_config::pricing::{Currency, PricingProvenance};
|
||||
use codewhale_models::reasoning_effort_tier::ReasoningEffortTier;
|
||||
let receipt = AutoRouteReceipt {
|
||||
tier: AutoRouteTier::Selected,
|
||||
pair: AutoRoutePair {
|
||||
@@ -4615,7 +4617,8 @@ mod released_provider_serde {
|
||||
|
||||
#[test]
|
||||
fn released_provider_wrappers_do_not_infer_brand_authority_from_custom_names() {
|
||||
use crate::work_graph::{ReasoningEffortTier, WorkActivityEvent};
|
||||
use crate::work_graph::WorkActivityEvent;
|
||||
use codewhale_models::reasoning_effort_tier::ReasoningEffortTier;
|
||||
for id in ["openai", "CustomA", "customa"] {
|
||||
let event = WorkActivityEvent::ReasoningEffortChanged {
|
||||
requested: ReasoningEffortTier::High,
|
||||
|
||||
@@ -969,7 +969,7 @@ pub(crate) fn compaction_checkpoint_for_restore(
|
||||
// with a stripped system prompt. Only the existing structural
|
||||
// validator can authorize a history block as that checkpoint.
|
||||
messages.iter().rev().find_map(|message| {
|
||||
if !crate::compaction::is_wire_compaction_checkpoint_message(message) {
|
||||
if !codewhale_models::turn_classify::is_wire_compaction_checkpoint_message(message) {
|
||||
return None;
|
||||
}
|
||||
match &message.content[0] {
|
||||
@@ -1869,7 +1869,7 @@ impl Engine {
|
||||
pub(super) async fn note_request_image_window(
|
||||
&mut self,
|
||||
provider: String,
|
||||
window: crate::image_attach::ImageWindow,
|
||||
window: codewhale_models::image_window::ImageWindow,
|
||||
) {
|
||||
let model = self.session.model.clone();
|
||||
let current = (window.elided > 0)
|
||||
|
||||
@@ -235,9 +235,9 @@ impl Engine {
|
||||
&prepared.config.model,
|
||||
);
|
||||
if let Some(cap) = prepared.image_cap {
|
||||
crate::image_attach::cap_request_images(&mut messages, cap);
|
||||
codewhale_models::image_window::cap_request_images(&mut messages, cap);
|
||||
}
|
||||
let sent = crate::image_attach::count_request_images(&messages);
|
||||
let sent = codewhale_models::image_window::count_request_images(&messages);
|
||||
self.learn_image_cap(&prepared.config.model, sent, rejection)
|
||||
}
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ pub(super) const MAX_CONTEXT_RECOVERY_ATTEMPTS: u8 = 2;
|
||||
const TOOL_RESULT_METADATA_SUMMARY_CHARS: usize = 320;
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) use crate::compaction::COMPACTION_SUMMARY_MARKER;
|
||||
pub(super) use codewhale_models::turn_classify::COMPACTION_SUMMARY_MARKER;
|
||||
|
||||
/// What the model sees of one tool result (#6508).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
|
||||
@@ -594,7 +594,7 @@ impl Engine {
|
||||
&model,
|
||||
);
|
||||
if let Some(cap) = image_cap {
|
||||
crate::image_attach::cap_request_images(&mut request.messages, cap);
|
||||
codewhale_models::image_window::cap_request_images(&mut request.messages, cap);
|
||||
}
|
||||
|
||||
let prepared = match route.client.prepare_outbound_request(request, true) {
|
||||
|
||||
@@ -57,9 +57,7 @@ async fn extension_prompt_changes_and_retirement_are_recorded_without_rewriting_
|
||||
display.contains(&block),
|
||||
"the entire admitted snapshot reaches the model"
|
||||
);
|
||||
assert!(crate::runtime_handoff::is_runtime_owned_user_message(
|
||||
recorded
|
||||
));
|
||||
assert!(codewhale_models::turn_classify::is_runtime_owned_user_message(recorded));
|
||||
assert!(
|
||||
engine
|
||||
.refresh_pinned_header_for_turn(&context)
|
||||
|
||||
@@ -55,7 +55,7 @@ async fn sync_session_projects_persisted_subagent_handoff_for_headless_restore()
|
||||
|block| matches!(block, ContentBlock::Text { text, .. } if text == "Keep the original task")
|
||||
));
|
||||
let restored =
|
||||
crate::runtime_handoff::restored_subagent_checkpoint_display(&snapshot.messages[1])
|
||||
codewhale_models::turn_classify::restored_subagent_checkpoint_display(&snapshot.messages[1])
|
||||
.expect("projected headless checkpoint");
|
||||
assert!(restored.contains("agent_headless"));
|
||||
assert!(restored.contains("Checkpoint: engine restore is covered."));
|
||||
|
||||
@@ -25,7 +25,7 @@ async fn trust_warning_is_internal_and_tracks_current_state() {
|
||||
assert!(crate::runtime_handoff::is_internal_runtime_handoff(
|
||||
&warning
|
||||
));
|
||||
assert!(crate::runtime_handoff::is_runtime_owned_user_message(
|
||||
assert!(codewhale_models::turn_classify::is_runtime_owned_user_message(
|
||||
&warning
|
||||
));
|
||||
// The transcript side (no history cell for it) is pinned in
|
||||
@@ -52,7 +52,7 @@ async fn trust_warning_is_internal_and_tracks_current_state() {
|
||||
assert!(!crate::runtime_handoff::is_internal_runtime_handoff(
|
||||
"ed
|
||||
));
|
||||
assert!(!crate::runtime_handoff::is_runtime_owned_user_message(
|
||||
assert!(!codewhale_models::turn_classify::is_runtime_owned_user_message(
|
||||
"ed
|
||||
));
|
||||
engine.session.add_message(prompt.clone());
|
||||
|
||||
@@ -1702,8 +1702,8 @@ pub(super) async fn execute_code_execution_tool(
|
||||
pub(crate) fn prepare_tools_for_strict_mode(tools: &mut [Tool]) -> bool {
|
||||
let mut all_strict = true;
|
||||
for tool in tools {
|
||||
if crate::tools::schema_sanitize::strict_schema_supported(&tool.input_schema) {
|
||||
crate::tools::schema_sanitize::sanitize_for_strict(&mut tool.input_schema);
|
||||
if codewhale_models::schema_sanitize::strict_schema_supported(&tool.input_schema) {
|
||||
codewhale_models::schema_sanitize::sanitize_for_strict(&mut tool.input_schema);
|
||||
tool.strict = Some(true);
|
||||
} else {
|
||||
tool.strict = None;
|
||||
|
||||
@@ -17,10 +17,10 @@
|
||||
//! the stream-chunk timeout.
|
||||
|
||||
use std::fmt;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use codewhale_runtime::stall_report::{StallReport, report_stall};
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::time::Instant;
|
||||
|
||||
@@ -72,125 +72,6 @@ impl fmt::Display for TurnPhase {
|
||||
}
|
||||
}
|
||||
|
||||
/// One detected stall episode.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct StallReport {
|
||||
/// Which watchdog saw it (`engine`, `ui`, `client`).
|
||||
pub source: &'static str,
|
||||
pub phase: String,
|
||||
pub detail: Option<String>,
|
||||
pub turn_id: Option<String>,
|
||||
/// Provider response/request id when the stream reported one, else the
|
||||
/// route label the request went to.
|
||||
pub provider_request: Option<String>,
|
||||
pub since_progress: Duration,
|
||||
pub bound: Option<Duration>,
|
||||
}
|
||||
|
||||
impl StallReport {
|
||||
/// One user-facing line: where it stalled and what to do.
|
||||
#[must_use]
|
||||
pub(crate) fn status_line(&self) -> String {
|
||||
let mut line = format!(
|
||||
"Turn stalled {} — no progress for {}s",
|
||||
self.phase,
|
||||
self.since_progress.as_secs()
|
||||
);
|
||||
if let Some(detail) = self.detail.as_deref().filter(|d| !d.is_empty()) {
|
||||
line.push_str(&format!(" ({detail})"));
|
||||
}
|
||||
line.push_str(". Press Esc to cancel and retry.");
|
||||
line
|
||||
}
|
||||
|
||||
fn record_body(&self) -> String {
|
||||
let timestamp = chrono::Utc::now().to_rfc3339();
|
||||
let bound = self.bound.map_or_else(
|
||||
|| "none (parked)".to_string(),
|
||||
|b| format!("{}s", b.as_secs()),
|
||||
);
|
||||
format!(
|
||||
"Kind: turn-stall\nSource: {source}\nTimestamp: {timestamp}\nPhase: {phase}\n\
|
||||
Detail: {detail}\nTurn: {turn}\nProvider request: {request}\n\
|
||||
No progress for: {since}s\nPhase bound: {bound}\n",
|
||||
source = self.source,
|
||||
phase = self.phase,
|
||||
detail = self.detail.as_deref().unwrap_or("-"),
|
||||
turn = self.turn_id.as_deref().unwrap_or("-"),
|
||||
request = self.provider_request.as_deref().unwrap_or("-"),
|
||||
since = self.since_progress.as_secs(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
static TEST_STALL_RECORD_DIR: std::cell::RefCell<Option<PathBuf>> =
|
||||
const { std::cell::RefCell::new(None) };
|
||||
}
|
||||
|
||||
/// Route stall records for the current test thread into `dir` (tests never
|
||||
/// write into the real `~/.codewhale/crashes`).
|
||||
#[cfg(test)]
|
||||
pub(crate) fn set_test_stall_record_dir(dir: Option<PathBuf>) {
|
||||
TEST_STALL_RECORD_DIR.with(|slot| *slot.borrow_mut() = dir);
|
||||
}
|
||||
|
||||
/// The selected profile's crash directory, shared with panic dumps.
|
||||
fn stall_record_dir() -> Option<PathBuf> {
|
||||
#[cfg(test)]
|
||||
{
|
||||
TEST_STALL_RECORD_DIR.with(|slot| slot.borrow().clone())
|
||||
}
|
||||
#[cfg(not(test))]
|
||||
{
|
||||
codewhale_config::codewhale_home()
|
||||
.ok()
|
||||
.map(|home| home.join("crashes"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Log a stall and write its record to `crashes/<timestamp>-turn-stall-<source>.log`.
|
||||
/// Best effort; returns the record path when a record directory exists. The
|
||||
/// write runs on its own short-lived thread so no caller (engine task or UI
|
||||
/// event loop) blocks a runtime worker on disk I/O (#6149).
|
||||
pub(crate) fn report_stall(report: &StallReport) -> Option<PathBuf> {
|
||||
let path = stall_record_dir().map(|dir| {
|
||||
let stamp = chrono::Utc::now().format("%Y%m%dT%H%M%S%.3fZ");
|
||||
dir.join(format!("{stamp}-turn-stall-{}.log", report.source))
|
||||
});
|
||||
if let Some(path) = path.clone() {
|
||||
let body = report.record_body();
|
||||
let writer = std::thread::spawn(move || {
|
||||
if let Some(dir) = path.parent() {
|
||||
let _ = std::fs::create_dir_all(dir);
|
||||
}
|
||||
let _ = std::fs::write(&path, body);
|
||||
});
|
||||
// Tests read the record right after reporting.
|
||||
#[cfg(test)]
|
||||
let _ = writer.join();
|
||||
#[cfg(not(test))]
|
||||
drop(writer);
|
||||
}
|
||||
let message = format!(
|
||||
"turn stall ({source}): phase={phase} since_progress={since}s bound={bound:?} turn={turn} request={request} detail={detail} record={record}",
|
||||
source = report.source,
|
||||
phase = report.phase,
|
||||
since = report.since_progress.as_secs(),
|
||||
bound = report.bound.map(|b| b.as_secs()),
|
||||
turn = report.turn_id.as_deref().unwrap_or("-"),
|
||||
request = report.provider_request.as_deref().unwrap_or("-"),
|
||||
detail = report.detail.as_deref().unwrap_or("-"),
|
||||
record = path
|
||||
.as_deref()
|
||||
.map_or_else(|| "-".to_string(), |p| p.display().to_string()),
|
||||
);
|
||||
tracing::warn!(target: "turn_stall", "{message}");
|
||||
codewhale_runtime::logging::warn(&message);
|
||||
path
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct HeartbeatState {
|
||||
phase: TurnPhase,
|
||||
@@ -389,10 +270,10 @@ fn spawn_turn_stall_watchdog_every(
|
||||
tick: Duration,
|
||||
) -> tokio::task::JoinHandle<()> {
|
||||
#[cfg(test)]
|
||||
let test_dir = TEST_STALL_RECORD_DIR.with(|slot| slot.borrow().clone());
|
||||
let test_dir = codewhale_runtime::stall_report::stall_record_dir();
|
||||
tokio::spawn(async move {
|
||||
#[cfg(test)]
|
||||
set_test_stall_record_dir(test_dir);
|
||||
codewhale_runtime::stall_report::set_test_stall_record_dir(test_dir);
|
||||
let mut ticker = tokio::time::interval(tick);
|
||||
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
||||
loop {
|
||||
@@ -417,6 +298,7 @@ fn spawn_turn_stall_watchdog_every(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use codewhale_runtime::stall_report::set_test_stall_record_dir;
|
||||
|
||||
fn later(secs: u64) -> Instant {
|
||||
Instant::now() + Duration::from_secs(secs)
|
||||
|
||||
@@ -4694,8 +4694,8 @@ impl Engine {
|
||||
codewhale_runtime::logging::warn(&envelope.message);
|
||||
// #6184: every silent provider wait leaves a
|
||||
// `crashes/` stall record, not only a toast.
|
||||
super::turn_heartbeat::report_stall(
|
||||
&super::turn_heartbeat::StallReport {
|
||||
codewhale_runtime::stall_report::report_stall(
|
||||
&codewhale_runtime::stall_report::StallReport {
|
||||
source: "engine",
|
||||
phase: "while waiting for the next stream event".to_string(),
|
||||
detail: Some(format!(
|
||||
@@ -4773,8 +4773,9 @@ impl Engine {
|
||||
&format!("{e:#}").to_lowercase(),
|
||||
)
|
||||
{
|
||||
let sent =
|
||||
crate::image_attach::count_request_images(&stream_request.messages);
|
||||
let sent = codewhale_models::image_window::count_request_images(
|
||||
&stream_request.messages,
|
||||
);
|
||||
let model = self.session.model.clone();
|
||||
if self.learn_image_cap(&model, sent, rejection).is_some() {
|
||||
terminal_stream_error = true;
|
||||
@@ -5397,8 +5398,9 @@ impl Engine {
|
||||
&& let Some(rejection) =
|
||||
crate::llm_client::image_count_rejection(&message.to_lowercase())
|
||||
{
|
||||
let sent =
|
||||
crate::image_attach::count_request_images(&stream_request.messages);
|
||||
let sent = codewhale_models::image_window::count_request_images(
|
||||
&stream_request.messages,
|
||||
);
|
||||
let model = self.session.model.clone();
|
||||
if self.learn_image_cap(&model, sent, rejection).is_some() {
|
||||
terminal_stream_error = true;
|
||||
|
||||
@@ -337,7 +337,9 @@ impl Engine {
|
||||
&& is_image_input_rejection_message(&message)
|
||||
&& self.active_route_capabilities.image_input != CapabilityState::Unsupported
|
||||
&& !progress.image_rejection_recovered
|
||||
&& crate::image_attach::count_request_images(&stream_request.messages) > 0
|
||||
&& codewhale_models::image_window::count_request_images(
|
||||
&stream_request.messages,
|
||||
) > 0
|
||||
{
|
||||
return self.resend_without_images(progress).await;
|
||||
}
|
||||
@@ -1270,7 +1272,7 @@ impl Engine {
|
||||
rejection: crate::llm_client::ImageCountRejection,
|
||||
error: &anyhow::Error,
|
||||
) -> Option<PhaseResult<AcceptedModelStep>> {
|
||||
let sent = crate::image_attach::count_request_images(&request.messages);
|
||||
let sent = codewhale_models::image_window::count_request_images(&request.messages);
|
||||
let model = self.session.model.clone();
|
||||
let cap = self.learn_image_cap(&model, sent, rejection)?;
|
||||
if cap == 0 {
|
||||
|
||||
@@ -587,7 +587,8 @@ impl Engine {
|
||||
}
|
||||
}
|
||||
if let Some(cap) = image_cap {
|
||||
let window = crate::image_attach::cap_request_images(&mut request.messages, cap);
|
||||
let window =
|
||||
codewhale_models::image_window::cap_request_images(&mut request.messages, cap);
|
||||
self.note_request_image_window(image_provider, window).await;
|
||||
}
|
||||
let request_input = crate::compaction::estimate_request_input_tokens(
|
||||
|
||||
@@ -830,43 +830,6 @@ pub enum Event {
|
||||
},
|
||||
}
|
||||
|
||||
const TOOL_PROJECTION_WARNING_MAX_NAMES: usize = 8;
|
||||
const TOOL_PROJECTION_WARNING_MAX_NAME_CHARS: usize = 64;
|
||||
|
||||
/// Return a privacy-safe, display-bounded sample of omitted wire tool names.
|
||||
///
|
||||
/// MCP catalogs may contain thousands of tools and their names are supplied by
|
||||
/// external servers. Keep the exact count separately, but never let one route
|
||||
/// diagnostic flood a transcript, toast, or runtime record.
|
||||
#[must_use]
|
||||
pub fn bounded_tool_projection_warning_names(omitted_tool_names: &[String]) -> Vec<String> {
|
||||
omitted_tool_names
|
||||
.iter()
|
||||
.take(TOOL_PROJECTION_WARNING_MAX_NAMES)
|
||||
.map(|name| {
|
||||
let cleaned: String = name
|
||||
.chars()
|
||||
.filter(|c| !c.is_control() && !codewhale_sanitize::sanitize::is_bidi_control(*c))
|
||||
.collect();
|
||||
let collapsed = cleaned.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||
let collapsed = if collapsed.is_empty() {
|
||||
"<unnamed>".to_string()
|
||||
} else {
|
||||
collapsed
|
||||
};
|
||||
if collapsed.chars().count() <= TOOL_PROJECTION_WARNING_MAX_NAME_CHARS {
|
||||
return collapsed;
|
||||
}
|
||||
let mut out: String = collapsed
|
||||
.chars()
|
||||
.take(TOOL_PROJECTION_WARNING_MAX_NAME_CHARS - 1)
|
||||
.collect();
|
||||
out.push('…');
|
||||
out
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Render the already-bounded name sample, marking an incomplete sample with
|
||||
/// a language-neutral ellipsis. The exact total remains available in typed
|
||||
/// runtime metadata.
|
||||
@@ -1090,6 +1053,10 @@ pub fn bounded_gate_reason(reason: &str) -> String {
|
||||
#[cfg(test)]
|
||||
mod tool_projection_warning_tests {
|
||||
use super::*;
|
||||
use codewhale_models::tool_projection::{
|
||||
TOOL_PROJECTION_WARNING_MAX_NAME_CHARS, TOOL_PROJECTION_WARNING_MAX_NAMES,
|
||||
bounded_tool_projection_warning_names,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn projection_warning_names_are_count_and_length_bounded() {
|
||||
|
||||
@@ -174,7 +174,7 @@ fn billing_to_wire(billing: &RouteBillingEnvelope) -> wire::RouteBillingEnvelope
|
||||
openrouter_vendor: billing
|
||||
.openrouter_vendor
|
||||
.as_deref()
|
||||
.map(crate::cost_status::sanitize_persisted_route_label),
|
||||
.map(codewhale_runtime::safe_label::sanitize_persisted_route_label),
|
||||
billing_surface: billing.billing_surface.clone(),
|
||||
endpoint_fingerprint: billing.endpoint_fingerprint.clone(),
|
||||
provider_live_pricing: billing
|
||||
@@ -301,7 +301,7 @@ fn mcp_snapshot_to_wire(snapshot: &McpManagerSnapshot) -> wire::McpManagerSnapsh
|
||||
/// `EventMsg::McpSessionBoot`, which every SSE and stream-JSON consumer
|
||||
/// receives and any frame-retaining log keeps.
|
||||
///
|
||||
/// URLs reuse the existing masking in `client::redact_url_for_display`. Stdio
|
||||
/// URLs reuse the existing masking in `codewhale_secrets::sanitize::redact_url_for_display`. Stdio
|
||||
/// keeps the program name and elides the arguments, because a secret there can
|
||||
/// be positional and is not reliably recognizable by key.
|
||||
fn redacted_command_or_url(raw: &str) -> String {
|
||||
@@ -315,7 +315,7 @@ fn redacted_command_or_url(raw: &str) -> String {
|
||||
// mentions a URL — `docker run -e TOKEN=… img --url https://…` — would
|
||||
// reach the wire with its positional secret intact.
|
||||
if !trimmed.is_empty() && !trimmed.contains(char::is_whitespace) && trimmed.contains("://") {
|
||||
return crate::client::redact_url_for_display(trimmed);
|
||||
return codewhale_secrets::sanitize::redact_url_for_display(trimmed);
|
||||
}
|
||||
if let Some((program, rest)) = trimmed.split_once(char::is_whitespace)
|
||||
&& !rest.trim().is_empty()
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
//! below is a separate route/usage copy (never another money counter), and the
|
||||
//! shared test reset clears both stores.
|
||||
|
||||
use codewhale_runtime::safe_label::{safe_receipt_field, sanitize_persisted_route_label};
|
||||
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque};
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
|
||||
@@ -2108,166 +2109,6 @@ pub fn route_receipt(
|
||||
)
|
||||
}
|
||||
|
||||
const MAX_RECEIPT_FIELD_CHARS: usize = 96;
|
||||
|
||||
fn safe_receipt_field(raw: &str) -> String {
|
||||
let sanitized = sanitize_persisted_route_label(raw);
|
||||
let mut out = String::with_capacity(raw.len().min(MAX_RECEIPT_FIELD_CHARS));
|
||||
let mut previous_separator = false;
|
||||
for ch in sanitized.chars() {
|
||||
if out.chars().count() >= MAX_RECEIPT_FIELD_CHARS {
|
||||
break;
|
||||
}
|
||||
let safe = if ch.is_alphanumeric() || matches!(ch, '.' | '_' | '-' | '/' | ':' | '+') {
|
||||
ch
|
||||
} else {
|
||||
'_'
|
||||
};
|
||||
let separator = safe == '_';
|
||||
if separator && previous_separator {
|
||||
continue;
|
||||
}
|
||||
out.push(safe);
|
||||
previous_separator = separator;
|
||||
}
|
||||
if out.is_empty() { "-".to_string() } else { out }
|
||||
}
|
||||
|
||||
pub(crate) fn sanitize_persisted_route_label(raw: &str) -> String {
|
||||
const MAX_PERSISTED_ROUTE_LABEL_CHARS: usize = 256;
|
||||
let value = raw.trim();
|
||||
let lower = value.to_ascii_lowercase();
|
||||
|
||||
if value.is_empty() {
|
||||
return "-".to_string();
|
||||
}
|
||||
|
||||
// URLs are not route labels. Endpoints have a dedicated, validated hash
|
||||
// field; persisting a URL here risks leaking userinfo, query credentials,
|
||||
// or fragments through a custom provider/model name.
|
||||
if value.contains("://") {
|
||||
return "redacted-url".to_string();
|
||||
}
|
||||
|
||||
let authorization_value = ["bearer ", "basic ", "digest ", "token ", "apikey "]
|
||||
.iter()
|
||||
.any(|scheme| lower.starts_with(scheme))
|
||||
|| lower.contains("authorization:")
|
||||
|| lower.contains("proxy-authorization:");
|
||||
if authorization_value {
|
||||
return "redacted-credential".to_string();
|
||||
}
|
||||
|
||||
// Reject credential assignments regardless of common casing or separator:
|
||||
// FOO_API_KEY=..., access-token:..., password = ....
|
||||
for (index, ch) in value.char_indices() {
|
||||
if !matches!(ch, '=' | ':') {
|
||||
continue;
|
||||
}
|
||||
let name = lower[..index]
|
||||
.trim()
|
||||
.trim_matches(|ch: char| matches!(ch, '"' | '\'' | '{' | '[' | ','));
|
||||
let name = name.rsplit([' ', ',', ';']).next().unwrap_or(name);
|
||||
let normalized = name.replace('-', "_");
|
||||
if normalized.ends_with("api_key")
|
||||
|| normalized.ends_with("token")
|
||||
|| normalized.ends_with("secret")
|
||||
|| normalized.ends_with("password")
|
||||
|| normalized.ends_with("passwd")
|
||||
{
|
||||
return "redacted-credential".to_string();
|
||||
}
|
||||
}
|
||||
|
||||
// Common credential token prefixes. These are intentionally checked at
|
||||
// word boundaries so model ids containing an incidental "sk" survive.
|
||||
let credential_prefix = lower
|
||||
.split(|ch: char| ch.is_whitespace() || matches!(ch, '=' | ':' | ',' | ';' | '"' | '\''))
|
||||
.filter(|part| !part.is_empty())
|
||||
.any(|part| {
|
||||
[
|
||||
"sk-",
|
||||
"sk_",
|
||||
"rk-",
|
||||
"pk-",
|
||||
"ghp_",
|
||||
"gho_",
|
||||
"ghu_",
|
||||
"ghs_",
|
||||
"github_pat_",
|
||||
"hf_",
|
||||
"glpat-",
|
||||
"xoxb-",
|
||||
"xoxp-",
|
||||
"xoxa-",
|
||||
"akia",
|
||||
"aiza",
|
||||
"eyj",
|
||||
]
|
||||
.iter()
|
||||
.any(|prefix| part.starts_with(prefix))
|
||||
});
|
||||
if credential_prefix {
|
||||
return "redacted-credential".to_string();
|
||||
}
|
||||
|
||||
let windows_absolute = value.as_bytes().get(1) == Some(&b':')
|
||||
&& value
|
||||
.as_bytes()
|
||||
.get(2)
|
||||
.is_some_and(|separator| matches!(separator, b'/' | b'\\'));
|
||||
let contains_local_root = [
|
||||
"/users/",
|
||||
"/volumes/",
|
||||
"/home/",
|
||||
"/private/",
|
||||
"\\users\\",
|
||||
"file://",
|
||||
"/.ssh/",
|
||||
"\\.ssh\\",
|
||||
]
|
||||
.iter()
|
||||
.any(|needle| lower.contains(needle));
|
||||
let looks_like_relative_path = value.contains('\\')
|
||||
|| lower.starts_with(".ssh/")
|
||||
|| lower.starts_with(".ssh\\")
|
||||
|| lower.split('/').any(|segment| {
|
||||
matches!(
|
||||
segment,
|
||||
"." | ".."
|
||||
| ".ssh"
|
||||
| ".config"
|
||||
| "secrets"
|
||||
| "secret"
|
||||
| "credentials"
|
||||
| "credential"
|
||||
| "relative"
|
||||
| "workspace"
|
||||
| "tmp"
|
||||
)
|
||||
});
|
||||
if std::path::Path::new(value).is_absolute()
|
||||
|| windows_absolute
|
||||
|| value.starts_with("~/")
|
||||
|| value.starts_with("./")
|
||||
|| value.starts_with("../")
|
||||
|| contains_local_root
|
||||
|| looks_like_relative_path
|
||||
{
|
||||
return "redacted-local-path".to_string();
|
||||
}
|
||||
let bounded: String = value
|
||||
.chars()
|
||||
.filter(|ch| !ch.is_control())
|
||||
.take(MAX_PERSISTED_ROUTE_LABEL_CHARS)
|
||||
.collect();
|
||||
if bounded.is_empty() {
|
||||
"-".to_string()
|
||||
} else {
|
||||
bounded
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate and canonicalize an endpoint before producing the cryptographic
|
||||
/// fingerprint persisted in a receipt. Secret-bearing/malformed URLs receive
|
||||
/// no fingerprint at all; userinfo, query strings, and fragments are never fed
|
||||
|
||||
@@ -560,7 +560,8 @@ pub(crate) async fn probe_ds4_models(
|
||||
use crate::client::CodewhaleClient;
|
||||
use crate::core::model_client::ModelClient;
|
||||
|
||||
let endpoint = crate::client::redact_url_for_display(&config.active_route_base_url());
|
||||
let endpoint =
|
||||
codewhale_secrets::sanitize::redact_url_for_display(&config.active_route_base_url());
|
||||
let client = CodewhaleClient::new(config)?;
|
||||
let configured_alias = client.model().to_string();
|
||||
let models = match tokio::time::timeout(
|
||||
@@ -597,7 +598,8 @@ pub(crate) async fn probe_ds4_models(
|
||||
}
|
||||
|
||||
fn ds4_probe_error(config: &codewhale_runtime::config::Config, error: &str) -> String {
|
||||
let endpoint = crate::client::redact_url_for_display(&config.active_route_base_url());
|
||||
let endpoint =
|
||||
codewhale_secrets::sanitize::redact_url_for_display(&config.active_route_base_url());
|
||||
let status = error
|
||||
.split_whitespace()
|
||||
.collect::<Vec<_>>()
|
||||
|
||||
@@ -56,6 +56,7 @@ use std::path::{Path, PathBuf};
|
||||
use base64::{Engine as _, engine::general_purpose::STANDARD};
|
||||
|
||||
use crate::model_profile::SupportState;
|
||||
use codewhale_models::image_window::{parse_data_url, tool_result_text_with_omission};
|
||||
use codewhale_models::{ContentBlock, ImageUrlContent};
|
||||
|
||||
/// Largest source image accepted, in bytes, before base64 expansion.
|
||||
@@ -393,16 +394,6 @@ pub(crate) fn provider_tool_result_image_refs(
|
||||
(image, omitted)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub(crate) fn tool_result_text_with_omission(content: &str, omitted: usize) -> String {
|
||||
if omitted == 0 {
|
||||
return content.to_string();
|
||||
}
|
||||
format!(
|
||||
"{content}\n[{omitted} tool-result image block(s) omitted: invalid, unsupported, oversized, or additional.]"
|
||||
)
|
||||
}
|
||||
|
||||
/// Copy/export projection that retains metadata but never inline base64.
|
||||
#[must_use]
|
||||
pub(crate) fn safe_tool_result_content_blocks(
|
||||
@@ -853,29 +844,6 @@ pub fn images_since_last_user_prompt(messages: &[codewhale_models::Message]) ->
|
||||
.sum()
|
||||
}
|
||||
|
||||
/// Split a `data:<media-type>;base64,<payload>` URL.
|
||||
///
|
||||
/// Anthropic's Messages API models an image as a tagged `source` rather than a
|
||||
/// URL, so the native route has to take the data URL back apart. Returns
|
||||
/// `None` for `http(s)` URLs and for anything malformed, which the caller
|
||||
/// renders as a remote source or a visible degradation respectively.
|
||||
#[must_use]
|
||||
pub fn parse_data_url(url: &str) -> Option<(&str, &str)> {
|
||||
let rest = url.strip_prefix("data:")?;
|
||||
let (header, payload) = rest.split_once(',')?;
|
||||
let media_type = header.strip_suffix(";base64")?;
|
||||
if media_type.is_empty() || payload.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some((media_type, payload))
|
||||
}
|
||||
|
||||
/// Whether a URL is one a provider can fetch for itself.
|
||||
#[must_use]
|
||||
pub fn is_remote_image_url(url: &str) -> bool {
|
||||
url.starts_with("https://") || url.starts_with("http://")
|
||||
}
|
||||
|
||||
/// The outcome of expanding a user turn's attachment placeholders.
|
||||
#[derive(Debug, Clone, Default, PartialEq)]
|
||||
pub struct ExpandedAttachments {
|
||||
@@ -1001,109 +969,6 @@ pub fn strip_images_when_unsupported(
|
||||
stripped
|
||||
}
|
||||
|
||||
pub(crate) const ELIDED_IMAGE_NOTE: &str = "[Image not attached to this request: this model accepts only a limited number of images per request, so the oldest are left out. It remains in the session; do not guess what it showed.]";
|
||||
|
||||
pub(crate) const ELIDED_TOOL_IMAGE_NOTE: &str = "[Tool image not attached to this request: this model accepts only a limited number of images per request, so the oldest are left out; do not guess what it showed. Take a fresh observation (for example a new screenshot) if you need the current state; do not repeat an action to recover its image.]";
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub(crate) struct ImageWindow {
|
||||
pub total: usize,
|
||||
pub elided: usize,
|
||||
pub cap: usize,
|
||||
}
|
||||
|
||||
fn block_carries_image(block: &ContentBlock) -> bool {
|
||||
match block {
|
||||
ContentBlock::ImageUrl { .. } => true,
|
||||
ContentBlock::ToolResult { content_blocks, .. } => {
|
||||
content_blocks.as_ref().is_some_and(|blocks| {
|
||||
blocks.iter().any(|value| {
|
||||
value.get("type").and_then(serde_json::Value::as_str) == Some("image")
|
||||
})
|
||||
})
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub(crate) fn count_request_images(messages: &[codewhale_models::Message]) -> usize {
|
||||
messages
|
||||
.iter()
|
||||
.flat_map(|message| &message.content)
|
||||
.filter(|block| block_carries_image(block))
|
||||
.count()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub(crate) fn images_to_elide(total: usize, cap: usize) -> usize {
|
||||
if total <= cap {
|
||||
return 0;
|
||||
}
|
||||
let chunk = (cap / 4).max(1);
|
||||
((total - cap).div_ceil(chunk) * chunk).min(total)
|
||||
}
|
||||
|
||||
pub(crate) fn cap_request_images(
|
||||
messages: &mut [codewhale_models::Message],
|
||||
cap: usize,
|
||||
) -> ImageWindow {
|
||||
let total = count_request_images(messages);
|
||||
let mut window = cap_request_images_with_notes(
|
||||
messages,
|
||||
total - images_to_elide(total, cap),
|
||||
ELIDED_IMAGE_NOTE,
|
||||
ELIDED_TOOL_IMAGE_NOTE,
|
||||
);
|
||||
window.cap = cap;
|
||||
window
|
||||
}
|
||||
|
||||
pub(crate) fn cap_request_images_with_notes(
|
||||
messages: &mut [codewhale_models::Message],
|
||||
cap: usize,
|
||||
image_note: &str,
|
||||
tool_note: &str,
|
||||
) -> ImageWindow {
|
||||
let total = count_request_images(messages);
|
||||
let elided = total.saturating_sub(cap);
|
||||
let mut remaining = elided;
|
||||
for block in messages.iter_mut().flat_map(|message| &mut message.content) {
|
||||
if remaining == 0 {
|
||||
break;
|
||||
}
|
||||
if !block_carries_image(block) {
|
||||
continue;
|
||||
}
|
||||
match block {
|
||||
ContentBlock::ImageUrl { .. } => {
|
||||
*block = ContentBlock::Text {
|
||||
text: image_note.to_string(),
|
||||
cache_control: None,
|
||||
};
|
||||
}
|
||||
ContentBlock::ToolResult {
|
||||
content,
|
||||
content_blocks,
|
||||
..
|
||||
} => {
|
||||
if let Some(blocks) = content_blocks.as_mut() {
|
||||
blocks.retain(|value| {
|
||||
value.get("type").and_then(serde_json::Value::as_str) != Some("image")
|
||||
});
|
||||
if blocks.is_empty() {
|
||||
*content_blocks = None;
|
||||
}
|
||||
}
|
||||
*content = format!("{content}\n{tool_note}");
|
||||
}
|
||||
_ => continue,
|
||||
}
|
||||
remaining -= 1;
|
||||
}
|
||||
ImageWindow { total, elided, cap }
|
||||
}
|
||||
|
||||
/// Total inline-image byte budget for one compaction retry that follows a
|
||||
/// provider body-size rejection.
|
||||
///
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use super::*;
|
||||
use codewhale_models::Role;
|
||||
use codewhale_models::image_window::is_remote_image_url;
|
||||
|
||||
/// A 1x1 PNG, as bytes rather than a fixture file so the encoding tests
|
||||
/// have no filesystem dependency.
|
||||
|
||||
@@ -15042,7 +15042,7 @@ fn exec_stream_final_answer_text(
|
||||
}
|
||||
let turn_start = messages.iter().rposition(|message| {
|
||||
message.role == Role::User
|
||||
&& !crate::runtime_handoff::is_runtime_owned_user_message(message)
|
||||
&& !codewhale_models::turn_classify::is_runtime_owned_user_message(message)
|
||||
&& !message
|
||||
.content
|
||||
.iter()
|
||||
|
||||
@@ -338,9 +338,10 @@ impl ProviderLivePricingQuote {
|
||||
) -> Option<Self> {
|
||||
let provider_identity = provider_identity.trim();
|
||||
let wire_model = wire_model.trim();
|
||||
if crate::cost_status::sanitize_persisted_route_label(provider_identity)
|
||||
if codewhale_runtime::safe_label::sanitize_persisted_route_label(provider_identity)
|
||||
!= provider_identity
|
||||
|| crate::cost_status::sanitize_persisted_route_label(wire_model) != wire_model
|
||||
|| codewhale_runtime::safe_label::sanitize_persisted_route_label(wire_model)
|
||||
!= wire_model
|
||||
{
|
||||
return None;
|
||||
}
|
||||
@@ -408,9 +409,10 @@ impl ProviderLivePricingQuote {
|
||||
let provider_identity = provider_identity.trim();
|
||||
let wire_model = wire_model.trim();
|
||||
if self.provider != provider
|
||||
|| crate::cost_status::sanitize_persisted_route_label(&self.provider_identity)
|
||||
!= self.provider_identity
|
||||
|| crate::cost_status::sanitize_persisted_route_label(&self.wire_model)
|
||||
|| codewhale_runtime::safe_label::sanitize_persisted_route_label(
|
||||
&self.provider_identity,
|
||||
) != self.provider_identity
|
||||
|| codewhale_runtime::safe_label::sanitize_persisted_route_label(&self.wire_model)
|
||||
!= self.wire_model
|
||||
|| self.endpoint_fingerprint.len() != 64
|
||||
|| !self
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
//! tools can resolve and normalize reasoning effort without depending on
|
||||
//! presentation types.
|
||||
|
||||
use crate::work_graph::ReasoningEffortTier;
|
||||
use codewhale_config::ProviderKind;
|
||||
use codewhale_models::reasoning_effort_tier::ReasoningEffortTier;
|
||||
|
||||
/// Reasoning-effort tier, mirrored across DeepSeek and Codex effort pickers.
|
||||
///
|
||||
|
||||
@@ -150,8 +150,8 @@ pub(crate) fn session_receipt(
|
||||
.iter()
|
||||
.take(outcome.after_message_count)
|
||||
.filter(|message| {
|
||||
crate::runtime_handoff::classify_user_turn_prompt(message)
|
||||
!= crate::runtime_handoff::UserTurnPromptKind::NotPrompt
|
||||
codewhale_models::turn_classify::classify_user_turn_prompt(message)
|
||||
!= codewhale_models::turn_classify::UserTurnPromptKind::NotPrompt
|
||||
})
|
||||
.count();
|
||||
(
|
||||
@@ -458,7 +458,7 @@ pub(crate) fn run_receipts_command(
|
||||
|
||||
/// Tool calls in transcript order, plus each turn's posture. A turn starts at
|
||||
/// a real user prompt, by the same rule edit-last-turn and titles use
|
||||
/// ([`crate::runtime_handoff::classify_user_turn_prompt`]); runtime-injected
|
||||
/// ([`codewhale_models::turn_classify::classify_user_turn_prompt`]); runtime-injected
|
||||
/// messages and tool results do not start one.
|
||||
/// Each turn's prompt text (the user's words, without the `<turn_meta>`
|
||||
/// block) comes back too: it labels the turn's workspace snapshots.
|
||||
@@ -490,8 +490,8 @@ fn steps_from_messages(messages: &[Message]) -> (Vec<ToolStep>, Vec<TurnPosture>
|
||||
let mut turn_prompts: Vec<TurnPrompt> = Vec::new();
|
||||
let mut turn = 0usize;
|
||||
for message in messages {
|
||||
if crate::runtime_handoff::classify_user_turn_prompt(message)
|
||||
!= crate::runtime_handoff::UserTurnPromptKind::NotPrompt
|
||||
if codewhale_models::turn_classify::classify_user_turn_prompt(message)
|
||||
!= codewhale_models::turn_classify::UserTurnPromptKind::NotPrompt
|
||||
{
|
||||
turn += 1;
|
||||
turn_postures.push((turn.to_string(), turn_meta_posture(message)));
|
||||
@@ -554,7 +554,8 @@ fn steps_from_messages(messages: &[Message]) -> (Vec<ToolStep>, Vec<TurnPosture>
|
||||
/// The prompt a user message carries: its text blocks, less the
|
||||
/// `<turn_meta>` block the engine appends.
|
||||
pub(crate) fn prompt_text(message: &Message) -> Option<String> {
|
||||
let meta_index = crate::runtime_handoff::turn_metadata_text(message).map(|(index, _)| index);
|
||||
let meta_index =
|
||||
codewhale_models::turn_classify::turn_metadata_text(message).map(|(index, _)| index);
|
||||
message
|
||||
.content
|
||||
.iter()
|
||||
@@ -706,7 +707,7 @@ fn workspace_relative(path: &str, workspace: Option<&str>) -> String {
|
||||
/// The posture line the engine writes into a prompt's `<turn_meta>` block
|
||||
/// ([`crate::core::engine::PERMISSION_POSTURE_LINE`]).
|
||||
fn turn_meta_posture(message: &Message) -> Option<&'static str> {
|
||||
let (_, meta) = crate::runtime_handoff::turn_metadata_text(message)?;
|
||||
let (_, meta) = codewhale_models::turn_classify::turn_metadata_text(message)?;
|
||||
meta.lines().find_map(|line| {
|
||||
line.trim()
|
||||
.strip_prefix(crate::core::engine::PERMISSION_POSTURE_LINE)
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
//! - the provider enum and the non-secret configured route key,
|
||||
//! - the canonical wire model id,
|
||||
//! - a **normalized, redacted** endpoint identity (URL userinfo and sensitive
|
||||
//! query values masked by [`crate::client::redact_url_for_display`]),
|
||||
//! query values masked by [`codewhale_secrets::sanitize::redact_url_for_display`]),
|
||||
//! - a one-way credential *generation* digest that is never rendered.
|
||||
//!
|
||||
//! The credential generation is deliberately taken over the endpoint **and** the
|
||||
@@ -47,7 +47,7 @@ pub fn endpoint_identity(base_url: &str) -> String {
|
||||
if reqwest::Url::parse(trimmed).is_err() {
|
||||
return OPAQUE_ENDPOINT.to_string();
|
||||
}
|
||||
crate::client::redact_url_for_display(trimmed)
|
||||
codewhale_secrets::sanitize::redact_url_for_display(trimmed)
|
||||
.trim_end_matches('/')
|
||||
.to_string()
|
||||
}
|
||||
|
||||
@@ -11338,7 +11338,7 @@ fn provider_default_model_for_api(
|
||||
}
|
||||
|
||||
pub(crate) fn runtime_chat_model_id_is_safe(value: &str) -> bool {
|
||||
let sanitized = crate::cost_status::sanitize_persisted_route_label(value);
|
||||
let sanitized = codewhale_runtime::safe_label::sanitize_persisted_route_label(value);
|
||||
value == value.trim()
|
||||
&& !value.is_empty()
|
||||
&& value.len() <= 256
|
||||
@@ -11372,7 +11372,7 @@ fn runtime_chat_model_id_looks_like_host_port(value: &str) -> bool {
|
||||
}
|
||||
|
||||
pub(crate) fn runtime_chat_route_id_is_safe(value: &str) -> bool {
|
||||
let sanitized = crate::cost_status::sanitize_persisted_route_label(value);
|
||||
let sanitized = codewhale_runtime::safe_label::sanitize_persisted_route_label(value);
|
||||
value == value.trim()
|
||||
&& !value.is_empty()
|
||||
&& value.len() <= 128
|
||||
|
||||
@@ -8,6 +8,11 @@
|
||||
|
||||
use crate::tools::subagent::{AgentWorkerStatus, SubAgentResult, SubAgentStatus};
|
||||
use codewhale_models::Role;
|
||||
use codewhale_models::turn_classify::{
|
||||
RESTORED_CHECKPOINT_TURN_META, RESTORED_COMPLETION_HEADER, RESTORED_COMPLETIONS_HEADER,
|
||||
RESTORED_RUNNING_HEADER, RESTORED_TOPOLOGY_HEADER, UserTurnPromptKind,
|
||||
classify_user_turn_prompt, restored_subagent_checkpoint_display,
|
||||
};
|
||||
use codewhale_models::{ContentBlock, Message};
|
||||
use codewhale_runtime::safe_label::SafeLabel;
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -86,18 +91,6 @@ const SHELL_COMPLETION_HANDOFF_TURN_META: &str = concat!(
|
||||
"Input provenance: shell_completion (non-authoritative)\n",
|
||||
"</turn_meta>",
|
||||
);
|
||||
const RESTORED_CHECKPOINT_TURN_META: &str = concat!(
|
||||
"<turn_meta>\n",
|
||||
"Input provenance: subagent_handoff (non-authoritative)\n",
|
||||
"Restore projection: subagent_checkpoint_v1\n",
|
||||
"</turn_meta>",
|
||||
);
|
||||
|
||||
const RESTORED_COMPLETION_HEADER: &str = "[Codewhale restored sub-agent checkpoint]";
|
||||
const RESTORED_COMPLETIONS_HEADER: &str = "[Codewhale restored sub-agent checkpoints]";
|
||||
const RESTORED_RUNNING_HEADER: &str = "[Codewhale restored sub-agent runtime checkpoint]";
|
||||
const RESTORED_TOPOLOGY_HEADER: &str = "[Codewhale restored Agent topology checkpoint]";
|
||||
|
||||
const AGENT_TOPOLOGY_EVENT_PREFIX: &str =
|
||||
"<codewhale:runtime_state kind=\"agent_topology\" schema=\"v1\" visibility=\"internal\">\n";
|
||||
const AGENT_TOPOLOGY_EVENT_SUFFIX: &str = "\n</codewhale:runtime_state>";
|
||||
@@ -870,12 +863,12 @@ pub(crate) fn replace_agent_topology_checkpoint(
|
||||
});
|
||||
let ends_with_summary = messages
|
||||
.last()
|
||||
.is_some_and(crate::compaction::is_wire_compaction_checkpoint_message);
|
||||
.is_some_and(codewhale_models::turn_classify::is_wire_compaction_checkpoint_message);
|
||||
let position = if ends_with_tool_result || ends_with_summary {
|
||||
messages
|
||||
.iter()
|
||||
.rposition(|message| {
|
||||
!crate::compaction::is_wire_compaction_checkpoint_message(message)
|
||||
!codewhale_models::turn_classify::is_wire_compaction_checkpoint_message(message)
|
||||
&& classify_user_turn_prompt(message) != UserTurnPromptKind::NotPrompt
|
||||
})
|
||||
.map_or_else(
|
||||
@@ -1365,43 +1358,6 @@ fn restored_checkpoint_message(display: String) -> Message {
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the user-safe display body for an already projected checkpoint.
|
||||
/// The exact metadata marker keeps arbitrary user-authored text on the normal
|
||||
/// conversation path.
|
||||
pub(crate) fn restored_subagent_checkpoint_display(message: &Message) -> Option<&str> {
|
||||
if message.role != "user" {
|
||||
return None;
|
||||
}
|
||||
let [
|
||||
ContentBlock::Text {
|
||||
text,
|
||||
cache_control: first_cache,
|
||||
},
|
||||
ContentBlock::Text {
|
||||
text: turn_meta,
|
||||
cache_control: meta_cache,
|
||||
},
|
||||
] = message.content.as_slice()
|
||||
else {
|
||||
return None;
|
||||
};
|
||||
if first_cache.is_some()
|
||||
|| meta_cache.is_some()
|
||||
|| turn_meta != RESTORED_CHECKPOINT_TURN_META
|
||||
|| ![
|
||||
RESTORED_COMPLETION_HEADER,
|
||||
RESTORED_COMPLETIONS_HEADER,
|
||||
RESTORED_RUNNING_HEADER,
|
||||
RESTORED_TOPOLOGY_HEADER,
|
||||
]
|
||||
.iter()
|
||||
.any(|header| text.starts_with(header))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
Some(text)
|
||||
}
|
||||
|
||||
/// Only the restored topology sidecar belongs to the compaction prompt
|
||||
/// cluster. Other restored Agent events retain their own wire boundaries.
|
||||
pub(crate) fn is_restored_agent_topology_checkpoint(message: &Message) -> bool {
|
||||
@@ -1409,21 +1365,6 @@ pub(crate) fn is_restored_agent_topology_checkpoint(message: &Message) -> bool {
|
||||
.is_some_and(|display| display.starts_with(RESTORED_TOPOLOGY_HEADER))
|
||||
}
|
||||
|
||||
/// Classification used when locating a user-authored turn in the session log.
|
||||
///
|
||||
/// Runtime and tool messages are skipped because their provider-compatible
|
||||
/// `role = "user"` is not user authority. Unsupported user content is a real
|
||||
/// turn boundary, however, so callers must not skip it and edit an older turn.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum UserTurnPromptKind {
|
||||
/// Assistant messages, tool results, and runtime-owned control messages.
|
||||
NotPrompt,
|
||||
/// A genuine user turn containing editable text.
|
||||
Editable,
|
||||
/// A genuine user turn without editable text, such as an image-only turn.
|
||||
Unsupported,
|
||||
}
|
||||
|
||||
/// Authoritative target selection for edit-last-turn operations.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum EditLastTurnTarget {
|
||||
@@ -1435,38 +1376,6 @@ pub(crate) enum EditLastTurnTarget {
|
||||
Missing,
|
||||
}
|
||||
|
||||
/// Classify `message` for edit-last-turn and admitted-display handling.
|
||||
#[must_use]
|
||||
pub(crate) fn classify_user_turn_prompt(message: &Message) -> UserTurnPromptKind {
|
||||
if message.role != Role::User {
|
||||
return UserTurnPromptKind::NotPrompt;
|
||||
}
|
||||
if message.content.iter().any(|block| {
|
||||
matches!(
|
||||
block,
|
||||
ContentBlock::ToolResult { .. }
|
||||
| ContentBlock::ToolSearchToolResult { .. }
|
||||
| ContentBlock::CodeExecutionToolResult { .. }
|
||||
)
|
||||
}) {
|
||||
return UserTurnPromptKind::NotPrompt;
|
||||
}
|
||||
if is_runtime_owned_user_message(message)
|
||||
|| crate::compaction::is_wire_compaction_checkpoint_message(message)
|
||||
{
|
||||
return UserTurnPromptKind::NotPrompt;
|
||||
}
|
||||
|
||||
let turn_metadata_index = turn_metadata_text(message).map(|(index, _)| index);
|
||||
if message.content.iter().enumerate().any(|(index, block)| {
|
||||
Some(index) != turn_metadata_index && matches!(block, ContentBlock::Text { .. })
|
||||
}) {
|
||||
UserTurnPromptKind::Editable
|
||||
} else {
|
||||
UserTurnPromptKind::Unsupported
|
||||
}
|
||||
}
|
||||
|
||||
/// Locate the latest real user boundary without skipping unsupported content.
|
||||
#[must_use]
|
||||
pub(crate) fn edit_last_turn_target(messages: &[Message]) -> EditLastTurnTarget {
|
||||
@@ -1484,15 +1393,6 @@ pub(crate) fn edit_last_turn_target(messages: &[Message]) -> EditLastTurnTarget
|
||||
.unwrap_or(EditLastTurnTarget::Missing)
|
||||
}
|
||||
|
||||
/// True when a `role = "user"` message is runtime-owned rather than
|
||||
/// user-authored. Runtime authority is accepted only from the engine-owned
|
||||
/// structural `<turn_meta>` block, never from arbitrary user text that happens
|
||||
/// to resemble a runtime envelope or metadata marker.
|
||||
pub(crate) fn is_runtime_owned_user_message(message: &Message) -> bool {
|
||||
restored_subagent_checkpoint_display(message).is_some()
|
||||
|| has_non_authoritative_turn_provenance(message)
|
||||
}
|
||||
|
||||
/// Whether this text block is a runtime-owned `<turn_meta>` envelope that
|
||||
/// must stay out of the visible transcript.
|
||||
///
|
||||
@@ -1537,67 +1437,6 @@ pub(crate) fn archived_context_parts(text: &str) -> Option<(&str, &str)> {
|
||||
Some((&text[..tag_end], text.get(tag_end + 1..close_tag)?.trim()))
|
||||
}
|
||||
|
||||
/// Return engine-owned metadata in either the current trailing shape or the
|
||||
/// historical leading shape. Requiring a separate prompt block prevents a
|
||||
/// user who submits `<turn_meta>…</turn_meta>` as ordinary text from minting
|
||||
/// authority.
|
||||
pub(crate) fn turn_metadata_text(message: &Message) -> Option<(usize, &str)> {
|
||||
if message.content.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
if let Some(ContentBlock::Text {
|
||||
text,
|
||||
cache_control: None,
|
||||
}) = message.content.last()
|
||||
{
|
||||
let trimmed = text.trim();
|
||||
if is_complete_turn_metadata(trimmed) {
|
||||
return Some((message.content.len() - 1, trimmed));
|
||||
}
|
||||
}
|
||||
// Sessions written before the metadata-tail migration used
|
||||
// `[turn_meta, prompt, ...]`. Match the same conservative legacy shape as
|
||||
// the transcript renderer: a metadata envelope first and ordinary text
|
||||
// last. A single user-authored metadata example is never hidden.
|
||||
let ContentBlock::Text {
|
||||
text,
|
||||
cache_control: None,
|
||||
} = message.content.first()?
|
||||
else {
|
||||
return None;
|
||||
};
|
||||
let trimmed = text.trim();
|
||||
let trailing_text_is_ordinary = matches!(
|
||||
message.content.last(),
|
||||
Some(ContentBlock::Text { text, .. }) if !is_complete_turn_metadata(text.trim())
|
||||
);
|
||||
(is_complete_turn_metadata(trimmed) && trailing_text_is_ordinary).then_some((0, trimmed))
|
||||
}
|
||||
|
||||
fn is_complete_turn_metadata(text: &str) -> bool {
|
||||
text.starts_with("<turn_meta>") && text.ends_with("</turn_meta>")
|
||||
}
|
||||
|
||||
/// Recognize both the current condensed provenance line and the legacy
|
||||
/// provenance/authority pair. Any explicitly non-authoritative provenance is
|
||||
/// runtime-owned; this stays correct as new provenance variants are added.
|
||||
fn has_non_authoritative_turn_provenance(message: &Message) -> bool {
|
||||
let Some((_, metadata)) = turn_metadata_text(message) else {
|
||||
return false;
|
||||
};
|
||||
let mut has_provenance = false;
|
||||
let mut condensed_non_authoritative = false;
|
||||
let mut legacy_non_authoritative = false;
|
||||
for line in metadata.lines().map(str::trim) {
|
||||
if let Some(value) = line.strip_prefix("Input provenance: ") {
|
||||
has_provenance = true;
|
||||
condensed_non_authoritative |= value.ends_with(" (non-authoritative)");
|
||||
}
|
||||
legacy_non_authoritative |= line == "Input authority: non_authoritative";
|
||||
}
|
||||
condensed_non_authoritative || (has_provenance && legacy_non_authoritative)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -534,7 +534,7 @@ where
|
||||
{
|
||||
value
|
||||
.as_deref()
|
||||
.map(crate::cost_status::sanitize_persisted_route_label)
|
||||
.map(codewhale_runtime::safe_label::sanitize_persisted_route_label)
|
||||
.serialize(serializer)
|
||||
}
|
||||
|
||||
@@ -980,7 +980,7 @@ fn projected_user_text(message: &Message) -> Option<String> {
|
||||
// the settled history that carries the checkpoint, so leaving it in took
|
||||
// the cut away from the fork itself: `/undo`, retry and fork-again each
|
||||
// refused with "cannot identify an exact saved-history boundary".
|
||||
if crate::compaction::is_wire_compaction_checkpoint_message(message) {
|
||||
if codewhale_models::turn_classify::is_wire_compaction_checkpoint_message(message) {
|
||||
return None;
|
||||
}
|
||||
let text = message
|
||||
@@ -6066,7 +6066,7 @@ fn runtime_usage_bucket_key(
|
||||
.map_or(turn.created_at, |route| route.dispatched_at)
|
||||
.format("%Y-%m-%d")
|
||||
.to_string(),
|
||||
UsageGroupBy::Model => crate::cost_status::sanitize_persisted_route_label(
|
||||
UsageGroupBy::Model => codewhale_runtime::safe_label::sanitize_persisted_route_label(
|
||||
route
|
||||
.map(|route| route.model.as_str())
|
||||
.or_else(|| {
|
||||
@@ -6076,7 +6076,7 @@ fn runtime_usage_bucket_key(
|
||||
})
|
||||
.unwrap_or(&thread.model),
|
||||
),
|
||||
UsageGroupBy::Provider => crate::cost_status::sanitize_persisted_route_label(
|
||||
UsageGroupBy::Provider => codewhale_runtime::safe_label::sanitize_persisted_route_label(
|
||||
route
|
||||
.map(|route| {
|
||||
if route.provider_identity.trim().is_empty() {
|
||||
@@ -16374,14 +16374,14 @@ impl RuntimeThreadManager {
|
||||
effective_provider: Some(provider_identity.persisted_kind().to_string()),
|
||||
effective_provider_id: provider_identity
|
||||
.persisted_id()
|
||||
.map(crate::cost_status::sanitize_persisted_route_label),
|
||||
.map(codewhale_runtime::safe_label::sanitize_persisted_route_label),
|
||||
effective_openrouter_vendor: None,
|
||||
effective_billing_surface: None,
|
||||
effective_endpoint_fingerprint: None,
|
||||
effective_provider_live_pricing: None,
|
||||
effective_billing_mode: None,
|
||||
effective_dispatched_at: None,
|
||||
effective_model: Some(crate::cost_status::sanitize_persisted_route_label(&model)),
|
||||
effective_model: Some(codewhale_runtime::safe_label::sanitize_persisted_route_label(&model)),
|
||||
routed_usage: Vec::new(),
|
||||
routed_usage_drop_records: Vec::new(),
|
||||
routed_usage_source_ids: Vec::new(),
|
||||
@@ -16912,16 +16912,16 @@ impl RuntimeThreadManager {
|
||||
effective_provider: Some(route_identity.persisted_kind().to_string()),
|
||||
effective_provider_id: route_identity
|
||||
.persisted_id()
|
||||
.map(crate::cost_status::sanitize_persisted_route_label),
|
||||
.map(codewhale_runtime::safe_label::sanitize_persisted_route_label),
|
||||
effective_openrouter_vendor: None,
|
||||
effective_billing_surface: None,
|
||||
effective_endpoint_fingerprint: None,
|
||||
effective_provider_live_pricing: None,
|
||||
effective_billing_mode: None,
|
||||
effective_dispatched_at: None,
|
||||
effective_model: Some(crate::cost_status::sanitize_persisted_route_label(
|
||||
&route_model,
|
||||
)),
|
||||
effective_model: Some(
|
||||
codewhale_runtime::safe_label::sanitize_persisted_route_label(&route_model),
|
||||
),
|
||||
routed_usage: Vec::new(),
|
||||
routed_usage_drop_records: Vec::new(),
|
||||
routed_usage_source_ids: Vec::new(),
|
||||
|
||||
@@ -22512,7 +22512,7 @@ fn a_compaction_checkpoint_is_not_a_kept_prompt() {
|
||||
format!("{} the work so far", crate::compaction::SUMMARY_HEADER),
|
||||
));
|
||||
assert!(
|
||||
crate::compaction::is_wire_compaction_checkpoint_message(&checkpoint),
|
||||
codewhale_models::turn_classify::is_wire_compaction_checkpoint_message(&checkpoint),
|
||||
"the fixture is the engine's own checkpoint message"
|
||||
);
|
||||
let messages = vec![user("first"), checkpoint, user("undo me")];
|
||||
|
||||
@@ -13,12 +13,12 @@ use crate::project_context::find_git_root;
|
||||
use crate::tools::goal::{GoalPauseReason, GoalSnapshot};
|
||||
use crate::tools::plan::PlanSnapshot;
|
||||
use crate::tools::todo::TodoListSnapshot;
|
||||
use crate::work_graph::ReasoningEffortTier;
|
||||
use chrono::{DateTime, Utc};
|
||||
use codewhale_config::ProviderKind;
|
||||
use codewhale_core::ContextReference;
|
||||
#[cfg(test)]
|
||||
use codewhale_core::{ContextReferenceKind, ContextReferenceSource};
|
||||
use codewhale_models::reasoning_effort_tier::ReasoningEffortTier;
|
||||
use codewhale_models::{ContentBlock, Message, SystemPrompt};
|
||||
use codewhale_runtime::session_tree::{SessionEntry, SessionImportContainer, SessionJournal};
|
||||
use codewhale_util::utils::write_atomic;
|
||||
@@ -5148,8 +5148,8 @@ fn recovered_legacy_title(
|
||||
array_complete: bool,
|
||||
) -> Option<String> {
|
||||
let stale = messages.iter().any(|message| {
|
||||
crate::runtime_handoff::classify_user_turn_prompt(message)
|
||||
== crate::runtime_handoff::UserTurnPromptKind::NotPrompt
|
||||
codewhale_models::turn_classify::classify_user_turn_prompt(message)
|
||||
== codewhale_models::turn_classify::UserTurnPromptKind::NotPrompt
|
||||
&& message.content.iter().any(|block| match block {
|
||||
ContentBlock::Text { text, .. } => {
|
||||
truncate_title(text, 50) == stored
|
||||
@@ -5296,8 +5296,8 @@ pub(crate) fn conversation_title_prompt(messages: &[Message]) -> Option<&str> {
|
||||
messages
|
||||
.iter()
|
||||
.find(|message| {
|
||||
crate::runtime_handoff::classify_user_turn_prompt(message)
|
||||
!= crate::runtime_handoff::UserTurnPromptKind::NotPrompt
|
||||
codewhale_models::turn_classify::classify_user_turn_prompt(message)
|
||||
!= codewhale_models::turn_classify::UserTurnPromptKind::NotPrompt
|
||||
})
|
||||
.and_then(|m| {
|
||||
m.content.iter().find_map(|block| match block {
|
||||
|
||||
@@ -65,8 +65,8 @@ pub mod rlm;
|
||||
pub mod run_tool;
|
||||
pub mod runtime_mcp;
|
||||
mod rust_format;
|
||||
pub mod schema_canonicalize;
|
||||
pub mod schema_sanitize;
|
||||
#[cfg(test)]
|
||||
mod schema_sanitize_apply_patch_tests;
|
||||
pub mod search;
|
||||
pub mod send_later;
|
||||
pub mod session;
|
||||
|
||||
@@ -18,12 +18,12 @@ use crate::client::CodewhaleClient;
|
||||
use crate::tools::goal::SharedGoalState;
|
||||
use codewhale_models::Tool;
|
||||
|
||||
use super::schema_canonicalize;
|
||||
use super::schema_sanitize;
|
||||
use super::spec::{
|
||||
ApprovalRequirement, RichToolResult, ToolCapability, ToolContext, ToolError, ToolResult,
|
||||
ToolResultContentBlock, ToolSpec,
|
||||
};
|
||||
use codewhale_models::schema_canonicalize;
|
||||
use codewhale_models::schema_sanitize;
|
||||
|
||||
// === Types ===
|
||||
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
use crate::tools::apply_patch::ApplyPatchTool;
|
||||
use crate::tools::spec::ToolSpec;
|
||||
use codewhale_models::schema_sanitize::{
|
||||
sanitize_for_kimi_parameters, sanitize_for_xai_parameters,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn xai_sanitize_flattens_apply_patch_root_one_of() {
|
||||
// The exact shape that produced the live 400:
|
||||
// "apply_patch: tool parameter root must be an object type (root
|
||||
// schema is an anyOf/oneOf union with a non-object branch)".
|
||||
let mut schema = ApplyPatchTool.input_schema();
|
||||
assert!(schema.get("oneOf").is_some(), "fixture must match the tool");
|
||||
|
||||
let note = sanitize_for_xai_parameters(&mut schema);
|
||||
|
||||
assert_eq!(schema["type"], "object");
|
||||
assert!(schema.get("oneOf").is_none());
|
||||
assert!(schema.get("anyOf").is_none());
|
||||
assert!(schema["properties"].get("patch").is_some());
|
||||
assert!(schema["properties"].get("changes").is_some());
|
||||
assert_eq!(
|
||||
note.as_deref(),
|
||||
Some(
|
||||
"Exactly one of these parameter groups must be provided: `changes` | `patch` | `replace`."
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kimi_parameters_flattens_actual_apply_patch_root_and_returns_constraint_note() {
|
||||
let mut schema = ApplyPatchTool.input_schema();
|
||||
|
||||
let note = sanitize_for_kimi_parameters(&mut schema).unwrap();
|
||||
|
||||
assert_eq!(schema["type"], "object");
|
||||
assert!(schema.get("oneOf").is_none());
|
||||
assert!(schema.get("anyOf").is_none());
|
||||
assert!(schema.get("allOf").is_none());
|
||||
assert_eq!(schema["properties"]["patch"]["type"], "string");
|
||||
assert_eq!(schema["properties"]["replace"]["type"], "array");
|
||||
assert_eq!(schema["properties"]["changes"]["type"], "array");
|
||||
assert_eq!(
|
||||
note.as_deref(),
|
||||
Some(
|
||||
"Exactly one of these parameter groups must be provided: `changes` | `patch` | `replace`."
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -5059,7 +5059,7 @@ fn bash_schema_declares_what_each_action_requires() {
|
||||
#[test]
|
||||
fn bash_required_groups_survive_a_provider_that_drops_root_composition() {
|
||||
let mut schema = BashTool::new("Bash").input_schema();
|
||||
let note = crate::tools::schema_sanitize::sanitize_for_responses(&mut schema)
|
||||
let note = codewhale_models::schema_sanitize::sanitize_for_responses(&mut schema)
|
||||
.expect("dropped required groups must be restated for the model");
|
||||
|
||||
assert!(note.contains("At least one"), "{note}");
|
||||
|
||||
@@ -10560,7 +10560,9 @@ impl SubAgentTranscriptArtifactWriter {
|
||||
self.sync_messages(replaced, false)?;
|
||||
let checkpoints = replacement
|
||||
.iter()
|
||||
.filter(|message| crate::compaction::is_wire_compaction_checkpoint_message(message))
|
||||
.filter(|message| {
|
||||
codewhale_models::turn_classify::is_wire_compaction_checkpoint_message(message)
|
||||
})
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
self.append_messages(&checkpoints, false)?;
|
||||
|
||||
@@ -6438,7 +6438,7 @@ fn agent_tool_role_schema_is_a_closed_canonical_enum() {
|
||||
|
||||
#[test]
|
||||
fn provider_schema_sanitizers_preserve_the_closed_fleet_role_enum() {
|
||||
use crate::tools::schema_sanitize;
|
||||
use codewhale_models::schema_sanitize;
|
||||
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let manager = new_shared_subagent_manager(tmp.path().to_path_buf(), 1);
|
||||
@@ -6601,7 +6601,7 @@ fn agent_tool_schema_bounds_fields_by_explicit_action() {
|
||||
// actual contract both before and after the generic provider sanitizer;
|
||||
// required-only `not` branches used to be pruned into an impossible schema.
|
||||
let mut generic = agent_schema.clone();
|
||||
crate::tools::schema_sanitize::sanitize(&mut generic);
|
||||
codewhale_models::schema_sanitize::sanitize(&mut generic);
|
||||
for (provider, schema) in [("canonical", agent_schema), ("generic", generic)] {
|
||||
let validator = draft_2020_validator(&schema);
|
||||
for target in [
|
||||
@@ -6644,7 +6644,7 @@ fn agent_tool_schema_bounds_fields_by_explicit_action() {
|
||||
|
||||
#[test]
|
||||
fn agent_tool_schema_rejects_empty_input_across_provider_forms() {
|
||||
use crate::tools::schema_sanitize;
|
||||
use codewhale_models::schema_sanitize;
|
||||
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let manager = new_shared_subagent_manager(tmp.path().to_path_buf(), 1);
|
||||
@@ -19322,7 +19322,7 @@ async fn compacting_child_chat_client(
|
||||
}));
|
||||
} else if summaries.load(Ordering::SeqCst) > 0 {
|
||||
saw_checkpoint.store(
|
||||
wire.contains(crate::compaction::COMPACTION_SUMMARY_MARKER),
|
||||
wire.contains(codewhale_models::turn_classify::COMPACTION_SUMMARY_MARKER),
|
||||
Ordering::SeqCst,
|
||||
);
|
||||
json!({ "role": "assistant", "content": "done after compaction" })
|
||||
@@ -19483,7 +19483,7 @@ async fn worker_compacts_past_its_context_window_and_keeps_working() {
|
||||
load_subagent_transcript_artifact(&state_root, &agent_id).expect("transcript loads");
|
||||
let checkpoint_at = transcript
|
||||
.iter()
|
||||
.position(crate::compaction::is_wire_compaction_checkpoint_message)
|
||||
.position(codewhale_models::turn_classify::is_wire_compaction_checkpoint_message)
|
||||
.expect("checkpoint recorded");
|
||||
assert!(checkpoint_at >= 7, "replaced history kept before it");
|
||||
assert!(
|
||||
|
||||
@@ -169,7 +169,7 @@ pub(super) fn structured_plan_schema() -> Value {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::structured_plan_schema;
|
||||
use crate::tools::schema_sanitize::{sanitize, sanitize_for_strict};
|
||||
use codewhale_models::schema_sanitize::{sanitize, sanitize_for_strict};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
fn property_names(schema: &Value) -> Vec<&str> {
|
||||
|
||||
@@ -639,7 +639,7 @@ fn auto_reasoning_change_invalidates_the_previous_route_and_receipt() {
|
||||
.expect("effort activity");
|
||||
assert_eq!(
|
||||
effective,
|
||||
crate::work_graph::ReasoningEffortTier::Medium,
|
||||
codewhale_models::reasoning_effort_tier::ReasoningEffortTier::Medium,
|
||||
"the activity receipt must not reuse the previous turn's effective tier"
|
||||
);
|
||||
}
|
||||
@@ -712,8 +712,14 @@ fn cycle_effort_updates_effort_status_and_compaction() {
|
||||
operation,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(*requested, crate::work_graph::ReasoningEffortTier::Low);
|
||||
assert_eq!(*effective, crate::work_graph::ReasoningEffortTier::Low);
|
||||
assert_eq!(
|
||||
*requested,
|
||||
codewhale_models::reasoning_effort_tier::ReasoningEffortTier::Low
|
||||
);
|
||||
assert_eq!(
|
||||
*effective,
|
||||
codewhale_models::reasoning_effort_tier::ReasoningEffortTier::Low
|
||||
);
|
||||
assert_eq!(*provider_kind, Some(ProviderKind::Deepseek));
|
||||
assert_eq!(provider, "deepseek");
|
||||
assert!(operation.is_none());
|
||||
@@ -769,10 +775,13 @@ fn glm_5_turbo_records_enabled_with_granularity_unavailable() {
|
||||
provider,
|
||||
..
|
||||
} = &activity;
|
||||
assert_eq!(*requested, crate::work_graph::ReasoningEffortTier::Max);
|
||||
assert_eq!(
|
||||
*requested,
|
||||
codewhale_models::reasoning_effort_tier::ReasoningEffortTier::Max
|
||||
);
|
||||
assert_eq!(
|
||||
*effective,
|
||||
crate::work_graph::ReasoningEffortTier::ThinkingEnabledGranularityUnavailable
|
||||
codewhale_models::reasoning_effort_tier::ReasoningEffortTier::ThinkingEnabledGranularityUnavailable
|
||||
);
|
||||
assert_eq!(provider, "zai");
|
||||
assert_eq!(
|
||||
@@ -814,7 +823,7 @@ fn glm_5_1_records_enabled_with_granularity_unavailable() {
|
||||
.expect("effort activity");
|
||||
assert_eq!(
|
||||
effective,
|
||||
crate::work_graph::ReasoningEffortTier::ThinkingEnabledGranularityUnavailable
|
||||
codewhale_models::reasoning_effort_tier::ReasoningEffortTier::ThinkingEnabledGranularityUnavailable
|
||||
);
|
||||
}
|
||||
|
||||
@@ -851,7 +860,7 @@ fn unknown_model_on_exact_zai_endpoint_records_effective_unavailable() {
|
||||
.expect("effort activity");
|
||||
assert_eq!(
|
||||
effective,
|
||||
crate::work_graph::ReasoningEffortTier::Unavailable
|
||||
codewhale_models::reasoning_effort_tier::ReasoningEffortTier::Unavailable
|
||||
);
|
||||
}
|
||||
|
||||
@@ -896,10 +905,13 @@ fn compatible_zai_gateway_records_effective_unavailable() {
|
||||
provider,
|
||||
..
|
||||
} = &activity;
|
||||
assert_eq!(*requested, crate::work_graph::ReasoningEffortTier::Max);
|
||||
assert_eq!(
|
||||
*requested,
|
||||
codewhale_models::reasoning_effort_tier::ReasoningEffortTier::Max
|
||||
);
|
||||
assert_eq!(
|
||||
*effective,
|
||||
crate::work_graph::ReasoningEffortTier::Unavailable
|
||||
codewhale_models::reasoning_effort_tier::ReasoningEffortTier::Unavailable
|
||||
);
|
||||
assert_eq!(provider, "zai");
|
||||
assert_eq!(
|
||||
@@ -950,9 +962,9 @@ fn minimax_m3_high_and_max_receipts_do_not_claim_tier_granularity() {
|
||||
assert_eq!(
|
||||
effective,
|
||||
if requested == ReasoningEffort::Auto {
|
||||
crate::work_graph::ReasoningEffortTier::Auto
|
||||
codewhale_models::reasoning_effort_tier::ReasoningEffortTier::Auto
|
||||
} else {
|
||||
crate::work_graph::ReasoningEffortTier::Off
|
||||
codewhale_models::reasoning_effort_tier::ReasoningEffortTier::Off
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -1010,9 +1022,9 @@ fn minimax_anthropic_m3_high_and_max_receipts_match_adaptive_wire_truth() {
|
||||
assert_eq!(
|
||||
effective,
|
||||
if requested == ReasoningEffort::Auto {
|
||||
crate::work_graph::ReasoningEffortTier::Auto
|
||||
codewhale_models::reasoning_effort_tier::ReasoningEffortTier::Auto
|
||||
} else {
|
||||
crate::work_graph::ReasoningEffortTier::Off
|
||||
codewhale_models::reasoning_effort_tier::ReasoningEffortTier::Off
|
||||
}
|
||||
);
|
||||
assert_eq!(provider_kind, Some(ProviderKind::MinimaxAnthropic));
|
||||
@@ -1065,7 +1077,7 @@ fn named_custom_route_displays_and_persists_effective_unavailable() {
|
||||
} = activity;
|
||||
assert_eq!(
|
||||
effective,
|
||||
crate::work_graph::ReasoningEffortTier::Unavailable
|
||||
codewhale_models::reasoning_effort_tier::ReasoningEffortTier::Unavailable
|
||||
);
|
||||
assert_eq!(provider_kind, Some(ProviderKind::Custom));
|
||||
assert_eq!(provider, "my-gateway");
|
||||
@@ -1110,7 +1122,7 @@ fn custom_routes_named_with_builtin_slugs_retain_custom_kind_and_fail_closed() {
|
||||
} = activity;
|
||||
assert_eq!(
|
||||
effective,
|
||||
crate::work_graph::ReasoningEffortTier::Unavailable
|
||||
codewhale_models::reasoning_effort_tier::ReasoningEffortTier::Unavailable
|
||||
);
|
||||
assert_eq!(provider_kind, Some(ProviderKind::Custom));
|
||||
assert_eq!(provider, identity);
|
||||
|
||||
@@ -748,7 +748,9 @@ pub fn history_cells_from_message(msg: &Message) -> Vec<HistoryCell> {
|
||||
if crate::runtime_handoff::is_operate_contract_message(msg) {
|
||||
return Vec::new();
|
||||
}
|
||||
if let Some(display) = crate::runtime_handoff::restored_subagent_checkpoint_display(msg) {
|
||||
if let Some(display) =
|
||||
codewhale_models::turn_classify::restored_subagent_checkpoint_display(msg)
|
||||
{
|
||||
return vec![HistoryCell::System {
|
||||
content: display.to_string(),
|
||||
}];
|
||||
@@ -778,7 +780,7 @@ pub fn history_cells_from_message(msg: &Message) -> Vec<HistoryCell> {
|
||||
}
|
||||
|
||||
let mut cells = Vec::new();
|
||||
let runtime_owned = crate::runtime_handoff::is_runtime_owned_user_message(msg);
|
||||
let runtime_owned = codewhale_models::turn_classify::is_runtime_owned_user_message(msg);
|
||||
|
||||
for (block_index, block) in msg.content.iter().enumerate() {
|
||||
match block {
|
||||
|
||||
@@ -715,7 +715,7 @@ fn record_agent_current_activity(app: &mut App, message: &MailboxMessage) {
|
||||
meta.received_tokens = Some(meta.received_tokens.unwrap_or(0).saturating_add(turn_total));
|
||||
meta.resolved_provider = Some(route.provider.as_str().to_string());
|
||||
meta.resolved_model = Some(bound_agent_activity_text(
|
||||
&crate::cost_status::sanitize_persisted_route_label(&route.model),
|
||||
&codewhale_runtime::safe_label::sanitize_persisted_route_label(&route.model),
|
||||
))
|
||||
.filter(|model| !model.trim().is_empty());
|
||||
return;
|
||||
|
||||
@@ -16,8 +16,9 @@ use super::*;
|
||||
/// later usage envelope may confirm or replace it with the provider's
|
||||
/// effective route while also adding provider and token facts.
|
||||
pub(crate) fn record_agent_spawned_route(app: &mut App, agent_id: &str, model: &str) {
|
||||
let model =
|
||||
bound_agent_activity_text(&crate::cost_status::sanitize_persisted_route_label(model));
|
||||
let model = bound_agent_activity_text(
|
||||
&codewhale_runtime::safe_label::sanitize_persisted_route_label(model),
|
||||
);
|
||||
app.agent_progress_meta
|
||||
.entry(agent_id.to_string())
|
||||
.or_default()
|
||||
|
||||
@@ -873,8 +873,8 @@ where
|
||||
)
|
||||
}
|
||||
Err(_elapsed) => {
|
||||
crate::core::engine::turn_heartbeat::report_stall(
|
||||
&crate::core::engine::turn_heartbeat::StallReport {
|
||||
codewhale_runtime::stall_report::report_stall(
|
||||
&codewhale_runtime::stall_report::StallReport {
|
||||
source: "ui",
|
||||
phase: "while dispatching the message (route planning / engine admission)"
|
||||
.to_string(),
|
||||
|
||||
@@ -219,21 +219,19 @@ fn record_ui_stall(app: &App, phase: &str, since_progress: Duration, bound: Dura
|
||||
suspects.join(", ")
|
||||
)
|
||||
});
|
||||
crate::core::engine::turn_heartbeat::report_stall(
|
||||
&crate::core::engine::turn_heartbeat::StallReport {
|
||||
source: "ui",
|
||||
phase: phase.to_string(),
|
||||
detail,
|
||||
turn_id: app.runtime_turn_id.clone(),
|
||||
provider_request: app
|
||||
.active_turn
|
||||
.as_ref()
|
||||
.and_then(|turn| turn.route.as_ref())
|
||||
.map(|route| format!("{} / {}", route.provider_identity, route.model)),
|
||||
since_progress,
|
||||
bound: Some(bound),
|
||||
},
|
||||
);
|
||||
codewhale_runtime::stall_report::report_stall(&codewhale_runtime::stall_report::StallReport {
|
||||
source: "ui",
|
||||
phase: phase.to_string(),
|
||||
detail,
|
||||
turn_id: app.runtime_turn_id.clone(),
|
||||
provider_request: app
|
||||
.active_turn
|
||||
.as_ref()
|
||||
.and_then(|turn| turn.route.as_ref())
|
||||
.map(|route| format!("{} / {}", route.provider_identity, route.model)),
|
||||
since_progress,
|
||||
bound: Some(bound),
|
||||
});
|
||||
}
|
||||
|
||||
/// The UI watchdog, supervised by the engine heartbeat (#6184). Suspect
|
||||
|
||||
@@ -9688,7 +9688,7 @@ XML unless the user explicitly asks to debug sub-agent internals.\n\n\
|
||||
matches!(block, ContentBlock::Text { text, .. } if text == "Fix the resume regression")
|
||||
})
|
||||
}));
|
||||
let restored = crate::runtime_handoff::restored_subagent_checkpoint_display(
|
||||
let restored = codewhale_models::turn_classify::restored_subagent_checkpoint_display(
|
||||
app.api_messages.last().expect("restored handoff"),
|
||||
)
|
||||
.expect("projected handoff");
|
||||
@@ -14387,8 +14387,8 @@ async fn reserved_dispatch_waits_for_ui_acceptance_before_engine_op() {
|
||||
matches!(history_cells_from_message(&stored).as_slice(), [HistoryCell::System { content }] if content.contains(&command_message.display))
|
||||
);
|
||||
assert!(matches!(
|
||||
crate::runtime_handoff::classify_user_turn_prompt(&stored),
|
||||
crate::runtime_handoff::UserTurnPromptKind::NotPrompt
|
||||
codewhale_models::turn_classify::classify_user_turn_prompt(&stored),
|
||||
codewhale_models::turn_classify::UserTurnPromptKind::NotPrompt
|
||||
));
|
||||
assert!(
|
||||
dispatch_composer_message(
|
||||
@@ -16006,7 +16006,7 @@ fn turn_liveness_keeps_max_duration_exec_shell_wait_alive_with_heartbeat() {
|
||||
fn stall_heartbeat(
|
||||
phase: crate::core::engine::turn_heartbeat::TurnPhase,
|
||||
bound: Option<Duration>,
|
||||
stall: Option<crate::core::engine::turn_heartbeat::StallReport>,
|
||||
stall: Option<codewhale_runtime::stall_report::StallReport>,
|
||||
) -> crate::core::engine::turn_heartbeat::HeartbeatSnapshot {
|
||||
crate::core::engine::turn_heartbeat::HeartbeatSnapshot {
|
||||
phase,
|
||||
@@ -16016,8 +16016,8 @@ fn stall_heartbeat(
|
||||
}
|
||||
}
|
||||
|
||||
fn stall_report(phase: &str) -> crate::core::engine::turn_heartbeat::StallReport {
|
||||
crate::core::engine::turn_heartbeat::StallReport {
|
||||
fn stall_report(phase: &str) -> codewhale_runtime::stall_report::StallReport {
|
||||
codewhale_runtime::stall_report::StallReport {
|
||||
source: "engine",
|
||||
phase: phase.to_string(),
|
||||
detail: Some("mock / model".to_string()),
|
||||
@@ -16141,7 +16141,8 @@ fn stall_engine_report_shows_phase_and_held_queue() {
|
||||
/// live regardless of elapsed time while they report progress.
|
||||
#[test]
|
||||
fn stall_prior_session_subagent_is_suspect_but_long_running_child_is_live() {
|
||||
use crate::core::engine::turn_heartbeat::{TurnPhase, set_test_stall_record_dir};
|
||||
use crate::core::engine::turn_heartbeat::TurnPhase;
|
||||
use codewhale_runtime::stall_report::set_test_stall_record_dir;
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
set_test_stall_record_dir(Some(dir.path().to_path_buf()));
|
||||
|
||||
@@ -16267,7 +16268,7 @@ fn stall_recovery_hands_held_queued_message_back_to_composer() {
|
||||
/// a stall record.
|
||||
#[tokio::test]
|
||||
async fn stall_dispatch_task_overrun_reports_and_restores_message() {
|
||||
use crate::core::engine::turn_heartbeat::set_test_stall_record_dir;
|
||||
use codewhale_runtime::stall_report::set_test_stall_record_dir;
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
set_test_stall_record_dir(Some(dir.path().to_path_buf()));
|
||||
let mut app = create_test_app();
|
||||
@@ -24785,7 +24786,7 @@ fn auto_route_receipt_survives_session_snapshot_and_restore() {
|
||||
assert_eq!(saved.receipt, receipt);
|
||||
assert_eq!(
|
||||
saved.effective_reasoning_effort,
|
||||
Some(crate::work_graph::ReasoningEffortTier::High)
|
||||
Some(codewhale_models::reasoning_effort_tier::ReasoningEffortTier::High)
|
||||
);
|
||||
|
||||
let mut restored_app = create_test_app();
|
||||
|
||||
@@ -10,8 +10,8 @@ fn prompts(messages: &[Message]) -> Vec<String> {
|
||||
.iter()
|
||||
.filter(|message| {
|
||||
matches!(
|
||||
crate::runtime_handoff::classify_user_turn_prompt(message),
|
||||
crate::runtime_handoff::UserTurnPromptKind::Editable
|
||||
codewhale_models::turn_classify::classify_user_turn_prompt(message),
|
||||
codewhale_models::turn_classify::UserTurnPromptKind::Editable
|
||||
)
|
||||
})
|
||||
.flat_map(|message| &message.content)
|
||||
|
||||
@@ -49,8 +49,8 @@ pub use model::{
|
||||
ACTIVITY_CAP, AcceptanceRequirement, BoundedSet, BoundedVec, CompatPlanMetadata,
|
||||
CompatProjectionState, CompatTodoBinding, EdgeKind, EvidenceKind, EvidenceKindTag, EvidenceRef,
|
||||
EvidenceRefError, HISTORY_CAP, IdempotencyKey, NodeKind, NodeState, OperationBinding,
|
||||
Provenance, ReasoningEffortTier, SCHEMA_VERSION, SEEN_KEYS_CAP, Ts, WorkActivityEvent,
|
||||
WorkEdge, WorkGraphSnapshot, WorkNode, external_identity_is_well_formed,
|
||||
Provenance, SCHEMA_VERSION, SEEN_KEYS_CAP, Ts, WorkActivityEvent, WorkEdge, WorkGraphSnapshot,
|
||||
WorkNode, external_identity_is_well_formed,
|
||||
};
|
||||
pub(crate) use model::{ENDED_OPERATION_CAP, constrained_effective_reasoning_for_route};
|
||||
pub use reducer::apply;
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
//! - [`BoundedVec`] / [`BoundedSet`] are small deterministic FIFO containers
|
||||
//! (oldest entry evicted first); no hashing, so iteration order is stable.
|
||||
|
||||
use codewhale_models::reasoning_effort_tier::ReasoningEffortTier;
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
|
||||
use codewhale_config::ProviderKind;
|
||||
@@ -44,30 +45,6 @@ pub const SEEN_KEYS_CAP: usize = 1024;
|
||||
/// live in the session transcript — so older ones are evicted, not archived.
|
||||
pub const ENDED_OPERATION_CAP: usize = 256;
|
||||
|
||||
/// Canonical reasoning-effort tiers recorded as configuration facts. This is
|
||||
/// deliberately an enum rather than free-form text so Work Graph activity can
|
||||
/// never become a side channel for model reasoning.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ReasoningEffortTier {
|
||||
Off,
|
||||
Minimal,
|
||||
Low,
|
||||
Medium,
|
||||
High,
|
||||
#[serde(rename = "xhigh")]
|
||||
XHigh,
|
||||
Ultra,
|
||||
Auto,
|
||||
Max,
|
||||
/// Thinking is enabled, but the provider route exposes no supported
|
||||
/// effort tiers. This is an effective receipt, never a requested setting.
|
||||
ThinkingEnabledGranularityUnavailable,
|
||||
/// The configured route exposes no verified reasoning-control contract.
|
||||
/// This is an effective receipt, never a requested setting.
|
||||
Unavailable,
|
||||
}
|
||||
|
||||
/// Bounded, receipt-only activity attached to the session graph.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum WorkActivityEvent {
|
||||
|
||||
@@ -11,11 +11,12 @@ use codewhale_lane::LaneRegistry;
|
||||
use super::{
|
||||
BindingId, ChangeCtx, CompatPlanMetadata, CompatProjectionState, CompatTodoBinding, EdgeKind,
|
||||
IdempotencyKey, NodeKind, NodeState, OperationBinding, OperationIntent, OperationObservation,
|
||||
OperationOwnerSnapshot, Provenance, ReasoningEffortTier, WorkActivityEvent, WorkEdge,
|
||||
WorkEdgeId, WorkGraph, WorkGraphChange, WorkGraphSnapshot, WorkNode, WorkNodeId, WorkNodePatch,
|
||||
OperationOwnerSnapshot, Provenance, WorkActivityEvent, WorkEdge, WorkEdgeId, WorkGraph,
|
||||
WorkGraphChange, WorkGraphSnapshot, WorkNode, WorkNodeId, WorkNodePatch,
|
||||
external_identity_is_well_formed, fleet_task_owner_snapshot, import_legacy,
|
||||
lane_owner_snapshot, project_plan, project_todos, validate,
|
||||
};
|
||||
use codewhale_models::reasoning_effort_tier::ReasoningEffortTier;
|
||||
|
||||
pub(crate) const ACTIVE_OPERATION_SUMMARY_START: &str =
|
||||
"<!-- codewhale:active-work-operations:start -->";
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
use super::compat;
|
||||
use super::*;
|
||||
use codewhale_models::reasoning_effort_tier::ReasoningEffortTier;
|
||||
|
||||
const SESSION: &str = "sess-test";
|
||||
|
||||
|
||||
@@ -273,8 +273,8 @@ fn check_structural(snapshot: &WorkGraphSnapshot, out: &mut Vec<Violation>) {
|
||||
};
|
||||
if matches!(
|
||||
requested,
|
||||
super::ReasoningEffortTier::ThinkingEnabledGranularityUnavailable
|
||||
| super::ReasoningEffortTier::Unavailable
|
||||
codewhale_models::reasoning_effort_tier::ReasoningEffortTier::ThinkingEnabledGranularityUnavailable
|
||||
| codewhale_models::reasoning_effort_tier::ReasoningEffortTier::Unavailable
|
||||
) {
|
||||
out.push(Violation {
|
||||
code: ValidationCode::Structural,
|
||||
@@ -305,7 +305,9 @@ fn check_structural(snapshot: &WorkGraphSnapshot, out: &mut Vec<Violation>) {
|
||||
&& !model.chars().any(char::is_control)
|
||||
});
|
||||
if !provenance_is_bounded {
|
||||
if *effective != super::ReasoningEffortTier::Unavailable {
|
||||
if *effective
|
||||
!= codewhale_models::reasoning_effort_tier::ReasoningEffortTier::Unavailable
|
||||
{
|
||||
out.push(Violation {
|
||||
code: ValidationCode::Structural,
|
||||
message:
|
||||
@@ -327,7 +329,7 @@ fn check_structural(snapshot: &WorkGraphSnapshot, out: &mut Vec<Violation>) {
|
||||
}
|
||||
let constrained = match api_provider {
|
||||
codewhale_config::ProviderKind::Custom => {
|
||||
Some(super::ReasoningEffortTier::Unavailable)
|
||||
Some(codewhale_models::reasoning_effort_tier::ReasoningEffortTier::Unavailable)
|
||||
}
|
||||
api_provider => super::model::constrained_effective_reasoning_for_route(
|
||||
*requested,
|
||||
@@ -345,7 +347,7 @@ fn check_structural(snapshot: &WorkGraphSnapshot, out: &mut Vec<Violation>) {
|
||||
} else if constrained.is_none()
|
||||
&& matches!(
|
||||
effective,
|
||||
super::ReasoningEffortTier::ThinkingEnabledGranularityUnavailable
|
||||
codewhale_models::reasoning_effort_tier::ReasoningEffortTier::ThinkingEnabledGranularityUnavailable
|
||||
)
|
||||
{
|
||||
out.push(Violation {
|
||||
|
||||
Reference in new issue
Block a user