Skip to content

Commit e4467e0

Browse files
v0.14.4 apiproxy HOTFIX: stop poisoning tool_use input dict shape
Previous v0.14.3 replaced the entire tool_use.input dict with {"_omc_compressed_input_marker": "..."}. The LLM then saw this in conversation history and started copying the pattern when generating new tool calls, producing InputValidationError on every Edit/Bash call. Fix: rewrite_strings_recursive walks the input value and replaces only LARGE STRING VALUES in place, preserving the original key names. So {"file_path": "/x", "content": "<2KB>"} becomes {"file_path": "/x", "content": "<omc:ref/>"} — recognizable as a normal Write call by the LLM, no fake schema introduced. Also dropped marker `preview` field for blocks >= 8KB. At that size the LLM either needs the whole thing (expand-tool) or skips it; preview adds zero decision-quality and ~200B per marker. Verified in isolation against mock upstream: - tool_use.input keeps `file_path` and `content` keys intact - tool_result.content rewritten to marker - last user message untouched (existing safety rule) - upstream-visible payload preserves the assistant's tool-calling shape Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 019d2a1 commit e4467e0

1 file changed

Lines changed: 47 additions & 19 deletions

File tree

omnimcode-apiproxy/src/main.rs

Lines changed: 47 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -548,23 +548,12 @@ fn rewrite_request_body(body: &[u8], state: &AppState) -> Result<(Bytes, Rewrite
548548
}
549549
}
550550
"tool_use" => {
551-
// Compress big `input` JSON (e.g., Write/Edit
552-
// calls where the LLM emitted file content).
551+
// Compress big string values INSIDE the input dict.
552+
// Crucially, preserve the original key names so the
553+
// LLM doesn't see (and thus copy) a fake field name
554+
// when generating fresh tool calls in later turns.
553555
if let Some(input) = block.get_mut("input") {
554-
let serialized = serde_json::to_string(input)
555-
.unwrap_or_default();
556-
if serialized.len() >= state.rewrite_threshold {
557-
if let Ok(marker) = make_marker(&serialized, state) {
558-
out.bytes_tool_use_input += serialized.len();
559-
out.rewritten_count += 1;
560-
// Wrap marker as an object so the JSON
561-
// remains structurally an object — many
562-
// LLM clients assume `input` is a dict.
563-
*input = serde_json::json!({
564-
"_omc_compressed_input_marker": marker
565-
});
566-
}
567-
}
556+
rewrite_strings_recursive(input, state, &mut out);
568557
}
569558
}
570559
_ => {}
@@ -582,6 +571,42 @@ fn rewrite_request_body(body: &[u8], state: &AppState) -> Result<(Bytes, Rewrite
582571
Ok((bytes, out))
583572
}
584573

574+
/// v0.14.4 — walk a JSON value and replace any large STRING values in place,
575+
/// preserving all key names so the LLM doesn't see (and copy) a fake field
576+
/// name when generating new tool calls. Used for `tool_use.input`.
577+
///
578+
/// Two layers of value-rewriting:
579+
/// 1. A top-level string longer than threshold → marker.
580+
/// 2. Any string FIELD inside an object whose value exceeds threshold →
581+
/// marker (e.g. `{"content": "...big..."} → {"content": "<omc:ref ...>"}`).
582+
/// 3. Array elements that are strings → same rule, in place.
583+
fn rewrite_strings_recursive(
584+
val: &mut Value, state: &AppState, out: &mut RewriteOutcome,
585+
) {
586+
match val {
587+
Value::String(s) => {
588+
if s.len() >= state.rewrite_threshold {
589+
if let Ok(marker) = make_marker(s, state) {
590+
out.bytes_tool_use_input += s.len();
591+
out.rewritten_count += 1;
592+
*val = Value::String(marker);
593+
}
594+
}
595+
}
596+
Value::Object(map) => {
597+
for (_k, v) in map.iter_mut() {
598+
rewrite_strings_recursive(v, state, out);
599+
}
600+
}
601+
Value::Array(arr) => {
602+
for v in arr.iter_mut() {
603+
rewrite_strings_recursive(v, state, out);
604+
}
605+
}
606+
_ => {}
607+
}
608+
}
609+
585610
fn rewrite_tool_result_content(
586611
inner: &mut Value, state: &AppState, out: &mut RewriteOutcome,
587612
) {
@@ -614,13 +639,16 @@ fn rewrite_tool_result_content(
614639
fn make_marker(text: &str, state: &AppState) -> Result<String> {
615640
let hash = state.store.store(PROXY_CACHE_NAMESPACE, text)
616641
.map_err(anyhow::Error::msg)?;
642+
// For very large blocks the LLM almost certainly wants either:
643+
// (a) the full content (expand via tool), or (b) to move on.
644+
// The preview adds no decision-quality. Drop it past 8 KB.
645+
if text.len() >= 8192 {
646+
return Ok(format!("<omc:ref h=\"{}\" b=\"{}\"/>", hash, text.len()));
647+
}
617648
let preview: String = text.chars()
618649
.filter(|c| !c.is_control())
619650
.take(state.preview_bytes)
620651
.collect();
621-
// The marker uses an XML-ish form because LLMs are well-trained on
622-
// tagged content and don't try to "interpret" attribute values as
623-
// executable. The proxy's expand tool is the LLM's way out.
624652
Ok(format!(
625653
"<omc:ref hash_str=\"{}\" bytes=\"{}\" preview={:?}/>",
626654
hash, text.len(), preview

0 commit comments

Comments
 (0)