Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
51 changes: 51 additions & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,57 @@ jobs:
- name: Build
run: make build

# The binary needs no C toolchain — asserted, not asserted-in-a-comment.
#
# Our own docs told every evaluator to install one (`docs/setup.md` even named bifrost's
# tokenizer as a cgo dependency, which it never was). The only cgo dependency is tree-sitter,
# behind the `cg_skeleton` build tag. Nothing checked that, and the `build-test` job above runs
# exclusively with CGO_ENABLED=1 because `go test -race` requires it — so the configuration a
# user would actually build was never exercised.
#
# CGO_ENABLED=0 is what does the work: with cgo off the toolchain never consults CC at all, so a
# dependency that needs it fails to BUILD here rather than in a stranger's terminal. CC is pointed
# at a nonexistent path anyway, as a tripwire for the day somebody sets CGO_ENABLED=1 in this job
# and expects it to still be testing the same thing.
purego:
runs-on: ubuntu-latest
env:
CGO_ENABLED: "0"
CC: /nonexistent-c-compiler
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.25'
check-latest: true
- name: Build with no C compiler available
run: |
go build -o /tmp/cg-purego ./cmd/context-guru-proxy
file /tmp/cg-purego | tee /dev/stderr | grep -q "statically linked"
- name: It has to start, not just link
run: |
LISTEN_ADDR=127.0.0.1:4471 CONTEXT_GURU_KEEPALIVE=off /tmp/cg-purego &
for _ in $(seq 1 40); do
sleep 0.25
curl -fsS http://127.0.0.1:4471/healthz && break
done
curl -fsS http://127.0.0.1:4471/healthz | grep -q ok
# And the guard that matters most to a CGO-free build: a preset naming a component which is
# not registered without cgo starts the proxy and then fails at pipeline build. That is the
# `preset: coding` / `unknown component "skeleton"` breakage, and TestEveryPresetBuilds
# covers it — but it had never run in this configuration.
# -p 1: run one package binary at a time.
#
# A CI runner has 2 cores, and `go test` otherwise starts up to GOMAXPROCS package binaries
# in parallel. Adding this job therefore added a SECOND heavily-parallel run of the proxy
# package per PR, and under that contention a timing-sensitive control-plane test
# (TestCtlGetCampaignAggregatesPredictedAndRealPerTenant — rossoctl/context-guru#163) failed
# twice on unrelated PRs, then passed on re-run and passes 3/3 locally on main and on the
# branch. That flake is not this job's business to hunt, but it IS this job's business not to
# provoke it: serialising costs about a minute and removes the contention this job added.
- name: Test the packages whose behaviour depends on which components compile in
run: go test -p 1 ./config/... ./components/... ./apply/... ./proxy/... ./store/...

trivy:
runs-on: ubuntu-latest
steps:
Expand Down
4 changes: 3 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,9 @@ reference — port its *logic*, re-implement its transport in Go.

## Conventions

- Go 1.26, module `github.com/rossoctl/context-guru`. Build needs `CGO_ENABLED=1` (tree-sitter).
- Go 1.26, module `github.com/rossoctl/context-guru`. The build is pure Go — `make build` sets
`CGO_ENABLED=0`. cgo is needed only for `go test -race` and for `-tags cg_skeleton` (tree-sitter),
which is why the Makefile still exports `CGO_ENABLED=1` for the test targets.
- Match the surrounding code's style; keep packages small and single-purpose.
- **Commits: DCO sign-off is mandatory** — `git commit -s`. Author as the repo owner.
AI attribution uses `Assisted-By:` — never `Co-Authored-By`, never a "Generated with"
Expand Down
22 changes: 18 additions & 4 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,16 @@ LDFLAGS := -s -w \
-X $(PKG)/internal/buildinfo.Version=$(VERSION) \
-X $(PKG)/internal/buildinfo.Commit=$(COMMIT)

# CGO is required to compile the tree-sitter binding; a C toolchain (gcc/clang)
# must be present for make test/build/lint.
# CGO is on for DEVELOPMENT, and the reason is `go test -race`, which does not work
# without it. It is NOT a requirement of the shipped binary: tree-sitter is the only cgo
# dependency in the tree and it is behind the `cg_skeleton` build tag, so a default build
# is pure Go, and `make build` now builds it that way — so the documented "no C toolchain" claim
# is true of the command the docs actually tell you to run, which it was not while this variable
# applied to every target. CI proves it with no compiler on PATH at all (the `purego` job).
#
# Reading this variable as a SHIPPING requirement is what put "install a C toolchain" at the top of
# our own quickstart, and named bifrost's tokenizer as a cgo dependency in docs/setup.md, which it
# never was. It is needed for `go test -race` and for `-tags cg_skeleton`; nothing else.
export CGO_ENABLED=1

