Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
97 commits
Select commit Hold shift + click to select a range
29908d8
docs(coref): design co-reference-aware compaction
amiddavid Aug 17, 2026
557449a
feat(coref): add the co-reference index and offload component
amiddavid Aug 17, 2026
756b595
feat(harbor): measure co-reference density on real agent traffic
amiddavid Aug 17, 2026
0362bd8
docs(coref): add the component reference, a cheat sheet, and nav
amiddavid Aug 17, 2026
e7a2623
fix(coref): never cut an output the index cannot see into
amiddavid Aug 17, 2026
e4a196b
docs(coref): address review — worked examples, split status, reward gate
amiddavid Aug 17, 2026
39a3fb2
docs(coref): separate Tier-2's blind detector from Tier-3's absent one
amiddavid Aug 17, 2026
9b09bf8
fix(coref): make the marker addressable and stop it claiming safety
amiddavid Aug 17, 2026
95f1965
fix(coref): lower min_batch_frac to a value real traffic can clear
amiddavid Aug 17, 2026
1017b08
docs(coref): record the deferral gate as designed and unquantified
amiddavid Aug 17, 2026
a068fab
docs(coref): record the held-out selection experiment and correct wha…
amiddavid Aug 19, 2026
9d5f937
fix(offload): scope the kept-verbatim guard by session and pin it
amiddavid Aug 19, 2026
eae9850
docs(coref): measure whether the deferral prize is reachable, and fix…
amiddavid Aug 19, 2026
720fecc
docs(reproduce): record that the benchmarks cannot run on Apple silic…
amiddavid Aug 19, 2026
55a817f
fix(coref.py): stop a session-key collision discarding 98% of a bench…
amiddavid Aug 20, 2026
b156686
docs: reattribute the ~27% saving from mask to extract_llm
amiddavid Aug 20, 2026
a5db8b7
fix(extract_llm): price the applied turn as a cache-write, not a cach…
amiddavid Aug 20, 2026
263c5de
docs(coref): first LOCA run -- coref acts, and mask removes 8.3x more
amiddavid Aug 20, 2026
3eb72d1
feat(extract_llm): allow_cached_prefix -- full-body reach, priced and…
amiddavid Aug 20, 2026
48ffc1b
docs(coref): deferral measured on LOCA -- 72% fewer summarizations, a…
amiddavid Aug 20, 2026
7c9347d
feat(extract_llm): prefix_classes -- make the pre-filter's class sele…
amiddavid Aug 20, 2026
ebd0e72
docs(experiments): add the iterNNN experiment log, retro-fitted
amiddavid Aug 20, 2026
753f3de
docs(experiments): iter003 -- reward integration works; correct an ov…
amiddavid Aug 20, 2026
0226e74
docs(experiments): iter004 design and pre-registered reading, before …
amiddavid Aug 20, 2026
7409b4c
docs(experiments): iter004 -- reward arms invalid, and why; the fold …
amiddavid Aug 21, 2026
200500f
docs(experiments): iter004b -- reward parity, demonstrated per-task n…
amiddavid Aug 21, 2026
80e95d5
fix(summarize): emit the summary as a user message, not system
amiddavid Aug 21, 2026
f7ed8f2
docs(experiments): iter005 -- deferral blocked by a shipped summarize…
amiddavid Aug 21, 2026
0971a32
fix(summarize): drop tool_results orphaned by the span it removes
amiddavid Aug 21, 2026
2b7e9f9
docs(experiments): iter005b/005c -- two summarize fixes, a third defe…
amiddavid Aug 21, 2026
2d6902d
fix(summarize): make a tool exchange atomic, fixing both pairing defe…
amiddavid Aug 21, 2026
d6c5231
feat(schema): static message-shape validator, and assert every preset…
amiddavid Aug 21, 2026
2edf6a1
feat(harbor): measure whether a prefix rewrite was ever already free
amiddavid Aug 21, 2026
bce735a
docs(coref): record what "fold" actually means, and that a rewrite is…
amiddavid Aug 21, 2026
b45b5dd
docs: capture the session's design discussion so it survives context …
amiddavid Aug 21, 2026
f6ce269
docs: close three gaps found by auditing the session's discussion points
amiddavid Aug 21, 2026
cd2d006
docs(experiments): root-cause the HTML 400s to the replay shim; stop …
amiddavid Aug 21, 2026
0045797
docs(results): correct the LOCA band table; three rows were rig artif…
amiddavid Aug 21, 2026
212977b
docs: note the two-sided effect, align the shim with forever, record …
amiddavid Aug 21, 2026
e2b1974
docs(experiments): 32k band solves 53% vs 64k's 25% -- the band was t…
amiddavid Aug 21, 2026
cd46b36
docs(results): write down the headroom argument and the reasoning err…
amiddavid Aug 21, 2026
cd1e8e5
docs(results): re-score the selection experiment for $0; merged stays…
amiddavid Aug 21, 2026
ecf4103
docs(experiments): pre-register iteration 010 before running it
amiddavid Aug 21, 2026
4eecbdc
docs(experiments): amend iter010 to n=75 before running; correct 5x c…
amiddavid Aug 21, 2026
ccd1a32
docs: correct the 5x n and cost error across every doc that carried it
amiddavid Aug 21, 2026
2936af7
feat(harbor): add paired reward analysis, validated before the arms land
amiddavid Aug 21, 2026
77f36e3
docs(experiments): pre-register iteration 011, the deferral experiment
amiddavid Aug 21, 2026
d8ed3cf
fix(harbor): report per-arm error counts; retract iter008's zero-erro…
amiddavid Aug 21, 2026
65f5962
feat(harbor): capture the request that causes the HTML 400, at the mo…
amiddavid Aug 21, 2026
535de68
docs(experiments): log the ITT deviation and three refuted HTML-400 h…
amiddavid Aug 21, 2026
4f6a57a
docs(experiments): localise the extra HTML-400s to the CG proxy hop
amiddavid Aug 21, 2026
ba7f013
docs(experiments): iteration 010 results -- no reward effect, and the…
amiddavid Aug 21, 2026
04a23ce
docs(experiments): abort iteration 011 -- summarize has a fourth shap…
amiddavid Aug 21, 2026
2ec2445
fix(schema): accept parallel tool exchanges; add tests reproducing an…
amiddavid Aug 21, 2026
ded9636
docs(experiments): pre-register iteration 012, the fold without summa…
amiddavid Aug 21, 2026
f378d5d
docs(experiments): iteration 012 -- savings_pct is inflated 3-8x and …
amiddavid Aug 21, 2026
62126f4
fix(apply): recover Anthropic tool-call ids; they were invisible to a…
amiddavid Aug 22, 2026
e4c4284
docs(experiments): record the apply root cause and two vacuous checks
amiddavid Aug 22, 2026
caf32d7
fix(apply): stop leaking role="tool" onto Anthropic wire on a count c…
amiddavid Aug 22, 2026
feaebb9
docs(experiments): record the two-stage fix and its live verification
amiddavid Aug 22, 2026
a048bc3
docs(experiments): record a third defect of the same family (thinking…
amiddavid Aug 22, 2026
728cfc3
docs(experiments): iter011 arm 1 -- 94.7% savings, 63% more money, sa…
amiddavid Aug 22, 2026
aab8e19
docs(experiments): iteration 011 complete -- deferral is real, monoto…
amiddavid Aug 22, 2026
838e8cf
docs: retract iter011's economic magnitude; disambiguate 'fold' vs 'm…
amiddavid Aug 22, 2026
d91b445
feat(extract_llm): add the merged design as bulk adjudication (select…
amiddavid Aug 22, 2026
fe4d8d0
docs(experiments): pre-register iteration 014, merged vs separate com…
amiddavid Aug 22, 2026
91b818e
docs(experiments): iter013 arm 1; shim repairs is the wrong lossy-bas…
amiddavid Aug 22, 2026
4ed3669
docs(experiments): 128k reveals a negative interaction between proxy …
amiddavid Aug 23, 2026
1d18312
docs(experiments): iteration 014 -- the merged design mostly declines…
amiddavid Aug 23, 2026
4d36f32
docs(experiments): record the stale-proxy bug that invalidated two arms
amiddavid Aug 23, 2026
4ca1f13
fix(extract_llm): merged mode was never bulk -- it adjudicated one ou…
amiddavid Aug 23, 2026
af760c6
docs(experiments): retract the 'proxy suppresses agent clearing' expl…
amiddavid Aug 23, 2026
4bc6905
docs(experiments): answer why CG arms carry 8x the context -- tool re…
amiddavid Aug 23, 2026
21c0ea1
docs(experiments): expand is CALLED and REFUSED -- corrects the zero-…
amiddavid Aug 23, 2026
459e532
docs+feat: always-advertise expand, and count the flap instead of inf…
amiddavid Aug 23, 2026
327643a
docs(experiments): iter016 stopped -- always-advertise fails, and two…
amiddavid Aug 23, 2026
7419d7e
docs(experiments): withdraw the "CG arms carry 8x the context" claim …
amiddavid Aug 23, 2026
0afa393
fix(proxy): answer unresolvable expand calls instead of relaying them…
amiddavid Aug 23, 2026
b818725
docs(experiments): pre-register iteration 017, the fixed expand loop
amiddavid Aug 23, 2026
e8f3f5b
fix(expand): restore expand results on the REQUEST path, covering the…
amiddavid Aug 23, 2026
619fd35
fix(metrics): surface expand_restored; measure whether the refusal re…
amiddavid Aug 23, 2026
6b10e7b
docs(experiments): iteration 019 — probe why merged declines, and cor…
amiddavid Aug 25, 2026
cc1aa9f
feat(offload): run merged as specified — bulk batch, forced obligatio…
amiddavid Aug 25, 2026
5bcc118
docs(experiments): iter020 amendment 1 — disable the econ gate for th…
amiddavid Aug 25, 2026
a9d666f
feat(components): prefix asks — put the question to the request's mod…
amiddavid Aug 26, 2026
9514a27
fix(proxy): pass the RESOLVED session to a prefix ask, and test the w…
amiddavid Aug 26, 2026
22263bb
docs(results): the floor and the gate — min_tokens vs economic_gate
amiddavid Aug 26, 2026
92da9d9
fix(offload): record the OFFERED batch, and stop the merged call firi…
amiddavid Aug 26, 2026
659e7a6
fix(cheapmodel): the adjudication reply was being truncated, and it r…
amiddavid Aug 26, 2026
fcf78cd
feat(adjudicate): declare a maintenance tool so the model answers in …
amiddavid Aug 26, 2026
2cc4232
docs(experiments): iteration 020 results -- 22/75 at parity cost, and…
amiddavid Aug 26, 2026
04edf94
docs(experiments): pre-register iteration 021, merged versus the ship…
amiddavid Aug 26, 2026
e351c70
docs(experiments): iteration 021 frozen inputs, recorded before launch
amiddavid Aug 26, 2026
11dd046
docs(experiments): iter021 amendment 1 -- errored runs score zero, an…
amiddavid Aug 26, 2026
3c92ea4
docs(experiments): correct iteration 020 from 22/75 to 21/75
amiddavid Aug 26, 2026
7fda3fd
docs(experiments): iter021 amendment 2 -- arm B ran below its coverag…
amiddavid Aug 26, 2026
f45702e
docs(experiments): iteration 021 results -- merged adds nothing detec…
amiddavid Aug 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 122 additions & 0 deletions apply/anthropic_toolrole_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
package apply_test

import (
"context"
"encoding/json"
"testing"

bschemas "github.com/maximhq/bifrost/core/schemas"
"github.com/tidwall/gjson"

"github.com/rossoctl/context-guru/apply"
"github.com/rossoctl/context-guru/components"
"github.com/rossoctl/context-guru/store"
)

// A count-changing component (summarize) TOGETHER WITH one that rewrites tool text (format) on
// ANTHROPIC wire bytes. This combination is what live traffic always runs, and it exposed a defect
// that neither component shows alone:
//
// 400 messages: Unexpected role "tool"
//
// A synthetic role=tool message is an internal representation only; Anthropic has no such role. The
// rebuild emits a message verbatim only when it byte-matches its pre-pipeline form, so once format
// rewrote a tool message's text it fell through to a fresh marshal and leaked role="tool" onto the
// wire. Measured live at 12 of 39 runs, and it only became visible after the tool-call-id fix stopped
// these messages from being deleted outright -- one defect had been masking the other.
func TestAnthropicCountChangeNeverLeaksToolRole(t *testing.T) {
rows := make([]map[string]any, 0, 40)
for i := 0; i < 40; i++ {
rows = append(rows, map[string]any{
"id": 100000 + i, "path": "src/auth.py", "sym": "TOKEN_GRACE_41ab",
"status": "ok", "note": "row that format can restructure",
})
}
bigB, _ := json.MarshalIndent(rows, "", " ")
big := string(bigB)
msgs := []map[string]any{{"role": "user", "content": "start"}}
for i := 0; i < 8; i++ {
a, b := "pa_"+string(rune('a'+i)), "pb_"+string(rune('a'+i))
msgs = append(msgs,
map[string]any{"role": "assistant", "content": []map[string]any{
{"type": "text", "text": "calling two"},
{"type": "tool_use", "id": a, "name": "Read", "input": map[string]any{}},
{"type": "tool_use", "id": b, "name": "Read", "input": map[string]any{}},
}},
map[string]any{"role": "user", "content": []map[string]any{
{"type": "tool_result", "tool_use_id": a, "content": big},
{"type": "tool_result", "tool_use_id": b, "content": big},
}})
}
msgs = append(msgs, map[string]any{"role": "user", "content": "final question"})
body, _ := json.Marshal(map[string]any{"model": "claude-x", "messages": msgs})

rewroteAny := false
for _, keep := range []int{1, 2, 3, 4} {
cfg := pipe(t, "pipeline: [format, summarize]\ncomponents:\n summarize: {keep_last: "+
string(rune('0'+keep))+", start_from_message: 0, min_tokens: 1}\n")
p, _ := cfg.Build(nil)
out, changed := apply.BodyWithModel(context.Background(), p,
store.NewMemory(store.Options{}), bschemas.Anthropic, body, "", false,
components.ModelSpec{Incoming: stubModel{resp: "essential facts"}})
if !changed {
continue
}
// PRECONDITION. If no tool_result text was rewritten, the failing path is never entered and
// a pass proves nothing -- the first version of this test passed with the fix removed for
// exactly that reason. Fail loudly rather than pass vacuously.
rewrote := false
gjson.GetBytes(out, "messages").ForEach(func(_, m gjson.Result) bool {
m.Get("content").ForEach(func(_, blk gjson.Result) bool {
if blk.Get("type").String() == "tool_result" &&
blk.Get("content").String() != big && blk.Get("content").Exists() {
rewrote = true
}
return true
})
return true
})
if rewrote {
rewroteAny = true
}
arr := gjson.GetBytes(out, "messages").Array()
for i, m := range arr {
if r := m.Get("role").String(); r != "user" && r != "assistant" && r != "system" {
t.Fatalf("keep_last=%d: wire message %d has role %q -- Anthropic rejects this",
keep, i, r)
}
var uses []string
m.Get("content").ForEach(func(_, blk gjson.Result) bool {
if blk.Get("type").String() == "tool_use" {
uses = append(uses, blk.Get("id").String())
}
return true
})
if len(uses) == 0 {
continue
}
answered := map[string]bool{}
if i+1 < len(arr) {
arr[i+1].Get("content").ForEach(func(_, blk gjson.Result) bool {
if blk.Get("type").String() == "tool_result" {
answered[blk.Get("tool_use_id").String()] = true
}
return true
})
}
for _, u := range uses {
if !answered[u] {
t.Fatalf("keep_last=%d: wire message %d declares %q unanswered", keep, i, u)
}
}
}
}
// Asserted ONCE, across all keep_last values: at least one case must have carried a rewritten
// tool_result through a count change, or the role-leak path was never entered and a pass proves
// nothing. The first version of this test passed with the fix removed for exactly that reason,
// which is the same vacuous-check trap recorded twice in docs/experiments/loca/.
if !rewroteAny {
t.Fatal("no case produced a rewritten tool_result surviving a count change, so this test " +
"cannot detect the defect it exists for; fix the fixture")
}
}
113 changes: 109 additions & 4 deletions apply/apply.go
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,7 @@ func BodyOpts(ctx context.Context, pipe *components.Pipeline, st store.Store, o
// provider's cap of four counts them all (issue #32, defect 2).
ExistingBreakpoints: wireBreakpoints(body),
Mode: mode,
PrefixAsk: o.PrefixAsk,
}
tr.Session, tr.CacheAware, tr.MaxCachedIdx, tr.Messages = sessionID, cacheAware, maxCachedIdx, len(norm)
// The eligible (attempted) denominator: what age/supersession offloaders were
Expand Down Expand Up @@ -343,7 +344,37 @@ func BodyOpts(ctx context.Context, pipe *components.Pipeline, st store.Store, o
// retained message's ORIGINAL raw bytes (byte-lossless, incl. Anthropic
// tool_result) and marshaling only genuinely new messages (the summary).
if len(chat.Input) != len(norm) {
nb, ok := rebuildCountChanged(body, msgsRaw.Array(), normPre, slots, chat.Input)
// Write Anthropic tool_result text rewrites into the BODY before rebuilding.
//
// A synthetic role=tool message cannot be marshaled into an Anthropic request — the
// provider answers `messages: Unexpected role "tool"`. The rebuild below emits a message
// verbatim only when it byte-matches its pre-pipeline form, so a tool message whose text a
// component rewrote (format rewrites hundreds per run) would fall through to a fresh
// marshal and produce exactly that rejection. Measured live: 12 of 39 runs, immediately
// after the tool-call-id fix stopped these messages from being silently deleted — one
// defect had been masking the other.
//
// The write-back is the same mechanism the equal-count path uses (sjson at the block's
// exact path), applied here first so the rebuild only ever has to decide which messages
// to keep, never how to serialize one.
pre := body
for i := range norm {
if i >= len(slots) || slots[i].kind != anthropicToolText {
continue
}
id := norm[i].ChatToolMessage
if id == nil || id.ToolCallID == nil {
continue
}
newText, found := toolTextByID(chat.Input, *id.ToolCallID)
if !found || newText == slots[i].preText {
continue
}
if nb, err := sjson.SetBytes(pre, slots[i].path, newText); err == nil {
pre = nb
}
}
nb, ok := rebuildCountChanged(pre, gjson.GetBytes(pre, "messages").Array(), normPre, slots, norm, chat.Input)
if !ok && systemSplit {
res.Body, res.Changed = body, true // keep the split even when the rebuild declined
return res
Expand Down Expand Up @@ -670,6 +701,56 @@ func normalize(provider bschemas.ModelProvider, arr []gjson.Result) (norm []bsch
if err := json.Unmarshal([]byte(m.Raw), &cm); err != nil {
continue // unparseable message — leave it in the body untouched
}
// Recover ANTHROPIC tool calls, which bifrost's schema cannot represent.
//
// An Anthropic assistant turn carries its calls as `tool_use` CONTENT BLOCKS, and
// bschemas.ChatContentBlock has no such type — its Type enum is text/image_url/
// input_audio/file/refusal — so the ids are simply absent from the unmarshaled message.
// Anything that reasons about tool pairing therefore saw an assistant message with ZERO
// calls on every Anthropic request, which produced two defects from one cause:
//
// - dropOrphanedToolResults builds its answerable set from ToolCalls alone, so on
// Anthropic traffic the set was always empty, EVERY tool_result looked orphaned, and
// the "repair" deleted all of them. The provider then rejected the request for the
// unanswered calls that remained. Measured live: 28 of 75 runs
// (docs/experiments/loca/iter011/results.md).
// - schema.ValidateShape was blind to the same ids, so it could not see the breakage —
// which is why a test asserting all 11 presets emit shape-valid requests stayed green
// while live Anthropic traffic failed.
//
// Populating ToolCalls here fixes the whole class at the point where the dialect is still
// known, rather than teaching each consumer about Anthropic. The ids are what pairing
// needs; arguments are carried for completeness. Note this deliberately makes the
// `lossless` check below FALSE for such messages, which is honest — bifrost genuinely
// cannot round-trip them — and it only makes the write-back guard more conservative.
if provider == bschemas.Anthropic && cm.Role == bschemas.ChatMessageRoleAssistant &&
m.Get("content").IsArray() {
var calls []bschemas.ChatAssistantMessageToolCall
for _, blk := range m.Get("content").Array() {
if blk.Get("type").String() != "tool_use" {
continue
}
id, name := blk.Get("id").String(), blk.Get("name").String()
if id == "" {
continue
}
idc, namec, args := id, name, blk.Get("input").Raw
calls = append(calls, bschemas.ChatAssistantMessageToolCall{
ID: &idc,
Function: bschemas.ChatAssistantMessageToolCallFunction{
Name: &namec, Arguments: args,
},
})
}
if len(calls) > 0 {
if cm.ChatAssistantMessage == nil {
cm.ChatAssistantMessage = &bschemas.ChatAssistantMessage{}
}
if len(cm.ChatAssistantMessage.ToolCalls) == 0 {
cm.ChatAssistantMessage.ToolCalls = calls
}
}
}
preMarshal, _ := json.Marshal(cm)
norm = append(norm, cm)
slots = append(slots, slot{
Expand Down Expand Up @@ -713,7 +794,18 @@ func jsonEqual(a, b []byte) bool {
// normalized message (a survivor) is emitted as its ORIGINAL body raw bytes
// (byte-lossless); genuinely new messages (the summary) are marshaled fresh.
// Fail-open (returns body,false) if any survivor can't be mapped to the body.
func rebuildCountChanged(body []byte, orig []gjson.Result, normPre [][]byte, slots []slot, out []bschemas.ChatMessage) ([]byte, bool) {
// toolTextByID finds the post-pipeline text of the synthetic tool message answering id.
func toolTextByID(msgs []bschemas.ChatMessage, id string) (string, bool) {
for i := range msgs {
tm := msgs[i].ChatToolMessage
if tm != nil && tm.ToolCallID != nil && *tm.ToolCallID == id {
return schema.MessageText(msgs[i]), true
}
}
return "", false
}

func rebuildCountChanged(body []byte, orig []gjson.Result, normPre [][]byte, slots []slot, norm, out []bschemas.ChatMessage) ([]byte, bool) {
// The rebuild emits ONLY slot-mapped messages, so a body message normalize skipped
// (unparseable — it has no slot) would be silently DELETED from the forwarded
// request. Deleting a message is an ALTERED request, not a fail-open one, so decline
Expand All @@ -740,10 +832,23 @@ func rebuildCountChanged(body []byte, orig []gjson.Result, normPre [][]byte, slo
return body, false
}
matched := -1
for k := range normPre {
// A synthetic Anthropic tool message is matched by tool_call_id, NOT by bytes: its text may
// have been rewritten by a component, and that rewrite has already been written into the
// body above. Byte-matching it would fail and send it down the fresh-marshal path, which
// emits `role: "tool"` and is rejected outright by Anthropic.
if tm := out[i].ChatToolMessage; tm != nil && tm.ToolCallID != nil {
for k := range norm {
ntm := norm[k].ChatToolMessage
if !used[k] && ntm != nil && ntm.ToolCallID != nil &&
*ntm.ToolCallID == *tm.ToolCallID {
matched = k
break
}
}
}
for k := 0; matched < 0 && k < len(normPre); k++ {
if !used[k] && bytes.Equal(mb, normPre[k]) {
matched = k
break
}
}
if matched < 0 {
Expand Down
66 changes: 66 additions & 0 deletions apply/ctxmgmt_preserved_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package apply_test

import (
"context"
"encoding/json"
"strings"
"testing"

bschemas "github.com/maximhq/bifrost/core/schemas"
"github.com/tidwall/gjson"

"github.com/rossoctl/context-guru/apply"
"github.com/rossoctl/context-guru/components"
"github.com/rossoctl/context-guru/store"
)

// LOCA's context clearing is Anthropic's SERVER-SIDE context_management feature, passed as a request
// parameter (clear_tool_uses_20250919 with trigger input_tokens). If a count-changing component
// dropped that parameter while rewriting the body, the provider's clearing would be silently
// disabled -- and since the proxy also shrinks the request below the trigger, nothing would keep an
// oversized transcript legal. Five runs in the 128k arms died on "prompt is too long" at up to 6M
// tokens, so whether this survives is a product question, not a formality.
func TestContextManagementParamSurvivesCountChange(t *testing.T) {
big := strings.Repeat("verbose tool output line\n", 200)
msgs := []map[string]any{{"role": "user", "content": "go"}}
for i := 0; i < 6; i++ {
id := "t" + string(rune('a'+i))
msgs = append(msgs,
map[string]any{"role": "assistant", "content": []map[string]any{
{"type": "text", "text": "calling"},
{"type": "tool_use", "id": id, "name": "Read", "input": map[string]any{}}}},
map[string]any{"role": "user", "content": []map[string]any{
{"type": "tool_result", "tool_use_id": id, "content": big}}})
}
msgs = append(msgs, map[string]any{"role": "user", "content": "final"})
body, _ := json.Marshal(map[string]any{
"model": "claude-x",
"messages": msgs,
"context_management": map[string]any{"edits": []map[string]any{{
"type": "clear_tool_uses_20250919",
"trigger": map[string]any{"type": "input_tokens", "value": 128000},
"keep": map[string]any{"type": "tool_uses", "value": 3},
"clear_at_least": map[string]any{"type": "input_tokens", "value": 20000},
}}},
})
cfg := pipe(t, "pipeline: [format, summarize]\ncomponents:\n summarize: {keep_last: 2, start_from_message: 0, min_tokens: 1}\n")
p, _ := cfg.Build(nil)
out, changed := apply.BodyWithModel(context.Background(), p, store.NewMemory(store.Options{}),
bschemas.Anthropic, body, "", false,
components.ModelSpec{Incoming: stubModel{resp: "essential facts"}})
if !changed {
t.Skip("summarize did not act")
}
got := gjson.GetBytes(out, "context_management")
if !got.Exists() {
t.Fatal("context_management was DROPPED by the rewrite: the provider's own clearing would be " +
"silently disabled while the proxy also keeps requests under its trigger")
}
want := gjson.GetBytes(body, "context_management")
if got.Raw != want.Raw {
t.Errorf("context_management was altered:\n got %s\nwant %s", got.Raw, want.Raw)
}
if v := got.Get("edits.0.trigger.value").Int(); v != 128000 {
t.Errorf("trigger value changed to %d", v)
}
}
57 changes: 57 additions & 0 deletions apply/normalize_toolcalls_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package apply

import (
"encoding/json"
"testing"

bschemas "github.com/maximhq/bifrost/core/schemas"
"github.com/tidwall/gjson"
)

// Does normalize populate ChatAssistantMessage.ToolCalls for an ANTHROPIC assistant message whose
// tool calls arrive as CONTENT BLOCKS? dropOrphanedToolResults builds its "answerable"
// set exclusively from that field, so if it is empty on Anthropic traffic then every tool_result
// looks orphaned and the repair DELETES them all -- which is exactly what the instrumented rebuild
// showed (out contained no tool messages at all).
//
// The offload-level tests passed because they hand-build ChatMessages with ToolCalls populated, i.e.
// the OpenAI-shaped representation. That is why unit tests were green while live Anthropic traffic
// failed 28/75.
func TestNormalizePopulatesToolCallsForAnthropicBlocks(t *testing.T) {
body, _ := json.Marshal(map[string]any{
"model": "claude-x",
"messages": []map[string]any{
{"role": "user", "content": "go"},
{"role": "assistant", "content": []map[string]any{
{"type": "text", "text": "calling"},
{"type": "tool_use", "id": "t1", "name": "Read", "input": map[string]any{}},
}},
{"role": "user", "content": []map[string]any{
{"type": "tool_result", "tool_use_id": "t1", "content": "the output"},
}},
},
})
arr := gjson.GetBytes(body, "messages").Array()
norm, _ := normalize(bschemas.Anthropic, arr)
for i, m := range norm {
n := 0
if m.ChatAssistantMessage != nil {
n = len(m.ChatAssistantMessage.ToolCalls)
}
id := ""
if m.ChatToolMessage != nil && m.ChatToolMessage.ToolCallID != nil {
id = *m.ChatToolMessage.ToolCallID
}
t.Logf("norm[%d] role=%-9s ToolCalls=%d answers=%q", i, m.Role, n, id)
}
var calls int
for _, m := range norm {
if m.ChatAssistantMessage != nil {
calls += len(m.ChatAssistantMessage.ToolCalls)
}
}
if calls == 0 {
t.Errorf("no assistant ToolCalls recovered from Anthropic tool_use blocks: "+
"dropOrphanedToolResults will treat every tool_result as an orphan and delete it")
}
}
Loading