mirror of
https://github.com/akitaonrails/ai-memory.git
synced 2026-10-02 03:24:46 +08:00
@@ -51,6 +51,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
is not a rescope and falls through to the existing project-provenance logic
|
||||
unchanged; a genuinely DIFFERENT or unresolvable workspace still fails
|
||||
closed and disqualifies sticky, exactly as before. (#984)
|
||||
- Auto-improve no longer rejects every proposal from a model that spells the
|
||||
full-page edit mode as `"full"`. `edit_mode` had the same shape #458 fixed
|
||||
for `operation`: a free-form string validated by exact match, no schema
|
||||
constraint, and a system prompt that says "Full-page proposals" without the
|
||||
literal value. `gpt-oss-20b` via LM Studio answered `"full"` for every
|
||||
candidate, so runs finished with zero accepted proposals and only
|
||||
`unsupported_edit_mode` rejections. The schema now advertises
|
||||
`["full_page", "patch"]`, and normalisation folds `full`, `full-page` and
|
||||
`Full Page` into `full_page` (with a warning) for providers without
|
||||
constrained decoding. Unknown modes still fail validation. (#991)
|
||||
- Auto-improve no longer aborts a whole run when the model fills in
|
||||
`expected_base_body_sha256`. The field is computed by the server when a
|
||||
patch is materialized, but the schema shows it to the model, and a
|
||||
non-hash value on a full-page proposal reached staging and failed
|
||||
`hex_to_sha256` with `invalid expected_base_body_sha256: expected 64 hex
|
||||
chars` (HTTP 500 from `/admin/auto-improve`), discarding every other
|
||||
proposal in the run. Normalisation now drops any model-supplied value; the
|
||||
patch path still sets it from the materialized target. (#991)
|
||||
|
||||
## [2.4.2] - 2026-09-29
|
||||
|
||||
|
||||
@@ -296,7 +296,12 @@ pub struct AutoImproveProposal {
|
||||
#[serde(default, alias = "body", alias = "markdown", alias = "content")]
|
||||
pub body_markdown: String,
|
||||
/// `full_page` (default) or `patch`.
|
||||
///
|
||||
/// Advertised as an enum for the same reason as `operation`; still a
|
||||
/// `String` so an unconstrained provider's `"full"` reaches
|
||||
/// [`normalize_edit_mode`] instead of failing to deserialise.
|
||||
#[serde(default = "default_edit_mode")]
|
||||
#[schemars(extend("enum" = ["full_page", "patch"]))]
|
||||
pub edit_mode: String,
|
||||
/// Patch edits for existing _rules/ or procedures/ pages.
|
||||
#[serde(default)]
|
||||
@@ -359,6 +364,26 @@ fn default_edit_mode() -> String {
|
||||
"full_page".into()
|
||||
}
|
||||
|
||||
/// Map the ways a model spells the two supported edit modes onto their
|
||||
/// canonical form.
|
||||
///
|
||||
/// Same narrow policy as [`normalize_operation`]: the system prompt says
|
||||
/// "Full-page proposals", and models answer `"full"`. Unknown values are left
|
||||
/// untouched so they still fail validation.
|
||||
fn normalize_edit_mode(raw: &str) -> String {
|
||||
let squashed: String = raw
|
||||
.trim()
|
||||
.to_ascii_lowercase()
|
||||
.chars()
|
||||
.filter(|c| !matches!(c, ' ' | '_' | '-'))
|
||||
.collect();
|
||||
match squashed.as_str() {
|
||||
"" | "fullpage" | "full" => default_edit_mode(),
|
||||
"patch" => "patch".into(),
|
||||
_ => raw.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// A candidate the reviewer or validator rejected.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct AutoImproveRejectedCandidate {
|
||||
@@ -1486,8 +1511,18 @@ pub(crate) fn validate_response(
|
||||
fn normalize_proposal(proposal: &mut AutoImproveProposal, warnings: &mut Vec<String>) {
|
||||
normalize_kind(proposal, warnings);
|
||||
|
||||
if proposal.edit_mode.trim().is_empty() {
|
||||
proposal.edit_mode = default_edit_mode();
|
||||
// Server-owned: set when a patch is materialized. The schema still shows
|
||||
// it to the model, and a model-supplied non-hash value on a full-page
|
||||
// proposal would reach staging and fail the whole run on `hex_to_sha256`.
|
||||
proposal.expected_base_body_sha256 = None;
|
||||
|
||||
let original_edit_mode = proposal.edit_mode.clone();
|
||||
proposal.edit_mode = normalize_edit_mode(&original_edit_mode);
|
||||
if !original_edit_mode.trim().is_empty() && proposal.edit_mode != original_edit_mode {
|
||||
warnings.push(format!(
|
||||
"proposal {} edit_mode normalized from {:?} to {:?}",
|
||||
proposal.path, original_edit_mode, proposal.edit_mode
|
||||
));
|
||||
}
|
||||
if proposal.edit_mode == "patch" {
|
||||
return;
|
||||
@@ -3264,6 +3299,66 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Same shape as #458 on the neighbouring field: `gpt-oss-20b` via LM
|
||||
/// Studio answered `"edit_mode": "full"` for every candidate, so every run
|
||||
/// ended with zero accepted proposals and only `unsupported_edit_mode`
|
||||
/// rejections.
|
||||
#[test]
|
||||
fn a_proposal_saying_full_is_accepted_as_full_page() {
|
||||
let mut candidate = proposal("gotchas/thing.md", "gotcha", 0.91);
|
||||
candidate.edit_mode = "full".into();
|
||||
let raw = AutoImproveLlmResponse {
|
||||
summary: "ok".into(),
|
||||
proposals: vec![candidate],
|
||||
rejected_candidates: Vec::new(),
|
||||
};
|
||||
let (accepted, rejected, warnings) =
|
||||
validate_response(raw, &cfg(), &ExistingPageIndex::default());
|
||||
assert!(rejected.is_empty(), "got {rejected:?}");
|
||||
assert_eq!(accepted.len(), 1);
|
||||
assert_eq!(accepted[0].edit_mode, "full_page");
|
||||
assert!(
|
||||
warnings
|
||||
.iter()
|
||||
.any(|w| w.contains("edit_mode normalized from \"full\"")),
|
||||
"normalisation should be reported, got {warnings:?}"
|
||||
);
|
||||
|
||||
let mut unknown = proposal("gotchas/thing.md", "gotcha", 0.91);
|
||||
unknown.edit_mode = "rewrite".into();
|
||||
let raw = AutoImproveLlmResponse {
|
||||
summary: "ok".into(),
|
||||
proposals: vec![unknown],
|
||||
rejected_candidates: Vec::new(),
|
||||
};
|
||||
let (accepted, rejected, _) = validate_response(raw, &cfg(), &ExistingPageIndex::default());
|
||||
assert!(accepted.is_empty());
|
||||
assert_eq!(rejected[0].reason, "unsupported_edit_mode");
|
||||
}
|
||||
|
||||
/// Once `"full"` stopped being rejected, the same `gpt-oss-20b` runs
|
||||
/// failed at staging with `invalid expected_base_body_sha256: expected 64
|
||||
/// hex chars`: the model filled in a field only the server can compute.
|
||||
#[test]
|
||||
fn a_model_supplied_base_sha_is_dropped_from_full_page_proposals() {
|
||||
for supplied in ["", "null", "abc123", "N/A"] {
|
||||
let mut candidate = proposal("gotchas/thing.md", "gotcha", 0.91);
|
||||
candidate.expected_base_body_sha256 = Some(supplied.into());
|
||||
let raw = AutoImproveLlmResponse {
|
||||
summary: "ok".into(),
|
||||
proposals: vec![candidate],
|
||||
rejected_candidates: Vec::new(),
|
||||
};
|
||||
let (accepted, rejected, _) =
|
||||
validate_response(raw, &cfg(), &ExistingPageIndex::default());
|
||||
assert!(rejected.is_empty(), "{supplied:?}: got {rejected:?}");
|
||||
assert_eq!(
|
||||
accepted[0].expected_base_body_sha256, None,
|
||||
"{supplied:?} must not survive to staging"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn patch_to_missing_or_non_context_target_rejects() {
|
||||
let raw = AutoImproveLlmResponse {
|
||||
@@ -3673,3 +3768,64 @@ mod operation_normalization_tests {
|
||||
assert_eq!(parsed.operation, "create");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod edit_mode_normalization_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_spellings_models_actually_emit_are_accepted() {
|
||||
for raw in [
|
||||
"full_page",
|
||||
"full",
|
||||
"Full",
|
||||
"full-page",
|
||||
"Full Page",
|
||||
"FULLPAGE",
|
||||
"",
|
||||
" ",
|
||||
] {
|
||||
assert_eq!(
|
||||
normalize_edit_mode(raw),
|
||||
"full_page",
|
||||
"{raw:?} means full page and must normalise"
|
||||
);
|
||||
}
|
||||
for raw in ["patch", "PATCH", " Patch "] {
|
||||
assert_eq!(normalize_edit_mode(raw), "patch", "{raw:?} must normalise");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_edit_modes_are_left_to_fail() {
|
||||
for raw in ["rewrite", "replace", "append", "diff", "delete"] {
|
||||
assert_eq!(
|
||||
normalize_edit_mode(raw),
|
||||
raw,
|
||||
"{raw:?} is not a supported mode and must keep failing validation"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schema_constrains_edit_mode_to_the_supported_values() {
|
||||
let schema = schemars::schema_for!(AutoImproveProposal);
|
||||
let value = serde_json::to_value(&schema).expect("schema serialises");
|
||||
let variants = value
|
||||
.pointer("/properties/edit_mode/enum")
|
||||
.and_then(|e| e.as_array())
|
||||
.expect("edit_mode carries an enum constraint");
|
||||
assert_eq!(
|
||||
variants,
|
||||
&vec![serde_json::json!("full_page"), serde_json::json!("patch")]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_non_canonical_edit_mode_still_deserialises() {
|
||||
let parsed: AutoImproveProposal =
|
||||
serde_json::from_value(serde_json::json!({ "edit_mode": "full" }))
|
||||
.expect("must not fail to parse; normalisation happens before validation");
|
||||
assert_eq!(parsed.edit_mode, "full");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user