.DEFAULT_GOAL := help
Expand All @@ -18,9 +26,15 @@ help: ## Display this help
awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-12s\033[0m %s\n", $$1, $$2}'

.PHONY: build
build: ## Build the context-guru-proxy binary into ./bin
build: ## Build the context-guru-proxy binary into ./bin (pure Go — no C toolchain needed)
@mkdir -p bin
go build -ldflags "$(LDFLAGS)" -o bin/$(BINARY) ./cmd/context-guru-proxy
CGO_ENABLED=0 go build -ldflags "$(LDFLAGS)" -o bin/$(BINARY) ./cmd/context-guru-proxy

.PHONY: build-static
build-static: ## Same as build, plus -trimpath — the exact build releases ship
@mkdir -p bin
CGO_ENABLED=0 go build -trimpath -ldflags "$(LDFLAGS)" -o bin/$(BINARY) ./cmd/context-guru-proxy
@file bin/$(BINARY) 2>/dev/null || true

.PHONY: test
test: ## Run all tests with the race detector
Expand Down
15 changes: 9 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,9 @@ flowchart TD

## Install

Requires **Go 1.26** and a **C toolchain** (`CGO_ENABLED=1`). Build from the repo root:
Requires **Go 1.26**; no C toolchain — `make build` builds with cgo off and produces a statically
linked binary. (A C compiler is needed only for `make test`'s race detector and the optional
`cg_skeleton` tag.) Build from the repo root:

```sh
CGO_ENABLED=1 go build -tags cg_skeleton -o bin/context-guru-proxy ./cmd/context-guru-proxy
Expand All @@ -108,7 +110,7 @@ docker build -t context-guru:local .

```sh
# 1 — run the proxy (ships with the SWE-bench-winning cache-aware config by default)
./bin/context-guru-proxy # --preset codesmart; listens on :4000 (LISTEN_ADDR to change)
./bin/context-guru-proxy # --preset house (the default); listens on :4000

# 2 — point any agent at it (one port serves both dialects)
export ANTHROPIC_BASE_URL=http://localhost:4000/anthropic
Expand All @@ -129,9 +131,10 @@ curl -s localhost:4000/anthropic/v1/messages \
-d '{"model":"...","max_tokens":64,"messages":[ ... ]}'
```

Presets: **`codesmart`** (the default — the SWE-bench-winning cache-aware config
`[format, toon, dedup, failed_run, cmdfilter, extract_llm, extract, cachesplit]`), **`codesafe`** (the same
minus the LLM pass — deterministic-only `[format, dedup, failed_run, cmdfilter, extract, collapse, cachesplit]`,
Presets: **`house`** is the binary's default. **`codesmart`** is the SWE-bench-winning
cache-aware config, `[format, textclean, searchfold, dedup, failed_run, cmdfilter, extract_llm, extract, linecap, cachesplit]`, and is what the
published benchmark numbers describe — pass `--preset codesmart` to run it. **`codesafe`** (the same
minus the LLM pass — deterministic-only `[format, textclean, searchfold, dedup, failed_run, cmdfilter, extract, collapse, linecap, cachesplit]`,
zero model calls by policy), plus `general`, `agent`, `aggressive`, `coding`, `mcp`, `balanced`, `safe`,
`summarize`, `off`.
`codesmart`'s LLM relevance-trimmer (`extract_llm`) engages only when a cheap model is configured
Expand All @@ -140,7 +143,7 @@ See [docs/components.md](docs/components.md) and [docs/reference/presets.md](doc

| Flag / env | Default | Purpose |
|---|---|---|
| `--preset` / `PRESET` | `codesmart` | pipeline preset when no `--config` |
| `--preset` / `PRESET` | `house` | pipeline preset when no `--config` |
| `--config` / `CONFIG` | — | YAML config (overrides preset) |
| `LISTEN_ADDR` | `:4000` | listen address |
| `--anthropic-upstream` / `ANTHROPIC_UPSTREAM` | `https://api.anthropic.com` | Anthropic upstream base |
Expand Down
58 changes: 58 additions & 0 deletions components/all/hasoffload_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package all

import (
"testing"

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

// Pipeline.HasOffload must agree with the Offload interface for every component the binary can
// build — that agreement is the whole reason the gate asks the interface instead of keeping a list
// of "lossy" component names, and it is what stops the list-shaped version of this from rotting.
//
// This lives in `all` rather than in `components` because the registry is only populated here: the
// blank imports in all.go are what run each component's init(). The same test inside `components`
// skipped for lack of registrations, which looked like coverage and was not.
//
// It walks the registry rather than a fixture, so a new lossy component needs no change here, and a
// component that quietly stops implementing Offload fails immediately.
func TestHasOffloadAgreesWithTheOffloadInterface(t *testing.T) {
names := components.Names()
if len(names) == 0 {
t.Fatal("no components registered: this package's blank imports are what register them, " +
"so an empty registry means the test is checking nothing")
}
var checked, offloaders int
for _, name := range names {
c, err := components.New(name, nil)
if err != nil || c == nil {
// A component that will not build from a nil config block; the proxy-level tests
// exercise those with real configuration.
continue
}
_, isOffload := c.(components.Offload)
if isOffload {
offloaders++
}
got := components.NewPipeline([]components.Component{c}, nil).HasOffload()
if got != isOffload {
t.Errorf("component %q: implements components.Offload = %v, but a pipeline holding "+
"only it reports HasOffload() = %v", name, isOffload, got)
}
checked++
}
if checked == 0 {
t.Fatal("no component could be built from a nil config, so nothing was actually checked")
}
// Both sides must be non-empty or the agreement is trivial: all-false would agree with a
// HasOffload that always returns false, which is the mirror-image defect.
if offloaders == 0 {
t.Errorf("checked %d components and none implement Offload — either the registry is not "+
"what it should be, or this test would pass against a broken HasOffload", checked)
}
if offloaders == checked {
t.Errorf("all %d checked components implement Offload, so this test would also pass "+
"against a HasOffload that always returns true", checked)
}
t.Logf("checked %d of %d registered components; %d implement Offload", checked, len(names), offloaders)
}
66 changes: 66 additions & 0 deletions components/hasoffload_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package components

import (
"testing"

schemas "github.com/maximhq/bifrost/core/schemas"
)

// HasOffload's whole selling point is that it cannot rot the way a list of component names would,
// so it is worth a test in its own package rather than only proxy-level integration coverage.
//
// It is a type assertion over the pipeline's components, and the property that matters is that it
// answers by INTERFACE: a component that drops bytes must satisfy components.Offload, and adding
// one must not require anybody to remember to update a second list somewhere. These fakes stand in
// for the three shapes a real component takes.

type offloadStub struct{ name string }

func (o offloadStub) Name() string { return o.name }
func (o offloadStub) Enabled(*Ctx) bool { return true }
func (o offloadStub) Offload(*schemas.BifrostChatRequest, *Report, *Ctx) ([]string, error) {
return []string{"key"}, nil
}

type reformatStub struct{ name string }

func (r reformatStub) Name() string { return r.name }
func (r reformatStub) Enabled(*Ctx) bool { return true }
func (r reformatStub) Reformat(*schemas.BifrostChatRequest, *Report, *Ctx) error { return nil }

func TestHasOffload(t *testing.T) {
for _, c := range []struct {
name string
comps []Component
want bool
}{
{"nil pipeline", nil, false},
{"empty pipeline — the A/B control arm", []Component{}, false},
{"reformatters only", []Component{reformatStub{"format"}, reformatStub{"cachesplit"}}, false},
{"one offloader", []Component{offloadStub{"linecap"}}, true},
{"an offloader among reformatters", []Component{
reformatStub{"format"}, offloadStub{"mask"}, reformatStub{"cachesplit"}}, true},
// Position must not matter: the host asks "can this pipeline ever mint a marker", which is
// a property of the set, not of the order.
{"offloader last", []Component{reformatStub{"format"}, offloadStub{"extract"}}, true},
} {
t.Run(c.name, func(t *testing.T) {
var p *Pipeline
if c.comps != nil || c.name != "nil pipeline" {
p = NewPipeline(c.comps, nil)
}
if got := p.HasOffload(); got != c.want {
t.Errorf("HasOffload() = %v, want %v", got, c.want)
}
})
}
}

// TestHasOffloadIsNilSafe: the host calls this on a per-request pipeline that a fail-open path may
// leave nil, and a panic there would take down a request rather than degrade it.
func TestHasOffloadIsNilSafe(t *testing.T) {
var p *Pipeline
if p.HasOffload() {
t.Error("a nil pipeline reported an offloader")
}
}
24 changes: 24 additions & 0 deletions components/pipeline.go
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,30 @@ func (p *Pipeline) Find(name string) Component {
return nil
}

// HasOffload reports whether any component in this pipeline is an Offload — i.e. whether a
// request through it can produce a `<<cg:HASH>>` marker at all.
//
// Asked by the host to decide whether advertising `context_guru_expand` is meaningful. A
// pipeline with no Offload mints no markers, so every expand call a model makes against it MUST
// fail: there is nothing in the Store to resolve. Advertising it there costs a wasted round trip
// and a step of the user's turn to learn that.
//
// A type assertion, deliberately, rather than a list of component names: a name list is a second
// copy of "which components are lossy" that drifts the moment somebody adds one, and the
// interface is the definition. `components.Offload` cannot be implemented by accident — it
// requires returning cache keys that prove the original was stashed.
func (p *Pipeline) HasOffload() bool {
if p == nil {
return false
}
for _, c := range p.comps {
if _, ok := c.(Offload); ok {
return true
}
}
return false
}

// Has reports whether a component with this name is configured in the pipeline.
// Hosts use it to gate body-level work that belongs to a component's concern but
// cannot be done inside it — e.g. cacheinject's cache-prefix repair, which must
Expand Down
9 changes: 6 additions & 3 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -402,8 +402,11 @@ var presets = map[string][]string{
// stacking our offloaders beside it would reduce the same tool outputs first and
// there would be nothing left to attribute.
"agentdiet": {"format", "agentdiet", "cachesplit"},
// codesmart / codesafe are the SWE-bench study's winning configs, shipped as the
// recommended defaults (codesmart is the proxy default). Their tuned per-component
// codesmart / codesafe are the SWE-bench study's winning configs and the RECOMMENDED
// pipelines — but neither is the proxy default, which is `house` (see the --preset flag in
// cmd/context-guru-proxy/main.go). Asserting it here is how five documents came to repeat
// it: the claim was copied from three lines away from the flag that disproves it. Their
// tuned per-component
// settings live in presetConfigs; the name-lists here keep PresetPipeline (used by
// /compact?preset=) resolving them.
"codesmart": {"format", "textclean", "searchfold", "dedup", "failed_run", "cmdfilter", "extract_llm", "extract", "linecap", "cachesplit"},
Expand Down Expand Up @@ -442,7 +445,7 @@ var presets = map[string][]string{
// reader relies on when deciding whether the published numbers describe the shipped
// default. They describe an ancestor of it. Treat any preset change as a reason to
// re-measure, not as a documentation edit.
// - codesmart (the winning cache-aware config, and the proxy default): the LLM
// - codesmart (the winning cache-aware config; NOT the proxy default, which is `house`): the LLM
// relevance-trimmer extract_llm routed to the CHEAP model (model.source: config,
// nil-when-unset ⇒ it silently no-ops to deterministic — see docs), gated at 3000
// tok so most turns make no model call, ≤4 calls/req; the free deterministic extract
Expand Down
12 changes: 7 additions & 5 deletions docs/components.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ messages (`role:"tool"`; for Anthropic, `tool_result` blocks normalized to that
| `summarize` | Offload (LLM) | the middle of the transcript → one summary | via expand | long trajectories | `summary_level` (regular), `keep_last` (3), `min_tokens` (500), `resummarize_tokens` (6000), `model.source`, `trigger` |
| [`agentdiet`](components/agentdiet.md) | Offload (LLM) | useless/redundant/**expired** content in the step that just aged past the delay | via expand | a step above `min_step_tokens`, `delay_steps` turns back | `delay_steps` (2), `context_steps` (1), `min_step_tokens` (500), `min_saved_tokens` (400), `max_keep_ratio` (0.8), `model.source` |

Presets (`config/config.go`), verbatim: **`codesmart`** (the proxy default)
Presets (`config/config.go`), verbatim: **`house`** (the proxy default), **`codesmart`** (the SWE-bench arm)
`[format, textclean, searchfold, dedup, failed_run, cmdfilter, extract_llm, extract, linecap, cachesplit]` ·
**`codesafe`**
`[format, textclean, searchfold, dedup, failed_run, cmdfilter, extract, collapse, linecap, cachesplit]`
Expand Down Expand Up @@ -79,10 +79,12 @@ Absolutes (`min_request_tokens`, etc.) still win; when the window is unknown, fr
absolutes apply (backward compatible). This lets one config generalize across models/benchmarks.

**Reversibility in practice.** The `context_guru_expand` tool is advertised on outgoing requests
(`INJECT_EXPAND=auto|always|never`, default `auto` = whenever the request already declares
tools and the store persists), so Offload markers are genuinely recoverable — not just described in marker text.
Both conditions are properties of the **session**, not of the turn, so the `tools` array a session
sends is byte-identical on every request in it. That matters more than it looks: `tools` sits ahead of
(`INJECT_EXPAND=auto|always|never`, default `auto` = whenever the request already declares tools,
the store persists, **and the pipeline contains at least one Offload**), so Offload markers are
genuinely recoverable — not just described in marker text. That third condition is what keeps the
tool off pipelines that mint no markers, where every call to it would have to fail.
All three conditions are properties of the **session**, not of the turn, so the `tools` array a
session sends is byte-identical on every request in it. That matters more than it looks: `tools` sits ahead of
`system` and `messages` in the provider's prompt-cache hash, so the first request carrying a **new**
tools array re-creates the **entire** prefix at the write rate. `auto` used to also require a marker on
the request, which made the array grow on the first offloading turn and shrink again on the next turn
Expand Down
Loading
Loading