diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index a8e3784..0062e28 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -31,7 +31,7 @@ jobs: steps: - uses: actions/checkout@v4 - name: Trivy filesystem scan - uses: aquasecurity/trivy-action@0.28.0 + uses: aquasecurity/trivy-action@v0.36.0 with: scan-type: fs ignore-unfixed: true diff --git a/.gitignore b/.gitignore index a715f63..71040df 100644 --- a/.gitignore +++ b/.gitignore @@ -168,3 +168,6 @@ cython_debug/ *.test *.out coverage.txt + +# benchmark run output (results summarized in docs/PR, not tracked) +deploy/eval-containers/sweep-results.csv diff --git a/CLAUDE.md b/CLAUDE.md index 2c04931..364796b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,26 +1,29 @@ # CLAUDE.md -Guidance for working in `lab-context-engineering`, a Kagenti platform component. +Guidance for working in `context-guru` (repo dir `lab-context-engineering`), a Kagenti +platform component. ## What this repo is -A single **Go** core that reduces the token cost of LLM agent traffic. It ships as a -standalone proxy binary (`cmd/proxy`), an importable library (`engine`, `surfaces`), and -eval-containers wiring. Its lineage is the Python `winnow` prototype (`../winnow`), which -is the behavioral reference — port its *logic*, re-implement its transport in Go. +A single **Go** core (`components`) that reduces the token cost of LLM agent traffic, +operating on bifrost's provider-agnostic chat schema. It ships as a proxy binary +(`cmd/context-guru-proxy`), an importable library (`components`, `apply`, `schema`, +`config`, `expand`, `store`), a bifrost `LLMPlugin` adapter (`adapters/bifrost`), and +eval-containers wiring. Its lineage is the Python `winnow` prototype, the behavioral +reference — port its *logic*, re-implement its transport in Go. ## Hard boundaries - **No AuthBridge / kagenti-extensions code lives here.** That plugin is built in - `kagenti-extensions` and depends on this repo. Keep the public API (`engine`, - `surfaces`, `config`) clean and importable; never reach into another repo. -- **Fail open, always.** Any error in any compactor forwards the original request untouched. - Reductions must be reversible (markers + rewind store). Never drop content that is only - *predicted* unused — `provable_only` is on by default. + `kagenti-extensions` and depends on this repo. Keep the public API (`components`, + `apply`, `schema`, `config`) clean and importable; never reach into another repo. +- **Fail open, always.** Any component error/panic reverts that component only; the + original request is always forwarded as a valid fallback. Every lossy Offload must be + reversible (a `<>` marker + the stashed original in the Store). ## Conventions -- Go 1.25, module `github.com/kagenti/lab-context-engineering`. `make fmt lint test build`. +- Go 1.26, module `github.com/kagenti/context-guru`. Build needs `CGO_ENABLED=1` (tree-sitter). - 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" @@ -29,7 +32,8 @@ is the behavioral reference — port its *logic*, re-implement its transport in ## Layout -`cmd/proxy` (binary) · `engine` (Transform/Expand + Compactor pipeline) · `surfaces` (wire⇄canonical) -· `internal/*` (types, extract, relevance, signals, taxonomy, actions, cache, markers, -rewind, zones, session, compaction, tokens) · `config` · `observability` · `deploy` · -`docs`. +`components` (Component/Reformat/Offload + Pipeline + registry) · `components/{reformat,offload,dsl,all}` +· `apply` (wire body ⇄ pipeline, byte-lossless splice) · `schema` (bifrost-schema helpers) · +`expand` (marker + expand tool loop) · `store` · `session` · `metrics` · `config` · +`proxy` · `adapters/bifrost` · `cmd/context-guru-proxy` · `internal/{tokens,treesitter,buildinfo}` +· `deploy` · `docs`. See [docs/design.md](docs/design.md). diff --git a/Dockerfile b/Dockerfile index adedd3d..b05d2f2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,16 +1,35 @@ -# Build the lab-cx proxy. CGO is required (tree-sitter), so the final image is -# glibc-based (distroless/base), not static. -FROM golang:1.25 AS build +# context-guru-proxy: the LLM-proxy integration and eval-containers gateway. +# +# The module uses a local `replace github.com/maximhq/bifrost/core => ../bifrost/core`, +# so the build context MUST be the PARENT directory that contains both repos: +# +# docker build -f lab-context-engineering/Dockerfile -t context-guru-proxy . +# +# (run from .../context-engineering/). CI that builds standalone should either +# `go mod vendor` first or switch the replace to a pinned published bifrost. +# +# CGO is required (tree-sitter for the skeleton component), so the final image is +# glibc-based. It also carries a shell + curl for the eval-containers +# start/health scripts under /opt/gateway. +FROM golang:1.26 AS build WORKDIR /src -COPY . . +COPY bifrost/ ./bifrost/ +COPY lab-context-engineering/ ./lab-context-engineering/ +WORKDIR /src/lab-context-engineering ARG VERSION=dev ARG COMMIT=none RUN CGO_ENABLED=1 go build \ - -ldflags "-s -w -X github.com/kagenti/lab-context-engineering/internal/buildinfo.Version=${VERSION} -X github.com/kagenti/lab-context-engineering/internal/buildinfo.Commit=${COMMIT}" \ - -o /out/lab-cx ./cmd/proxy + -ldflags "-s -w -X github.com/kagenti/context-guru/internal/buildinfo.Version=${VERSION} -X github.com/kagenti/context-guru/internal/buildinfo.Commit=${COMMIT}" \ + -o /out/context-guru-proxy ./cmd/context-guru-proxy -FROM gcr.io/distroless/base-debian12:nonroot -COPY --from=build /out/lab-cx /usr/local/bin/lab-cx -EXPOSE 8080 -ENTRYPOINT ["/usr/local/bin/lab-cx"] -CMD ["proxy"] +FROM debian:bookworm-slim +RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl \ + && rm -rf /var/lib/apt/lists/* +COPY --from=build /out/context-guru-proxy /opt/gateway/main +COPY lab-context-engineering/deploy/eval-containers/start /opt/gateway/start +COPY lab-context-engineering/deploy/eval-containers/health /opt/gateway/health +RUN chmod +x /opt/gateway/start /opt/gateway/health +EXPOSE 4000 +# Default entrypoint is the eval-containers gateway wrapper; override with +# `/opt/gateway/main` to run the proxy directly with flags. +ENTRYPOINT ["/opt/gateway/start"] diff --git a/Makefile b/Makefile index 08ceea0..72e066c 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ -BINARY := lab-cx -PKG := github.com/kagenti/lab-context-engineering +BINARY := context-guru-proxy +PKG := github.com/kagenti/context-guru VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev) COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo none) LDFLAGS := -s -w \ @@ -18,9 +18,9 @@ help: ## Display this help awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-12s\033[0m %s\n", $$1, $$2}' .PHONY: build -build: ## Build the lab-cx binary into ./bin +build: ## Build the context-guru-proxy binary into ./bin @mkdir -p bin - go build -ldflags "$(LDFLAGS)" -o bin/$(BINARY) ./cmd/proxy + go build -ldflags "$(LDFLAGS)" -o bin/$(BINARY) ./cmd/context-guru-proxy .PHONY: test test: ## Run all tests with the race detector diff --git a/README.md b/README.md index ab604f4..18efb09 100644 --- a/README.md +++ b/README.md @@ -1,175 +1,104 @@ -# lab-context-engineering - -## Executive Summary -Context engineering attempts to reduce cost and latency by lowering token count and optimizing llm context without changing agent behavior or degregading its accurcy +# context-guru + +Provider-agnostic **context engineering** for LLM agents: a Go library that shrinks the +tokens a request carries — losslessly, or lossy-but-reversible — without touching the +agent. Same core runs as an **HTTP proxy/gateway** or an **in-process plugin**. + +- **Fail open, always** — any component error/panic reverts *that component only*; the + original request is always a valid fallback. +- **Never worse** — a component that grows the request is reverted. +- **Reversible** — every lossy drop leaves a `<>` marker and stashes the original, + recoverable via a model-callable `context_guru_expand` tool or `GET /expand`. + +## Architecture + +```mermaid +flowchart LR + A[Agent] -->|chat request| H{Host adapter} + H -->|proxy: proxy.Handler| P[apply.Body] + H -->|in-process: AuthBridge plugin| P + P -->|messages array| PIPE[Pipeline
ordered components] + PIPE --> P + P -->|byte-lossless splice| UP[Upstream provider] + UP -->|response| EX[expand loop] + EX -->|resolve markers from Store| UP + EX --> A + PIPE -.per-component Report.-> M[Emitter / Aggregator] + PIPE -.stash originals.-> S[(Store
TTL+LRU)] + EX -.resolve.-> S +``` -`lab-context-engineering` is a [Kagenti](https://github.com/kagenti/kagenti) platform -component: a single Go core that sits between an LLM coding agent and the model API, -reduces the context the request carries (caching, lossless reduction, and -verified-lossless extraction), and forwards a cheaper request upstream. It runs from one -codebase as a **standalone proxy** (`lab-cx proxy`), an **importable Go library** -(`engine`, `surfaces`, `config`), or **inside eval-containers**. +Components implement one of two lossiness-typed interfaces and are stacked in config order: -It is **fail-open** (any error in any stage forwards the original request untouched) and -every reduction is **reversible** (a namespaced marker plus a content-addressed rewind -store, so a downstream consumer or the agent can always recover the original bytes). +```mermaid +flowchart TD + C["Component — Name() · Enabled(ctx)"] + C --> R["Reformat: lossless repack
format · cacheinject"] + C --> O["Offload: drop + stash, returns cache_keys
skeleton · dedup · collapse · failed_run
cmdfilter · extract · smartcrush · mask · phi_evict"] +``` ## Install -Prerequisites: - -- **Go 1.25** -- **A C toolchain** (`gcc`/`clang`) — the code skeletonizer uses tree-sitter via cgo, so - `CGO_ENABLED=1` is required to build, test, and lint. The Makefile exports it for you. +Requires **Go 1.26** and a **C toolchain** (`CGO_ENABLED=1`; the `skeleton` component uses +tree-sitter via cgo). The module pins bifrost with a local `replace` to `../bifrost/core`, +so build from the parent directory that holds both repos: ```sh -make build # builds ./bin/lab-cx (CGO_ENABLED=1) -./bin/lab-cx version # prints: lab-cx () +cd .../context-engineering # dir containing lab-context-engineering/ and bifrost/ +CGO_ENABLED=1 go build -o bin/context-guru-proxy \ + ./lab-context-engineering/cmd/context-guru-proxy ``` -## Run - -Point any agent's base URL at the proxy — no agent changes: +Or build the gateway image (see [docs/setup.md](docs/setup.md)): ```sh -./bin/lab-cx proxy --preset balanced -# then, for any agent: -# ANTHROPIC_BASE_URL=http://localhost:8080 -# OPENAI_BASE_URL=http://localhost:8080/v1 +docker build -f lab-context-engineering/Dockerfile -t context-guru:local . ``` -Works with Claude Code, Bob/OpenClaw, Codex, Cursor, Aider. With a config file and the -cheap-model extractor enabled, forwarding all traffic to an upstream gateway: +## Run the proxy ```sh -./bin/lab-cx proxy --config configs/lab-cx.yaml \ - --upstream https://gateway.example/v1 \ - --extract-model claude-haiku-4-5 --extract-provider anthropic --extract-auth bearer \ - --extract-base https://gateway.example/v1 +context-guru-proxy --preset balanced # or --config cg.yaml ``` -`proxy` flags: `--addr` (default `:8080`), `--preset` -(`safe|balanced|aggressive|cache|coding|mcp`), `--config` (a YAML file that names which -components run, overrides `--preset`), `--upstream` (forward ALL requests here; default -routes by provider), `--extract-model/-provider/-auth/-base` and -`--summarize-model/-provider/-auth/-base` (enable + configure the cheap-model extractor / -summarizer; these override the config's transport block), `--max-body-bytes` -(default 32 MiB; `0` = no cap), `--upstream-timeout` (default `0s` = none; a non-zero -value caps the whole request and can truncate long SSE streams). - -Read the live aggregate metrics: +Point any agent at it (one port serves both dialects): ```sh -./bin/lab-cx stats # GETs /stats from a running proxy + prints a summary -./bin/lab-cx stats --addr http://localhost:8080 +ANTHROPIC_BASE_URL=http://localhost:4000/anthropic +OPENAI_BASE_URL=http://localhost:4000/openai/v1 ``` -Endpoints the proxy serves: - -- `GET /stats` — process-wide reduction snapshot as JSON (tokens before/after/saved, - cache injected, extracted, stage errors, added-latency p50/p95). -- `GET /labcx/expand?id=` — returns the original bytes behind a reversibility - marker. -- `GET /health`, `GET /ready` — liveness/readiness. - -Bypass / disable: - -- `LABCX_DISABLE=1` env var — run the proxy as a transparent passthrough. -- `x-labcx-bypass: true` request header — skip reduction for that single request. - -## Components - -- **Cache** — injects Anthropic `cache_control` breakpoints on the stable prefix - (lossless). Stands down when the client already self-caches (e.g. Claude Code). -- **Reduce** (deterministic, no model) — `relevance` scoring + `collapse` of - stale/duplicate/empty content, `dedup`, `cmdfilter` (trims noisy shell-command output), - `skeleton` (drops function bodies, keeps signatures, via tree-sitter), and `format` - re-encoders that re-express bulky structured output in cheaper lossless forms - (`json_compact`, `toon`, `jsonl`, `markdown_kv`, `tsv`, `csv`). -- **Extract** (cheap model) — a cheap model proposes a structured projection of a huge - tool/MCP output; accepted only if **structurally contained** in the original. Strategies - `code` (model writes a filter run in a **Starlark sandbox** over the full body), `single` - (one-shot JSON-return filter), `rlm` (chunked), and a `deterministic` fallback. -- **Summarize** (cheap model, opt-in) — replaces older turns with one factual - `` (ReSum-style prompt), keeping the last `keep_last` messages verbatim; the - dropped span is stored and recoverable. -- **Truncate** (no model, opt-in) — the naive baseline: keep the last `keep_last` - messages, drop the rest behind one recoverable note. The control to measure smarter - compactors against. -- **Tokenizer** — real BPE token counts via tiktoken `o200k_base` (`internal/tokens`); the - same counter gates every reduction (a component never inflates an output). -- **Reversibility** — namespaced markers + a content-addressed rewind store; recover via - `engine.FindMarkers` + `engine.Expand`, or the `/labcx/expand` endpoint. -- **Observability** — OpenTelemetry GenAI semantic conventions (`gen_ai.*`) plus the live - `/stats` aggregate. - -## Config - -Every compaction approach — `reduce`, `extract`, `summarize`, `truncate`, `cache` — -implements one interface, `engine.Compactor` (given the conversation, return the -transformed conversation). The `compactors:` list selects which run and in what order. - -The example config is [`configs/lab-cx.yaml`](configs/lab-cx.yaml). It folds onto a base -`preset`, then selects which compactors/reducers/encoders/extract-strategies run, **purely -by name**. Extension path — no core edit beyond one registration: - -1. **Add** a new encoder/reducer/extract-strategy/compactor, and **register by name** in - its registry (encoders in `internal/reduce/actions.go`, reducers via - `reduce.RegisterReducer`, strategies in `internal/extract` `RunExtraction`, compactors - via `engine.(*Engine).Register`). -2. **List it by name** in the matching list in `configs/lab-cx.yaml` (an empty/omitted list - means "all built-in defaults"; list order sets priority/run order). - -Full detail in [docs/RUNNING.md](docs/RUNNING.md). - -## Integrations - -- [docs/integration/claude-code.md](docs/integration/claude-code.md) — base-URL swap; - real runs measured (**−50% tokens** on a large-file task with correctness preserved; - do-no-harm on a trivial task). -- [docs/integration/bob.md](docs/integration/bob.md) — IBM Bob Shell (OpenAI-compatible - CLI); real run, **5/5 tasks correct**, cache-injection lever fires on every request. -- [docs/integration/swe-bench.md](docs/integration/swe-bench.md) — proxy before the - eval-containers gateway; wiring validated, full run is a documented runbook. -- [docs/integration/authbridge.md](docs/integration/authbridge.md) — import the engine - in-process from a Kagenti AuthBridge plugin (the plugin lives in `kagenti-extensions`). - -## Results - -Real measured numbers (2026-06-24). See [docs/RESULTS.md](docs/RESULTS.md) for the index. - -| Measurement | Result | Source | +| Flag / env | Default | Purpose | |---|---|---| -| `cmdfilter` on verbose command output | **−94%** tokens (reversible) | [RESULTS-offline.md](docs/RESULTS-offline.md) | -| `format` re-encode on structured output | **−35%** best / **−29%** TOON | [RESULTS-offline.md](docs/RESULTS-offline.md) | -| `skeleton` on a code read | **−78%** tokens | [RESULTS-offline.md](docs/RESULTS-offline.md) | -| Full deterministic pipeline, 10 real fixtures | **−93%** aggregate (all reversible) | [RESULTS-offline.md](docs/RESULTS-offline.md) | -| Cheap-model extractor (`claude-haiku-4-5`) | **−56%…−80%** on 4/6 structured fixtures; 2 honest declines; all contained | [RESULTS-extract.md](docs/RESULTS-extract.md) | -| **Claude Code, large-file task (haiku)** | **−50.2%** tokens (34,954 saved), answer correct (42 funcs) | [claude-code.md](docs/integration/claude-code.md) | -| **Bob (haiku), 5-task suite** | **5/5 correct** incl. a file edit; cache injected 6/6; 0 errors | [bob.md](docs/integration/bob.md) | - -These are real measured numbers on real tool-output fixtures and live agent runs, with a -real BPE tokenizer. **Claude Code** (self-caching): −50% on a large-file task with -`reduce_cached_prefix`, answer unchanged — and do-no-harm on a trivial task. **Bob** -(non-self-caching): 5/5 tasks correct, the cache-injection lever fires every request. The -end-to-end **SWE-bench** run is a **documented runbook, not yet executed** -([swe-bench.md](docs/integration/swe-bench.md)). - -## Repository layout +| `--preset` / `PRESET` | `balanced` | pipeline preset when no `--config` | +| `--config` / `CONFIG` | — | YAML config (overrides preset) | +| `LISTEN_ADDR` | `:4000` | listen address | +| `--openai-upstream` / `OPENAI_UPSTREAM` | `https://api.openai.com` | OpenAI upstream base | +| `--anthropic-upstream` / `ANTHROPIC_UPSTREAM` | `https://api.anthropic.com` | Anthropic upstream base | +| `OPENAI_API_KEY` / `ANTHROPIC_API_KEY` | — | real key injected on forward (gateway mode); empty = pass client auth through | +| `FORCE_MODEL` | — | overwrite the request `model` (eval-containers `EVAL_MODEL`) | + +Routes: `POST /openai/v1/chat/completions`, `POST /anthropic/v1/messages`, `GET /healthz`, +`GET /stats` (savings rollups), `GET /expand?id=` (recover an offloaded original). +Per-request: header `x-context-guru-session` sets the session key; `x-context-guru-bypass: true` +skips the pipeline. + +## Integrate + +| Option | What | Where | +|---|---|---| +| **Proxy / gateway** | `context-guru-proxy` in front of the provider; the eval-containers gateway image | `proxy/`, `cmd/context-guru-proxy/` | +| **In-process plugin** | AuthBridge (Kagenti sidecar) plugin importing this module, running the same pipeline on `pctx.Body` | plugin lives in `kagenti-extensions`; reuses `apply.Body` + `expand/` | +| _(also)_ **bifrost LLMPlugin** | run the pipeline as a `PreRequestHook` inside any bifrost deployment | `adapters/bifrost/` | -``` -cmd/proxy/ the lab-cx binary (proxy | stats | version) -cmd/labcx-bench/ offline + --extract measurement harness -engine/ Engine: Transform / Expand, Compactor pipeline (builtinCompactors) -surfaces/ anthropic | openai | gemini wire <-> canonical request -config/ Settings + presets, YAML config loader -canon/ canonical request type -observability/ OpenTelemetry GenAI emitter + /stats aggregator -internal/ tokens, reduce, extract, cheapmodel, cache, markers, store, proxyhttp, treesitter -configs/ example config (lab-cx.yaml) -docs/ RUNNING, RESULTS, integration guides -deploy/ eval-containers wiring -``` +Details in [docs/integrations.md](docs/integrations.md). + +## Docs + +- [docs/design.md](docs/design.md) — architecture: component model, fail-open pipeline, store, session, expand loop, metrics. +- [docs/components.md](docs/components.md) — every registered component: how it works, before→after, lossiness, config, best use. +- [docs/integrations.md](docs/integrations.md) — proxy gateway vs AuthBridge plugin, with request paths. +- [docs/setup.md](docs/setup.md) — setup + a concrete SWE-bench run through the eval-containers gateway. ## License diff --git a/adapters/bifrost/plugin.go b/adapters/bifrost/plugin.go new file mode 100644 index 0000000..e6556b2 --- /dev/null +++ b/adapters/bifrost/plugin.go @@ -0,0 +1,100 @@ +// Package bifrost adapts context-guru's pipeline to bifrost's LLMPlugin +// interface: our components run as a pre-LLM-call hook (design D2). Registering +// this plugin via BifrostConfig.LLMPlugins is all it takes for an embedded +// bifrost proxy — or any bifrost deployment — to run context engineering. +// +// The same components package backs the AuthBridge in-process plugin (P3); only +// this thin adapter is bifrost-specific. +package bifrost + +import ( + "github.com/kagenti/context-guru/components" + "github.com/kagenti/context-guru/schema" + "github.com/kagenti/context-guru/session" + "github.com/kagenti/context-guru/store" + bschemas "github.com/maximhq/bifrost/core/schemas" +) + +// SessionContextKey is the BifrostContext value key the transport sets from the +// x-context-guru-session header (or Anthropic metadata.user_id). Absent -> the +// plugin falls back to a content hash. +const SessionContextKey bschemas.BifrostContextKey = "context-guru-session" + +// Plugin runs the context-guru pipeline in PreRequestHook. It implements +// bifrost's schemas.LLMPlugin. It never aborts a request (fail-open): any +// component failure is already contained inside the pipeline. +type Plugin struct { + pipe *components.Pipeline + store store.Store +} + +// New builds the adapter from an already-constructed pipeline and store. +func New(pipe *components.Pipeline, st store.Store) *Plugin { + return &Plugin{pipe: pipe, store: st} +} + +func (*Plugin) GetName() string { return "context-guru" } +func (*Plugin) Cleanup() error { return nil } + +// PreRequestHook runs the pipeline over the chat request's messages. This is the +// canonical mutate-the-request phase: changes are committed and seen by the +// provider call and every fallback. Non-chat requests pass through untouched. +func (p *Plugin) PreRequestHook(ctx *bschemas.BifrostContext, req *bschemas.BifrostRequest) error { + if req == nil || req.ChatRequest == nil { + return nil + } + chat := req.ChatRequest + c := &components.Ctx{ + Ctx: ctx, + Session: p.resolveSession(ctx, chat), + Store: p.store, + Bypass: bypassed(ctx), + } + p.pipe.Run(chat, c) + return nil +} + +// PreLLMHook and PostLLMHook are pass-throughs today; the expand continuation +// loop lives in the transport wrapper (it must re-invoke upstream, which a hook +// cannot). Kept here to satisfy the LLMPlugin interface. +func (p *Plugin) PreLLMHook(_ *bschemas.BifrostContext, req *bschemas.BifrostRequest) (*bschemas.BifrostRequest, *bschemas.LLMPluginShortCircuit, error) { + return req, nil, nil +} + +func (p *Plugin) PostLLMHook(_ *bschemas.BifrostContext, resp *bschemas.BifrostResponse, e *bschemas.BifrostError) (*bschemas.BifrostResponse, *bschemas.BifrostError, error) { + return resp, e, nil +} + +// resolveSession prefers an explicit id set on the context (from a header), +// falling back to a hash of the system prompt + first user message. +func (p *Plugin) resolveSession(ctx *bschemas.BifrostContext, chat *bschemas.BifrostChatRequest) string { + explicit := "" + if v := ctx.Value(SessionContextKey); v != nil { + if s, ok := v.(string); ok { + explicit = s + } + } + var sys, firstUser string + for _, m := range chat.Input { + text := schema.MessageText(m) + switch m.Role { + case bschemas.ChatMessageRoleSystem: + sys += text + case bschemas.ChatMessageRoleUser: + if firstUser == "" { + firstUser = text + } + } + } + return session.Resolve(explicit, sys, firstUser) +} + +// BypassContextKey lets a caller disable the pipeline for one request +// (x-context-guru-bypass header -> context value). +const BypassContextKey bschemas.BifrostContextKey = "context-guru-bypass" + +func bypassed(ctx *bschemas.BifrostContext) bool { + v := ctx.Value(BypassContextKey) + b, _ := v.(bool) + return b +} diff --git a/adapters/bifrost/plugin_test.go b/adapters/bifrost/plugin_test.go new file mode 100644 index 0000000..f17d9fc --- /dev/null +++ b/adapters/bifrost/plugin_test.go @@ -0,0 +1,79 @@ +package bifrost + +import ( + "context" + "strings" + "testing" + "time" + + _ "github.com/kagenti/context-guru/components/all" + "github.com/kagenti/context-guru/config" + "github.com/kagenti/context-guru/schema" + "github.com/kagenti/context-guru/store" + bschemas "github.com/maximhq/bifrost/core/schemas" +) + +func toolMsg(text string) bschemas.ChatMessage { + t := text + return bschemas.ChatMessage{Role: bschemas.ChatMessageRoleTool, Content: &bschemas.ChatMessageContent{ContentStr: &t}} +} + +func newPlugin(t *testing.T, yaml string) *Plugin { + t.Helper() + cfg, err := config.LoadBytes([]byte(yaml)) + if err != nil { + t.Fatal(err) + } + pipe, err := cfg.Build(nil) + if err != nil { + t.Fatal(err) + } + return New(pipe, store.NewMemory(store.Options{})) +} + +func TestPreRequestHookRunsPipeline(t *testing.T) { + p := newPlugin(t, "pipeline: [dedup]\n") + dump := strings.Repeat("a verbose repeated tool output line\n", 60) + chat := &bschemas.BifrostChatRequest{Input: []bschemas.ChatMessage{toolMsg(dump), toolMsg(dump)}} + req := &bschemas.BifrostRequest{ChatRequest: chat} + ctx := bschemas.NewBifrostContext(context.Background(), time.Time{}) + + before := schema.MessagesTokens(chat) + if err := p.PreRequestHook(ctx, req); err != nil { + t.Fatal(err) + } + if schema.MessagesTokens(chat) >= before { + t.Fatal("PreRequestHook did not reduce the duplicate tool output") + } +} + +func TestNonChatRequestPassesThrough(t *testing.T) { + p := newPlugin(t, "pipeline: [dedup]\n") + ctx := bschemas.NewBifrostContext(context.Background(), time.Time{}) + if err := p.PreRequestHook(ctx, &bschemas.BifrostRequest{}); err != nil { + t.Fatalf("nil ChatRequest should be a no-op, got %v", err) + } +} + +func TestSessionResolutionPrefersExplicit(t *testing.T) { + p := newPlugin(t, "pipeline: [dedup]\n") + ctx := bschemas.NewBifrostContext(context.Background(), time.Time{}) + ctx.SetValue(SessionContextKey, "explicit-123") + chat := &bschemas.BifrostChatRequest{Input: []bschemas.ChatMessage{toolMsg("hi")}} + if got := p.resolveSession(ctx, chat); got != "explicit-123" { + t.Fatalf("explicit session id should win, got %q", got) + } +} + +func TestBypassSkips(t *testing.T) { + p := newPlugin(t, "pipeline: [dedup]\n") + dump := strings.Repeat("a verbose repeated tool output line\n", 60) + chat := &bschemas.BifrostChatRequest{Input: []bschemas.ChatMessage{toolMsg(dump), toolMsg(dump)}} + ctx := bschemas.NewBifrostContext(context.Background(), time.Time{}) + ctx.SetValue(BypassContextKey, true) + before := schema.MessagesTokens(chat) + _ = p.PreRequestHook(ctx, &bschemas.BifrostRequest{ChatRequest: chat}) + if schema.MessagesTokens(chat) != before { + t.Fatal("bypass should have left the request unchanged") + } +} diff --git a/apply/apply.go b/apply/apply.go new file mode 100644 index 0000000..a3f2cb0 --- /dev/null +++ b/apply/apply.go @@ -0,0 +1,306 @@ +// Package apply is the one place the pipeline meets a raw wire request, shared +// by every host adapter (the bifrost proxy and the AuthBridge plugin). It +// extracts the messages array, runs the pipeline on it, and splices the result +// back into the original body — byte-lossless for every other field (headroom +// invariant I1). This is what makes "one implementation behind both +// integrations" concrete: hosts differ only in how they obtain the body, +// provider, and session id. +// +// Provider normalization. Components operate on OpenAI-shaped tool outputs +// (role=="tool" messages with string content). The Anthropic Messages API +// instead carries tool outputs as `tool_result` content blocks INSIDE user +// messages — a shape bifrost's ChatContentBlock cannot even represent (it drops +// the payload on unmarshal). So for Anthropic requests we expand each +// tool_result block into a synthetic role=tool message the existing components +// already know how to shrink, run the pipeline, then splice each rewritten +// tool output back into its exact source block via sjson. Everything the +// pipeline did not touch stays byte-identical. +package apply + +import ( + "bytes" + "context" + "encoding/json" + "log/slog" + "os" + "reflect" + "strconv" + "strings" + + "github.com/kagenti/context-guru/components" + "github.com/kagenti/context-guru/schema" + "github.com/kagenti/context-guru/session" + "github.com/kagenti/context-guru/store" + bschemas "github.com/maximhq/bifrost/core/schemas" + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" +) + +// debugTraffic, when CONTEXT_GURU_DEBUG is set, logs each inbound tool output's +// token count + first line so we can analyze why components did/didn't fire on +// real agent traffic. Diagnostic only. +var debugTraffic = os.Getenv("CONTEXT_GURU_DEBUG") != "" + +func dumpToolOutputs(norm []bschemas.ChatMessage) { + tools := 0 + for _, m := range norm { + if m.Role != bschemas.ChatMessageRoleTool { + continue + } + tools++ + t := schema.MessageText(m) + head := strings.TrimSpace(t) + if i := strings.IndexByte(head, '\n'); i >= 0 { + head = head[:i] + } + if len(head) > 160 { + head = head[:160] + } + slog.Info("cg.debug.toolout", "tokens", schema.TextTokens(t), "lines", strings.Count(t, "\n")+1, "head", head) + } + slog.Info("cg.debug.request", "tool_outputs", tools, "total_tool_tokens", schema.MessagesTokens(&bschemas.BifrostChatRequest{Input: norm})) +} + +// slotKind is how a normalized message maps back to the raw body. +type slotKind int + +const ( + // wholeMessage: norm message i corresponds 1:1 to messages.; a change + // re-marshals the whole message (guarded by lossless round-trip). + wholeMessage slotKind = iota + // anthropicToolText: norm message is a synthetic role=tool extracted from an + // Anthropic tool_result block; a change rewrites only that block's `content` + // string field, which is byte-lossless for the rest of the message. + anthropicToolText +) + +// slot records how one normalized message writes back to the raw body. +type slot struct { + kind slotKind + path string // sjson path: "messages." (whole) or "messages..content..content" (tool text) + pre []byte // wholeMessage: canonical marshal of the original message + preText string // anthropicToolText: original tool-output text (change detection) + lossless bool // wholeMessage: does bifrost round-trip this message without dropping fields +} + +// Body runs the pipeline over the request body's messages and returns the +// rewritten body. changed=false means "forward the original unchanged" (no +// messages array, unparseable, or a re-serialization problem) — always fail +// open. explicitSession is the host-supplied session id ("" -> content hash). +func Body(ctx context.Context, pipe *components.Pipeline, st store.Store, provider bschemas.ModelProvider, body []byte, explicitSession string, bypass bool) ([]byte, bool) { + msgsRaw := gjson.GetBytes(body, "messages") + if !msgsRaw.Exists() || !msgsRaw.IsArray() { + return body, false + } + + norm, slots := normalize(provider, msgsRaw.Array()) + if len(norm) == 0 { + return body, false + } + + if debugTraffic { + dumpToolOutputs(norm) + } + chat := &bschemas.BifrostChatRequest{Provider: provider, Input: norm} + sys, firstUser := systemAndFirstUser(norm) + c := &components.Ctx{ + Ctx: ctx, + Session: session.Resolve(explicitSession, sys, firstUser), + Store: st, + Bypass: bypass, + } + + pipe.Run(chat, c) + + // A component changed the message count (none of the v1 set does) — the slot + // map no longer aligns, so fail open and forward the original untouched. + if len(chat.Input) != len(norm) { + return body, false + } + + out := body + changed := false + var changes []change + for i := range chat.Input { + s := slots[i] + switch s.kind { + case anthropicToolText: + newText := schema.MessageText(chat.Input[i]) + if newText == s.preText { + continue + } + var err error + if out, err = sjson.SetBytes(out, s.path, newText); err != nil { + return body, false + } + changed = true + changes = append(changes, mkChange(s.path, s.preText, newText)) + default: // wholeMessage + post, err := json.Marshal(chat.Input[i]) + if err != nil { + return body, false + } + if bytes.Equal(post, s.pre) { + continue // unmodified — keep the original bytes verbatim (I1) + } + if !s.lossless { + // bifrost can't round-trip this message; splicing our re-marshal would + // drop provider fields it doesn't model. Discard the change, keep the + // original bytes. ponytail: correctness over the marginal saving here. + continue + } + if out, err = sjson.SetRawBytes(out, s.path, post); err != nil { + return body, false + } + changed = true + var pm bschemas.ChatMessage + _ = json.Unmarshal(s.pre, &pm) + changes = append(changes, mkChange(s.path, schema.MessageText(pm), schema.MessageText(chat.Input[i]))) + } + } + if changed && dumpPath != "" { + dumpChanges(c.Session, changes) + } + return out, changed +} + +// change is one rewritten message, captured for the CONTEXT_GURU_DUMP trace so a +// human can see exactly what context-guru did to the wire. +type change struct { + Path string `json:"path"` + BeforeTokens int `json:"before_tokens"` + AfterTokens int `json:"after_tokens"` + Before string `json:"before"` + After string `json:"after"` +} + +func mkChange(path, before, after string) change { + return change{ + Path: path, BeforeTokens: schema.TextTokens(before), AfterTokens: schema.TextTokens(after), + Before: clip(before, 4000), After: clip(after, 4000), + } +} + +func clip(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] + "…[+" + strconv.Itoa(len(s)-n) + " bytes]" +} + +var dumpPath = os.Getenv("CONTEXT_GURU_DUMP") + +// dumpChanges appends one JSON line describing this request's rewrites. +func dumpChanges(session string, changes []change) { + f, err := os.OpenFile(dumpPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) + if err != nil { + return + } + defer f.Close() + before, after := 0, 0 + for _, c := range changes { + before += c.BeforeTokens + after += c.AfterTokens + } + rec := map[string]any{ + "session": session, "n_changed": len(changes), + "tokens_before": before, "tokens_after": after, "saved": before - after, + "changes": changes, + } + if b, err := json.Marshal(rec); err == nil { + f.Write(append(b, '\n')) + } +} + +// normalize builds the message slice the pipeline runs on plus a write-back slot +// per message. For OpenAI (and any non-Anthropic dialect) every message maps +// 1:1 to a whole-message slot — the request is already in the shape components +// expect. For Anthropic, each user-message `tool_result` block with string +// content becomes a synthetic role=tool message with a text-field write-back +// slot; the block's siblings and every other message are left for the raw body +// to carry verbatim. +func normalize(provider bschemas.ModelProvider, arr []gjson.Result) (norm []bschemas.ChatMessage, slots []slot) { + for i, m := range arr { + if provider == bschemas.Anthropic && + m.Get("role").String() == string(bschemas.ChatMessageRoleUser) && + m.Get("content").IsArray() { + handled := false + for b, blk := range m.Get("content").Array() { + if blk.Get("type").String() != "tool_result" { + continue + } + content := blk.Get("content") + if content.Type != gjson.String { + continue // array/structured tool_result content — skip (never lose non-text) + } + handled = true + text := content.String() + norm = append(norm, toolMessage(text, blk.Get("tool_use_id").String())) + slots = append(slots, slot{ + kind: anthropicToolText, + path: "messages." + strconv.Itoa(i) + ".content." + strconv.Itoa(b) + ".content", + preText: text, + }) + } + if handled { + continue // this user message contributed its tool_result blocks; body carries the rest + } + } + // Default: whole-message slot. Unmarshal via bifrost and record whether that + // round-trips losslessly. + var cm bschemas.ChatMessage + if err := json.Unmarshal([]byte(m.Raw), &cm); err != nil { + continue // unparseable message — leave it in the body untouched + } + preMarshal, _ := json.Marshal(cm) + norm = append(norm, cm) + slots = append(slots, slot{ + kind: wholeMessage, + path: "messages." + strconv.Itoa(i), + pre: preMarshal, + lossless: jsonEqual([]byte(m.Raw), preMarshal), + }) + } + return norm, slots +} + +// toolMessage builds a synthetic OpenAI-shaped tool message from an Anthropic +// tool_result so the (provider-agnostic) components can process it. +func toolMessage(text, toolUseID string) bschemas.ChatMessage { + m := bschemas.ChatMessage{Role: bschemas.ChatMessageRoleTool} + schema.SetMessageText(&m, text) + if toolUseID != "" { + id := toolUseID + m.ChatToolMessage = &bschemas.ChatToolMessage{ToolCallID: &id} + } + return m +} + +// jsonEqual reports whether two JSON documents are semantically equal (ignoring +// key order and whitespace). Used to decide whether bifrost's schema round-trips +// a message without dropping fields. +func jsonEqual(a, b []byte) bool { + var av, bv any + if err := json.Unmarshal(a, &av); err != nil { + return false + } + if err := json.Unmarshal(b, &bv); err != nil { + return false + } + return reflect.DeepEqual(av, bv) +} + +func systemAndFirstUser(msgs []bschemas.ChatMessage) (sys, firstUser string) { + for _, m := range msgs { + t := schema.MessageText(m) + switch m.Role { + case bschemas.ChatMessageRoleSystem: + sys += t + case bschemas.ChatMessageRoleUser: + if firstUser == "" { + firstUser = t + } + } + } + return sys, firstUser +} diff --git a/apply/apply_test.go b/apply/apply_test.go new file mode 100644 index 0000000..4705cec --- /dev/null +++ b/apply/apply_test.go @@ -0,0 +1,217 @@ +package apply_test + +import ( + "context" + "encoding/json" + "strings" + "testing" + + "github.com/kagenti/context-guru/apply" + _ "github.com/kagenti/context-guru/components/all" + "github.com/kagenti/context-guru/config" + "github.com/kagenti/context-guru/store" + bschemas "github.com/maximhq/bifrost/core/schemas" + "github.com/tidwall/gjson" +) + +func pipe(t *testing.T, yaml string) *config.Config { + t.Helper() + c, err := config.LoadBytes([]byte(yaml)) + if err != nil { + t.Fatal(err) + } + return c +} + +// TestI1UnmodifiedMessagesAndFieldsPreserved is the cache-safety invariant: +// messages a component doesn't touch, and every non-messages top-level field, +// come out byte-identical (headroom I1). +func TestI1UnmodifiedMessagesAndFieldsPreserved(t *testing.T) { + cfg := pipe(t, "pipeline: [dedup]\n") + p, _ := cfg.Build(nil) + st := store.NewMemory(store.Options{}) + + dump := strings.Repeat("repeated tool output line with content\n", 60) + body, _ := json.Marshal(map[string]any{ + "model": "gpt-x", + "temperature": 0.7, + "top_p": 0.95, + "metadata": map[string]any{"user_id": "u1"}, + "messages": []map[string]any{ + {"role": "system", "content": "you are helpful"}, + {"role": "user", "content": "please help"}, + {"role": "tool", "tool_call_id": "a", "content": dump}, + {"role": "tool", "tool_call_id": "b", "content": dump}, // dedup collapses this one + }, + }) + + out, changed := apply.Body(context.Background(), p, st, bschemas.OpenAI, body, "", false) + if !changed { + t.Fatal("expected dedup to change the body") + } + + // Non-messages fields: byte-identical. + for _, path := range []string{"model", "temperature", "top_p", "metadata.user_id"} { + if gjson.GetBytes(out, path).Raw != gjson.GetBytes(body, path).Raw { + t.Fatalf("field %q not preserved: %q -> %q", path, gjson.GetBytes(body, path).Raw, gjson.GetBytes(out, path).Raw) + } + } + // Untouched messages (system, user, first tool) are byte-identical. + for _, i := range []string{"0", "1", "2"} { + if gjson.GetBytes(out, "messages."+i).Raw != gjson.GetBytes(body, "messages."+i).Raw { + t.Fatalf("message %s should be unmodified:\n old=%s\n new=%s", i, + gjson.GetBytes(body, "messages."+i).Raw, gjson.GetBytes(out, "messages."+i).Raw) + } + } + // Only the duplicate (index 3) changed. + if gjson.GetBytes(out, "messages.3.content").Raw == gjson.GetBytes(body, "messages.3.content").Raw { + t.Fatal("the duplicate tool output should have been collapsed") + } +} + +// TestLosslessGuardProtectsUnmodeledFields is the data-safety invariant: a +// component that modifies a message bifrost can't round-trip losslessly (here an +// Anthropic user turn carrying a tool_result block, whose payload bifrost drops) +// must NOT corrupt it. cacheinject would add cache_control to that boundary +// message; the guard discards the change rather than splice a lossy re-marshal. +func TestLosslessGuardProtectsUnmodeledFields(t *testing.T) { + cfg := pipe(t, "pipeline: [cacheinject]\n") + p, _ := cfg.Build(nil) + st := store.NewMemory(store.Options{}) + + body, _ := json.Marshal(map[string]any{ + "model": "claude-x", + "messages": []any{ + map[string]any{"role": "user", "content": "please run the tool"}, + map[string]any{"role": "user", "content": []any{ + map[string]any{"type": "tool_result", "tool_use_id": "tu_1", "content": "CRITICAL TOOL OUTPUT that must survive"}, + }}, + map[string]any{"role": "user", "content": "now answer"}, + }, + }) + + out, _ := apply.Body(context.Background(), p, st, bschemas.Anthropic, body, "", false) + + // The tool_result payload must be byte-identical — never dropped. + if gjson.GetBytes(out, "messages.1").Raw != gjson.GetBytes(body, "messages.1").Raw { + t.Fatalf("tool_result message corrupted:\n old=%s\n new=%s", + gjson.GetBytes(body, "messages.1").Raw, gjson.GetBytes(out, "messages.1").Raw) + } + if !strings.Contains(string(out), "CRITICAL TOOL OUTPUT") { + t.Fatalf("tool_result content was dropped: %s", out) + } +} + +// TestMixedContentNotFlattened proves an offload skips a tool message that +// carries a non-text block (an image), so the image is never silently dropped. +func TestMixedContentNotFlattened(t *testing.T) { + cfg := pipe(t, "pipeline: [collapse]\ncomponents:\n collapse: {max_tokens: 5, head_lines: 1, tail_lines: 1}\n") + p, _ := cfg.Build(nil) + st := store.NewMemory(store.Options{}) + + long := strings.Repeat("verbose tool line that would normally be collapsed\n", 40) + body, _ := json.Marshal(map[string]any{ + "model": "gpt-x", + "messages": []any{ + map[string]any{"role": "user", "content": "go"}, + map[string]any{"role": "tool", "tool_call_id": "a", "content": []any{ + map[string]any{"type": "text", "text": long}, + map[string]any{"type": "image_url", "image_url": map[string]any{"url": "data:image/png;base64,AAAA"}}, + }}, + }, + }) + + out, changed := apply.Body(context.Background(), p, st, bschemas.OpenAI, body, "", false) + if changed { + t.Fatal("collapse must skip a mixed text+image message, not rewrite it") + } + if gjson.GetBytes(out, "messages.1.content.1.image_url.url").String() != "data:image/png;base64,AAAA" { + t.Fatalf("image block was dropped: %s", out) + } +} + +// TestAnthropicToolResultOffloaded is the payoff for the normalization layer: +// dedup must now fire on Anthropic tool outputs (tool_result blocks inside user +// messages), collapsing a duplicate while preserving the block's siblings +// (tool_use_id, is_error) and every other message byte-for-byte. +func TestAnthropicToolResultOffloaded(t *testing.T) { + cfg := pipe(t, "pipeline: [dedup]\ncomponents:\n dedup: {min_tokens: 20}\n") + p, _ := cfg.Build(nil) + st := store.NewMemory(store.Options{}) + + long := strings.Repeat("a line of tool output that repeats verbatim across two calls\n", 30) + body, _ := json.Marshal(map[string]any{ + "model": "claude-sonnet-4-6", + "messages": []any{ + map[string]any{"role": "user", "content": "fix the failing test"}, + map[string]any{"role": "user", "content": []any{ + map[string]any{"type": "tool_result", "tool_use_id": "t1", "content": long}, + }}, + map[string]any{"role": "user", "content": []any{ + map[string]any{"type": "tool_result", "tool_use_id": "t2", "is_error": false, "content": long}, + }}, + }, + }) + + out, changed := apply.Body(context.Background(), p, st, bschemas.Anthropic, body, "", false) + if !changed { + t.Fatal("dedup should have fired on the Anthropic tool_result duplicate") + } + // First tool_result stays verbatim; the duplicate is collapsed to a pointer. + if gjson.GetBytes(out, "messages.1.content.0.content").String() != long { + t.Fatal("the first tool_result must be left untouched") + } + dup := gjson.GetBytes(out, "messages.2.content.0.content").String() + if !strings.Contains(dup, "identical to an earlier") || !strings.Contains(dup, "< forward unchanged; got changed=%v %s", changed, out) + } +} diff --git a/canon/request.go b/canon/request.go deleted file mode 100644 index 1c784a7..0000000 --- a/canon/request.go +++ /dev/null @@ -1,118 +0,0 @@ -// Package canon defines lab-context-engineering's canonical request model: an -// Anthropic-shaped chat request. Every surface (Anthropic, OpenAI, Gemini) maps a -// provider's wire format onto this model, the stage pipeline reads and mutates it, -// and the surface renders it back. It is the one shape the engine understands. -// -// The model is intentionally a generic decoded-JSON container (Root) rather than a -// fully-typed struct: an LLM request carries many provider-specific top-level fields -// (max_tokens, temperature, stream, metadata, ...) and per-block extensions -// (cache_control). Keeping the whole object as decoded JSON preserves every field -// across a decode→encode round-trip and lets stages add breakpoints freely — exactly -// what the reference prototype relied on. Typed helpers give ergonomic access to the -// parts stages actually touch (messages and content blocks). -package canon - -import ( - "bytes" - "encoding/json" -) - -// Request is a canonical (Anthropic-shaped) chat request. Root is the decoded -// top-level JSON object and is the source of truth; the typed accessors below read -// and mutate it in place. -type Request struct { - Root map[string]any -} - -// Decode parses an Anthropic-shaped request body into a Request. Numbers are -// preserved as json.Number so an integer field re-encodes as "1", not "1.0". -func Decode(body []byte) (Request, error) { - dec := json.NewDecoder(bytes.NewReader(body)) - dec.UseNumber() - var root map[string]any - if err := dec.Decode(&root); err != nil { - return Request{}, err - } - if root == nil { - root = map[string]any{} - } - return Request{Root: root}, nil -} - -// Encode serializes the request back to JSON bytes. -func (r Request) Encode() ([]byte, error) { - return json.Marshal(r.Root) -} - -// Clone returns a deep copy so a surface can mutate without touching the caller's -// data. It round-trips through JSON, preserving json.Number fidelity. -func (r Request) Clone() Request { - b, err := json.Marshal(r.Root) - if err != nil { - return Request{Root: map[string]any{}} - } - out, err := Decode(b) - if err != nil { - return Request{Root: map[string]any{}} - } - return out -} - -// Model returns the "model" field, or "" if absent. -func (r Request) Model() string { - s, _ := r.Root["model"].(string) - return s -} - -// Messages returns the raw message list as a slice of maps. Mutating an element -// mutates the underlying request. Returns nil if there are no messages. -func (r Request) Messages() []map[string]any { - raw, ok := r.Root["messages"].([]any) - if !ok { - return nil - } - out := make([]map[string]any, 0, len(raw)) - for _, m := range raw { - if mm, ok := m.(map[string]any); ok { - out = append(out, mm) - } - } - return out -} - -// SetMessages replaces the message list. Compactors that rebuild the conversation -// (summarize, truncate) write the new list back through this; block-level compactors -// mutate the maps returned by Messages in place and need not call it. -func (r Request) SetMessages(msgs []map[string]any) { - raw := make([]any, len(msgs)) - for i, m := range msgs { - raw[i] = m - } - r.Root["messages"] = raw -} - -// Blocks returns the content blocks of a message as a slice of maps. Anthropic -// content may be a plain string (returned as a single synthesized text block) or a -// list of block objects. -func Blocks(msg map[string]any) []map[string]any { - switch c := msg["content"].(type) { - case string: - return []map[string]any{{"type": "text", "text": c}} - case []any: - out := make([]map[string]any, 0, len(c)) - for _, b := range c { - if bb, ok := b.(map[string]any); ok { - out = append(out, bb) - } - } - return out - default: - return nil - } -} - -// BlockType returns a block's "type" field. -func BlockType(block map[string]any) string { - s, _ := block["type"].(string) - return s -} diff --git a/cmd/context-guru-proxy/main.go b/cmd/context-guru-proxy/main.go new file mode 100644 index 0000000..af78987 --- /dev/null +++ b/cmd/context-guru-proxy/main.go @@ -0,0 +1,75 @@ +// Command context-guru-proxy is the LLM proxy integration and the +// eval-containers gateway. It runs the context-guru component pipeline on +// inbound chat requests, then forwards them to the configured upstream +// provider, exposing /openai + /anthropic on one port plus /healthz, /stats, +// and /expand. +// +// Config is loaded from --config (YAML); upstreams and listen address from +// flags/env. Fail open: on any pipeline trouble the original request is +// forwarded untouched. +package main + +import ( + "flag" + "log" + "log/slog" + "net/http" + "os" + + _ "github.com/kagenti/context-guru/components/all" + "github.com/kagenti/context-guru/config" + "github.com/kagenti/context-guru/metrics" + "github.com/kagenti/context-guru/proxy" +) + +func main() { + var ( + addr = envOr("LISTEN_ADDR", ":4000") + cfgPath = flag.String("config", envOr("CONFIG", ""), "path to context-guru YAML config") + preset = flag.String("preset", envOr("PRESET", "balanced"), "preset to use when --config is absent") + openai = flag.String("openai-upstream", envOr("OPENAI_UPSTREAM", "https://api.openai.com"), "OpenAI upstream base URL") + anthropic = flag.String("anthropic-upstream", envOr("ANTHROPIC_UPSTREAM", "https://api.anthropic.com"), "Anthropic upstream base URL") + ) + flag.Parse() + + cfg, err := loadConfig(*cfgPath, *preset) + if err != nil { + log.Fatalf("config: %v", err) + } + + agg := metrics.NewAggregator() + emitter := metrics.Tee{agg, metrics.Slog{L: slog.Default()}} + pipe, err := cfg.Build(emitter) + if err != nil { + log.Fatalf("build pipeline: %v", err) + } + + h := proxy.New(pipe, cfg.NewStore(), agg, proxy.Options{ + OpenAIUpstream: *openai, + AnthropicUpstream: *anthropic, + // Gateway mode: real provider keys live here (eval-containers passes them + // via env); the agent holds only a placeholder. Empty => pass client auth. + OpenAIKey: os.Getenv("OPENAI_API_KEY"), + AnthropicKey: os.Getenv("ANTHROPIC_API_KEY"), + ForceModel: os.Getenv("FORCE_MODEL"), // eval-containers pins EVAL_MODEL's model here + }) + + slog.Info("context-guru-proxy listening", "addr", addr, "pipeline", cfg.Pipeline) + if err := http.ListenAndServe(addr, h.Mux()); err != nil { + log.Fatal(err) + } +} + +func loadConfig(path, preset string) (*config.Config, error) { + if path != "" { + return config.Load(path) + } + return config.LoadBytes([]byte("preset: " + preset + "\n")) +} + +func envOr(key, def string) string { + if v := os.Getenv(key); v != "" { + return v + } + return def +} diff --git a/cmd/labcx-bench/main.go b/cmd/labcx-bench/main.go deleted file mode 100644 index 79cda7e..0000000 --- a/cmd/labcx-bench/main.go +++ /dev/null @@ -1,720 +0,0 @@ -// Command labcx-bench is an OFFLINE measurement harness: it proves, on REAL tool-output -// fixtures, how much each DETERMINISTIC reduction component reduces tokens/bytes — with -// no model in the loop (the Extract stage is OFF). For every fixture it builds a minimal -// canonical request that surfaces the fixture as a tool_result, runs the engine with one -// component enabled at a time (and the full deterministic pipeline), and reports -// tokens/bytes before+after, % saved, whether the reduction is reversible (the original -// recovers byte-for-byte via the rewind store), and the added latency. -// -// The token counts come from the same real BPE tokenizer the engine uses (o200k_base, -// internal/tokens). Run it and paste the table into docs/RESULTS-offline.md: -// -// CGO_ENABLED=1 go run ./cmd/labcx-bench # human table -// CGO_ENABLED=1 go run ./cmd/labcx-bench --json # machine-readable rows -// -// With --extract the harness flips into ONLINE mode: it enables cheap-model extraction, -// wires a REAL Anthropic-compatible gateway (claude-haiku-4-5) via cheapmodel.Anthropic, -// and measures the LLM extractor on the large structured fixtures. See docs/RESULTS-extract.md -// for the recorded run. The env vars ANTHROPIC_BASE_URL + ANTHROPIC_AUTH_TOKEN must be set: -// -// source /tmp/lcx_env.sh && CGO_ENABLED=1 go run ./cmd/labcx-bench --extract -package main - -import ( - "context" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "flag" - "fmt" - "os" - "path/filepath" - "strings" - "time" - - "github.com/kagenti/lab-context-engineering/canon" - "github.com/kagenti/lab-context-engineering/config" - "github.com/kagenti/lab-context-engineering/engine" - "github.com/kagenti/lab-context-engineering/internal/cheapmodel" - "github.com/kagenti/lab-context-engineering/internal/extract" - "github.com/kagenti/lab-context-engineering/internal/markers" - "github.com/kagenti/lab-context-engineering/internal/reduce" - "github.com/kagenti/lab-context-engineering/internal/tokens" -) - -// fixture is one real tool output plus how to surface it as a canonical request. -type fixture struct { - name string // table label - relPath string // path under testdata/fixtures - toolName string // tool that produced it (read → skeleton path; "" → generic) - filePath string // input.file_path for read tools (drives FilePath/skeleton) - command string // input.command for Bash-style tools (drives cmdfilter) -} - -// fixtureSet is the committed corpus. Each entry names the component it primarily -// exercises (see testdata/fixtures/README.md for provenance of every file). -var fixtureSet = []fixture{ - // cmd_outputs → command-output filter - {"pytest_failures", "cmd_outputs/pytest_failures.txt", "Bash", "", "pytest -q tests/"}, - {"cargo_test_failure", "cmd_outputs/cargo_test_failure.txt", "Bash", "", "cargo test"}, - {"cargo_build", "cmd_outputs/cargo_build.txt", "Bash", "", "cargo build"}, - // structured_json → format re-encoder (toon / tsv / csv / json_compact) - {"flights_search", "structured_json/flights_search.json", "search_flights", "", ""}, - {"users_directory", "structured_json/users_directory.json", "list_users", "", ""}, - {"products_inventory", "structured_json/products_inventory.json", "list_products", "", ""}, - {"oc_pods_slice", "structured_json/oc_pods_slice.json", "Bash", "", "oc get pods -o json"}, - // search_results → format re-encoder on nested record arrays - {"glab_issue_list", "search_results/glab_issue_list.json", "Bash", "", "glab issue list -F json"}, - {"glab_mr_list", "search_results/glab_mr_list.json", "Bash", "", "glab mr list -F json"}, - // file_reads → code skeletonizer - {"runner_py", "file_reads/runner.py", "Read", "scripts/benchmark-sessions/lib/runner.py", ""}, -} - -// component is one deterministic configuration to measure in isolation, plus the full -// deterministic pipeline. extract is always OFF (no model). -type component struct { - name string - settings func() config.Settings -} - -// isolate builds a settings value that turns the reduce stage on but enables only the -// named reducers / encoders, with caching off and extraction off — so a measured -// reduction is attributable to that one component. -func isolate(reducers, encoders []string, cmdFilter bool) config.Settings { - return config.Settings{ - CacheEnabled: false, - ReduceEnabled: true, - ProtectRecent: 2, - // ProvableOnly off so an unused tool_result collapses to reason "unused" (a - // collapse-eligible reason); reductions stay reversible regardless. - ProvableOnly: false, - CollapseOutputs: true, - CmdFilter: cmdFilter, - Reducers: reducers, - Encoders: encoders, - Compactors: []string{"reduce"}, - ExtractEnabled: false, - } -} - -func components() []component { - return []component{ - {"cmdfilter", func() config.Settings { - // Only the command-output filter: no reducers in the routing loop. - s := isolate([]string{"__none__"}, nil, true) - return s - }}, - {"format_toon", func() config.Settings { - // Only the format re-encoder, restricted to TOON. - return isolate([]string{"format"}, []string{"toon"}, false) - }}, - {"format_best", func() config.Settings { - // Only the format re-encoder, all encoders (it picks the smallest faithful). - return isolate([]string{"format"}, nil, false) - }}, - {"skeleton", func() config.Settings { - // Only the code skeletonizer. - return isolate([]string{"skeleton"}, nil, false) - }}, - {"collapse", func() config.Settings { - // Only the collapse reducer (reversible marker for unused outputs). - return isolate([]string{"collapse"}, nil, false) - }}, - {"pipeline_full", func() config.Settings { - // Full deterministic pipeline: cmdfilter + all reducers/encoders + cache, - // extraction OFF. This is the "balanced" preset minus the model. - s := config.Default() - s.ExtractEnabled = false - s.ProvableOnly = false - return s - }}, - } -} - -// row is one measured (fixture, component) result. -type row struct { - Fixture string `json:"fixture"` - Component string `json:"component"` - TokensBefore int `json:"tokens_before"` - TokensAfter int `json:"tokens_after"` - PctSaved float64 `json:"pct_saved"` - BytesBefore int `json:"bytes_before"` - BytesAfter int `json:"bytes_after"` - Reversible bool `json:"reversible"` - AddedLatency float64 `json:"added_latency_ms"` - changed bool // internal: did this component touch the fixture at all -} - -func main() { - jsonOut := flag.Bool("json", false, "emit machine-readable JSON rows instead of a table") - extractMode := flag.Bool("extract", false, "ONLINE: measure the real cheap-model extractor against the live gateway (claude-haiku-4-5)") - flag.Parse() - - if *extractMode { - runExtract(*jsonOut) - return - } - - rows := measureAll() - - if *jsonOut { - enc := json.NewEncoder(os.Stdout) - enc.SetIndent("", " ") - if err := enc.Encode(rows); err != nil { - fmt.Fprintln(os.Stderr, "labcx-bench:", err) - os.Exit(1) - } - return - } - fmt.Print(markdownTable(rows)) -} - -// fixturesDir locates testdata/fixtures by walking up from the working directory for the -// go.mod that marks the module root, so the harness runs from any subdirectory (and from -// `go test`, whose cwd is the package dir). -func fixturesDir() (string, error) { - dir, err := os.Getwd() - if err != nil { - return "", err - } - for { - if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil { - return filepath.Join(dir, "testdata", "fixtures"), nil - } - parent := filepath.Dir(dir) - if parent == dir { - return "", fmt.Errorf("module root (go.mod) not found above %s", dir) - } - dir = parent - } -} - -// measureAll runs every component against every fixture and returns the rows in a stable -// order (fixture order, then component order). -func measureAll() []row { - base, err := fixturesDir() - if err != nil { - fmt.Fprintln(os.Stderr, "labcx-bench:", err) - os.Exit(1) - } - comps := components() - var rows []row - for _, fx := range fixtureSet { - body, err := os.ReadFile(filepath.Join(base, filepath.FromSlash(fx.relPath))) - if err != nil { - fmt.Fprintf(os.Stderr, "labcx-bench: read %s: %v\n", fx.relPath, err) - os.Exit(1) - } - fixtureText := string(body) - for _, c := range comps { - rows = append(rows, measure(fx, fixtureText, c)) - } - } - return rows -} - -// measure runs one component against one fixture in isolation and verifies reversibility. -func measure(fx fixture, fixtureText string, c component) row { - settings := c.settings() - eng := engine.New(settings, nil, nil) - - req := buildRequest(fx, fixtureText) - before := req.Clone() - - start := time.Now() - out, _ := eng.Transform(context.Background(), req) - elapsed := time.Since(start) - - beforeText := toolResultText(before, fx) - afterText := toolResultText(out, fx) - - r := row{ - Fixture: fx.name, - Component: c.name, - TokensBefore: tokens.Count(beforeText), - TokensAfter: tokens.Count(afterText), - BytesBefore: len(beforeText), - BytesAfter: len(afterText), - AddedLatency: float64(elapsed.Microseconds()) / 1000.0, - changed: afterText != beforeText, - } - if r.TokensBefore > 0 { - r.PctSaved = round2(float64(r.TokensBefore-r.TokensAfter) / float64(r.TokensBefore) * 100) - } - r.Reversible = verifyReversible(eng, beforeText, afterText) - return r -} - -// verifyReversible confirms the reduction is recoverable: if the component left a lab-cx -// marker, the rewind store must return the exact original; if it left the text untouched -// (a no-op for this fixture/component), that is trivially reversible. A reduction that -// changed the text but exposes no recoverable original would be a (disallowed) lossy drop. -func verifyReversible(eng *engine.Engine, before, after string) bool { - if after == before { - return true // no-op: nothing to recover - } - ids := engine.FindMarkers(after) - if len(ids) == 0 { - // The format re-encoder rewrites in place behind a recovery note that DOES carry - // a marker; if no marker is present and the text changed, treat as not provably - // reversible. - return false - } - for _, id := range ids { - orig, ok := eng.Expand(id) - if !ok || !strings.Contains(before, orig) { - return false - } - } - return true -} - -// buildRequest wraps a fixture as a canonical Anthropic-shaped request: an assistant -// tool_use (so command-aware and read-aware passes see the command / file path) followed -// by the tool_result carrying the fixture, then two neutral trailing turns so the -// fixture sits OUTSIDE the protect-recent window and is eligible for reduction. -func buildRequest(fx fixture, fixtureText string) canon.Request { - input := map[string]any{} - if fx.command != "" { - input["command"] = fx.command - } - if fx.filePath != "" { - input["file_path"] = fx.filePath - } - root := map[string]any{ - "model": "claude-sonnet-4-6", - "messages": []any{ - map[string]any{"role": "user", "content": "Run the tool and report back."}, - map[string]any{"role": "assistant", "content": []any{ - map[string]any{"type": "tool_use", "id": "tu_1", "name": fx.toolName, "input": input}, - }}, - map[string]any{"role": "user", "content": []any{ - map[string]any{"type": "tool_result", "tool_use_id": "tu_1", "content": fixtureText}, - }}, - map[string]any{"role": "assistant", "content": "Acknowledged; continuing."}, - map[string]any{"role": "user", "content": "Please proceed with the next step."}, - }, - } - return canon.Request{Root: root} -} - -// toolResultText pulls the (possibly reduced) text of the fixture's tool_result block -// back out of a request, so before/after are compared on the SAME block. -func toolResultText(req canon.Request, fx fixture) string { - for _, m := range req.Messages() { - for _, b := range canon.Blocks(m) { - if canon.BlockType(b) != "tool_result" { - continue - } - switch c := b["content"].(type) { - case string: - return c - case []any: - var parts []string - for _, x := range c { - if bb, ok := x.(map[string]any); ok && bb["type"] == "text" { - if t, ok := bb["text"].(string); ok { - parts = append(parts, t) - } - } - } - return strings.Join(parts, "\n") - } - } - } - return "" -} - -func round2(f float64) float64 { - return float64(int(f*100+0.5*sign(f))) / 100 -} - -func sign(f float64) float64 { - if f < 0 { - return -1 - } - return 1 -} - -// markdownTable renders the measured rows as the table pasted into RESULTS-offline.md. -func markdownTable(rows []row) string { - var b strings.Builder - b.WriteString("| fixture | component | tokens_before | tokens_after | %saved | bytes_before | bytes_after | reversible | added_latency_ms |\n") - b.WriteString("| --- | --- | ---: | ---: | ---: | ---: | ---: | :---: | ---: |\n") - // Stable order: fixtures as declared, components as declared. - for _, r := range rows { - rev := "yes" - if !r.Reversible { - rev = "no" - } - note := "" - if !r.changed { - note = " (no-op)" - } - fmt.Fprintf(&b, "| %s | %s%s | %d | %d | %.2f | %d | %d | %s | %.3f |\n", - r.Fixture, r.Component, note, r.TokensBefore, r.TokensAfter, r.PctSaved, - r.BytesBefore, r.BytesAfter, rev, r.AddedLatency) - } - return b.String() -} - -// --------------------------------------------------------------------------- -// --extract: ONLINE measurement of the REAL cheap-model extractor. -// --------------------------------------------------------------------------- - -// extractFloor is the LLMCompactFloor used for the --extract measurement. The committed -// structured fixtures are only a few hundred tokens, well below the production default of -// 3000, so we lower the floor to 200 here purely so that extraction actually FIRES on the -// corpus. This is a measurement-only setting; it does not change any default. -const extractFloor = 200 - -// extractFixture is a large/structured fixture surfaced as a tool_result, paired with a -// realistic recent goal that references specific records — so HarvestIdentifiers builds a -// non-trivial keep-set and the extractor has something concrete to filter toward. -type extractFixture struct { - name string - relPath string - toolName string - command string - goal string -} - -// extractFixtureSet is the structured_json + search_results corpus (the large, record-shaped -// fixtures extraction targets). Each goal names records that exist in the fixture. -var extractFixtureSet = []extractFixture{ - {"flights_search", "structured_json/flights_search.json", "search_flights", "", - "Book the cheapest nonstop SFO->JFK flight. I only care about flights FL003 and FL004 and their prices; ignore the rest."}, - {"users_directory", "structured_json/users_directory.json", "list_users", "", - "I need the admin account only — find user_0037 (alice.target@corp.com) and confirm the role is admin."}, - {"products_inventory", "structured_json/products_inventory.json", "list_products", "", - "Reorder the out-of-stock items. I care about SKU0001 and SKU0004 (in_stock=false) and their prices."}, - {"oc_pods_slice", "structured_json/oc_pods_slice.json", "Bash", "oc get pods -o json", - "Find pods that are NOT Running or that have restarts. I care about alertmanager-main-0 and any pod with restartCount > 0."}, - {"glab_issue_list", "search_results/glab_issue_list.json", "Bash", "glab issue list -F json", - "Triage open issues. I only care about issue iid 156 (Support glab CI pipeline filtering) — its title, state, and labels."}, - {"glab_mr_list", "search_results/glab_mr_list.json", "Bash", "glab mr list -F json", - "Review the glab MR. I only care about merge request iid 314 (feat(glab): add GitLab CLI support) — its state, merge_status, and source_branch."}, -} - -// extractRow is one measured (fixture) extraction result. -type extractRow struct { - Fixture string `json:"fixture"` - TokensBefore int `json:"tokens_before"` - TokensAfter int `json:"tokens_after"` - PctSaved float64 `json:"pct_saved"` - Strategy string `json:"strategy"` - Contained bool `json:"contained"` - LatencyMs float64 `json:"latency_ms"` - Model string `json:"model"` -} - -// runExtract enables real cheap-model extraction and measures it against the structured -// corpus, printing a Markdown table (or JSON with --json). It requires the gateway env. -func runExtract(jsonOut bool) { - base := os.Getenv("ANTHROPIC_BASE_URL") - token := os.Getenv("ANTHROPIC_AUTH_TOKEN") - if base == "" || token == "" { - fmt.Fprintln(os.Stderr, "labcx-bench --extract: ANTHROPIC_BASE_URL and ANTHROPIC_AUTH_TOKEN must be set (source /tmp/lcx_env.sh)") - os.Exit(1) - } - dir, err := fixturesDir() - if err != nil { - fmt.Fprintln(os.Stderr, "labcx-bench:", err) - os.Exit(1) - } - - model := cheapmodel.Anthropic{ - BaseURL: base, - APIKey: token, - Model: "claude-haiku-4-5", - AuthScheme: "bearer", - } - - var rows []extractRow - for _, fx := range extractFixtureSet { - body, err := os.ReadFile(filepath.Join(dir, filepath.FromSlash(fx.relPath))) - if err != nil { - fmt.Fprintf(os.Stderr, "labcx-bench: read %s: %v\n", fx.relPath, err) - os.Exit(1) - } - rows = append(rows, measureExtract(fx, string(body), model)) - } - - if jsonOut { - enc := json.NewEncoder(os.Stdout) - enc.SetIndent("", " ") - if err := enc.Encode(rows); err != nil { - fmt.Fprintln(os.Stderr, "labcx-bench:", err) - os.Exit(1) - } - return - } - fmt.Print(extractTable(rows)) -} - -// measureExtract runs the engine with extraction ENABLED against one fixture and records -// the real result. The engine is configured so the fixture qualifies as an LLM candidate -// (ExtractEnabled, low LLMCompactFloor, small ProtectRecent). To record the strategy the -// model actually chose — which engine.EnableExtract does not surface — we register a -// strategy-capturing extract func that mirrors EnableExtract's logic exactly (same -// goal/keep-set, RunExtraction, then reversible splice via the engine store + recovery -// marker) using cheapmodel.Anthropic as the model. There is exactly ONE model call path. -func measureExtract(fx extractFixture, fixtureText string, model engine.Model) extractRow { - settings := config.Settings{ - CacheEnabled: false, - ReduceEnabled: true, - ProtectRecent: 1, - ProvableOnly: false, - CollapseOutputs: true, - Reducers: []string{"__none__"}, // no deterministic reducer touches the body first - Compactors: []string{"reduce"}, - LLMCompactFloor: extractFloor, - } - eng := engine.New(settings, nil, nil) - - cfg := engine.DefaultExtractConfig() - cfg.Floor = extractFloor - - req := buildExtractRequest(fx, fixtureText) - before := req.Clone() - beforeText := toolResultTextAny(before) - - start := time.Now() - // reduce runs first (no deterministic reducer touches the body); then we run the - // extraction ourselves over the same candidate set the engine's Extractor would, so - // we can record which strategy RunExtraction selected for the row. - out, _ := eng.Transform(context.Background(), req) - opts := reduce.DefaultOpts() - opts.ProtectRecent = 1 - opts.ProvableOnly = false - opts.LLMCompactFloor = extractFloor - cands := reduce.SelectLLMCandidates(out, opts) - chosen := runCapturingExtract(eng, out, cands, model, cfg.Floor) - elapsed := time.Since(start) - - afterText := toolResultTextAny(out) - - r := extractRow{ - Fixture: fx.name, - TokensBefore: tokens.Count(beforeText), - TokensAfter: tokens.Count(afterText), - Strategy: chosen, - LatencyMs: float64(elapsed.Milliseconds()), - Model: "claude-haiku-4-5", - } - if r.TokensBefore > 0 { - r.PctSaved = round2(float64(r.TokensBefore-r.TokensAfter) / float64(r.TokensBefore) * 100) - } - if chosen == "" { - r.Strategy = "none" - } - // Contained: the engine only splices a result that left a reversible recovery marker - // whose stored original is a substring of the pre-extraction body. Verify via the - // public FindMarkers + Expand round-trip — a spliced block is lossless and reversible. - r.Contained = verifyContained(eng, beforeText, afterText) - return r -} - -// runCapturingExtract runs the same extraction the engine's Extractor would over cands, -// but records which strategy RunExtraction selected and performs the reversible splice -// through the engine's public store + recovery marker. Returns the chosen strategy. -func runCapturingExtract(eng *engine.Engine, req canon.Request, cands []reduce.Candidate, model engine.Model, floor int) string { - icfg := extract.Cfg{Mode: "auto", Floor: floor, AllowDeterministic: true, MaxChars: 4000} - cache := extract.NewCache() - goal := recentGoalText(req, 6) - keep := extract.HarvestIdentifiers(goal, 60) - ctx := context.Background() - chosen := "none" - for _, c := range cands { - func() { - defer func() { _ = recover() }() // fail-open per candidate - key := goalKey(extract.ContentKey(c.Text), goal, keep) - result, ok := cache.Get(key) - var strat string - if ok { - strat = "cache" - } else { - result, strat = extract.RunExtraction(ctx, c.Text, goal, keep, c.TokenEst, icfg, model) - if strat == "none" || result == "" { - chosen = "none" - return - } - cache.Put(key, result) - } - chosen = strat - spliceResult(eng, req, c, result) - }() - } - return chosen -} - -// spliceResult mirrors engine.(*Engine).splice using the public API: store the original -// for reversal, write the extracted text plus a recovery marker, never inflate. -func spliceResult(eng *engine.Engine, req canon.Request, c reduce.Candidate, result string) { - block := blockAtExtract(req, c.MsgIndex, c.BlockIndex) - if block == nil { - return - } - rid := eng.Store().Put(c.Text) - label := c.FilePath - if label == "" { - label = c.ToolName - } - if label == "" { - label = "tool output" - } - newText := strings.TrimRight(result, "\n") + "\n" + markers.RecoveryNote(label, "extracted", rid) - if tokens.Count(newText) >= tokens.Count(c.Text) { - return // never inflate - } - switch block["type"] { - case "tool_result": - block["content"] = newText - case "text": - block["text"] = newText - } -} - -func blockAtExtract(req canon.Request, mi, bi int) map[string]any { - msgs := req.Messages() - if mi < 0 || mi >= len(msgs) { - return nil - } - list, ok := msgs[mi]["content"].([]any) - if !ok || bi < 0 || bi >= len(list) { - return nil - } - blk, _ := list[bi].(map[string]any) - return blk -} - -// goalKey mirrors engine.goalKey: a goal-aware composite of the body content key, goal, -// and keep-set, so a different goal is a cache miss. -func goalKey(contentKey, goal string, keep []string) string { - h := sha256.New() - h.Write([]byte(contentKey)) - h.Write([]byte{0}) - h.Write([]byte(goal)) - for _, k := range keep { - h.Write([]byte{0}) - h.Write([]byte(k)) - } - return hex.EncodeToString(h.Sum(nil))[:24] -} - -// recentGoalText concatenates the text of the last k turns (excluding tool_result content), -// mirroring the engine's goal-extraction window. -func recentGoalText(req canon.Request, k int) string { - msgs := req.Messages() - start := len(msgs) - k - if start < 0 { - start = 0 - } - var out []string - for _, m := range msgs[start:] { - switch c := m["content"].(type) { - case string: - out = append(out, c) - case []any: - for _, b := range c { - if bb, ok := b.(map[string]any); ok && bb["type"] == "text" { - if t, ok := bb["text"].(string); ok { - out = append(out, t) - } - } - } - } - } - s := strings.Join(out, "\n") - if len(s) > 6000 { - s = s[:6000] - } - return s -} - -// buildExtractRequest surfaces the fixture as a tool_result, with the fixture's realistic -// goal as the recent trailing user turn (so it sits OUTSIDE protect-recent=1 and the goal -// conditions the keep-set). -func buildExtractRequest(fx extractFixture, fixtureText string) canon.Request { - input := map[string]any{} - if fx.command != "" { - input["command"] = fx.command - } - root := map[string]any{ - "model": "claude-sonnet-4-6", - "messages": []any{ - map[string]any{"role": "user", "content": "Run the tool and report back."}, - map[string]any{"role": "assistant", "content": []any{ - map[string]any{"type": "tool_use", "id": "tu_1", "name": fx.toolName, "input": input}, - }}, - map[string]any{"role": "user", "content": []any{ - map[string]any{"type": "tool_result", "tool_use_id": "tu_1", "content": fixtureText}, - }}, - map[string]any{"role": "assistant", "content": "Acknowledged; continuing."}, - map[string]any{"role": "user", "content": fx.goal}, - }, - } - return canon.Request{Root: root} -} - -// toolResultTextAny pulls the (possibly extracted) tool_result text out of a request. -func toolResultTextAny(req canon.Request) string { - for _, m := range req.Messages() { - for _, b := range canon.Blocks(m) { - if canon.BlockType(b) != "tool_result" { - continue - } - switch c := b["content"].(type) { - case string: - return c - case []any: - var parts []string - for _, x := range c { - if bb, ok := x.(map[string]any); ok && bb["type"] == "text" { - if t, ok := bb["text"].(string); ok { - parts = append(parts, t) - } - } - } - return strings.Join(parts, "\n") - } - } - } - return "" -} - -// verifyContained confirms a spliced extraction is lossless + reversible: the marker's -// stored original must be a substring of the original body. A decline (no marker, text -// unchanged) is reported as not-contained=false honestly (nothing was spliced). -func verifyContained(eng *engine.Engine, before, after string) bool { - if after == before { - return false // no splice happened (decline) - } - ids := engine.FindMarkers(after) - if len(ids) == 0 { - return false - } - for _, id := range ids { - orig, ok := eng.Expand(id) - if !ok || !strings.Contains(before, orig) { - return false - } - } - return true -} - -// extractTable renders the measured extraction rows as the table pasted into RESULTS-extract.md. -func extractTable(rows []extractRow) string { - var b strings.Builder - b.WriteString("| fixture | tokens_before | tokens_after | %saved | strategy | contained | latency_ms | model |\n") - b.WriteString("| --- | ---: | ---: | ---: | :---: | :---: | ---: | :--- |\n") - for _, r := range rows { - contained := "yes" - if !r.Contained { - contained = "no" - } - fmt.Fprintf(&b, "| %s | %d | %d | %.2f | %s | %s | %.0f | %s |\n", - r.Fixture, r.TokensBefore, r.TokensAfter, r.PctSaved, r.Strategy, contained, r.LatencyMs, r.Model) - } - return b.String() -} diff --git a/cmd/labcx-bench/main_test.go b/cmd/labcx-bench/main_test.go deleted file mode 100644 index 34a6986..0000000 --- a/cmd/labcx-bench/main_test.go +++ /dev/null @@ -1,130 +0,0 @@ -package main - -import ( - "context" - "os" - "path/filepath" - "strings" - "testing" - - "github.com/kagenti/lab-context-engineering/config" - "github.com/kagenti/lab-context-engineering/engine" -) - -// findFixture returns the committed fixture by name (fails the test if absent). -func findFixture(t *testing.T, name string) fixture { - t.Helper() - for _, fx := range fixtureSet { - if fx.name == name { - return fx - } - } - t.Fatalf("fixture %q not in fixtureSet", name) - return fixture{} -} - -// componentByName returns the settings constructor for a named component (fails if absent). -func componentByName(t *testing.T, name string) func() config.Settings { - t.Helper() - for _, c := range components() { - if c.name == name { - return c.settings - } - } - t.Fatalf("component %q not defined", name) - return nil -} - -// readFixture loads a committed fixture from disk via the same resolver the harness uses. -func readFixture(t *testing.T, fx fixture) string { - t.Helper() - base, err := fixturesDir() - if err != nil { - t.Fatalf("fixturesDir: %v", err) - } - body, err := os.ReadFile(filepath.Join(base, filepath.FromSlash(fx.relPath))) - if err != nil { - t.Fatalf("read %s: %v", fx.relPath, err) - } - return string(body) -} - -// TestHarnessReducesAndRecovers runs the harness end to end on one committed fixture and -// asserts the core promise: a deterministic component reduces tokens > 0 AND the original -// is recoverable byte-for-byte from the rewind store (reversible). -func TestHarnessReducesAndRecovers(t *testing.T) { - fx := findFixture(t, "runner_py") - text := readFixture(t, fx) - - r := measure(fx, text, component{name: "skeleton", settings: componentByName(t, "skeleton")}) - - if r.TokensBefore <= 0 { - t.Fatalf("expected non-empty fixture, got tokens_before=%d", r.TokensBefore) - } - if r.TokensAfter >= r.TokensBefore { - t.Fatalf("expected token reduction: tokens_after=%d not < tokens_before=%d", - r.TokensAfter, r.TokensBefore) - } - if r.BytesAfter >= r.BytesBefore { - t.Fatalf("expected byte reduction: bytes_after=%d not < bytes_before=%d", - r.BytesAfter, r.BytesBefore) - } - if r.PctSaved <= 0 { - t.Fatalf("expected pct_saved > 0, got %.2f", r.PctSaved) - } - if !r.Reversible { - t.Fatalf("skeleton reduction must be reversible (recoverable via rewind store)") - } -} - -// TestCmdFilterReducesRealCommandOutput proves the command-output filter reduces a real -// large command output (rtk's cargo build) and stays reversible. -func TestCmdFilterReducesRealCommandOutput(t *testing.T) { - fx := findFixture(t, "cargo_build") - text := readFixture(t, fx) - r := measure(fx, text, component{name: "cmdfilter", settings: componentByName(t, "cmdfilter")}) - if r.TokensAfter >= r.TokensBefore { - t.Fatalf("cmdfilter should reduce cargo build noise: before=%d after=%d", - r.TokensBefore, r.TokensAfter) - } - if !r.Reversible { - t.Fatalf("cmdfilter reduction must be reversible") - } -} - -// TestEveryRowReversible runs the whole matrix and asserts no component ever produces an -// irreversible (unrecoverable lossy) result — fail-open and reversibility are the repo's -// hard invariants. -func TestEveryRowReversible(t *testing.T) { - for _, r := range measureAll() { - if !r.Reversible { - t.Errorf("%s / %s: not reversible (tokens %d->%d)", - r.Fixture, r.Component, r.TokensBefore, r.TokensAfter) - } - } -} - -// TestFindMarkersExpandRoundTrip is a direct check that the engine's public recovery API -// (FindMarkers + Expand) round-trips an original through a reducing pass. -func TestFindMarkersExpandRoundTrip(t *testing.T) { - fx := findFixture(t, "glab_mr_list") - text := readFixture(t, fx) - - eng := engine.New(componentByName(t, "collapse")(), nil, nil) - req := buildRequest(fx, text) - before := toolResultText(req.Clone(), fx) - out, _ := eng.Transform(context.Background(), req) - after := toolResultText(out, fx) - - ids := engine.FindMarkers(after) - if len(ids) == 0 { - t.Fatalf("expected a recovery marker after collapse") - } - orig, ok := eng.Expand(ids[0]) - if !ok { - t.Fatalf("Expand(%q) returned not-found", ids[0]) - } - if !strings.Contains(before, orig) { - t.Fatalf("recovered original does not match the pre-reduction text") - } -} diff --git a/cmd/proxy/main.go b/cmd/proxy/main.go deleted file mode 100644 index 8b91a6a..0000000 --- a/cmd/proxy/main.go +++ /dev/null @@ -1,347 +0,0 @@ -// Command lab-cx is the standalone context-engineering proxy: it sits between any -// LLM coding agent and the model API, reduces token cost on the request, and -// forwards it upstream. The same engine that powers this binary is importable as -// a library (see ../../engine) so other hosts — e.g. a Kagenti AuthBridge plugin -// — can wrap it without running this process. -package main - -import ( - "encoding/json" - "flag" - "fmt" - "io" - "net/http" - "os" - "strings" - "time" - - "github.com/kagenti/lab-context-engineering/config" - "github.com/kagenti/lab-context-engineering/engine" - "github.com/kagenti/lab-context-engineering/internal/buildinfo" - "github.com/kagenti/lab-context-engineering/internal/cheapmodel" - "github.com/kagenti/lab-context-engineering/internal/proxyhttp" - "github.com/kagenti/lab-context-engineering/observability" -) - -func usage() { - fmt.Fprintf(os.Stderr, `lab-cx — context-engineering proxy for LLM agents - -Usage: - lab-cx proxy [--addr :8080] [--preset balanced] [--upstream URL] start the proxy - lab-cx stats [--addr http://localhost:8080] print live /stats - lab-cx version print version - -Point your agent at the proxy, e.g.: - ANTHROPIC_BASE_URL=http://localhost:8080 OPENAI_BASE_URL=http://localhost:8080/v1 - -Flags: - --addr listen address (default :8080) - --preset safe|balanced|aggressive|cache|coding|mcp (default balanced) - --config path to a YAML config file (overrides --preset; names which reducers/ - encoders/extract-strategies/stages run, by name; see configs/lab-cx.yaml). - --extract-model/-provider/-auth/-base still override the file's transport. - --upstream forward ALL requests here (e.g. an eval gateway); default routes by - provider (api.anthropic.com / api.openai.com) - --mode initial reduction mode: on|off|deterministic (default on). Settable at - runtime without a restart: POST /labcx/mode {"mode":"off"} (GET reads it). - off = transparent passthrough; deterministic is reserved (behaves like on). - --max-body-bytes cap request body size in bytes (default 33554432 = 32 MiB); - 0 means no cap - --upstream-timeout max total time per upstream request (e.g. 30s; default 0s = no - timeout). WARNING: a non-zero value caps the WHOLE request - including streamed responses and can truncate long LLM SSE streams -`) -} - -func main() { - if len(os.Args) < 2 { - usage() - os.Exit(2) - } - switch os.Args[1] { - case "version", "-v", "--version": - fmt.Printf("lab-cx %s (%s)\n", buildinfo.Version, buildinfo.Commit) - case "proxy": - runProxy(os.Args[2:]) - case "stats": - runStats(os.Args[2:]) - case "help", "-h", "--help": - usage() - default: - fmt.Fprintf(os.Stderr, "unknown command %q\n\n", os.Args[1]) - usage() - os.Exit(2) - } -} - -func runProxy(args []string) { - fs := flag.NewFlagSet("proxy", flag.ExitOnError) - addr := fs.String("addr", ":8080", "listen address") - preset := fs.String("preset", "balanced", "settings preset") - configPath := fs.String("config", "", "path to a YAML config file (overrides --preset; see configs/lab-cx.yaml)") - upstream := fs.String("upstream", "", "forward all requests to this base URL") - mode := fs.String("mode", "on", "initial reduction mode: on|off|deterministic (runtime-settable via POST /labcx/mode)") - extractModel := fs.String("extract-model", "", "enable cheap-model extraction with this model (e.g. claude-haiku-4-5)") - extractProvider := fs.String("extract-provider", "anthropic", "extraction model provider: anthropic|openai") - extractBase := fs.String("extract-base", "", "base URL for the extraction model (default per provider)") - extractAuth := fs.String("extract-auth", "x-api-key", "anthropic auth scheme: x-api-key|bearer (bearer for gateway endpoints)") - summarizeModel := fs.String("summarize-model", "", "enable LLM summarization with this model (e.g. claude-haiku-4-5)") - summarizeProvider := fs.String("summarize-provider", "anthropic", "summarizer model provider: anthropic|openai") - summarizeBase := fs.String("summarize-base", "", "base URL for the summarizer model (default per provider)") - summarizeAuth := fs.String("summarize-auth", "x-api-key", "anthropic auth scheme: x-api-key|bearer") - reduceCachedPrefix := fs.Bool("reduce-cached-prefix", false, "also run the deterministic reducers and the LLM extractor on the client's self-cached prefix (e.g. Claude Code) instead of deferring to its cache") - maxBodyBytes := fs.Int64("max-body-bytes", 33554432, "cap request body size in bytes (32 MiB default); 0 means no cap") - upstreamTimeout := fs.String("upstream-timeout", "0s", "max total time per upstream request (e.g. 30s); 0 means no timeout. WARNING: a non-zero value caps the WHOLE request including streamed responses and can truncate long LLM SSE streams") - _ = fs.Parse(args) - - upstreamTimeoutDur, err := time.ParseDuration(*upstreamTimeout) - if err != nil { - fmt.Fprintf(os.Stderr, "lab-cx: invalid --upstream-timeout %q: %v\n", *upstreamTimeout, err) - os.Exit(2) - } - - initialMode, ok := proxyhttp.ParseMode(*mode) - if !ok { - fmt.Fprintf(os.Stderr, "lab-cx: invalid --mode %q (want on|off|deterministic)\n", *mode) - os.Exit(2) - } - - if os.Getenv("LABCX_DISABLE") == "1" { - fmt.Fprintln(os.Stderr, "lab-cx: LABCX_DISABLE=1 — running as a transparent passthrough") - } - - // Resolve settings: a --config file (with named component selection) takes - // precedence over --preset. The file may also name each LLM compactor's transport - // (provider/model/auth/base/credentials); the matching --extract-*/--summarize-* - // flags still override it. - var settings config.Settings - var tr config.Transports - settingsSrc := "preset=" + *preset - if *configPath != "" { - var loadErr error - settings, tr, loadErr = config.LoadFull(*configPath) - if loadErr != nil { - fmt.Fprintf(os.Stderr, "lab-cx: %v\n", loadErr) - os.Exit(2) - } - settingsSrc = "config=" + *configPath - } else { - settings = config.Preset(*preset) - } - // --extract-*/--summarize-* flags override the config transport block (explicit flag - // wins; else the config value; else the flag default). - if v := flagOrConfig(fs, "extract-model", *extractModel, tr.Extract.Model); v != "" { - *extractModel = v - } - if v := flagOrConfig(fs, "extract-provider", *extractProvider, tr.Extract.Provider); v != "" { - *extractProvider = v - } - if v := flagOrConfig(fs, "extract-auth", *extractAuth, tr.Extract.Auth); v != "" { - *extractAuth = v - } - if v := flagOrConfig(fs, "extract-base", *extractBase, tr.Extract.Base); v != "" { - *extractBase = v - } - if v := flagOrConfig(fs, "summarize-model", *summarizeModel, tr.Summarize.Model); v != "" { - *summarizeModel = v - } - if v := flagOrConfig(fs, "summarize-provider", *summarizeProvider, tr.Summarize.Provider); v != "" { - *summarizeProvider = v - } - if v := flagOrConfig(fs, "summarize-auth", *summarizeAuth, tr.Summarize.Auth); v != "" { - *summarizeAuth = v - } - if v := flagOrConfig(fs, "summarize-base", *summarizeBase, tr.Summarize.Base); v != "" { - *summarizeBase = v - } - - if os.Getenv("LABCX_DISABLE") == "1" { - settings.Disabled = true - } - if *reduceCachedPrefix { - settings.ReduceCachedPrefix = true - } - eng := engine.New(settings, nil, nil) - - // Wire the extract compactor. Source "incoming" reuses the proxied request's own - // model + credentials (no static client); otherwise build a static client. - useIncoming := false - if tr.Extract.Source == "incoming" { - eng.EnableExtractSpec(engine.ModelSpec{UseIncoming: true}, engine.DefaultExtractConfig()) - useIncoming = true - fmt.Fprintln(os.Stderr, "lab-cx: extraction enabled (source=incoming)") - } else if *extractModel != "" { - key := resolveKey(tr.Extract, *extractProvider, "LABCX_EXTRACT_KEY") - model, err := buildModel(*extractProvider, *extractModel, *extractBase, *extractAuth, key) - if err != nil { - fmt.Fprintf(os.Stderr, "lab-cx: %v\n", err) - os.Exit(2) - } - eng.EnableExtract(model, engine.DefaultExtractConfig()) - fmt.Fprintf(os.Stderr, "lab-cx: extraction enabled (provider=%s model=%s)\n", *extractProvider, *extractModel) - } - - // Wire the summarize compactor (same credential mechanism as extract). - if tr.Summarize.Source == "incoming" { - eng.EnableSummarizeSpec(engine.ModelSpec{UseIncoming: true}, engine.DefaultSummarizeConfig()) - useIncoming = true - fmt.Fprintln(os.Stderr, "lab-cx: summarization enabled (source=incoming)") - } else if *summarizeModel != "" { - key := resolveKey(tr.Summarize, *summarizeProvider, "LABCX_SUMMARIZE_KEY") - model, err := buildModel(*summarizeProvider, *summarizeModel, *summarizeBase, *summarizeAuth, key) - if err != nil { - fmt.Fprintf(os.Stderr, "lab-cx: %v\n", err) - os.Exit(2) - } - eng.EnableSummarize(model, engine.DefaultSummarizeConfig()) - fmt.Fprintf(os.Stderr, "lab-cx: summarization enabled (provider=%s model=%s)\n", *summarizeProvider, *summarizeModel) - } - - agg := observability.NewAggregator(defaultCostRates()) - cfg := proxyhttp.Config{ - Engine: eng, Upstream: *upstream, - // Stream each event via slog AND fold it into the Aggregator served at /stats. - Emitter: observability.Tee{observability.SlogEmitter{}, agg}, - Aggregator: agg, - MaxBodyBytes: *maxBodyBytes, - UpstreamTimeout: upstreamTimeoutDur, - InitialMode: initialMode, - } - if useIncoming { - // A compactor reuses the proxied request's own model + credentials: build a - // per-request model client from the incoming auth header, request model id, and - // resolved upstream base. - cfg.BuildRequestModel = incomingModel - } - handler := proxyhttp.New(cfg) - - fmt.Fprintf(os.Stderr, "lab-cx proxy listening on %s (%s)\n", *addr, settingsSrc) - if err := http.ListenAndServe(*addr, handler); err != nil { - fmt.Fprintln(os.Stderr, "lab-cx:", err) - os.Exit(1) - } -} - -// defaultCostRates is a small input/output price table ($/MTok) used to estimate the -// dollar value of saved tokens. Savings are priced on input rates (reduction removes -// input tokens). DefaultCostKey prices any model not listed here. Edit to match your -// provider's published pricing; values here are illustrative defaults. -func defaultCostRates() map[string]observability.CostRate { - return map[string]observability.CostRate{ - "claude-haiku-4-5": {InputPerMTok: 1.00, OutputPerMTok: 5.00}, - observability.DefaultCostKey: {InputPerMTok: 3.00, OutputPerMTok: 15.00}, - } -} - -// runStats GETs /stats from a running proxy and prints the JSON plus a one-line summary. -func runStats(args []string) { - fs := flag.NewFlagSet("stats", flag.ExitOnError) - addr := fs.String("addr", "http://localhost:8080", "base URL of a running lab-cx proxy") - _ = fs.Parse(args) - - url := *addr + "/stats" - resp, err := http.Get(url) - if err != nil { - fmt.Fprintf(os.Stderr, "lab-cx: GET %s: %v\n", url, err) - os.Exit(1) - } - defer resp.Body.Close() - body, _ := io.ReadAll(resp.Body) - if resp.StatusCode != http.StatusOK { - fmt.Fprintf(os.Stderr, "lab-cx: %s returned %d: %s\n", url, resp.StatusCode, string(body)) - os.Exit(1) - } - - var snap observability.Snapshot - if err := json.Unmarshal(body, &snap); err != nil { - fmt.Fprintf(os.Stderr, "lab-cx: bad /stats response: %v\n", err) - os.Exit(1) - } - fmt.Println(string(body)) - fmt.Println(observability.SummaryOf(snap)) -} - -// flagOrConfig resolves an extract-* knob: an explicitly-set flag wins; otherwise the -// config value (if non-empty); otherwise the flag's current value (its default). This -// lets a --config file name the extraction transport while --extract-* flags override. -func flagOrConfig(fs *flag.FlagSet, name, flagVal, cfgVal string) string { - set := false - fs.Visit(func(f *flag.Flag) { - if f.Name == name { - set = true - } - }) - if set { - return flagVal - } - if cfgVal != "" { - return cfgVal - } - return flagVal -} - -// resolveKey resolves an LLM compactor's API key: an inline key in the config wins, -// then the config-named env var (key_env), then the compactor's fallback env var, then -// the provider default env. Secrets are never required in YAML, but allowed. -func resolveKey(tr config.ModelTransport, provider, fallbackEnv string) string { - if tr.APIKey != "" { - return tr.APIKey - } - if tr.KeyEnv != "" { - if v := os.Getenv(tr.KeyEnv); v != "" { - return v - } - } - if fallbackEnv != "" { - if v := os.Getenv(fallbackEnv); v != "" { - return v - } - } - if provider == "openai" { - return os.Getenv("OPENAI_API_KEY") - } - if v := os.Getenv("ANTHROPIC_API_KEY"); v != "" { - return v - } - return os.Getenv("ANTHROPIC_AUTH_TOKEN") -} - -// buildModel constructs a cheap-model client for a provider/model/base/auth/key. -func buildModel(provider, model, base, auth, key string) (engine.Model, error) { - switch provider { - case "openai": - return cheapmodel.OpenAI{BaseURL: base, APIKey: key, Model: model}, nil - case "anthropic", "": - return cheapmodel.Anthropic{BaseURL: base, APIKey: key, Model: model, AuthScheme: auth}, nil - default: - return nil, fmt.Errorf("unknown provider %q (want anthropic|openai)", provider) - } -} - -// incomingModel builds a per-request model client that reuses the proxied request's own -// model id + credentials (from the incoming auth header) and the resolved upstream base. -// Returns nil when no usable credential is present (the compactor then no-ops). -func incomingModel(surfaceName, model, base string, h http.Header) engine.Model { - switch surfaceName { - case "openai": - if key := bearerToken(h.Get("Authorization")); key != "" { - return cheapmodel.OpenAI{BaseURL: base, APIKey: key, Model: model} - } - case "anthropic": - if key := h.Get("x-api-key"); key != "" { - return cheapmodel.Anthropic{BaseURL: base, APIKey: key, Model: model, AuthScheme: "x-api-key"} - } - if key := bearerToken(h.Get("Authorization")); key != "" { - return cheapmodel.Anthropic{BaseURL: base, APIKey: key, Model: model, AuthScheme: "bearer"} - } - } - return nil -} - -func bearerToken(h string) string { - const p = "Bearer " - if strings.HasPrefix(h, p) { - return strings.TrimSpace(h[len(p):]) - } - return "" -} diff --git a/components/all/all.go b/components/all/all.go new file mode 100644 index 0000000..c9b9da8 --- /dev/null +++ b/components/all/all.go @@ -0,0 +1,12 @@ +// Package all blank-imports every built-in component so their init() +// registrations run. Binaries (context-guru-proxy, the AuthBridge plugin) +// import this package for its side effects, then build a pipeline by name from +// config. Import it for effect: +// +// import _ "github.com/kagenti/context-guru/components/all" +package all + +import ( + _ "github.com/kagenti/context-guru/components/offload" + _ "github.com/kagenti/context-guru/components/reformat" +) diff --git a/components/all/all_test.go b/components/all/all_test.go new file mode 100644 index 0000000..65f3924 --- /dev/null +++ b/components/all/all_test.go @@ -0,0 +1,118 @@ +package all_test + +import ( + "context" + "strings" + "testing" + + "github.com/kagenti/context-guru/components" + _ "github.com/kagenti/context-guru/components/all" + "github.com/kagenti/context-guru/config" + "github.com/kagenti/context-guru/expand" + "github.com/kagenti/context-guru/schema" + "github.com/kagenti/context-guru/store" + "github.com/maximhq/bifrost/core/schemas" +) + +func toolMsg(text string) schemas.ChatMessage { + t := text + return schemas.ChatMessage{Role: schemas.ChatMessageRoleTool, Content: &schemas.ChatMessageContent{ContentStr: &t}} +} + +// TestPipelineReducesAndExpands drives the real config->pipeline path with +// cmdfilter + dedup, then recovers an offloaded original via the expand marker. +func TestPipelineReducesAndExpands(t *testing.T) { + // A pytest-style output the builtin filter should shrink. + var pytest strings.Builder + pytest.WriteString("===== test session starts =====\n") + for i := 0; i < 40; i++ { + pytest.WriteString("tests/test_module.py::test_case_" + strings.Repeat("x", 3) + " PASSED\n") + } + pytest.WriteString("1 failed, 40 passed in 2.10s\n") + + // A big log dump, included twice — dedup should collapse the second. + dump := strings.Repeat("2026-07-11T10:00:00Z INFO some verbose service log line with detail\n", 30) + + req := &schemas.BifrostChatRequest{ + Provider: schemas.Anthropic, + Input: []schemas.ChatMessage{ + toolMsg(pytest.String()), + toolMsg(dump), + toolMsg(dump), + }, + } + + cfg, err := config.LoadBytes([]byte("pipeline: [cmdfilter, dedup]\n")) + if err != nil { + t.Fatal(err) + } + st := store.NewMemory(store.Options{}) + pipe, err := cfg.Build(nil) + if err != nil { + t.Fatal(err) + } + c := &components.Ctx{Ctx: context.Background(), Session: "s1", Store: st} + + before := schema.MessagesTokens(req) + rr := pipe.Run(req, c) + after := schema.MessagesTokens(req) + + if after >= before { + t.Fatalf("pipeline did not reduce tokens: before=%d after=%d", before, after) + } + if rr.Saved() <= 0 { + t.Fatalf("run report shows no savings: %+v", rr) + } + + // pytest message should now carry an expand marker; recover the original. + got := schema.MessageText(req.Input[0]) + keys := expand.ParseMarkers(got) + if len(keys) != 1 { + t.Fatalf("expected one expand marker in filtered pytest output, got %q", got) + } + orig, ok := expand.Resolve(st, keys[0]) + if !ok || !strings.Contains(orig, "test_case_") || !strings.Contains(orig, "PASSED") { + t.Fatalf("expand did not recover the original pytest output (ok=%v)", ok) + } + + // dedup should have collapsed the third message (second copy of dump). + third := schema.MessageText(req.Input[2]) + if !strings.Contains(third, "identical to an earlier") { + t.Fatalf("dedup did not collapse the duplicate: %q", third) + } + dupKeys := expand.ParseMarkers(third) + if orig, ok := expand.Resolve(st, dupKeys[0]); !ok || orig != dump { + t.Fatal("dedup marker did not resolve to the original dump") + } +} + +// TestCacheinjectAddsBreakpoint checks the Reformat path adds cache_control on an +// Anthropic request without tripping the never-worse guard. +func TestCacheinjectAddsBreakpoint(t *testing.T) { + block := schemas.ChatContentBlock{Type: schemas.ChatContentBlockTypeText} + txt := "system-ish stable prefix content" + block.Text = &txt + newer := "newest turn" + req := &schemas.BifrostChatRequest{ + Provider: schemas.Anthropic, + Input: []schemas.ChatMessage{ + {Role: schemas.ChatMessageRoleUser, Content: &schemas.ChatMessageContent{ContentBlocks: []schemas.ChatContentBlock{block}}}, + {Role: schemas.ChatMessageRoleUser, Content: &schemas.ChatMessageContent{ContentStr: &newer}}, + }, + } + cfg, err := config.LoadBytes([]byte("pipeline: [cacheinject]\n")) + if err != nil { + t.Fatal(err) + } + pipe, _ := cfg.Build(nil) + c := &components.Ctx{Ctx: context.Background(), Session: "s", Store: store.NewMemory(store.Options{})} + rr := pipe.Run(req, c) + + if rr.Components[0].Reverted { + t.Fatalf("cacheinject was reverted (never-worse false positive): %+v", rr.Components[0]) + } + cc := req.Input[0].Content.ContentBlocks[0].CacheControl + if cc == nil || cc.Type != schemas.CacheControlTypeEphemeral { + t.Fatal("cacheinject did not set cache_control on the prefix boundary block") + } +} diff --git a/components/all/more_test.go b/components/all/more_test.go new file mode 100644 index 0000000..2f00ff0 --- /dev/null +++ b/components/all/more_test.go @@ -0,0 +1,120 @@ +package all_test + +import ( + "context" + "encoding/json" + "strings" + "testing" + + "github.com/kagenti/context-guru/components" + _ "github.com/kagenti/context-guru/components/all" + "github.com/kagenti/context-guru/config" + "github.com/kagenti/context-guru/expand" + "github.com/kagenti/context-guru/schema" + "github.com/kagenti/context-guru/store" + "github.com/maximhq/bifrost/core/schemas" +) + +func run(t *testing.T, yaml string, req *schemas.BifrostChatRequest) (*components.RunReport, store.Store) { + t.Helper() + cfg, err := config.LoadBytes([]byte(yaml)) + if err != nil { + t.Fatal(err) + } + pipe, err := cfg.Build(nil) + if err != nil { + t.Fatal(err) + } + st := store.NewMemory(store.Options{}) + c := &components.Ctx{Ctx: context.Background(), Session: "s", Store: st} + return pipe.Run(req, c), st +} + +func TestFormatCompactsJSON(t *testing.T) { + type row struct { + ID int `json:"id"` + Name string `json:"name"` + } + rows := make([]row, 20) + for i := range rows { + rows[i] = row{ID: i, Name: "item-with-a-longish-name"} + } + pretty, _ := json.MarshalIndent(rows, "", " ") + req := &schemas.BifrostChatRequest{Input: []schemas.ChatMessage{toolMsg(string(pretty))}} + before := schema.MessagesTokens(req) + run(t, "pipeline: [format]\n", req) + after := schema.MessagesTokens(req) + if after >= before { + t.Fatalf("format did not compact JSON: before=%d after=%d", before, after) + } + // still valid JSON (lossless) + var back []row + if err := json.Unmarshal([]byte(schema.MessageText(req.Input[0])), &back); err != nil || len(back) != 20 { + t.Fatalf("format broke JSON validity: %v", err) + } +} + +func TestSkeletonElidesBodies(t *testing.T) { + code := "```go\n" + + "package main\n\n" + + "func Add(a, b int) int {\n" + + "\tsum := 0\n\tsum += a\n\tsum += b\n\tfor i := 0; i < 10; i++ { sum += i }\n\treturn sum\n}\n\n" + + "func Mul(a, b int) int {\n\tp := 0\n\tfor i := 0; i < b; i++ { p += a }\n\treturn p\n}\n" + + "```" + req := &schemas.BifrostChatRequest{Input: []schemas.ChatMessage{toolMsg(code)}} + _, st := run(t, "pipeline: [skeleton]\ncomponents:\n skeleton: {min_tokens: 5}\n", req) + got := schema.MessageText(req.Input[0]) + if !strings.Contains(got, "func Add(a, b int) int") || !strings.Contains(got, "{ … }") { + t.Fatalf("skeleton should keep signatures and elide bodies: %q", got) + } + if strings.Contains(got, "sum += a") { + t.Fatalf("skeleton should have removed body statements: %q", got) + } + keys := expand.ParseMarkers(got) + if len(keys) != 1 { + t.Fatalf("expected expand marker, got %q", got) + } + if orig, ok := expand.Resolve(st, keys[0]); !ok || !strings.Contains(orig, "sum += a") { + t.Fatal("expand did not recover original source") + } +} + +func TestFailedRunSupersedes(t *testing.T) { + run1 := "=== test session starts ===\n" + strings.Repeat("detail line about the failing run\n", 20) + "3 failed, 2 passed in 1.2s\n" + run2 := "=== test session starts ===\n" + strings.Repeat("detail line about the passing run\n", 20) + "0 failed, 5 passed in 1.0s\n" + req := &schemas.BifrostChatRequest{Input: []schemas.ChatMessage{toolMsg(run1), toolMsg("some unrelated note"), toolMsg(run2)}} + _, st := run(t, "pipeline: [failed_run]\n", req) + first := schema.MessageText(req.Input[0]) + if !strings.Contains(first, "superseded") { + t.Fatalf("earlier run should be collapsed: %q", first) + } + if !strings.Contains(schema.MessageText(req.Input[2]), "0 failed") { + t.Fatal("latest run must be kept in full") + } + if keys := expand.ParseMarkers(first); len(keys) != 1 { + t.Fatal("collapsed run should carry an expand marker") + } else if orig, ok := expand.Resolve(st, keys[0]); !ok || !strings.Contains(orig, "3 failed") { + t.Fatal("expand must recover the superseded run") + } +} + +func TestCollapseHeadTail(t *testing.T) { + var b strings.Builder + for i := 0; i < 30; i++ { + b.WriteString("log line number with some content ") + b.WriteString(strings.Repeat("x", 5)) + b.WriteByte('\n') + } + req := &schemas.BifrostChatRequest{Input: []schemas.ChatMessage{toolMsg(b.String())}} + before := schema.MessagesTokens(req) + _, st := run(t, "pipeline: [collapse]\ncomponents:\n collapse: {max_tokens: 20, head_lines: 3, tail_lines: 3}\n", req) + got := schema.MessageText(req.Input[0]) + if schema.TextTokens(got) >= before || !strings.Contains(got, "lines omitted") { + t.Fatalf("collapse should head/tail truncate: %q", got) + } + if keys := expand.ParseMarkers(got); len(keys) != 1 { + t.Fatal("collapse should leave an expand marker") + } else if _, ok := expand.Resolve(st, keys[0]); !ok { + t.Fatal("collapse original must be recoverable") + } +} diff --git a/components/all/p4_test.go b/components/all/p4_test.go new file mode 100644 index 0000000..409375a --- /dev/null +++ b/components/all/p4_test.go @@ -0,0 +1,148 @@ +package all_test + +import ( + "encoding/json" + "fmt" + "strings" + "testing" + + "github.com/kagenti/context-guru/expand" + "github.com/kagenti/context-guru/schema" + bschemas "github.com/maximhq/bifrost/core/schemas" +) + +func userMsg(text string) bschemas.ChatMessage { + t := text + return bschemas.ChatMessage{Role: bschemas.ChatMessageRoleUser, Content: &bschemas.ChatMessageContent{ContentStr: &t}} +} + +func TestMaskHidesOlderToolOutputs(t *testing.T) { + big := strings.Repeat("some older tool output content line\n", 30) + msgs := make([]bschemas.ChatMessage, 5) + for i := range msgs { + msgs[i] = toolMsg(fmt.Sprintf("output %d\n%s", i, big)) + } + req := &bschemas.BifrostChatRequest{Input: msgs} + _, st := run(t, "pipeline: [mask]\ncomponents:\n mask: {keep_recent: 2, min_tokens: 10}\n", req) + + for i := 0; i < 3; i++ { + if !strings.Contains(schema.MessageText(req.Input[i]), "masked") { + t.Fatalf("msg %d should be masked", i) + } + } + if !strings.Contains(schema.MessageText(req.Input[4]), "output 4") { + t.Fatal("most recent tool output must stay verbatim") + } + keys := expand.ParseMarkers(schema.MessageText(req.Input[0])) + if orig, ok := expand.Resolve(st, keys[0]); !ok || !strings.Contains(orig, "output 0") { + t.Fatal("masked original must be recoverable") + } +} + +func TestSmartCrushSamplesArrayKeepsErrors(t *testing.T) { + type row struct { + ID int `json:"id"` + Msg string `json:"msg"` + } + rows := make([]row, 20) + for i := range rows { + rows[i] = row{ID: i, Msg: "routine event happened here with detail"} + } + rows[10].Msg = "ERROR: the thing exploded catastrophically" + arr, _ := json.Marshal(rows) + req := &bschemas.BifrostChatRequest{Input: []bschemas.ChatMessage{toolMsg(string(arr))}} + before := schema.MessagesTokens(req) + _, st := run(t, "pipeline: [smartcrush]\ncomponents:\n smartcrush: {min_items: 5, min_tokens: 50, keep_first: 3, keep_last: 2}\n", req) + + got := schema.MessageText(req.Input[0]) + if schema.TextTokens(got) >= before { + t.Fatalf("smartcrush should shrink the array: %q", got) + } + if !strings.Contains(got, "exploded catastrophically") { + t.Fatal("the error item must be preserved") + } + keys := expand.ParseMarkers(got) + if orig, ok := expand.Resolve(st, keys[0]); !ok || !strings.Contains(orig, `"id":15`) { + t.Fatal("dropped items must be recoverable in full") + } +} + +func TestPhiEvictTrimsToBudgetKeepsNewest(t *testing.T) { + filler := strings.Repeat("irrelevant verbose padding content here\n", 40) + req := &bschemas.BifrostChatRequest{Input: []bschemas.ChatMessage{ + userMsg("please summarize the deployment rollout status"), + toolMsg(filler), + toolMsg(filler + "unrelated"), + toolMsg("the deployment rollout status is green and healthy\n" + filler), + }} + before := schema.MessagesTokens(req) + _, st := run(t, "pipeline: [phi_evict]\ncomponents:\n phi_evict: {budget_tokens: 400, weights: aggressive}\n", req) + after := schema.MessagesTokens(req) + if after >= before { + t.Fatalf("phi_evict should trim toward budget: before=%d after=%d", before, after) + } + // newest tool output (index 3) must survive. + if strings.Contains(schema.MessageText(req.Input[3]), "evicted") { + t.Fatal("most recent tool output must not be evicted") + } + // at least one earlier one evicted + recoverable. + var found bool + for _, i := range []int{1, 2} { + if keys := expand.ParseMarkers(schema.MessageText(req.Input[i])); len(keys) == 1 { + if _, ok := expand.Resolve(st, keys[0]); ok { + found = true + } + } + } + if !found { + t.Fatal("expected an evicted, recoverable earlier output") + } +} + +func TestExtractProjectsRelevantLines(t *testing.T) { + var b strings.Builder + for i := 0; i < 40; i++ { + b.WriteString("noise line about unrelated internals blah blah\n") + } + b.WriteString("the authentication token refresh succeeded for user\n") + for i := 0; i < 40; i++ { + b.WriteString("more noise line about unrelated internals\n") + } + req := &bschemas.BifrostChatRequest{Input: []bschemas.ChatMessage{ + userMsg("why did the authentication token refresh happen"), + toolMsg(b.String()), + }} + before := schema.MessagesTokens(req) + _, st := run(t, "pipeline: [extract]\ncomponents:\n extract: {min_tokens: 50, head_lines: 2, tail_lines: 2}\n", req) + got := schema.MessageText(req.Input[1]) + if schema.TextTokens(got) >= before { + t.Fatalf("extract should project down: %q", got[:min(80, len(got))]) + } + if !strings.Contains(got, "authentication token refresh succeeded") { + t.Fatal("the query-relevant line must be kept") + } + if keys := expand.ParseMarkers(got); len(keys) != 1 { + t.Fatal("extract should leave an expand marker") + } else if _, ok := expand.Resolve(st, keys[0]); !ok { + t.Fatal("extract original must be recoverable") + } +} + +// TestSkeletonSkipsNonToolMessages locks the role-scope fix: skeleton must +// leave a user/assistant message's own code untouched (only tool outputs are +// offloaded), otherwise it would mangle the caller's code with no live recovery. +func TestSkeletonSkipsNonToolMessages(t *testing.T) { + code := "```go\nfunc Add(a, b int) int {\n\tsum := 0\n\tsum += a\n\tsum += b\n\tfor i := 0; i < 10; i++ { sum += i }\n\treturn sum\n}\n```" + req := &bschemas.BifrostChatRequest{Input: []bschemas.ChatMessage{userMsg(code)}} + run(t, "pipeline: [skeleton]\ncomponents:\n skeleton: {min_tokens: 5}\n", req) + if got := schema.MessageText(req.Input[0]); got != code { + t.Fatalf("skeleton must not rewrite a non-tool message; got %q", got) + } +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/components/component.go b/components/component.go new file mode 100644 index 0000000..dd05b00 --- /dev/null +++ b/components/component.go @@ -0,0 +1,140 @@ +// Package components defines context-guru's component model: the abstract API +// every context-engineering operation implements, the per-component report used +// for metrics, the runtime context handed to each component, and the pipeline +// that stacks them in configured order. +// +// The API is split by lossiness (design D3, after headroom's Rust traits) so +// reversibility is type-enforced: +// +// - Reformat: lossless repack (re-encode, skeletonize, add cache_control). +// No information leaves the wire, so nothing needs stashing. +// - Offload: drops bytes and MUST return a non-optional cache_key proving it +// stashed the original in the Store — you cannot compile an Offload that +// silently loses data. +// +// The pipeline is fail-open (any error/panic reverts that component), applies a +// never-worse guard (a component that grows the request is reverted), and emits +// one Report per component. +package components + +import ( + "context" + "time" + + "github.com/kagenti/context-guru/store" + "github.com/maximhq/bifrost/core/schemas" +) + +// Component is the common surface: identity + a per-request enable check. +type Component interface { + Name() string + Enabled(*Ctx) bool +} + +// Reformat is a lossless component: it repacks the request denser in place and +// loses no information. Examples: format re-encode, code skeleton, cache_control +// injection. +type Reformat interface { + Component + Reformat(req *schemas.BifrostChatRequest, rep *Report, c *Ctx) error +} + +// Offload is a lossy-but-reversible component: it drops bytes from the wire and +// returns the cache_keys under which it stashed the originals (via c.Store) — +// one per offloaded item. If it shrinks the request but returns no keys, the +// pipeline treats it as a failed offload and reverts (you cannot silently lose +// data). Returning no keys AND leaving the request unchanged is a legitimate +// no-op (set rep.Skipped). Examples: collapse, dedup, cmdfilter, extract, +// smartcrush. +type Offload interface { + Component + Offload(req *schemas.BifrostChatRequest, rep *Report, c *Ctx) (cacheKeys []string, err error) +} + +// Optional capability interfaces a component MAY also implement. + +// Configurable receives its typed config block from the registry/loader. +type Configurable interface { + Configure(raw []byte) error +} + +// NeedsModel is implemented by components that call a cheap LLM (extract, +// summarize); the pipeline injects the resolved ModelSpec via the Ctx. +type NeedsModel interface { + NeedsModel() bool +} + +// ModelSpec tells a NeedsModel component where to get an LLM: a statically +// configured client, or the proxied request's own model+credentials. +type ModelSpec struct { + Source string // "config" | "incoming" + // concrete client wiring is added with the extract component (P4) +} + +// Ctx is the per-request runtime handed to every component. +type Ctx struct { + Ctx context.Context + Session string + Store store.Store + Model ModelSpec + // Bypass short-circuits the whole pipeline (x-context-guru-bypass header). + Bypass bool +} + +// Report is the per-component result, modelled after lean-ctx's ToolOutput +// token accounting and headroom's record_pipeline_run inputs. The pipeline +// fills TokensBefore/After/DurationMs; the component fills CacheKey and may set +// Skipped. It feeds every Emitter. +type Report struct { + Component string + Kind string // "reformat" | "offload" + TokensBefore int + TokensAfter int + DurationMs float64 + CacheKeys []string // set by Offload components (one per stashed original) + Skipped bool // component ran but chose not to act + Reverted bool // pipeline reverted it (error/panic/never-worse) + Err error +} + +// Saved returns non-negative tokens saved by this component. +func (r Report) Saved() int { + if r.TokensAfter > r.TokensBefore { + return 0 + } + return r.TokensBefore - r.TokensAfter +} + +// RunReport aggregates a whole pipeline run for one request. +type RunReport struct { + Session string + TokensBefore int + TokensAfter int + DurationMs float64 + Components []Report +} + +// Saved returns the net tokens saved across the run. +func (rr RunReport) Saved() int { + if rr.TokensAfter > rr.TokensBefore { + return 0 + } + return rr.TokensBefore - rr.TokensAfter +} + +// Emitter receives one Report per component and one RunReport per request. +// Defined here (not in metrics) so the pipeline has no dependency on any +// concrete telemetry backend; metrics provides the implementations. +type Emitter interface { + Component(Report) + Run(RunReport) +} + +// NopEmitter discards all telemetry. Default when none is configured. +type NopEmitter struct{} + +func (NopEmitter) Component(Report) {} +func (NopEmitter) Run(RunReport) {} + +// clock is injectable in tests; production uses time.Now. +var clock = time.Now diff --git a/components/dsl/dsl.go b/components/dsl/dsl.go new file mode 100644 index 0000000..94feca2 --- /dev/null +++ b/components/dsl/dsl.go @@ -0,0 +1,339 @@ +// Package dsl is a declarative, user-extensible text-filter engine, adapted +// from rtk's TOML filter DSL (design D11). It lets command/log/tool-output +// shrinking be authored in YAML — no recompile — with the same 8-stage pipeline +// and Lossiness typing rtk proved out. +// +// Adaptation for the proxy world: rtk matches a shell command string; we match a +// per-message "selector" (the tool name, or the first line of content) since a +// proxy sees tool OUTPUTS, not the command that produced them. The content +// stages are unchanged. +// +// Pipeline order (each stage optional, applied in this exact order): +// 1. strip_ansi 2. replace[] 3. match_output[]+unless +// 4. strip/keep lines 5. truncate_lines_at 6. head/tail +// 7. max_lines 8. on_empty +// +// Filters are lossy (they drop lines), so the cmdfilter component that wraps +// this engine is an Offload: it stashes the original before applying a filter. +package dsl + +import ( + "fmt" + "regexp" + "sort" + "strings" + + "gopkg.in/yaml.v3" +) + +// Lossiness records what a filter did to its input, so the caller can emit an +// accurate recovery hint (after rtk's Lossiness enum). +type Lossiness int + +const ( + LossNone Lossiness = iota // nothing dropped, or only reversible reformatting + LossTail // a clean contiguous tail was dropped (tail -n +N recovers) + LossWhole // non-contiguous / whole-blob loss; only full retrieval recovers +) + +// Def is a raw filter definition (from YAML). All fields except Match are optional. +type Def struct { + Description string `yaml:"description"` + Match string `yaml:"match"` // regex against the selector key + StripANSI bool `yaml:"strip_ansi"` + Replace []ReplaceRule `yaml:"replace"` + MatchOutput []MatchRule `yaml:"match_output"` + StripLinesMatching []string `yaml:"strip_lines_matching"` + KeepLinesMatching []string `yaml:"keep_lines_matching"` + TruncateLinesAt *int `yaml:"truncate_lines_at"` + HeadLines *int `yaml:"head_lines"` + TailLines *int `yaml:"tail_lines"` + MaxLines *int `yaml:"max_lines"` + OnEmpty *string `yaml:"on_empty"` +} + +// ReplaceRule is a chained line-by-line regex substitution ($1 backrefs allowed). +type ReplaceRule struct { + Pattern string `yaml:"pattern"` + Replacement string `yaml:"replacement"` +} + +// MatchRule short-circuits: if Pattern matches the whole blob (and Unless does +// not), return Message immediately. +type MatchRule struct { + Pattern string `yaml:"pattern"` + Message string `yaml:"message"` + Unless string `yaml:"unless"` +} + +// File is a filter document: a schema version + named filters + inline tests. +type File struct { + SchemaVersion int `yaml:"schema_version"` + Filters map[string]Def `yaml:"filters"` + Tests map[string][]TestCase `yaml:"tests"` +} + +// TestCase is an inline filter test (input -> expected), run by RunTests. +type TestCase struct { + Name string `yaml:"name"` + Input string `yaml:"input"` + Expected string `yaml:"expected"` +} + +// Compiled is a filter with its regexes prebuilt. +type Compiled struct { + Name string + match *regexp.Regexp + def Def + replace []compiledReplace + matchOut []compiledMatch + stripLines []*regexp.Regexp + keepLines []*regexp.Regexp +} + +type compiledReplace struct { + re *regexp.Regexp + repl string +} +type compiledMatch struct { + re *regexp.Regexp + msg string + unless *regexp.Regexp +} + +var ansiRe = regexp.MustCompile("\x1b\\[[0-9;?]*[ -/]*[@-~]") + +// Compile validates and precompiles a filter definition. +func Compile(name string, d Def) (*Compiled, error) { + if d.Match == "" { + return nil, fmt.Errorf("dsl: filter %q missing match", name) + } + if len(d.StripLinesMatching) > 0 && len(d.KeepLinesMatching) > 0 { + return nil, fmt.Errorf("dsl: filter %q sets both strip_lines_matching and keep_lines_matching", name) + } + m, err := regexp.Compile(d.Match) + if err != nil { + return nil, fmt.Errorf("dsl: filter %q match: %w", name, err) + } + c := &Compiled{Name: name, match: m, def: d} + for i, r := range d.Replace { + re, err := regexp.Compile(r.Pattern) + if err != nil { + return nil, fmt.Errorf("dsl: filter %q replace[%d]: %w", name, i, err) + } + c.replace = append(c.replace, compiledReplace{re: re, repl: r.Replacement}) + } + for i, r := range d.MatchOutput { + re, err := regexp.Compile(r.Pattern) + if err != nil { + return nil, fmt.Errorf("dsl: filter %q match_output[%d]: %w", name, i, err) + } + cm := compiledMatch{re: re, msg: r.Message} + if r.Unless != "" { + u, err := regexp.Compile(r.Unless) + if err != nil { + return nil, fmt.Errorf("dsl: filter %q match_output[%d].unless: %w", name, i, err) + } + cm.unless = u + } + c.matchOut = append(c.matchOut, cm) + } + for _, p := range d.StripLinesMatching { + re, err := regexp.Compile(p) + if err != nil { + return nil, fmt.Errorf("dsl: filter %q strip_lines_matching: %w", name, err) + } + c.stripLines = append(c.stripLines, re) + } + for _, p := range d.KeepLinesMatching { + re, err := regexp.Compile(p) + if err != nil { + return nil, fmt.Errorf("dsl: filter %q keep_lines_matching: %w", name, err) + } + c.keepLines = append(c.keepLines, re) + } + return c, nil +} + +// Apply runs the 8-stage pipeline over input, returning the filtered output and +// what kind of loss occurred. +func Apply(c *Compiled, input string) (string, Lossiness) { + lines := strings.Split(input, "\n") + + // 1. strip_ansi + if c.def.StripANSI { + for i := range lines { + lines[i] = ansiRe.ReplaceAllString(lines[i], "") + } + } + // 2. replace (chained, line-by-line) + if len(c.replace) > 0 { + for i := range lines { + for _, r := range c.replace { + lines[i] = r.re.ReplaceAllString(lines[i], r.repl) + } + } + } + // 3. match_output — first matching rule wins, unless the guard matches. + if len(c.matchOut) > 0 { + blob := strings.Join(lines, "\n") + for _, m := range c.matchOut { + if m.re.MatchString(blob) && (m.unless == nil || !m.unless.MatchString(blob)) { + return m.msg, LossWhole + } + } + } + // 4. strip/keep lines (mutually exclusive) + if len(c.stripLines) > 0 { + lines = filterLines(lines, c.stripLines, false) + } else if len(c.keepLines) > 0 { + lines = filterLines(lines, c.keepLines, true) + } + // 5. truncate_lines_at (unicode-safe per-line cap) + if c.def.TruncateLinesAt != nil { + n := *c.def.TruncateLinesAt + for i, l := range lines { + if r := []rune(l); len(r) > n { + lines[i] = string(r[:n]) + } + } + } + + loss := LossNone + // 6. head/tail + if c.def.HeadLines != nil || c.def.TailLines != nil { + lines, loss = headTail(lines, c.def.HeadLines, c.def.TailLines) + } + // 7. max_lines (absolute cap, counts the omission marker) + if c.def.MaxLines != nil && len(lines) > *c.def.MaxLines { + n := *c.def.MaxLines + omitted := len(lines) - n + lines = append(lines[:n:n], fmt.Sprintf("... (%d lines truncated)", omitted)) + if loss == LossNone { + loss = LossTail + } else { + loss = LossWhole + } + } + out := strings.Join(lines, "\n") + // 8. on_empty + if strings.TrimSpace(out) == "" && c.def.OnEmpty != nil { + return *c.def.OnEmpty, LossNone + } + return out, loss +} + +func filterLines(lines []string, res []*regexp.Regexp, keep bool) []string { + out := lines[:0:0] + for _, l := range lines { + matched := false + for _, re := range res { + if re.MatchString(l) { + matched = true + break + } + } + if matched == keep { + out = append(out, l) + } + } + return out +} + +// headTail keeps head+tail lines with an omission marker between; returns the +// loss kind (LossTail when a clean contiguous tail was dropped, else LossWhole). +func headTail(lines []string, head, tail *int) ([]string, Lossiness) { + total := len(lines) + switch { + case head != nil && tail != nil: + if total <= *head+*tail { + return lines, LossNone + } + omitted := total - *head - *tail + out := append([]string{}, lines[:*head]...) + out = append(out, fmt.Sprintf("... (%d lines omitted)", omitted)) + out = append(out, lines[total-*tail:]...) + return out, LossWhole // non-contiguous drop + case head != nil: + if total <= *head { + return lines, LossNone + } + out := append([]string{}, lines[:*head]...) + out = append(out, fmt.Sprintf("... (%d lines omitted)", total-*head)) + return out, LossTail // clean tail drop + case tail != nil: + if total <= *tail { + return lines, LossNone + } + out := []string{fmt.Sprintf("... (%d lines omitted)", total-*tail)} + out = append(out, lines[total-*tail:]...) + return out, LossWhole + } + return lines, LossNone +} + +// Registry holds compiled filters, matched first-by-sorted-name for determinism. +type Registry struct{ filters []*Compiled } + +// Load parses a YAML filter document and appends its filters to the registry. +// schema_version must be 1. Filters are stored sorted by name. +func (r *Registry) Load(b []byte) error { + var f File + dec := yaml.NewDecoder(strings.NewReader(string(b))) + dec.KnownFields(true) + if err := dec.Decode(&f); err != nil { + return fmt.Errorf("dsl: %w", err) + } + if f.SchemaVersion != 1 { + return fmt.Errorf("dsl: unsupported schema_version %d (want 1)", f.SchemaVersion) + } + names := make([]string, 0, len(f.Filters)) + for n := range f.Filters { + names = append(names, n) + } + sort.Strings(names) + for _, n := range names { + c, err := Compile(n, f.Filters[n]) + if err != nil { + return err + } + r.filters = append(r.filters, c) + } + return nil +} + +// Match returns the first filter whose match regex matches key, or nil. +func (r *Registry) Match(key string) *Compiled { + for _, c := range r.filters { + if c.match.MatchString(key) { + return c + } + } + return nil +} + +// Len reports how many filters are loaded. +func (r *Registry) Len() int { return len(r.filters) } + +// RunTests compiles and runs the inline [tests] in a filter document, returning +// the names of failing cases (empty = all pass). Powers a `verify` command. +func RunTests(b []byte) (failures []string, err error) { + var f File + if err := yaml.Unmarshal(b, &f); err != nil { + return nil, err + } + for name, def := range f.Filters { + c, err := Compile(name, def) + if err != nil { + return nil, err + } + for _, tc := range f.Tests[name] { + got, _ := Apply(c, tc.Input) + if strings.TrimRight(got, "\n") != strings.TrimRight(tc.Expected, "\n") { + failures = append(failures, name+"/"+tc.Name) + } + } + } + sort.Strings(failures) + return failures, nil +} diff --git a/components/dsl/dsl_more_test.go b/components/dsl/dsl_more_test.go new file mode 100644 index 0000000..788791f --- /dev/null +++ b/components/dsl/dsl_more_test.go @@ -0,0 +1,88 @@ +package dsl + +import ( + "strings" + "testing" +) + +// TestApplyStages covers strip_ansi + replace + truncate_lines_at in one pass. +func TestApplyStages(t *testing.T) { + doc := ` +schema_version: 1 +filters: + f: + match: "." + strip_ansi: true + replace: + - pattern: "secret=\\w+" + replacement: "secret=REDACTED" + truncate_lines_at: 20 +` + var r Registry + if err := r.Load([]byte(doc)); err != nil { + t.Fatal(err) + } + c := r.Match("anything") + if c == nil { + t.Fatal("filter did not match") + } + out, _ := Apply(c, "\x1b[31msecret=abc123\x1b[0m and a good deal more text past twenty runes") + if strings.Contains(out, "\x1b[") { + t.Fatalf("strip_ansi failed: %q", out) + } + if !strings.Contains(out, "secret=REDACTED") { + t.Fatalf("replace failed: %q", out) + } + for _, line := range strings.Split(out, "\n") { + if len([]rune(line)) > 20 { + t.Fatalf("truncate_lines_at not applied: %q", line) + } + } +} + +func TestHeadTailCombined(t *testing.T) { + doc := "schema_version: 1\nfilters:\n f:\n match: .\n head_lines: 1\n tail_lines: 1\n" + var r Registry + if err := r.Load([]byte(doc)); err != nil { + t.Fatal(err) + } + out, loss := Apply(r.Match("x"), "h\nm1\nm2\nt") + if !strings.Contains(out, "omitted") || loss != LossWhole { + t.Fatalf("head+tail should drop the middle (LossWhole): %q loss=%d", out, loss) + } + out2, loss2 := Apply(r.Match("x"), "a\nb") // <= head+tail: untouched + if loss2 != LossNone || strings.Contains(out2, "omitted") { + t.Fatalf("small input must be untouched: %q loss=%d", out2, loss2) + } +} + +func TestTailOnly(t *testing.T) { + doc := "schema_version: 1\nfilters:\n f:\n match: .\n tail_lines: 2\n" + var r Registry + if err := r.Load([]byte(doc)); err != nil { + t.Fatal(err) + } + out, loss := Apply(r.Match("x"), "1\n2\n3\n4\n5") + if !strings.HasPrefix(out, "... (3 lines omitted)") || !strings.HasSuffix(out, "4\n5") || loss != LossWhole { + t.Fatalf("tail-only wrong: %q loss=%d", out, loss) + } +} + +func TestKeepLinesMatching(t *testing.T) { + doc := "schema_version: 1\nfilters:\n f:\n match: .\n keep_lines_matching: ['ERROR']\n" + var r Registry + if err := r.Load([]byte(doc)); err != nil { + t.Fatal(err) + } + out, _ := Apply(r.Match("x"), "info a\nERROR boom\ninfo b") + if strings.TrimSpace(out) != "ERROR boom" { + t.Fatalf("keep_lines_matching should retain only matching lines: %q", out) + } +} + +func TestLoadRejectsBadSchemaVersion(t *testing.T) { + var r Registry + if err := r.Load([]byte("schema_version: 2\nfilters: {}\n")); err == nil { + t.Fatal("schema_version != 1 must be rejected") + } +} diff --git a/components/dsl/dsl_test.go b/components/dsl/dsl_test.go new file mode 100644 index 0000000..79bb2aa --- /dev/null +++ b/components/dsl/dsl_test.go @@ -0,0 +1,106 @@ +package dsl + +import ( + "strings" + "testing" +) + +const makeFilter = ` +schema_version: 1 +filters: + make: + match: "^make" + strip_lines_matching: + - "^make\\[\\d+\\]:" + - "^\\s*$" + max_lines: 50 + on_empty: "make: ok" +tests: + make: + - name: strips entering/leaving + input: | + make[1]: Entering directory '/x' + gcc -O2 foo.c + make[1]: Leaving directory '/x' + expected: | + gcc -O2 foo.c + - name: on_empty when all stripped + input: | + make[1]: Entering directory '/x' + make[1]: Leaving directory '/x' + expected: "make: ok" +` + +func TestStripLinesAndOnEmpty(t *testing.T) { + var r Registry + if err := r.Load([]byte(makeFilter)); err != nil { + t.Fatal(err) + } + c := r.Match("make test") + if c == nil { + t.Fatal("filter did not match selector") + } + out, _ := Apply(c, "make[1]: Entering directory '/x'\ngcc -O2 foo.c\nmake[1]: Leaving directory '/x'") + if strings.TrimSpace(out) != "gcc -O2 foo.c" { + t.Fatalf("strip_lines wrong: %q", out) + } + empty, _ := Apply(c, "make[1]: Entering directory '/x'\nmake[1]: Leaving directory '/x'") + if empty != "make: ok" { + t.Fatalf("on_empty wrong: %q", empty) + } +} + +func TestInlineTestsRun(t *testing.T) { + fails, err := RunTests([]byte(makeFilter)) + if err != nil { + t.Fatal(err) + } + if len(fails) != 0 { + t.Fatalf("inline tests should pass, failures: %v", fails) + } +} + +func TestMatchOutputWithUnless(t *testing.T) { + doc := ` +schema_version: 1 +filters: + build: + match: "build" + match_output: + - pattern: "BUILD SUCCESSFUL" + message: "build: ok" + unless: "WARNING" +` + var r Registry + if err := r.Load([]byte(doc)); err != nil { + t.Fatal(err) + } + c := r.Match("build") + if out, loss := Apply(c, "compiling...\nBUILD SUCCESSFUL in 3s"); out != "build: ok" || loss != LossWhole { + t.Fatalf("match_output should collapse to message: %q loss=%d", out, loss) + } + // unless guard: a warning present -> do NOT collapse + if out, _ := Apply(c, "WARNING: deprecated\nBUILD SUCCESSFUL"); out == "build: ok" { + t.Fatal("unless guard should have prevented collapse") + } +} + +func TestMaxLinesTailLossiness(t *testing.T) { + doc := "schema_version: 1\nfilters:\n log:\n match: log\n max_lines: 2\n" + var r Registry + if err := r.Load([]byte(doc)); err != nil { + t.Fatal(err) + } + out, loss := Apply(r.Match("log"), "a\nb\nc\nd") + if !strings.Contains(out, "truncated") || loss != LossTail { + t.Fatalf("max_lines should truncate with Tail loss: %q loss=%d", out, loss) + } +} + +func TestStripKeepMutuallyExclusive(t *testing.T) { + doc := "schema_version: 1\nfilters:\n x:\n match: x\n strip_lines_matching: ['a']\n keep_lines_matching: ['b']\n" + var r Registry + if err := r.Load([]byte(doc)); err == nil { + t.Fatal("expected compile error for strip+keep together") + } +} diff --git a/components/offload/cmdfilter.go b/components/offload/cmdfilter.go new file mode 100644 index 0000000..d453edc --- /dev/null +++ b/components/offload/cmdfilter.go @@ -0,0 +1,150 @@ +// Package offload holds the lossy-but-reversible components (they drop bytes and +// stash the original for the expand tool loop). Each registers itself via +// init(); a binary blank-imports components/all to pull them in. +package offload + +import ( + "crypto/sha256" + "encoding/hex" + "strings" + + "github.com/kagenti/context-guru/components" + "github.com/kagenti/context-guru/components/dsl" + "github.com/kagenti/context-guru/expand" + "github.com/kagenti/context-guru/schema" + "github.com/maximhq/bifrost/core/schemas" + "gopkg.in/yaml.v3" +) + +func init() { components.Register("cmdfilter", newCmdfilter) } + +// Cmdfilter shrinks tool-output messages with declarative DSL filters. It is an +// Offload: it stashes the original before filtering so the expand tool can +// recover it. Filters match on the tool output's first non-empty line (the +// proxy-world stand-in for rtk's shell command). +type Cmdfilter struct{ reg *dsl.Registry } + +type cmdfilterConfig struct { + Filters []string `yaml:"filters"` // inline filter YAML documents + DisableBuiltins bool `yaml:"disable_builtins"` // skip the bundled starter filters +} + +func newCmdfilter(raw []byte) (components.Component, error) { + var cfg cmdfilterConfig + if len(raw) > 0 { + if err := yaml.Unmarshal(raw, &cfg); err != nil { + return nil, err + } + } + reg := &dsl.Registry{} + if !cfg.DisableBuiltins { + if err := reg.Load([]byte(builtinFilters)); err != nil { + return nil, err + } + } + for _, doc := range cfg.Filters { + if err := reg.Load([]byte(doc)); err != nil { + return nil, err + } + } + return &Cmdfilter{reg: reg}, nil +} + +func (Cmdfilter) Name() string { return "cmdfilter" } + +func (f *Cmdfilter) Enabled(*components.Ctx) bool { return f.reg.Len() > 0 } + +func (f *Cmdfilter) Offload(req *schemas.BifrostChatRequest, rep *components.Report, c *components.Ctx) ([]string, error) { + var keys []string + for i := range req.Input { + m := &req.Input[i] + if m.Role != schemas.ChatMessageRoleTool { + continue + } + if !schema.Rewritable(*m) { + continue // non-text blocks would be dropped by a text rewrite + } + content := schema.MessageText(*m) + if content == "" { + continue + } + filt := f.reg.Match(selectorKey(content)) + if filt == nil { + continue + } + out, loss := dsl.Apply(filt, content) + if out == content { + continue + } + // Compare the FULL rewritten text (marker + recovery hint included) against + // the original — the marker costs tokens too, so filtering that barely wins + // can still make the message larger (rtk never_worse, at the message level). + key := hashKey(content) + newText := out + "\n" + expand.Marker(key) + recoveryHint(loss) + if schema.TextTokens(newText) >= schema.TextTokens(content) { + continue + } + c.Store.Put(key, []byte(content)) + schema.SetMessageText(m, newText) + keys = append(keys, key) + } + if len(keys) == 0 { + rep.Skipped = true + } + return keys, nil +} + +// selectorKey is the string a filter's match regex is tested against: the first +// non-empty, trimmed line of the tool output. +func selectorKey(content string) string { + for _, line := range strings.Split(content, "\n") { + if s := strings.TrimSpace(line); s != "" { + return s + } + } + return "" +} + +func recoveryHint(loss dsl.Lossiness) string { + if loss == dsl.LossNone { + return "" + } + return " [full output: call " + expand.ToolName + "]" +} + +func hashKey(s string) string { + h := sha256.Sum256([]byte(s)) + return hex.EncodeToString(h[:])[:16] +} + +// builtinFilters is a small starter set adapted from rtk built-ins. Users add +// more via the cmdfilter `filters:` config with no recompile. +const builtinFilters = ` +schema_version: 1 +filters: + pytest: + description: keep failures + summary, drop passing noise + match: "(pytest|=+ test session starts)" + strip_lines_matching: + - "^\\s*$" + - " PASSED" + - "^\\.+$" + max_lines: 80 + on_empty: "pytest: all passed" + npm-install: + description: collapse npm/yarn install chatter + match: "^(npm|yarn|added|removed) " + strip_lines_matching: + - "^npm warn" + - "^\\s*$" + max_lines: 40 + on_empty: "install: ok" + make: + description: drop make directory chatter + match: "^(make|gcc|cc|clang) " + strip_lines_matching: + - "^make\\[\\d+\\]:" + - "^\\s*$" + max_lines: 60 + on_empty: "make: ok" +` diff --git a/components/offload/collapse.go b/components/offload/collapse.go new file mode 100644 index 0000000..9463dac --- /dev/null +++ b/components/offload/collapse.go @@ -0,0 +1,82 @@ +package offload + +import ( + "fmt" + "strings" + + "github.com/kagenti/context-guru/components" + "github.com/kagenti/context-guru/expand" + "github.com/kagenti/context-guru/schema" + "github.com/maximhq/bifrost/core/schemas" + "gopkg.in/yaml.v3" +) + +func init() { components.Register("collapse", newCollapse) } + +// Collapse is the content-agnostic fallback for an oversized tool output that no +// more specific component handled: it keeps a head + tail window, stashes the +// full original, and leaves an expand marker. Runs late in the pipeline (after +// cmdfilter/format), and skips anything already carrying a marker so it never +// double-collapses. +type Collapse struct { + maxTokens int + headLines int + tailLines int +} + +type collapseConfig struct { + MaxTokens int `yaml:"max_tokens"` + HeadLines int `yaml:"head_lines"` + TailLines int `yaml:"tail_lines"` +} + +func newCollapse(raw []byte) (components.Component, error) { + cfg := collapseConfig{MaxTokens: 2000, HeadLines: 20, TailLines: 20} + if len(raw) > 0 { + if err := yaml.Unmarshal(raw, &cfg); err != nil { + return nil, err + } + } + return &Collapse{maxTokens: cfg.MaxTokens, headLines: cfg.HeadLines, tailLines: cfg.TailLines}, nil +} + +func (Collapse) Name() string { return "collapse" } +func (Collapse) Enabled(*components.Ctx) bool { return true } + +func (cl *Collapse) Offload(req *schemas.BifrostChatRequest, rep *components.Report, c *components.Ctx) ([]string, error) { + var keys []string + for i := range req.Input { + m := &req.Input[i] + if m.Role != schemas.ChatMessageRoleTool { + continue + } + if !schema.Rewritable(*m) { + continue // non-text blocks would be dropped by a text rewrite + } + content := schema.MessageText(*m) + if content == "" || schema.TextTokens(content) <= cl.maxTokens { + continue + } + if len(expand.ParseMarkers(content)) > 0 { + continue // already offloaded by an earlier component + } + lines := strings.Split(content, "\n") + if len(lines) <= cl.headLines+cl.tailLines { + continue // few long lines; head/tail wouldn't help + } + omitted := len(lines) - cl.headLines - cl.tailLines + var b strings.Builder + b.WriteString(strings.Join(lines[:cl.headLines], "\n")) + fmt.Fprintf(&b, "\n... (%d lines omitted) ", omitted) + key := hashKey(content) + b.WriteString(expand.Marker(key) + " [full output: call " + expand.ToolName + "]\n") + b.WriteString(strings.Join(lines[len(lines)-cl.tailLines:], "\n")) + c.Store.Put(key, []byte(content)) + schema.SetMessageText(m, b.String()) + keys = append(keys, key) + } + if len(keys) == 0 { + rep.Skipped = true + } + return keys, nil +} diff --git a/components/offload/common.go b/components/offload/common.go new file mode 100644 index 0000000..3d02bb1 --- /dev/null +++ b/components/offload/common.go @@ -0,0 +1,74 @@ +package offload + +import ( + "strings" + + "github.com/kagenti/context-guru/schema" + bschemas "github.com/maximhq/bifrost/core/schemas" +) + +// errWords mark a tool output (or an item) as carrying a failure — such items +// are preserved by smartcrush and prioritized elsewhere, since dropping the one +// error in a haystack is exactly the accuracy loss to avoid. +var errWords = []string{"error", "fail", "exception", "panic", "fatal", "traceback"} + +func hasError(s string) bool { + ls := strings.ToLower(s) + for _, w := range errWords { + if strings.Contains(ls, w) { + return true + } + } + return false +} + +// lastUserText returns the text of the most recent user message — the query +// relevance is scored against (extract, phi_evict). +func lastUserText(req *bschemas.BifrostChatRequest) string { + for i := len(req.Input) - 1; i >= 0; i-- { + if req.Input[i].Role == bschemas.ChatMessageRoleUser { + return schema.MessageText(req.Input[i]) + } + } + return "" +} + +// keywords extracts lowercased content words (>3 chars) as a set — a cheap +// relevance signal without embeddings. +func keywords(s string) map[string]struct{} { + out := map[string]struct{}{} + for _, f := range strings.FieldsFunc(strings.ToLower(s), func(r rune) bool { + return !(r >= 'a' && r <= 'z') && !(r >= '0' && r <= '9') + }) { + if len(f) > 3 { + out[f] = struct{}{} + } + } + return out +} + +// overlap is the fraction of query terms present in text (0..1). +func overlap(query map[string]struct{}, text string) float64 { + if len(query) == 0 { + return 0 + } + tk := keywords(text) + hit := 0 + for w := range query { + if _, ok := tk[w]; ok { + hit++ + } + } + return float64(hit) / float64(len(query)) +} + +// toolIndices returns the indices of tool-role messages, in order. +func toolIndices(req *bschemas.BifrostChatRequest) []int { + var out []int + for i := range req.Input { + if req.Input[i].Role == bschemas.ChatMessageRoleTool { + out = append(out, i) + } + } + return out +} diff --git a/components/offload/dedup.go b/components/offload/dedup.go new file mode 100644 index 0000000..eba8cd6 --- /dev/null +++ b/components/offload/dedup.go @@ -0,0 +1,64 @@ +package offload + +import ( + "github.com/kagenti/context-guru/components" + "github.com/kagenti/context-guru/expand" + "github.com/kagenti/context-guru/schema" + "github.com/maximhq/bifrost/core/schemas" + "gopkg.in/yaml.v3" +) + +func init() { components.Register("dedup", newDedup) } + +// Dedup replaces a tool output that is byte-identical to an earlier one in the +// same request with a short pointer + expand marker, stashing the original. +// Exact-match only in v1; near-duplicate (similarity threshold) is deferred. +type Dedup struct{ minTokens int } + +type dedupConfig struct { + MinTokens int `yaml:"min_tokens"` +} + +func newDedup(raw []byte) (components.Component, error) { + cfg := dedupConfig{MinTokens: 100} + if len(raw) > 0 { + if err := yaml.Unmarshal(raw, &cfg); err != nil { + return nil, err + } + } + return &Dedup{minTokens: cfg.MinTokens}, nil +} + +func (Dedup) Name() string { return "dedup" } +func (Dedup) Enabled(*components.Ctx) bool { return true } + +func (d *Dedup) Offload(req *schemas.BifrostChatRequest, rep *components.Report, c *components.Ctx) ([]string, error) { + seen := map[string]int{} // content hash -> first message index + var keys []string + for i := range req.Input { + m := &req.Input[i] + if m.Role != schemas.ChatMessageRoleTool { + continue + } + if !schema.Rewritable(*m) { + continue // non-text blocks would be dropped by a text rewrite + } + content := schema.MessageText(*m) + if content == "" || schema.TextTokens(content) < d.minTokens { + continue + } + h := hashKey(content) + if _, dup := seen[h]; !dup { + seen[h] = i + continue + } + // Later duplicate: stash + collapse to a pointer. + c.Store.Put(h, []byte(content)) + schema.SetMessageText(m, "[identical to an earlier tool output] "+expand.Marker(h)) + keys = append(keys, h) + } + if len(keys) == 0 { + rep.Skipped = true + } + return keys, nil +} diff --git a/components/offload/extract.go b/components/offload/extract.go new file mode 100644 index 0000000..2da0901 --- /dev/null +++ b/components/offload/extract.go @@ -0,0 +1,124 @@ +package offload + +import ( + "strings" + + "github.com/kagenti/context-guru/components" + "github.com/kagenti/context-guru/expand" + "github.com/kagenti/context-guru/schema" + bschemas "github.com/maximhq/bifrost/core/schemas" + "gopkg.in/yaml.v3" +) + +func init() { components.Register("extract", newExtract) } + +// Extract projects a large tool output down to the part relevant to the current +// query, stashing the full original. v1 ships the deterministic strategy: +// keep lines matching the recent query's keywords (plus head/tail context and +// any error lines). The LLM strategies (code = model-generated Starlark filter +// with a containment validator; rlm = recursive chunked) are the winnow-parity +// refinement — they need a cheap-model client (ModelSpec), which is not yet +// wired; selecting them falls back to deterministic with a note. +type Extract struct { + minTokens int + head int + tail int + strategy string +} + +type extractConfig struct { + MinTokens int `yaml:"min_tokens"` + Head int `yaml:"head_lines"` + Tail int `yaml:"tail_lines"` + Strategy string `yaml:"strategy"` // deterministic | code | rlm +} + +func newExtract(raw []byte) (components.Component, error) { + cfg := extractConfig{MinTokens: 300, Head: 5, Tail: 5, Strategy: "deterministic"} + if len(raw) > 0 { + if err := yaml.Unmarshal(raw, &cfg); err != nil { + return nil, err + } + } + return &Extract{minTokens: cfg.MinTokens, head: cfg.Head, tail: cfg.Tail, strategy: cfg.Strategy}, nil +} + +func (Extract) Name() string { return "extract" } +func (Extract) Enabled(*components.Ctx) bool { return true } + +// NeedsModel reports whether the configured strategy calls an LLM. The pipeline +// injects the ModelSpec when true. (The LLM path is not yet implemented; see the +// package note — this reports intent for when the cheap-model client lands.) +func (e *Extract) NeedsModel() bool { return e.strategy == "code" || e.strategy == "rlm" } + +func (e *Extract) Offload(req *bschemas.BifrostChatRequest, rep *components.Report, c *components.Ctx) ([]string, error) { + query := keywords(lastUserText(req)) + if len(query) == 0 { + rep.Skipped = true + return nil, nil // nothing to condition relevance on + } + var keys []string + for _, i := range toolIndices(req) { + msg := &req.Input[i] + if !schema.Rewritable(*msg) { + continue // non-text blocks would be dropped by a text rewrite + } + content := schema.MessageText(*msg) + if content == "" || schema.TextTokens(content) < e.minTokens { + continue + } + if len(expand.ParseMarkers(content)) > 0 { + continue + } + projected, ok := e.project(content, query) + if !ok || schema.TextTokens(projected) >= schema.TextTokens(content) { + continue + } + key := hashKey(content) + c.Store.Put(key, []byte(content)) + schema.SetMessageText(msg, projected+"\n"+expand.Marker(key)+" [full output: call "+expand.ToolName+"]") + keys = append(keys, key) + } + if len(keys) == 0 { + rep.Skipped = true + } + return keys, nil +} + +// project keeps head/tail context plus every line relevant to the query or +// carrying an error, collapsing the runs between with an ellipsis marker. +func (e *Extract) project(content string, query map[string]struct{}) (string, bool) { + lines := strings.Split(content, "\n") + if len(lines) <= e.head+e.tail+1 { + return "", false + } + keep := make([]bool, len(lines)) + for i := 0; i < e.head && i < len(lines); i++ { + keep[i] = true + } + for i := len(lines) - e.tail; i < len(lines); i++ { + if i >= 0 { + keep[i] = true + } + } + for i, ln := range lines { + if hasError(ln) || overlap(query, ln) > 0 { + keep[i] = true + } + } + var b strings.Builder + gap := false + for i, ln := range lines { + if keep[i] { + if gap { + b.WriteString("…\n") + gap = false + } + b.WriteString(ln) + b.WriteByte('\n') + } else { + gap = true + } + } + return strings.TrimRight(b.String(), "\n"), true +} diff --git a/components/offload/failed_run.go b/components/offload/failed_run.go new file mode 100644 index 0000000..536dedb --- /dev/null +++ b/components/offload/failed_run.go @@ -0,0 +1,80 @@ +package offload + +import ( + "regexp" + + "github.com/kagenti/context-guru/components" + "github.com/kagenti/context-guru/expand" + "github.com/kagenti/context-guru/schema" + "github.com/maximhq/bifrost/core/schemas" + "gopkg.in/yaml.v3" +) + +func init() { components.Register("failed_run", newFailedRun) } + +// runMarkers identify a tool output that is a test/build run — the kind that is +// superseded when the agent re-runs after a fix. Deliberately broad; false +// positives only cost an expand round-trip, never data (the original is stashed). +var runMarkers = regexp.MustCompile(`(?i)(\d+ (passed|failed|error)|BUILD (SUCCESS|FAIL)|=+ (FAILURES|test session)|Traceback \(most recent|\bFAILED\b|\bpanic:|\bnpm ERR!)`) + +// FailedRun collapses earlier test/build runs that a later run supersedes: only +// the most recent run-like tool output is kept in full; earlier ones become a +// pointer + stash. This is the "provable-reason" collapse — a superseded run is +// safely recoverable via expand if the agent still needs it. +type FailedRun struct{ minTokens int } + +type failedRunConfig struct { + MinTokens int `yaml:"min_tokens"` +} + +func newFailedRun(raw []byte) (components.Component, error) { + cfg := failedRunConfig{MinTokens: 100} + if len(raw) > 0 { + if err := yaml.Unmarshal(raw, &cfg); err != nil { + return nil, err + } + } + return &FailedRun{minTokens: cfg.MinTokens}, nil +} + +func (FailedRun) Name() string { return "failed_run" } +func (FailedRun) Enabled(*components.Ctx) bool { return true } + +func (fr *FailedRun) Offload(req *schemas.BifrostChatRequest, rep *components.Report, c *components.Ctx) ([]string, error) { + // Find indices of run-like tool outputs, in order. + var runs []int + for i := range req.Input { + m := req.Input[i] + if m.Role != schemas.ChatMessageRoleTool { + continue + } + if !schema.Rewritable(m) { + continue // non-text blocks would be dropped by a text rewrite + } + content := schema.MessageText(m) + if schema.TextTokens(content) < fr.minTokens { + continue + } + if len(expand.ParseMarkers(content)) > 0 { + continue // already offloaded + } + if runMarkers.MatchString(content) { + runs = append(runs, i) + } + } + if len(runs) < 2 { + rep.Skipped = true + return nil, nil + } + // Keep the last run in full; collapse every earlier one. + var keys []string + for _, i := range runs[:len(runs)-1] { + m := &req.Input[i] + content := schema.MessageText(*m) + key := hashKey(content) + c.Store.Put(key, []byte(content)) + schema.SetMessageText(m, "[superseded by a later run] "+expand.Marker(key)+" [full output: call "+expand.ToolName+"]") + keys = append(keys, key) + } + return keys, nil +} diff --git a/components/offload/mask.go b/components/offload/mask.go new file mode 100644 index 0000000..ac8520c --- /dev/null +++ b/components/offload/mask.go @@ -0,0 +1,69 @@ +package offload + +import ( + "github.com/kagenti/context-guru/components" + "github.com/kagenti/context-guru/expand" + "github.com/kagenti/context-guru/schema" + bschemas "github.com/maximhq/bifrost/core/schemas" + "gopkg.in/yaml.v3" +) + +func init() { components.Register("mask", newMask) } + +// Mask hides older tool outputs beyond a keep-recent window (after CE-Manager's +// context garbage collection): the newest KeepRecent tool results stay verbatim, +// older ones are replaced with a short marker + stash. Age-based, complementary +// to the content-based offloaders. +type Mask struct { + keepRecent int + minTokens int +} + +type maskConfig struct { + KeepRecent int `yaml:"keep_recent"` + MinTokens int `yaml:"min_tokens"` +} + +func newMask(raw []byte) (components.Component, error) { + cfg := maskConfig{KeepRecent: 3, MinTokens: 100} + if len(raw) > 0 { + if err := yaml.Unmarshal(raw, &cfg); err != nil { + return nil, err + } + } + return &Mask{keepRecent: cfg.KeepRecent, minTokens: cfg.MinTokens}, nil +} + +func (Mask) Name() string { return "mask" } +func (Mask) Enabled(*components.Ctx) bool { return true } + +func (m *Mask) Offload(req *bschemas.BifrostChatRequest, rep *components.Report, c *components.Ctx) ([]string, error) { + tools := toolIndices(req) + if len(tools) <= m.keepRecent { + rep.Skipped = true + return nil, nil + } + var keys []string + // Mask every tool output except the most recent keepRecent. + for _, i := range tools[:len(tools)-m.keepRecent] { + msg := &req.Input[i] + if !schema.Rewritable(*msg) { + continue // non-text blocks would be dropped by a text rewrite + } + content := schema.MessageText(*msg) + if content == "" || schema.TextTokens(content) < m.minTokens { + continue + } + if len(expand.ParseMarkers(content)) > 0 { + continue + } + key := hashKey(content) + c.Store.Put(key, []byte(content)) + schema.SetMessageText(msg, "[older tool output masked] "+expand.Marker(key)+" [full output: call "+expand.ToolName+"]") + keys = append(keys, key) + } + if len(keys) == 0 { + rep.Skipped = true + } + return keys, nil +} diff --git a/components/offload/phi_evict.go b/components/offload/phi_evict.go new file mode 100644 index 0000000..7f22bb4 --- /dev/null +++ b/components/offload/phi_evict.go @@ -0,0 +1,131 @@ +package offload + +import ( + "sort" + + "github.com/kagenti/context-guru/components" + "github.com/kagenti/context-guru/expand" + "github.com/kagenti/context-guru/schema" + bschemas "github.com/maximhq/bifrost/core/schemas" + "gopkg.in/yaml.v3" +) + +func init() { components.Register("phi_evict", newPhiEvict) } + +// PhiEvict ranks tool outputs by a lean-ctx-style context-field score Φ and +// offloads the lowest-scoring ones until the transcript fits a token budget. +// +// Φ = wR·relevance + wH·recency − wC·cost − wD·redundancy +// +// This is the scalar-ranking essence of lean-ctx's Context Field; the full +// heat-diffusion/PageRank/Thompson-bandit machinery is a documented refinement. +// The MMR/Lost-in-the-Middle reorder is a separate ordering concern (deferred); +// here Φ drives eviction, the reduction. The most recent tool output is never +// evicted (the agent is most likely to need it). +type PhiEvict struct { + budget int + weights weights +} + +type weights struct{ R, H, C, D float64 } + +type phiConfig struct { + BudgetTokens int `yaml:"budget_tokens"` + Weights string `yaml:"weights"` // balanced | aggressive | conservative +} + +func newPhiEvict(raw []byte) (components.Component, error) { + cfg := phiConfig{BudgetTokens: 120000, Weights: "balanced"} + if len(raw) > 0 { + if err := yaml.Unmarshal(raw, &cfg); err != nil { + return nil, err + } + } + return &PhiEvict{budget: cfg.BudgetTokens, weights: presetWeights(cfg.Weights)}, nil +} + +func presetWeights(name string) weights { + switch name { + case "aggressive": // punish cost harder + return weights{R: 0.30, H: 0.10, C: 0.45, D: 0.15} + case "conservative": // trust relevance/recency, light cost + return weights{R: 0.45, H: 0.20, C: 0.05, D: 0.05} + default: // balanced (lean-ctx defaults, cost/redundancy folded) + return weights{R: 0.40, H: 0.20, C: 0.30, D: 0.10} + } +} + +func (PhiEvict) Name() string { return "phi_evict" } +func (PhiEvict) Enabled(*components.Ctx) bool { return true } + +type scored struct { + idx int + tokens int + phi float64 +} + +func (p *PhiEvict) Offload(req *bschemas.BifrostChatRequest, rep *components.Report, c *components.Ctx) ([]string, error) { + tools := toolIndices(req) + total := 0 + for _, i := range tools { + total += schema.TextTokens(schema.MessageText(req.Input[i])) + } + if total <= p.budget || len(tools) <= 1 { + rep.Skipped = true + return nil, nil + } + + query := keywords(lastUserText(req)) + items := make([]scored, 0, len(tools)) + seen := map[string]struct{}{} + for pos, i := range tools { + content := schema.MessageText(req.Input[i]) + tk := schema.TextTokens(content) + recency := float64(pos) / float64(len(tools)-1) // 0..1, newest = 1 + cost := 0.0 + if total > 0 { + cost = float64(tk) / float64(total) + } + redundancy := 0.0 + if h := hashKey(content); h != "" { + if _, dup := seen[h]; dup { + redundancy = 1 + } + seen[h] = struct{}{} + } + phi := p.weights.R*overlap(query, content) + p.weights.H*recency - + p.weights.C*cost - p.weights.D*redundancy + items = append(items, scored{idx: i, tokens: tk, phi: phi}) + } + + // Evict lowest Φ first; never the most recent tool output. + newest := tools[len(tools)-1] + sort.SliceStable(items, func(a, b int) bool { return items[a].phi < items[b].phi }) + + var keys []string + for _, it := range items { + if total <= p.budget { + break + } + if it.idx == newest { + continue + } + msg := &req.Input[it.idx] + if !schema.Rewritable(*msg) { + continue // non-text blocks would be dropped by a text rewrite + } + content := schema.MessageText(*msg) + if len(expand.ParseMarkers(content)) > 0 { + continue + } + key := hashKey(content) + c.Store.Put(key, []byte(content)) + schema.SetMessageText(msg, "[evicted to fit context budget] "+expand.Marker(key)+" [full output: call "+expand.ToolName+"]") + keys = append(keys, key) + total -= it.tokens + } + if len(keys) == 0 { + rep.Skipped = true + } + return keys, nil +} diff --git a/components/offload/skeleton.go b/components/offload/skeleton.go new file mode 100644 index 0000000..d4e0878 --- /dev/null +++ b/components/offload/skeleton.go @@ -0,0 +1,171 @@ +package offload + +import ( + "regexp" + "strings" + + "github.com/kagenti/context-guru/components" + "github.com/kagenti/context-guru/expand" + "github.com/kagenti/context-guru/internal/treesitter" + "github.com/kagenti/context-guru/schema" + "github.com/maximhq/bifrost/core/schemas" + sitter "github.com/tree-sitter/go-tree-sitter" + "gopkg.in/yaml.v3" +) + +func init() { components.Register("skeleton", newSkeleton) } + +// Skeleton strips function/method bodies from fenced code blocks, keeping +// signatures/imports/types (after headroom's code-aware compressor). It drops +// information (the bodies), so it is an Offload: the whole original message is +// stashed and an expand marker left for recovery. Class bodies are preserved so +// method signatures survive. +// +// v1 targets fenced ```lang code blocks (where the language is explicit); file +// reads without a fence/path are a later addition. +type Skeleton struct{ minTokens int } + +type skeletonConfig struct { + MinTokens int `yaml:"min_tokens"` +} + +func newSkeleton(raw []byte) (components.Component, error) { + cfg := skeletonConfig{MinTokens: 80} + if len(raw) > 0 { + if err := yaml.Unmarshal(raw, &cfg); err != nil { + return nil, err + } + } + return &Skeleton{minTokens: cfg.MinTokens}, nil +} + +func (Skeleton) Name() string { return "skeleton" } +func (Skeleton) Enabled(*components.Ctx) bool { return true } + +var fenceRe = regexp.MustCompile("(?s)```([A-Za-z0-9+#_-]*)\n(.*?)\n```") + +// fenceLang maps a fenced code-block language token to a tree-sitter grammar. +var fenceLang = map[string]string{ + "go": "go", "golang": "go", "py": "python", "python": "python", + "js": "javascript", "javascript": "javascript", "jsx": "javascript", + "ts": "typescript", "typescript": "typescript", "tsx": "tsx", + "rs": "rust", "rust": "rust", "java": "java", "c": "c", "h": "c", + "cpp": "cpp", "c++": "cpp", "cc": "cpp", "rb": "ruby", "ruby": "ruby", + "php": "php", "cs": "c_sharp", "csharp": "c_sharp", "kt": "kotlin", + "kotlin": "kotlin", "swift": "swift", "scala": "scala", +} + +func (s *Skeleton) Offload(req *schemas.BifrostChatRequest, rep *components.Report, c *components.Ctx) ([]string, error) { + var keys []string + for i := range req.Input { + m := &req.Input[i] + if m.Role != schemas.ChatMessageRoleTool { + continue // only tool outputs, like every sibling offloader — never mangle the user's/assistant's own code + } + if !schema.Rewritable(*m) { + continue // non-text blocks would be dropped by a text rewrite + } + content := schema.MessageText(*m) + if content == "" || !strings.Contains(content, "```") { + continue + } + matches := fenceRe.FindAllStringSubmatchIndex(content, -1) + if matches == nil { + continue + } + var out strings.Builder + last, changed := 0, false + for _, mt := range matches { // mt: full[0,1] lang[2,3] body[4,5] + grammar := fenceLang[strings.ToLower(content[mt[2]:mt[3]])] + body := content[mt[4]:mt[5]] + if grammar == "" || schema.TextTokens(body) < s.minTokens { + continue + } + skel, ok := skeletonize([]byte(body), grammar) + if !ok || schema.TextTokens(skel) >= schema.TextTokens(body) { + continue + } + out.WriteString(content[last:mt[4]]) + out.WriteString(skel) + last = mt[5] + changed = true + } + if !changed { + continue + } + out.WriteString(content[last:]) + key := hashKey(content) + c.Store.Put(key, []byte(content)) + schema.SetMessageText(m, out.String()+"\n"+expand.Marker(key)+" [full source: call "+expand.ToolName+"]") + keys = append(keys, key) + } + if len(keys) == 0 { + rep.Skipped = true + } + return keys, nil +} + +// skeletonize parses src and replaces function/method/constructor bodies with a +// placeholder, keeping everything else. Returns ok=false on parse failure or +// when there is nothing to elide (fail-open: caller leaves the block untouched). +func skeletonize(src []byte, grammar string) (string, bool) { + tree, _, ok := treesitter.Parse(grammar, src) + if !ok || tree == nil { + return "", false + } + defer tree.Close() + root := tree.RootNode() + if root == nil { + return "", false + } + var ranges [][2]uint + var walk func(n *sitter.Node, parentKind string) + walk = func(n *sitter.Node, parentKind string) { + kind := n.Kind() + if isBodyKind(kind) && isDeclKind(parentKind) { + ranges = append(ranges, [2]uint{n.StartByte(), n.EndByte()}) + return // don't recurse into an elided body (avoids nested double-elision) + } + for i := uint(0); i < n.ChildCount(); i++ { + if ch := n.Child(i); ch != nil { + walk(ch, kind) + } + } + } + walk(root, "") + if len(ranges) == 0 { + return "", false + } + var b strings.Builder + last := uint(0) + for _, r := range ranges { + b.Write(src[last:r[0]]) + b.WriteString(placeholder(src[r[0]:r[1]])) + last = r[1] + } + b.Write(src[last:]) + return b.String(), true +} + +func isBodyKind(kind string) bool { + switch kind { + case "block", "statement_block", "compound_statement", "suite", "function_body": + return true + } + return strings.Contains(kind, "body") +} + +func isDeclKind(parentKind string) bool { + return strings.Contains(parentKind, "function") || + strings.Contains(parentKind, "method") || + strings.Contains(parentKind, "constructor") +} + +// placeholder preserves a brace-delimited body's outer braces so the skeleton +// stays syntactically suggestive; otherwise elides to an ellipsis. +func placeholder(seg []byte) string { + if len(seg) >= 2 && seg[0] == '{' && seg[len(seg)-1] == '}' { + return "{ … }" + } + return "…" +} diff --git a/components/offload/smartcrush.go b/components/offload/smartcrush.go new file mode 100644 index 0000000..c7e403a --- /dev/null +++ b/components/offload/smartcrush.go @@ -0,0 +1,110 @@ +package offload + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/kagenti/context-guru/components" + "github.com/kagenti/context-guru/expand" + "github.com/kagenti/context-guru/schema" + bschemas "github.com/maximhq/bifrost/core/schemas" + "gopkg.in/yaml.v3" +) + +func init() { components.Register("smartcrush", newSmartCrush) } + +// SmartCrush is headroom's statistical JSON-array compressor, in essence: keep a +// first-K/last-K anchor window plus any item that carries an error signal, drop +// the rest, and stash the full original. Schema-preserving (kept items are +// verbatim originals). v1 uses fixed anchors; headroom's Kneedle adaptive-K is a +// documented refinement. +type SmartCrush struct { + minItems int + minTokens int + keepFirst int + keepLast int +} + +type smartCrushConfig struct { + MinItems int `yaml:"min_items"` + MinTokens int `yaml:"min_tokens"` + KeepFirst int `yaml:"keep_first"` + KeepLast int `yaml:"keep_last"` +} + +func newSmartCrush(raw []byte) (components.Component, error) { + cfg := smartCrushConfig{MinItems: 5, MinTokens: 200, KeepFirst: 3, KeepLast: 2} + if len(raw) > 0 { + if err := yaml.Unmarshal(raw, &cfg); err != nil { + return nil, err + } + } + return &SmartCrush{minItems: cfg.MinItems, minTokens: cfg.MinTokens, keepFirst: cfg.KeepFirst, keepLast: cfg.KeepLast}, nil +} + +func (SmartCrush) Name() string { return "smartcrush" } +func (SmartCrush) Enabled(*components.Ctx) bool { return true } + +func (s *SmartCrush) Offload(req *bschemas.BifrostChatRequest, rep *components.Report, c *components.Ctx) ([]string, error) { + var keys []string + for _, i := range toolIndices(req) { + msg := &req.Input[i] + if !schema.Rewritable(*msg) { + continue // non-text blocks would be dropped by a text rewrite + } + content := schema.MessageText(*msg) + trimmed := strings.TrimSpace(content) + if len(trimmed) == 0 || trimmed[0] != '[' || schema.TextTokens(content) < s.minTokens { + continue + } + var items []json.RawMessage + if err := json.Unmarshal([]byte(trimmed), &items); err != nil || len(items) < s.minItems { + continue + } + keep := s.keepSet(items) + if len(keep) >= len(items) { + continue // nothing to drop + } + kept := make([]json.RawMessage, 0, len(keep)) + for idx := range items { + if _, ok := keep[idx]; ok { + kept = append(kept, items[idx]) + } + } + crushed, err := json.Marshal(kept) + if err != nil { + continue + } + key := hashKey(content) + c.Store.Put(key, []byte(content)) + note := fmt.Sprintf(" [%d of %d items shown; full array: call %s] %s", + len(kept), len(items), expand.ToolName, expand.Marker(key)) + schema.SetMessageText(msg, string(crushed)+note) + keys = append(keys, key) + } + if len(keys) == 0 { + rep.Skipped = true + } + return keys, nil +} + +// keepSet is the set of item indices to preserve: first-K, last-K, and any item +// whose raw JSON carries an error signal. +func (s *SmartCrush) keepSet(items []json.RawMessage) map[int]struct{} { + keep := map[int]struct{}{} + for i := 0; i < s.keepFirst && i < len(items); i++ { + keep[i] = struct{}{} + } + for i := len(items) - s.keepLast; i < len(items); i++ { + if i >= 0 { + keep[i] = struct{}{} + } + } + for i, it := range items { + if hasError(string(it)) { + keep[i] = struct{}{} + } + } + return keep +} diff --git a/components/pipeline.go b/components/pipeline.go new file mode 100644 index 0000000..631b276 --- /dev/null +++ b/components/pipeline.go @@ -0,0 +1,115 @@ +package components + +import ( + "fmt" + + "github.com/kagenti/context-guru/schema" + "github.com/maximhq/bifrost/core/schemas" +) + +// Pipeline runs an ordered list of components over a request. Order is set by +// config (the pipeline: name-list); this type just executes it. Each component +// is isolated: a panic or error reverts only that component, and a component +// that fails to shrink the request is reverted too (never-worse guard, after +// rtk). The original request is always a valid fallback — fail open, always. +type Pipeline struct { + comps []Component + emitter Emitter +} + +// NewPipeline builds a pipeline from already-constructed components in order. +func NewPipeline(comps []Component, e Emitter) *Pipeline { + if e == nil { + e = NopEmitter{} + } + return &Pipeline{comps: comps, emitter: e} +} + +// Run applies every enabled component to req in place and returns the aggregate +// report. req is mutated; on any per-component failure that component's changes +// are rolled back, so the returned request is never worse than the input. +func (p *Pipeline) Run(req *schemas.BifrostChatRequest, c *Ctx) *RunReport { + rr := &RunReport{Session: c.Session, TokensBefore: schema.MessagesTokens(req)} + if c.Bypass { + rr.TokensAfter = rr.TokensBefore + return rr + } + for _, comp := range p.comps { + if !comp.Enabled(c) { + continue + } + rep := p.runOne(comp, req, c) + rr.Components = append(rr.Components, rep) + rr.DurationMs += rep.DurationMs + p.emitter.Component(rep) + } + rr.TokensAfter = schema.MessagesTokens(req) + p.emitter.Run(*rr) + return rr +} + +// runOne executes a single component with snapshot/restore isolation and the +// never-worse guard. It never returns an error — failures are recorded on the +// Report and the request is reverted. +func (p *Pipeline) runOne(comp Component, req *schemas.BifrostChatRequest, c *Ctx) (rep Report) { + rep = Report{Component: comp.Name()} + before := schema.CloneMessages(req.Input) + rep.TokensBefore = tokensOf(before) + start := clock() + + defer func() { + rep.DurationMs = float64(clock().Sub(start).Microseconds()) / 1000.0 + if r := recover(); r != nil { + // Fail open: revert and record. A component panic never breaks the request. + req.Input = before + rep.Reverted = true + rep.TokensAfter = rep.TokensBefore + rep.Err = fmt.Errorf("panic: %v", r) + } + }() + + var err error + switch t := comp.(type) { + case Reformat: + rep.Kind = "reformat" + err = t.Reformat(req, &rep, c) + case Offload: + rep.Kind = "offload" + var keys []string + keys, err = t.Offload(req, &rep, c) + rep.CacheKeys = keys + default: + // Registered but implements neither interface — skip, don't fail the run. + rep.Skipped = true + rep.TokensAfter = rep.TokensBefore + return rep + } + + after := tokensOf(req.Input) + switch { + case err != nil: + req.Input = before + rep.Reverted = true + rep.TokensAfter = rep.TokensBefore + rep.Err = err + case rep.Kind == "offload" && after < rep.TokensBefore && len(rep.CacheKeys) == 0 && !rep.Skipped: + // An Offload that dropped content without stashing an original is a + // contract violation — reversibility would be broken. Revert. + req.Input = before + rep.Reverted = true + rep.TokensAfter = rep.TokensBefore + rep.Err = fmt.Errorf("offload dropped content without stashing a cache_key") + case after > rep.TokensBefore: + // never-worse: a component must not grow the request. + req.Input = before + rep.Reverted = true + rep.TokensAfter = rep.TokensBefore + default: + rep.TokensAfter = after + } + return rep +} + +func tokensOf(msgs []schemas.ChatMessage) int { + return schema.MessagesTokens(&schemas.BifrostChatRequest{Input: msgs}) +} diff --git a/components/pipeline_test.go b/components/pipeline_test.go new file mode 100644 index 0000000..f98b802 --- /dev/null +++ b/components/pipeline_test.go @@ -0,0 +1,146 @@ +package components + +import ( + "context" + "strings" + "testing" + + "github.com/kagenti/context-guru/store" + "github.com/maximhq/bifrost/core/schemas" +) + +// --- fakes --- + +type shrink struct{} // Reformat: collapses runs of spaces (lossless-ish, always smaller) +func (shrink) Name() string { return "shrink" } +func (shrink) Enabled(*Ctx) bool { return true } +func (shrink) Reformat(req *schemas.BifrostChatRequest, _ *Report, _ *Ctx) error { + for i := range req.Input { + t := msgText(req.Input[i]) + setText(&req.Input[i], strings.Join(strings.Fields(t), " ")) + } + return nil +} + +type dropStash struct{} // Offload: replaces content with a marker, stashes original +func (dropStash) Name() string { return "dropstash" } +func (dropStash) Enabled(*Ctx) bool { return true } +func (dropStash) Offload(req *schemas.BifrostChatRequest, _ *Report, c *Ctx) ([]string, error) { + key := "k1" + c.Store.Put(key, []byte(msgText(req.Input[0]))) + setText(&req.Input[0], "<>") + return []string{key}, nil +} + +type boom struct{} // panics — must be caught, request reverted +func (boom) Name() string { return "boom" } +func (boom) Enabled(*Ctx) bool { return true } +func (boom) Reformat(*schemas.BifrostChatRequest, *Report, *Ctx) error { panic("kaboom") } + +type grow struct{} // grows the request — never-worse guard must revert +func (grow) Name() string { return "grow" } +func (grow) Enabled(*Ctx) bool { return true } +func (grow) Reformat(req *schemas.BifrostChatRequest, _ *Report, _ *Ctx) error { + setText(&req.Input[0], strings.Repeat("padding ", 200)) + return nil +} + +type badOffload struct{} // drops but returns empty key — contract violation, revert +func (badOffload) Name() string { return "badoffload" } +func (badOffload) Enabled(*Ctx) bool { return true } +func (badOffload) Offload(req *schemas.BifrostChatRequest, _ *Report, _ *Ctx) ([]string, error) { + setText(&req.Input[0], "x") + return nil, nil +} + +// --- helpers --- + +func msgText(m schemas.ChatMessage) string { + if m.Content != nil && m.Content.ContentStr != nil { + return *m.Content.ContentStr + } + return "" +} +func setText(m *schemas.ChatMessage, s string) { + m.Content = &schemas.ChatMessageContent{ContentStr: &s} +} +func reqWith(text string) *schemas.BifrostChatRequest { + t := text + return &schemas.BifrostChatRequest{Input: []schemas.ChatMessage{ + {Role: schemas.ChatMessageRoleUser, Content: &schemas.ChatMessageContent{ContentStr: &t}}, + }} +} +func testCtx() *Ctx { + return &Ctx{Ctx: context.Background(), Session: "s", Store: store.NewMemory(store.Options{})} +} + +// --- tests --- + +func TestReformatShrinks(t *testing.T) { + req := reqWith("hello world foo") + rr := NewPipeline([]Component{shrink{}}, nil).Run(req, testCtx()) + if got := msgText(req.Input[0]); got != "hello world foo" { + t.Fatalf("shrink not applied: %q", got) + } + if rr.Saved() < 0 || rr.TokensAfter > rr.TokensBefore { + t.Fatalf("expected savings, got before=%d after=%d", rr.TokensBefore, rr.TokensAfter) + } +} + +func TestOffloadStashesAndReports(t *testing.T) { + req := reqWith(strings.Repeat("some large tool output line\n", 50)) + c := testCtx() + rr := NewPipeline([]Component{dropStash{}}, nil).Run(req, c) + if len(rr.Components) != 1 || len(rr.Components[0].CacheKeys) != 1 || rr.Components[0].CacheKeys[0] != "k1" { + t.Fatalf("expected offload report with cache_key k1, got %+v", rr.Components) + } + if _, ok := c.Store.Get("k1"); !ok { + t.Fatal("original not stashed in store") + } + if !strings.Contains(msgText(req.Input[0]), "<>") { + t.Fatalf("marker not written: %q", msgText(req.Input[0])) + } +} + +func TestFailOpenOnPanic(t *testing.T) { + req := reqWith("keep me intact") + rr := NewPipeline([]Component{boom{}}, nil).Run(req, testCtx()) + if msgText(req.Input[0]) != "keep me intact" { + t.Fatalf("panic must revert; got %q", msgText(req.Input[0])) + } + if !rr.Components[0].Reverted || rr.Components[0].Err == nil { + t.Fatalf("expected reverted+err report, got %+v", rr.Components[0]) + } +} + +func TestNeverWorseReverts(t *testing.T) { + req := reqWith("small") + rr := NewPipeline([]Component{grow{}}, nil).Run(req, testCtx()) + if msgText(req.Input[0]) != "small" { + t.Fatalf("never-worse must revert a growing component; got len %d", len(msgText(req.Input[0]))) + } + if !rr.Components[0].Reverted { + t.Fatal("expected reverted report") + } +} + +func TestOffloadEmptyKeyReverts(t *testing.T) { + req := reqWith("original content here") + rr := NewPipeline([]Component{badOffload{}}, nil).Run(req, testCtx()) + if msgText(req.Input[0]) != "original content here" { + t.Fatalf("empty-key offload must revert; got %q", msgText(req.Input[0])) + } + if !rr.Components[0].Reverted { + t.Fatal("expected reverted report for empty cache_key") + } +} + +func TestBypassSkipsEverything(t *testing.T) { + req := reqWith("hello world") + c := testCtx() + c.Bypass = true + rr := NewPipeline([]Component{shrink{}}, nil).Run(req, c) + if msgText(req.Input[0]) != "hello world" || len(rr.Components) != 0 { + t.Fatal("bypass must skip the pipeline") + } +} diff --git a/components/reformat/cacheinject.go b/components/reformat/cacheinject.go new file mode 100644 index 0000000..27fc32c --- /dev/null +++ b/components/reformat/cacheinject.go @@ -0,0 +1,68 @@ +// Package reformat holds the lossless components (they repack the request +// denser or add caching hints without losing information). Each registers via +// init(); a binary blank-imports components/all to pull them in. +package reformat + +import ( + "github.com/kagenti/context-guru/components" + "github.com/maximhq/bifrost/core/schemas" +) + +func init() { components.Register("cacheinject", newCacheinject) } + +// Cacheinject places an Anthropic-family cache_control breakpoint on the prefix +// boundary so the provider's KV cache hits across turns. It is a Reformat: it +// adds a control directive, changes no model-visible content, and loses nothing. +// +// v1 heuristic: mark the last content block of the last message BEFORE the +// newest turn (a more stable boundary than the live message). Refined with +// sticky prefix tracking in P5. No-op for non-Anthropic providers and when a +// breakpoint is already present. +type Cacheinject struct{} + +func newCacheinject(_ []byte) (components.Component, error) { return &Cacheinject{}, nil } + +func (Cacheinject) Name() string { return "cacheinject" } + +func (Cacheinject) Enabled(c *components.Ctx) bool { return true } + +func (Cacheinject) Reformat(req *schemas.BifrostChatRequest, rep *components.Report, _ *components.Ctx) error { + if !cacheAware(req.Provider) { + rep.Skipped = true + return nil + } + // Boundary = the message just before the newest turn; fall back to the last + // message when there are too few to have a stable prefix. + if len(req.Input) == 0 { + rep.Skipped = true + return nil + } + idx := len(req.Input) - 1 + if len(req.Input) >= 2 { + idx = len(req.Input) - 2 + } + m := &req.Input[idx] + if m.Content == nil || len(m.Content.ContentBlocks) == 0 { + // String-content messages can't carry a block-level breakpoint; skip + // rather than restructure them (keeps this strictly lossless). + rep.Skipped = true + return nil + } + last := &m.Content.ContentBlocks[len(m.Content.ContentBlocks)-1] + if last.CacheControl != nil { + rep.Skipped = true // already marked; leave byte-stable + return nil + } + last.CacheControl = &schemas.CacheControl{Type: schemas.CacheControlTypeEphemeral} + return nil +} + +// cacheAware reports whether the provider honours Anthropic-style cache_control. +func cacheAware(p schemas.ModelProvider) bool { + switch p { + case schemas.Anthropic, schemas.Bedrock, schemas.Vertex: + return true + default: + return false + } +} diff --git a/components/reformat/format.go b/components/reformat/format.go new file mode 100644 index 0000000..46df12d --- /dev/null +++ b/components/reformat/format.go @@ -0,0 +1,74 @@ +package reformat + +import ( + "encoding/json" + "strings" + + "github.com/kagenti/context-guru/components" + "github.com/kagenti/context-guru/schema" + "github.com/maximhq/bifrost/core/schemas" + "gopkg.in/yaml.v3" +) + +func init() { components.Register("format", newFormat) } + +// Format re-encodes JSON tool outputs denser without losing data (a Reformat): +// pretty-printed JSON is re-marshaled compact. It's strictly lossless — same +// value, fewer whitespace tokens — so no stash is needed. (A TOON encoder is a +// planned future option; v1 ships json-compact only.) +type Format struct{ minTokens int } + +type formatConfig struct { + MinTokens int `yaml:"min_tokens"` +} + +func newFormat(raw []byte) (components.Component, error) { + cfg := formatConfig{MinTokens: 50} + if len(raw) > 0 { + if err := yaml.Unmarshal(raw, &cfg); err != nil { + return nil, err + } + } + return &Format{minTokens: cfg.MinTokens}, nil +} + +func (Format) Name() string { return "format" } +func (Format) Enabled(*components.Ctx) bool { return true } + +func (f *Format) Reformat(req *schemas.BifrostChatRequest, rep *components.Report, _ *components.Ctx) error { + acted := false + for i := range req.Input { + m := &req.Input[i] + if m.Role != schemas.ChatMessageRoleTool { + continue + } + if !schema.Rewritable(*m) { + continue // non-text blocks would be dropped by a text rewrite + } + content := schema.MessageText(*m) + trimmed := strings.TrimSpace(content) + if len(trimmed) == 0 || (trimmed[0] != '{' && trimmed[0] != '[') { + continue + } + if schema.TextTokens(content) < f.minTokens { + continue + } + var v any + if err := json.Unmarshal([]byte(trimmed), &v); err != nil { + continue // not valid JSON — leave untouched + } + compact, err := json.Marshal(v) + if err != nil { + continue + } + if schema.TextTokens(string(compact)) >= schema.TextTokens(content) { + continue // already compact / no win + } + schema.SetMessageText(m, string(compact)) + acted = true + } + if !acted { + rep.Skipped = true + } + return nil +} diff --git a/components/registry.go b/components/registry.go new file mode 100644 index 0000000..4642da8 --- /dev/null +++ b/components/registry.go @@ -0,0 +1,55 @@ +package components + +import ( + "fmt" + "sort" + "sync" +) + +// Constructor builds a component instance. It receives the raw config block for +// its name (may be nil), so a component can be configured purely from YAML with +// no core change. Returning an error aborts pipeline construction. +type Constructor func(raw []byte) (Component, error) + +var ( + regMu sync.RWMutex + registry = map[string]Constructor{} +) + +// Register makes a component available by name. Called from each component's +// init(); double-registration or an empty name panics at boot (the +// database/sql pattern AuthBridge also uses). +func Register(name string, c Constructor) { + if name == "" { + panic("components: Register with empty name") + } + regMu.Lock() + defer regMu.Unlock() + if _, dup := registry[name]; dup { + panic("components: duplicate registration for " + name) + } + registry[name] = c +} + +// Names lists every registered component name, sorted. +func Names() []string { + regMu.RLock() + defer regMu.RUnlock() + out := make([]string, 0, len(registry)) + for n := range registry { + out = append(out, n) + } + sort.Strings(out) + return out +} + +// New builds a single component by name with its raw config block. +func New(name string, raw []byte) (Component, error) { + regMu.RLock() + ctor, ok := registry[name] + regMu.RUnlock() + if !ok { + return nil, fmt.Errorf("components: unknown component %q (registered: %v)", name, Names()) + } + return ctor(raw) +} diff --git a/config/config.go b/config/config.go index 94ecc84..0826713 100644 --- a/config/config.go +++ b/config/config.go @@ -1,92 +1,106 @@ -// Package config holds the engine's settings and named presets. Settings are plain -// data; the proxy resolves them from LABCX_*-style env / preset and hands them to the -// engine. Mirrors the knobs the reference prototype exposes, trimmed to what the Go core -// uses. +// Package config loads context-guru's configuration and builds a pipeline from +// it. One strict YAML struct serves both hosts (design D9): the proxy loads a +// file; the AuthBridge plugin hands its config: subtree to LoadBytes; a k8s +// ConfigMap/CRD just renders the same YAML. +// +// The pipeline: name-list controls order + enablement. Each component's own +// typed config lives under components:; it's handed to the component's +// constructor verbatim, so adding a component makes it configurable with no +// change here. A preset expands to a default pipeline + component configs, +// which the explicit fields then override. package config -// Settings configures a reduction pass end to end. -type Settings struct { - Disabled bool // global kill switch: forward untouched +import ( + "bytes" + "fmt" + "os" - // Cache stage (cache_control injection). - CacheEnabled bool - CacheBreakpoints int - CacheStableGap int - CacheToolsBreakpoint bool + "github.com/kagenti/context-guru/components" + "github.com/kagenti/context-guru/store" + "gopkg.in/yaml.v3" +) - // Reduce stage. - ReduceEnabled bool - ProtectRecent int - ProtectRecentToolUses int - ProvableOnly bool - CollapseOutputs bool - ContextLimit int - ReduceCachedPrefix bool - CmdFilter bool - RehydrateOnCompaction bool - - // Extract compactor (cheap-model projection of large structured outputs). - ExtractEnabled bool - ExtractMode string // "" | auto | single | rlm | deterministic - LLMCompactFloor int - LLMCompactStructuredOnly bool - - // Summarize compactor (LLM trajectory summarization; off by default). - SummarizeEnabled bool - SummarizeLevel string // concise | regular | highly_detailed - SummarizeKeepLast int // recent messages kept verbatim after the summary - SummarizeTriggerTokens int // skip below this whole-conversation token count +// Config is the whole configuration document. +type Config struct { + Preset string `yaml:"preset"` + Pipeline []string `yaml:"pipeline"` + Components map[string]yaml.Node `yaml:"components"` + Store store.Options `yaml:"store"` +} - // Truncate compactor (naive: keep last N messages, drop older; off by default). - TruncateEnabled bool - TruncateKeepLast int - TruncateTriggerTokens int +// Load reads and parses a YAML config file (strict: unknown keys are rejected). +func Load(path string) (*Config, error) { + b, err := os.ReadFile(path) + if err != nil { + return nil, err + } + return LoadBytes(b) +} - // Named component selection. Each list is referenced BY NAME so that a component - // added tomorrow (a new reducer/encoder/extract strategy/compactor) is controlled - // purely from config — register it by name in its package, then list it here. An - // empty list means "use all built-in defaults" (no filtering). - // - // Reducers filters internal/reduce reducers by Reducer.Name (e.g. collapse, skeleton, - // format). Encoders filters AND orders the format re-encoders by name (e.g. - // json_compact, toon, jsonl, markdown_kv, tsv, csv). ExtractStrategies restricts the - // internal/extract strategy order (e.g. code, single, rlm, deterministic). Compactors - // selects which compactors run and in what order (reduce, extract, summarize, - // truncate, cache). - Reducers []string - Encoders []string - ExtractStrategies []string - Compactors []string +// LoadBytes parses a YAML config document (strict). Used by the AuthBridge +// plugin's Configure, which receives its subtree as bytes. +func LoadBytes(b []byte) (*Config, error) { + var c Config + dec := yaml.NewDecoder(bytes.NewReader(b)) + dec.KnownFields(true) // reject typos loudly + if err := dec.Decode(&c); err != nil { + return nil, fmt.Errorf("config: %w", err) + } + if err := c.applyPreset(); err != nil { + return nil, err + } + return &c, nil } -// Default is the "balanced" preset: lossless caching + accuracy-safe reduction on, -// LLM extraction off (it needs an injected model client; the proxy enables it). -func Default() Settings { - return Settings{ - CacheEnabled: true, CacheBreakpoints: 4, CacheStableGap: 2, CacheToolsBreakpoint: true, - ReduceEnabled: true, ProtectRecent: 2, ProvableOnly: true, CollapseOutputs: true, - CmdFilter: true, RehydrateOnCompaction: true, - LLMCompactFloor: 3000, LLMCompactStructuredOnly: true, +// applyPreset fills an empty Pipeline from the named preset. Explicit fields in +// the document always win (they were already decoded). +func (c *Config) applyPreset() error { + if c.Preset == "" { + return nil + } + p, ok := presets[c.Preset] + if !ok { + return fmt.Errorf("config: unknown preset %q", c.Preset) + } + if len(c.Pipeline) == 0 { + c.Pipeline = append([]string(nil), p...) } + return nil } -// Preset returns a named settings preset, or Default() for an unknown name. -func Preset(name string) Settings { - s := Default() - switch name { - case "safe": - // Lossless only: no predicted drops, no LLM, caching + lossless re-encode. - s.ProvableOnly = true - s.CollapseOutputs = false - case "balanced", "": - // defaults - case "aggressive": - s.ProvableOnly = false - s.ProtectRecent = 1 - case "cache": - s.ReduceEnabled = false - case "coding", "mcp": - s.ProtectRecentToolUses = 8 +// presets map a name to a default pipeline (component names in run order). The +// referenced components are registered by P1+; an unknown name surfaces at +// Build time as a clear error. +var presets = map[string][]string{ + "off": {}, // passthrough: no components (baseline / A-B control) + "safe": {"format", "cacheinject"}, + "balanced": {"format", "dedup", "failed_run", "cmdfilter", "cacheinject"}, + "aggressive": {"format", "dedup", "failed_run", "cmdfilter", "smartcrush", "extract", "cacheinject"}, + "coding": {"format", "skeleton", "cmdfilter", "cacheinject"}, + "mcp": {"format", "smartcrush", "cacheinject"}, +} + +// Build constructs the ordered pipeline from the config, wiring each named +// component with its raw config block. +func (c *Config) Build(e components.Emitter) (*components.Pipeline, error) { + comps := make([]components.Component, 0, len(c.Pipeline)) + for _, name := range c.Pipeline { + var raw []byte + if node, ok := c.Components[name]; ok { + b, err := yaml.Marshal(&node) + if err != nil { + return nil, fmt.Errorf("config: marshal %q block: %w", name, err) + } + raw = b + } + comp, err := components.New(name, raw) + if err != nil { + return nil, err + } + comps = append(comps, comp) } - return s + return components.NewPipeline(comps, e), nil } + +// NewStore builds the configured state store (in-memory for v1). +func (c *Config) NewStore() store.Store { return store.NewMemory(c.Store) } diff --git a/config/config_more_test.go b/config/config_more_test.go new file mode 100644 index 0000000..a86b791 --- /dev/null +++ b/config/config_more_test.go @@ -0,0 +1,44 @@ +package config + +import ( + "testing" + + _ "github.com/kagenti/context-guru/components/all" +) + +func TestBuildUnknownComponentErrors(t *testing.T) { + c, err := LoadBytes([]byte("pipeline: [nonesuch]\n")) + if err != nil { + t.Fatal(err) + } + if _, err := c.Build(nil); err == nil { + t.Fatal("Build must error on an unregistered component") + } +} + +func TestBuildEmptyPipeline(t *testing.T) { + c, _ := LoadBytes([]byte("preset: off\n")) + p, err := c.Build(nil) + if err != nil || p == nil { + t.Fatalf("the off preset should build a no-op pipeline: %v", err) + } +} + +// TestBuildMarshalsComponentBlock exercises the components: config-block +// marshal path in Build (the raw block is handed to the constructor). +func TestBuildMarshalsComponentBlock(t *testing.T) { + c, err := LoadBytes([]byte("pipeline: [collapse]\ncomponents:\n collapse: {max_tokens: 123}\n")) + if err != nil { + t.Fatal(err) + } + if _, err := c.Build(nil); err != nil { + t.Fatalf("build with a component config block failed: %v", err) + } +} + +func TestNewStoreNonNil(t *testing.T) { + c, _ := LoadBytes([]byte("store: {ttl_seconds: 5}\n")) + if c.NewStore() == nil { + t.Fatal("NewStore returned nil") + } +} diff --git a/config/config_test.go b/config/config_test.go new file mode 100644 index 0000000..a934238 --- /dev/null +++ b/config/config_test.go @@ -0,0 +1,49 @@ +package config + +import ( + "strings" + "testing" +) + +func TestLoadStrictRejectsUnknownFields(t *testing.T) { + _, err := LoadBytes([]byte("pipeline: [format]\nbogus_key: 1\n")) + if err == nil || !strings.Contains(err.Error(), "bogus_key") { + t.Fatalf("expected strict rejection of unknown field, got %v", err) + } +} + +func TestPresetExpandsPipeline(t *testing.T) { + c, err := LoadBytes([]byte("preset: balanced\n")) + if err != nil { + t.Fatal(err) + } + if len(c.Pipeline) == 0 || c.Pipeline[0] != "format" { + t.Fatalf("preset did not expand pipeline: %v", c.Pipeline) + } +} + +func TestExplicitPipelineOverridesPreset(t *testing.T) { + c, err := LoadBytes([]byte("preset: balanced\npipeline: [cacheinject]\n")) + if err != nil { + t.Fatal(err) + } + if len(c.Pipeline) != 1 || c.Pipeline[0] != "cacheinject" { + t.Fatalf("explicit pipeline should win: %v", c.Pipeline) + } +} + +func TestUnknownPresetErrors(t *testing.T) { + if _, err := LoadBytes([]byte("preset: nope\n")); err == nil { + t.Fatal("expected error for unknown preset") + } +} + +func TestStoreOptionsParse(t *testing.T) { + c, err := LoadBytes([]byte("store: {ttl_seconds: 60, max_entries: 5}\n")) + if err != nil { + t.Fatal(err) + } + if c.Store.TTLSeconds != 60 || c.Store.MaxEntries != 5 { + t.Fatalf("store options not parsed: %+v", c.Store) + } +} diff --git a/config/file.go b/config/file.go deleted file mode 100644 index 57e3de5..0000000 --- a/config/file.go +++ /dev/null @@ -1,209 +0,0 @@ -package config - -import ( - "bytes" - "fmt" - "os" - - "gopkg.in/yaml.v3" -) - -// fileConfig is the on-disk YAML shape. It is intentionally separate from Settings: -// the file groups knobs under compactor sections (reduce/extract/summarize/truncate/ -// cache) and carries the named component-selection lists, which we then fold onto a -// Settings derived from the chosen preset. Pointers distinguish "key absent" (keep -// preset/default) from "key set to the zero value" (an explicit override). -// -// Extending the file is a one-line change: a new reducer/encoder/extract-strategy is -// referenced purely by NAME in the relevant list (reduce.reducers, reduce.encoders, -// extract.strategies, compactors) — see configs/lab-cx.yaml. No new struct field is -// needed to wire a newly-registered component on or off. -type fileConfig struct { - Preset string `yaml:"preset"` - Compactors []string `yaml:"compactors"` - - Reduce *struct { - Enabled *bool `yaml:"enabled"` - ProtectRecent *int `yaml:"protect_recent"` - ProtectRecentToolUses *int `yaml:"protect_recent_tool_uses"` - ProvableOnly *bool `yaml:"provable_only"` - CollapseOutputs *bool `yaml:"collapse_outputs"` - ContextLimit *int `yaml:"context_limit"` - ReduceCachedPrefix *bool `yaml:"reduce_cached_prefix"` - CmdFilter *bool `yaml:"cmd_filter"` - RehydrateOnCompaction *bool `yaml:"rehydrate_on_compaction"` - Reducers []string `yaml:"reducers"` - Encoders []string `yaml:"encoders"` - } `yaml:"reduce"` - - Extract *struct { - Enabled *bool `yaml:"enabled"` - Mode string `yaml:"mode"` - Strategies []string `yaml:"strategies"` - Floor *int `yaml:"floor"` - StructuredOnly *bool `yaml:"structured_only"` - modelTransportYAML `yaml:",inline"` - } `yaml:"extract"` - - Summarize *struct { - Enabled *bool `yaml:"enabled"` - Level string `yaml:"level"` - KeepLast *int `yaml:"keep_last"` - TriggerTokens *int `yaml:"trigger_tokens"` - modelTransportYAML `yaml:",inline"` - } `yaml:"summarize"` - - Truncate *struct { - Enabled *bool `yaml:"enabled"` - KeepLast *int `yaml:"keep_last"` - TriggerTokens *int `yaml:"trigger_tokens"` - } `yaml:"truncate"` - - Cache *struct { - Enabled *bool `yaml:"enabled"` - Breakpoints *int `yaml:"breakpoints"` - StableGap *int `yaml:"stable_gap"` - ToolsBreakpoint *bool `yaml:"tools_breakpoint"` - } `yaml:"cache"` -} - -// modelTransportYAML is the LLM-model transport block shared by every LLM compactor -// (extract, summarize). The engine core ignores these; the proxy reads them to build -// the cheap-model client. Inlined into each compactor's YAML section. -type modelTransportYAML struct { - Source string `yaml:"source"` // "config" (default) | "incoming" - Provider string `yaml:"provider"` - Model string `yaml:"model"` - Auth string `yaml:"auth"` - Base string `yaml:"base"` - APIKey string `yaml:"api_key"` // inline secret (allowed); prefer key_env for hygiene - KeyEnv string `yaml:"key_env"` // env var name to read the key from -} - -// ModelTransport holds one LLM compactor's transport + credentials parsed from a config -// file. The engine ignores these; the proxy uses them to construct the model client. -// Empty fields fall back to flag/default. Source "incoming" reuses the proxied request's -// own model + credentials instead of a configured static client. -type ModelTransport struct { - Source string - Provider string - Model string - Auth string - Base string - APIKey string - KeyEnv string -} - -func (m modelTransportYAML) transport() ModelTransport { - return ModelTransport{ - Source: m.Source, Provider: m.Provider, Model: m.Model, - Auth: m.Auth, Base: m.Base, APIKey: m.APIKey, KeyEnv: m.KeyEnv, - } -} - -// Transports carries the per-compactor model transports the proxy needs to construct -// model clients. -type Transports struct { - Extract ModelTransport - Summarize ModelTransport -} - -// Load reads a YAML config file and folds it onto the preset-derived Settings. Unknown -// top-level keys are an error (strict). Missing keys fall back to the preset/defaults. -func Load(path string) (Settings, error) { - s, _, err := LoadFull(path) - return s, err -} - -// LoadFull is Load plus the per-compactor model transports (provider/model/auth/base/ -// credentials/source) the proxy needs to construct the model clients. -func LoadFull(path string) (Settings, Transports, error) { - raw, err := os.ReadFile(path) - if err != nil { - return Settings{}, Transports{}, fmt.Errorf("config: read %s: %w", path, err) - } - var fc fileConfig - dec := yaml.NewDecoder(bytes.NewReader(raw)) - dec.KnownFields(true) // strict: unknown keys are an error - if err := dec.Decode(&fc); err != nil { - return Settings{}, Transports{}, fmt.Errorf("config: parse %s: %w", path, err) - } - - s := Preset(fc.Preset) - if fc.Compactors != nil { - s.Compactors = fc.Compactors - } - - if r := fc.Reduce; r != nil { - setBool(&s.ReduceEnabled, r.Enabled) - setInt(&s.ProtectRecent, r.ProtectRecent) - setInt(&s.ProtectRecentToolUses, r.ProtectRecentToolUses) - setBool(&s.ProvableOnly, r.ProvableOnly) - setBool(&s.CollapseOutputs, r.CollapseOutputs) - setInt(&s.ContextLimit, r.ContextLimit) - setBool(&s.ReduceCachedPrefix, r.ReduceCachedPrefix) - setBool(&s.CmdFilter, r.CmdFilter) - setBool(&s.RehydrateOnCompaction, r.RehydrateOnCompaction) - if r.Reducers != nil { - s.Reducers = r.Reducers - } - if r.Encoders != nil { - s.Encoders = r.Encoders - } - } - - if e := fc.Extract; e != nil { - setBool(&s.ExtractEnabled, e.Enabled) - if e.Mode != "" { - s.ExtractMode = e.Mode - } - setInt(&s.LLMCompactFloor, e.Floor) - setBool(&s.LLMCompactStructuredOnly, e.StructuredOnly) - if e.Strategies != nil { - s.ExtractStrategies = e.Strategies - } - } - - if su := fc.Summarize; su != nil { - setBool(&s.SummarizeEnabled, su.Enabled) - if su.Level != "" { - s.SummarizeLevel = su.Level - } - setInt(&s.SummarizeKeepLast, su.KeepLast) - setInt(&s.SummarizeTriggerTokens, su.TriggerTokens) - } - - if tr := fc.Truncate; tr != nil { - setBool(&s.TruncateEnabled, tr.Enabled) - setInt(&s.TruncateKeepLast, tr.KeepLast) - setInt(&s.TruncateTriggerTokens, tr.TriggerTokens) - } - - if c := fc.Cache; c != nil { - setBool(&s.CacheEnabled, c.Enabled) - setInt(&s.CacheBreakpoints, c.Breakpoints) - setInt(&s.CacheStableGap, c.StableGap) - setBool(&s.CacheToolsBreakpoint, c.ToolsBreakpoint) - } - - var t Transports - if e := fc.Extract; e != nil { - t.Extract = e.transport() - } - if su := fc.Summarize; su != nil { - t.Summarize = su.transport() - } - return s, t, nil -} - -func setBool(dst *bool, v *bool) { - if v != nil { - *dst = *v - } -} - -func setInt(dst *int, v *int) { - if v != nil { - *dst = *v - } -} diff --git a/config/file_test.go b/config/file_test.go deleted file mode 100644 index f25eaba..0000000 --- a/config/file_test.go +++ /dev/null @@ -1,176 +0,0 @@ -package config - -import ( - "os" - "path/filepath" - "reflect" - "testing" -) - -// writeTmp writes content to a temp YAML file and returns its path. -func writeTmp(t *testing.T, content string) string { - t.Helper() - dir := t.TempDir() - p := filepath.Join(dir, "cfg.yaml") - if err := os.WriteFile(p, []byte(content), 0o644); err != nil { - t.Fatalf("write temp config: %v", err) - } - return p -} - -// TestLoadDisablesExtractAndSelectsComponents is the spec'd acceptance test: a YAML -// that disables extract and enables only the toon encoder + only the collapse reducer -// must produce Settings/Components that reflect exactly that. -func TestLoadDisablesExtractAndSelectsComponents(t *testing.T) { - p := writeTmp(t, ` -preset: balanced -reduce: - reducers: [collapse] - encoders: [toon] -extract: - enabled: false -`) - s, err := Load(p) - if err != nil { - t.Fatalf("Load: %v", err) - } - if s.ExtractEnabled { - t.Errorf("ExtractEnabled = true, want false") - } - if !reflect.DeepEqual(s.Encoders, []string{"toon"}) { - t.Errorf("Encoders = %v, want [toon]", s.Encoders) - } - if !reflect.DeepEqual(s.Reducers, []string{"collapse"}) { - t.Errorf("Reducers = %v, want [collapse]", s.Reducers) - } -} - -// TestLoadFullShape exercises every documented key and the preset base + overrides. -func TestLoadFullShape(t *testing.T) { - p := writeTmp(t, ` -preset: balanced -compactors: [reduce, extract, cache] -reduce: - protect_recent: 5 - provable_only: false - reducers: [collapse, skeleton, format, dedup, cmdfilter] - encoders: [json_compact, toon, csv] -extract: - enabled: true - mode: single - strategies: [code, single, deterministic] - floor: 1234 -cache: - enabled: true - breakpoints: 7 -`) - s, err := Load(p) - if err != nil { - t.Fatalf("Load: %v", err) - } - if s.ProtectRecent != 5 { - t.Errorf("ProtectRecent = %d, want 5", s.ProtectRecent) - } - if s.ProvableOnly { - t.Errorf("ProvableOnly = true, want false (override)") - } - if !reflect.DeepEqual(s.Compactors, []string{"reduce", "extract", "cache"}) { - t.Errorf("Compactors = %v", s.Compactors) - } - if !reflect.DeepEqual(s.ExtractStrategies, []string{"code", "single", "deterministic"}) { - t.Errorf("ExtractStrategies = %v", s.ExtractStrategies) - } - if !s.ExtractEnabled { - t.Errorf("ExtractEnabled = false, want true") - } - if s.ExtractMode != "single" { - t.Errorf("ExtractMode = %q, want single", s.ExtractMode) - } - if s.LLMCompactFloor != 1234 { - t.Errorf("LLMCompactFloor = %d, want 1234", s.LLMCompactFloor) - } - if s.CacheBreakpoints != 7 { - t.Errorf("CacheBreakpoints = %d, want 7", s.CacheBreakpoints) - } -} - -// TestLoadCompactorsAndTransports exercises the summarize/truncate blocks and the -// per-compactor LLM transport + credential fields, returned via LoadFull. -func TestLoadCompactorsAndTransports(t *testing.T) { - p := writeTmp(t, ` -preset: balanced -compactors: [extract, summarize, truncate, cache] -extract: - enabled: true - source: config - provider: anthropic - model: claude-haiku-4-5 - api_key: sk-inline -summarize: - enabled: true - level: highly_detailed - keep_last: 4 - trigger_tokens: 5000 - source: incoming - provider: openai - model: gpt-4o-mini - key_env: MY_SUM_KEY -truncate: - enabled: true - keep_last: 8 - trigger_tokens: 12000 -`) - s, tr, err := LoadFull(p) - if err != nil { - t.Fatalf("LoadFull: %v", err) - } - if !reflect.DeepEqual(s.Compactors, []string{"extract", "summarize", "truncate", "cache"}) { - t.Errorf("Compactors = %v", s.Compactors) - } - if !s.SummarizeEnabled || s.SummarizeLevel != "highly_detailed" || s.SummarizeKeepLast != 4 || s.SummarizeTriggerTokens != 5000 { - t.Errorf("summarize settings not folded: %+v", s) - } - if !s.TruncateEnabled || s.TruncateKeepLast != 8 || s.TruncateTriggerTokens != 12000 { - t.Errorf("truncate settings not folded: %+v", s) - } - if tr.Extract.Source != "config" || tr.Extract.Model != "claude-haiku-4-5" || tr.Extract.APIKey != "sk-inline" { - t.Errorf("extract transport = %+v", tr.Extract) - } - if tr.Summarize.Source != "incoming" || tr.Summarize.Provider != "openai" || tr.Summarize.KeyEnv != "MY_SUM_KEY" { - t.Errorf("summarize transport = %+v", tr.Summarize) - } -} - -// TestLoadDefaultsFromPreset: missing keys fall back to the preset/defaults. -func TestLoadDefaultsFromPreset(t *testing.T) { - p := writeTmp(t, `preset: safe`) - s, err := Load(p) - if err != nil { - t.Fatalf("Load: %v", err) - } - want := Preset("safe") - if s.ProvableOnly != want.ProvableOnly || s.CollapseOutputs != want.CollapseOutputs { - t.Errorf("safe preset not applied: got ProvableOnly=%v CollapseOutputs=%v", - s.ProvableOnly, s.CollapseOutputs) - } - if s.CacheBreakpoints != want.CacheBreakpoints { - t.Errorf("CacheBreakpoints = %d, want preset %d", s.CacheBreakpoints, want.CacheBreakpoints) - } -} - -// TestLoadStrictUnknownKey: an unknown top-level key is an error. -func TestLoadStrictUnknownKey(t *testing.T) { - p := writeTmp(t, ` -preset: balanced -bogus_key: 1 -`) - if _, err := Load(p); err == nil { - t.Fatal("expected error on unknown top-level key, got nil") - } -} - -func TestLoadMissingFile(t *testing.T) { - if _, err := Load("/no/such/file.yaml"); err == nil { - t.Fatal("expected error for missing file") - } -} diff --git a/configs/lab-cx.yaml b/configs/lab-cx.yaml deleted file mode 100644 index fc6d7cb..0000000 --- a/configs/lab-cx.yaml +++ /dev/null @@ -1,72 +0,0 @@ -# lab-cx example config — pass with: lab-cx proxy --config configs/lab-cx.yaml -# -# This file controls which components run and in what order, purely by NAME. It folds -# onto a base `preset`, then applies the overrides below. Unknown top-level keys are a -# hard error (strict). Any key you omit falls back to the preset/defaults. -# -# ── HOW TO ADD A NEW COMPONENT (one-line config, no core edit needed) ─────────────── -# • new ENCODER : register it in internal/reduce/actions.go `allEncoders` -# (unique name + rank), then list its name under `reduce.encoders`. -# • new REDUCER : register it via reduce.RegisterReducer (its Reducer.Name), then -# list that name under `reduce.reducers`. -# • new EXTRACT STRATEGY : add it to internal/extract `RunExtraction`'s switch + -# `rawStrategyOrder`, then list its name under `extract.strategies`. -# • new COMPACTOR : implement engine.Compactor, register it by name (Engine.Register), -# then list its name under `compactors`. -# Each list is referenced by NAME; enabling/disabling/ordering is pure config. -# An empty or omitted list means "use all built-in defaults". -# ───────────────────────────────────────────────────────────────────────────────────── - -preset: balanced # base defaults: safe|balanced|aggressive|cache|coding|mcp - -# Which compactors run, in order. Built-ins: reduce, extract, summarize, truncate, cache. -# extract runs before reduce so it sees large structured tool outputs intact. -compactors: [extract, reduce, cache] - -reduce: - protect_recent: 2 # keep the N most recent turns at full fidelity - provable_only: true # never drop merely-predicted-unused content - # Enabled reducers, by Reducer.Name. Built-ins: collapse, skeleton, format. - reducers: [collapse, skeleton, format] - cmd_filter: true # dedup/trim noisy shell command output - # Format re-encoders, by name — this list also sets PRIORITY (first listed wins ties). - encoders: [json_compact, toon, jsonl, markdown_kv, tsv, csv] - -extract: - enabled: true # cheap-model projection of large structured outputs - mode: auto # auto | single | rlm | deterministic - strategies: [code, single, rlm, deterministic] - floor: 3000 # token floor before extraction is considered - # Model transport + credentials (read by the proxy; --extract-* flags override these). - # source: config (default) uses the model/credentials below; source: incoming reuses - # the proxied request's OWN model + credentials (no static client needed). - source: config # config | incoming - provider: anthropic # anthropic | openai - model: claude-haiku-4-5 - auth: x-api-key # x-api-key | bearer (bearer for gateway endpoints) - # base: https://gateway.example/v1 # optional: override the model base URL - # api_key: sk-... # optional inline key (prefer key_env for hygiene) - key_env: LABCX_EXTRACT_KEY # env var holding the key (default fallbacks apply) - -# LLM trajectory summarization (off unless listed in `compactors` AND enabled here). -# Replaces older messages with one factual summary, keeping the last keep_last verbatim. -summarize: - enabled: false - level: regular # concise | regular | highly_detailed - keep_last: 3 # recent messages kept verbatim after the summary - trigger_tokens: 8000 # skip summarizing below this whole-conversation size - source: config # same transport mechanism as extract (config|incoming) - provider: anthropic - model: claude-haiku-4-5 - auth: x-api-key - key_env: LABCX_SUMMARIZE_KEY - -# Naive truncation baseline (no model): keep the last keep_last messages, drop the rest. -truncate: - enabled: false - keep_last: 6 - trigger_tokens: 8000 - -cache: - enabled: true - breakpoints: 4 # ephemeral cache_control breakpoints on the stable prefix diff --git a/deploy/eval-containers/README.md b/deploy/eval-containers/README.md index 395168f..80cd936 100644 --- a/deploy/eval-containers/README.md +++ b/deploy/eval-containers/README.md @@ -1,54 +1,45 @@ -# Running lab-cx inside eval-containers +# context-guru as an eval-containers gateway -[eval-containers](https://github.com/exgentic/eval-containers) runs `one benchmark + -one agent + one model`, with the agent's LLM traffic always flowing through a -**gateway** (bifrost/litellm) on port 4000. `lab-cx` slots into that request path as a -plain HTTP proxy — no changes to the agent images, no changes to the gateway. +`context-guru-proxy` is a drop-in eval-containers gateway flavor: it runs the +context-engineering pipeline on every request, then forwards to the real +provider. Swap it in with `EVAL_GATEWAY_IMAGE` — no agent or benchmark changes. -The image is built from this repo's root `Dockerfile`: +## Build + +The module uses a local `replace` to `../bifrost/core`, so build from the parent +directory that holds both repos: ```sh -docker build -t ghcr.io/kagenti/lab-context-engineering:latest . +cd .../context-engineering +docker build -f lab-context-engineering/Dockerfile -t context-guru-proxy:latest . ``` -## Placement A — before the gateway (recommended) +## Run under eval-containers -``` -runner (agent) ──▶ lab-cx ──▶ gateway ──▶ provider +```sh +EVAL_GATEWAY_IMAGE=context-guru-proxy:latest \ + eval-containers run --agent --model / ``` -lab-cx sees what the agent sends, in its native protocol, before any model-name -rewrite — the right place to control the agent's context. Use -[`compose.override.yaml`](compose.override.yaml): +The gateway (`start`) reads: +- `EVAL_MODEL=/` — the model pins every call (`FORCE_MODEL`); the provider selects the upstream. +- `OPENAI_API_KEY` / `ANTHROPIC_API_KEY` — the real key, injected on forward (the agent holds only `sk-proxy`). +- `OPENAI_API_BASE` / `ANTHROPIC_API_BASE` — optional upstream overrides. +- `CONTEXT_GURU_PRESET` (default `balanced`) — which pipeline preset to run. -```sh -docker compose -f compose.yaml -f .../deploy/eval-containers/compose.override.yaml up \ - -y --abort-on-container-exit -``` +Endpoints on `:4000`: `/openai/v1/chat/completions`, `/anthropic/v1/messages`, +plus `/healthz`, `/stats` (savings rollups), `/expand?id=` (recover offloaded content). -It adds a `lab-cx` service on the `internal` network, repoints the runner's -`ANTHROPIC_BASE_URL` / `OPENAI_BASE_URL` / `GOOGLE_GEMINI_BASE_URL` at it, and sets -`--upstream http://gateway:4000` so lab-cx forwards the full (prefixed) path on to the -gateway. The agent still holds only `sk-proxy`; the real key stays in the gateway. +## Measuring the effect -## Placement B — after the gateway +Run the same `EVAL_TASK_ID` twice — once normally, once with the agent sending +`x-context-guru-bypass: true` — and compare. `/stats` reports token-weighted +savings per component; per-task attribution improves once a session id is stamped +(`x-context-guru-session`). -``` -runner (agent) ──▶ gateway ──▶ lab-cx ──▶ provider -``` +## Known gaps (tracked, P5) -lab-cx sees normalized (OpenAI-shaped) traffic. Point the gateway's `OPENAI_API_BASE` -at lab-cx (`http://lab-cx:8080`) and put `lab-cx` on the `upstream` network, with -`--upstream` set to the real provider base. - -## Notes - -- **Suffix routing**: lab-cx reduces any path ending in `/v1/messages` or - `/chat/completions`, so the eval prefixes (`/anthropic`, `/openai/v1`) work - unchanged. The Gemini prefix (`/genai`) is forwarded untouched (fail-open) until the - Gemini surface lands. -- **Isolation/cost invariants are preserved**: lab-cx adds no egress (before-gateway it - lives on `internal` only), doesn't touch `sk-proxy`/`TASK_ID`, and the gateway keeps - emitting OTel + enforcing `EVAL_MODEL_MAX_BUDGET`. -- **Measuring the effect**: compare `trajectory.jsonl` / `result.json` token + cost - totals with and without the override on the same `EVAL_TASK_ID`. +- OTel `gen_ai` spans are not yet emitted (eval-containers' otelcol ingests them); + today savings come from `/stats`. +- The image build needs the parent-dir context (or `go mod vendor`) until bifrost + is pinned to a published version. diff --git a/deploy/eval-containers/aggregate.py b/deploy/eval-containers/aggregate.py new file mode 100644 index 0000000..e2342b1 --- /dev/null +++ b/deploy/eval-containers/aggregate.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python3 +"""Summarize sweep-results.csv into a comparison table. + +Per config: cells run, #reward=1, mean within-run token savings% (the honest +metric — before/after are the SAME request stream, immune to per-run trajectory +variance), total tokens saved. Also a reward-parity view restricted to tasks the +baseline actually resolved, so 'did context-guru hurt correctness' is answerable. +""" +import csv, os, statistics as st +from collections import defaultdict + +CSV = os.path.join(os.path.dirname(__file__), "sweep-results.csv") + + +def num(x): + try: + return float(x) + except Exception: + return None + + +def main(): + rows = [r for r in csv.DictReader(open(CSV)) if not str(r["note"]).startswith("up-failed")] + by_cfg = defaultdict(list) + for r in rows: + by_cfg[r["config"]].append(r) + + # baseline-resolved task set (reward==1) for honest parity + base_ok = {r["task"] for r in by_cfg.get("baseline", []) if num(r["reward"]) == 1} + + order = ["baseline", "cg-format", "cg-dedup", "cg-cmdfilter", "cg-cacheinject", "cg-balanced"] + print(f"{'config':<16}{'cells':>6}{'reward=1':>9}{'mean_save%':>11}{'tot_saved':>10}" + f"{'parity(base✓)':>15}") + for cfg in order + [c for c in by_cfg if c not in order]: + rs = by_cfg.get(cfg) + if not rs: + continue + cells = len(rs) + won = sum(1 for r in rs if num(r["reward"]) == 1) + saves = [num(r["gw_pct"]) for r in rs if num(r["gw_pct"]) is not None] + tot = sum(int(float(r["gw_saved"])) for r in rs if num(r["gw_saved"]) is not None) + meansave = f"{st.mean(saves):.1f}" if saves else "-" + # reward on tasks baseline resolved + par = [r for r in rs if r["task"] in base_ok] + par_won = sum(1 for r in par if num(r["reward"]) == 1) + parity = f"{par_won}/{len(par)}" if par else "-" + print(f"{cfg:<16}{cells:>6}{won:>9}{meansave:>11}{tot:>10}{parity:>15}") + + print("\nPer-task within-run savings% (cg-balanced) and reward (baseline vs balanced):") + tasks = sorted({r["task"] for r in rows}) + bal = {r["task"]: r for r in by_cfg.get("cg-balanced", [])} + base = {r["task"]: r for r in by_cfg.get("baseline", [])} + for t in tasks: + b, bl = base.get(t), bal.get(t) + print(f" {t:<34} baseline_reward={b['reward'] if b else '-':<4} " + f"balanced_reward={bl['reward'] if bl else '-':<4} " + f"balanced_save%={bl['gw_pct'] if bl else '-'}") + + +if __name__ == "__main__": + main() diff --git a/deploy/eval-containers/compose.contextguru.yaml b/deploy/eval-containers/compose.contextguru.yaml new file mode 100644 index 0000000..ce13d75 --- /dev/null +++ b/deploy/eval-containers/compose.contextguru.yaml @@ -0,0 +1,36 @@ +# Compose override that swaps the eval-containers gateway for the locally-built +# context-guru image and wires it for the IBM litellm upstream (Anthropic-native). +# +# Usage (from containers/benchmarks/swe-bench/): +# docker compose -f compose.yaml -f up --abort-on-container-exit +# +# Required env (export before `up`): +# EVAL_TASK_ID, EVAL_MODEL=anthropic/claude-sonnet-4-6, EVAL_AGENT=claude-code +# ANTHROPIC_API_BASE=, ANTHROPIC_API_KEY= +# OPENAI_API_KEY/OPENAI_API_BASE are unused for Anthropic but the base service +# marks them required (:?), so pass any placeholder. +# CONTEXT_GURU_PRESET selects the pipeline: `off` (baseline passthrough), +# `safe`, `balanced`, ... +services: + # SWE-bench runner images are published amd64-only; pin the platform so an + # arm64 host pulls/runs the amd64 image under emulation instead of failing with + # "no matching manifest for linux/arm64". + runner: + platform: linux/amd64 + gateway: + image: context-guru:local + pull_policy: never + # Mount the shared output volume so the gateway can write its change-capture + # (CONTEXT_GURU_DUMP) where we can read it after the run. + volumes: + - output:/output + environment: + # our start script reads these to target the upstream + inject the real key + ANTHROPIC_API_BASE: ${ANTHROPIC_API_BASE:?set to the IBM litellm base URL} + ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:?set to the litellm token} + # pipeline selection: a non-empty list wins, else the preset is used + CONTEXT_GURU_PIPELINE: ${CONTEXT_GURU_PIPELINE:-} + CONTEXT_GURU_PRESET: ${CONTEXT_GURU_PRESET:-off} + CONTEXT_GURU_DEBUG: ${CONTEXT_GURU_DEBUG:-} + # when set, the gateway appends a before→after record per rewritten message + CONTEXT_GURU_DUMP: ${CONTEXT_GURU_DUMP:-} diff --git a/deploy/eval-containers/compose.override.yaml b/deploy/eval-containers/compose.override.yaml deleted file mode 100644 index 7271096..0000000 --- a/deploy/eval-containers/compose.override.yaml +++ /dev/null @@ -1,48 +0,0 @@ -# Drop-in override that inserts lab-cx BEFORE the eval-containers gateway, so it sees -# exactly what the agent sends (native protocol, pre model-name rewrite) and controls -# the context before it reaches the gateway: -# -# runner (agent) ──▶ lab-cx ──▶ gateway ──▶ provider -# -# Usage (from an eval-containers benchmark dir): -# docker compose -f compose.yaml -f /path/to/this/compose.override.yaml up ... -# -# It repoints the runner's *_BASE_URL at lab-cx and points lab-cx at the gateway. -# Nothing about the agent images or the gateway changes. For the AFTER-gateway -# placement instead, see README.md in this directory. - -services: - lab-cx: - image: ghcr.io/kagenti/lab-context-engineering:latest - # Run our components: reduce + cache (preset balanced) AND the cheap-model extractor. - # The extractor calls claude-haiku-4-5 back THROUGH the gateway's /anthropic route - # using the same sk-proxy credential the agent holds (x-api-key) — no real key here, - # no new egress. Summarize/truncate are intentionally NOT enabled for these runs. - command: - - "proxy" - - "--addr" - - ":8080" - - "--preset" - - "balanced" - - "--upstream" - - "http://gateway:4000" - - "--extract-model" - - "claude-haiku-4-5" - - "--extract-base" - - "http://gateway:4000/anthropic" - - "--extract-auth" - - "x-api-key" - environment: - LABCX_EXTRACT_KEY: sk-proxy - networks: - - internal - - runner: - environment: - # Route the agent through lab-cx; it forwards the (full, prefixed) path to the - # gateway. Suffix routing means /anthropic/v1/messages etc. are still reduced. - ANTHROPIC_BASE_URL: http://lab-cx:8080/anthropic - OPENAI_BASE_URL: http://lab-cx:8080/openai/v1 - GOOGLE_GEMINI_BASE_URL: http://lab-cx:8080/genai - depends_on: - - lab-cx diff --git a/deploy/eval-containers/health b/deploy/eval-containers/health new file mode 100755 index 0000000..a1f9005 --- /dev/null +++ b/deploy/eval-containers/health @@ -0,0 +1,3 @@ +#!/bin/sh +# Readiness probe: 0 when the proxy answers /healthz. +exec curl -sf -m 1 "http://127.0.0.1:${PORT:-4000}/healthz" >/dev/null diff --git a/deploy/eval-containers/start b/deploy/eval-containers/start new file mode 100755 index 0000000..5ab8437 --- /dev/null +++ b/deploy/eval-containers/start @@ -0,0 +1,48 @@ +#!/bin/sh +# eval-containers gateway entrypoint for context-guru-proxy. +# +# Contract (see eval-containers .agents/gateways/RULES.md): parse +# EVAL_MODEL=/; the model pins every call (FORCE_MODEL) and the +# provider selects the upstream. Provider auth is read from the provider's +# native env var (OPENAI_API_KEY / ANTHROPIC_API_KEY) — the proxy injects it, +# replacing the agent's placeholder sk-proxy. Listens on :4000, exposing +# /openai/v1/chat/completions and /anthropic/v1/messages. +set -eu + +: "${EVAL_MODEL:?EVAL_MODEL must be set to /}" +case "$EVAL_MODEL" in + */*) PROVIDER=${EVAL_MODEL%%/*}; MODEL_NAME=${EVAL_MODEL#*/} ;; + *) echo "EVAL_MODEL must be of form / (got: $EVAL_MODEL)" >&2; exit 2 ;; +esac +export FORCE_MODEL="$MODEL_NAME" + +# Provider -> upstream base URL (root, no /v1 suffix; the proxy appends the +# canonical provider path). OPENAI_API_BASE may include /v1 — strip it. +case "$PROVIDER" in + openai) + base="${OPENAI_API_BASE:-https://api.openai.com}" + export OPENAI_UPSTREAM="${base%/v1}" + ;; + anthropic) + export ANTHROPIC_UPSTREAM="${ANTHROPIC_API_BASE:-https://api.anthropic.com}" + ;; + *) + echo "unsupported provider for context-guru gateway: $PROVIDER" >&2 + exit 2 + ;; +esac + +export LISTEN_ADDR="${LISTEN_ADDR:-:${PORT:-4000}}" + +# Pipeline selection: CONTEXT_GURU_PIPELINE (comma-separated component names) +# wins — it renders a one-line config so a sweep can pin any component combo +# (including a single component, or an empty list for a passthrough baseline) +# without new presets. Otherwise fall back to a named preset. +# Non-empty CONTEXT_GURU_PIPELINE wins; an empty/unset value falls through to the +# named preset (so an always-present-but-empty env var from compose doesn't force +# passthrough — baseline uses CONTEXT_GURU_PRESET=off instead). +if [ -n "${CONTEXT_GURU_PIPELINE:-}" ]; then + printf 'pipeline: [%s]\n' "${CONTEXT_GURU_PIPELINE}" > /tmp/cg-config.yaml + exec /opt/gateway/main --config /tmp/cg-config.yaml +fi +exec /opt/gateway/main --preset "${CONTEXT_GURU_PRESET:-balanced}" diff --git a/deploy/eval-containers/sweep.py b/deploy/eval-containers/sweep.py new file mode 100755 index 0000000..44b192b --- /dev/null +++ b/deploy/eval-containers/sweep.py @@ -0,0 +1,205 @@ +#!/usr/bin/env python3 +"""Resumable SWE-bench sweep: baseline vs context-guru (per-component + combined) +vs competitors, through the eval-containers compose stack against the IBM litellm +upstream (claude-sonnet-4-6). + +One "cell" = (task, config). Each cell runs the eval-containers stack with the +right gateway image / preset / agent, waits for the runner to exit, then records +reward + wall-clock (from the output volume) and context-guru's own token-savings +(from the gateway /stats) into a CSV. Resumable: cells already in the CSV are +skipped, so it can be re-run/continued freely. + +Config selector grammar (col 3): + cg:off our gateway, empty pipeline (passthrough baseline) + cg: our gateway, CONTEXT_GURU_PIPELINE=a,b,c + cg:preset=balanced our gateway, a named preset + headroom headroom gateway (wired via a separate override) + +Usage: + sweep.py --tasks t1 t2 ... (defaults to the built-in 10) + sweep.py --only baseline cg-dedup (subset of configs, e.g. for validation) + sweep.py --one-task t1 (run every config on a single task) +""" +import argparse, csv, json, os, subprocess, sys, time + +ROOT = "/Users/osherelhadad/Documents/context-engineering" +SWE = f"{ROOT}/eval-containers/containers/benchmarks/swe-bench" +DEPLOY = f"{ROOT}/lab-context-engineering/deploy/eval-containers" +CG_OVERRIDE = f"{DEPLOY}/compose.contextguru.yaml" +HEADROOM_OVERRIDE = f"{DEPLOY}/compose.headroom.yaml" +RESULTS = f"{DEPLOY}/sweep-results.csv" +POLL_SECS, POLL_MAX = 15, 320 # up to ~80 min/cell backstop + +TASKS = [ + "sympy__sympy-13647", "sympy__sympy-16766", "sympy__sympy-20438", + "sphinx-doc__sphinx-7910", "sphinx-doc__sphinx-9320", + "scikit-learn__scikit-learn-12973", "scikit-learn__scikit-learn-25931", + "pydata__xarray-4629", "django__django-11820", "django__django-14089", +] +# name, agent, selector. Every context-guru component is run ALONE (default +# config, no task-specific tuning) so we can see which actually fire on real +# Claude Code traffic, plus the combined preset and competitors. +CONFIGS = [ + ("baseline", "claude-code", "cg:off"), + # lossless / provably-unneeded + ("cg-format", "claude-code", "cg:format"), + ("cg-dedup", "claude-code", "cg:dedup"), + ("cg-cmdfilter", "claude-code", "cg:cmdfilter"), + ("cg-cacheinject", "claude-code", "cg:cacheinject"), + ("cg-failed_run", "claude-code", "cg:failed_run"), + # lossy offloaders (run without expand yet — measures savings AND reward impact) + ("cg-skeleton", "claude-code", "cg:skeleton"), + ("cg-collapse", "claude-code", "cg:collapse"), + ("cg-mask", "claude-code", "cg:mask"), + ("cg-smartcrush", "claude-code", "cg:smartcrush"), + ("cg-extract", "claude-code", "cg:extract"), + ("cg-phi_evict", "claude-code", "cg:phi_evict"), + # combined + competitors + ("cg-balanced", "claude-code", "cg:preset=balanced"), + ("rtk", "claude-code-rtk", "cg:off"), + ("headroom", "claude-code", "headroom"), +] +FIELDS = ["task", "config", "agent", "reward", "passed", "wall_s", + "gw_requests", "gw_before", "gw_after", "gw_saved", "gw_pct", "note"] + + +def creds(): + s = json.load(open(os.path.expanduser("~/.claude/settings.json")))["env"] + return s["ANTHROPIC_BASE_URL"], s["ANTHROPIC_AUTH_TOKEN"] + + +def sh(cmd, **kw): + return subprocess.run(cmd, shell=True, capture_output=True, text=True, **kw) + + +def done_cells(): + if not os.path.exists(RESULTS): + return set() + with open(RESULTS) as f: + return {(r["task"], r["config"]) for r in csv.DictReader(f)} + + +def append_row(row): + new = not os.path.exists(RESULTS) + with open(RESULTS, "a", newline="") as f: + w = csv.DictWriter(f, fieldnames=FIELDS) + if new: + w.writeheader() + w.writerow(row) + + +def vol_json(proj, path): + r = sh(f"docker run --rm -v {proj}_output:/o alpine cat /o/{path} 2>/dev/null") + try: + return json.loads(r.stdout) + except Exception: + return {} + + +CG_IMAGE = "context-guru:local" +CG_TAR = "/tmp/context-guru-local.tar" + + +def ensure_cg_image(): + """Insurance against Rancher Desktop's disk-pressure image GC evicting our + gateway mid-sweep: keep a tar and reload it if the tag goes missing.""" + have = sh(f"docker image inspect {CG_IMAGE} >/dev/null 2>&1").returncode == 0 + if have and not os.path.exists(CG_TAR): + sh(f"docker save -o {CG_TAR} {CG_IMAGE}") + elif not have and os.path.exists(CG_TAR): + print(" (gateway image was evicted — reloading from tar)", flush=True) + sh(f"docker load -i {CG_TAR}") + + +def run_cell(task, name, agent, selector, base, token): + if selector != "headroom": + ensure_cg_image() + proj = f"sw-{name}".lower().replace("_", "-") + env = dict(os.environ) + env.update( + ANTHROPIC_API_BASE=base, ANTHROPIC_API_KEY=token, + OPENAI_API_KEY="unused", OPENAI_API_BASE="http://unused.invalid/v1", + EVAL_MODEL="anthropic/claude-sonnet-4-6", EVAL_TIMEOUT="1800", + EVAL_TASK_ID=task, EVAL_AGENT=agent, EVAL_GATEWAY_LABEL=name, + ) + files = f"-f {SWE}/compose.yaml" + if selector == "headroom": + files += f" -f {HEADROOM_OVERRIDE}" + else: + files += f" -f {CG_OVERRIDE}" + if selector == "cg:off": + env["CONTEXT_GURU_PIPELINE"] = "" + elif selector.startswith("cg:preset="): + env["CONTEXT_GURU_PRESET"] = selector.split("=", 1)[1] + elif selector.startswith("cg:"): + env["CONTEXT_GURU_PIPELINE"] = selector[3:] + + sh(f"docker compose -p {proj} down -v") + up = sh(f"docker compose -p {proj} {files} up -d", cwd=SWE, env=env) + if up.returncode != 0: + note = ("up-failed: " + up.stderr.strip().replace("\n", " ")[-200:]) + return {"task": task, "config": name, "agent": agent, "note": note} + + status = "timeout" + for _ in range(POLL_MAX): + st = sh(f"docker inspect -f '{{{{.State.Status}}}}' {proj}-runner-1").stdout.strip() + if st == "exited": + status = "exited" + break + time.sleep(POLL_SECS) + + task_res = vol_json(proj, "task/result.json") + ag = vol_json(proj, "agent/result.json") + stats_raw = sh(f"docker exec {proj}-gateway-1 sh -c 'curl -s localhost:4000/stats || wget -qO- localhost:4000/stats' 2>/dev/null").stdout + try: + stats = json.loads(stats_raw) + except Exception: + stats = {} + wall = "" + try: + from datetime import datetime + fmt = "%Y-%m-%dT%H:%M:%SZ" + wall = int((datetime.strptime(ag["ended_at"], fmt) - datetime.strptime(ag["started_at"], fmt)).total_seconds()) + except Exception: + pass + row = { + "task": task, "config": name, "agent": agent, + "reward": task_res.get("reward", ""), "passed": task_res.get("passed", ""), + "wall_s": wall, "gw_requests": stats.get("requests", ""), + "gw_before": stats.get("tokens_before", ""), "gw_after": stats.get("tokens_after", ""), + "gw_saved": stats.get("saved_tokens", ""), "gw_pct": stats.get("savings_pct", ""), + "note": status if task_res else status + "/no-result", + } + sh(f"docker compose -p {proj} down -v") + return row + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--tasks", nargs="*", default=TASKS) + ap.add_argument("--only", nargs="*", default=None, help="config names to include") + ap.add_argument("--one-task", default=None) + a = ap.parse_args() + tasks = [a.one_task] if a.one_task else a.tasks + configs = [c for c in CONFIGS if (a.only is None or c[0] in a.only)] + base, token = creds() + done = done_cells() + total = len(tasks) * len(configs) + n = 0 + for task in tasks: + for name, agent, sel in configs: + n += 1 + if (task, name) in done: + print(f"[{n}/{total}] skip {task}/{name} (done)", flush=True) + continue + print(f"[{n}/{total}] RUN {task}/{name} ({agent}, {sel})", flush=True) + t0 = time.time() + row = run_cell(task, name, agent, sel, base, token) + append_row(row) + print(f" -> reward={row.get('reward')} wall={row.get('wall_s')} " + f"gw_saved={row.get('gw_saved')} note={row.get('note')} " + f"({int(time.time()-t0)}s cell)", flush=True) + + +if __name__ == "__main__": + main() diff --git a/docs/RESULTS-extract.md b/docs/RESULTS-extract.md deleted file mode 100644 index f32b6a8..0000000 --- a/docs/RESULTS-extract.md +++ /dev/null @@ -1,89 +0,0 @@ -# Real cheap-model extractor results (ONLINE, claude-haiku-4-5) - -- **Date measured:** 2026-06-24 -- **Mode:** ONLINE. The cheap-model **Extract** stage is **ON** for every row. Each row is - the result of a REAL call to a live model gateway — there is no mock and no canned - output anywhere in this measurement. -- **Model:** `claude-haiku-4-5`, served via an Anthropic-compatible LiteLLM gateway - (`ANTHROPIC_BASE_URL`), bearer-authenticated (`cheapmodel.Anthropic{AuthScheme: "bearer"}`). - The token is never printed or committed. -- **`LLMCompactFloor` used:** **200** tokens. The production default is 3000. The committed - structured fixtures are only a few hundred tokens each, so the floor is lowered to 200 - **purely for this measurement** so that extraction actually fires on the corpus. A - fixture under 200 tokens is below the floor and never becomes an extraction candidate — - it shows up honestly as `strategy=none`, `0% saved`. This is a measurement-only knob and - changes no default. -- **`ProtectRecent`:** 1 (the goal turn is the only protected trailing turn; the - `tool_result` sits outside the protect window so it is eligible). -- **Latency:** `latency_ms` is **wall-clock of the full `engine.Transform`, including the - real network round-trip to the gateway model** (plus, for the `code` strategy, local - Starlark execution of the model-written filter). It is therefore dominated by network + - inference time, not engine overhead. -- **Tokenizer:** `o200k_base` via `internal/tokens` — the same counter the engine uses to - gate reductions. -- **Fixtures:** the large/structured corpus only — the `structured_json/` and - `search_results/` record-shaped fixtures under `testdata/fixtures/`. Each is surfaced as - a `tool_result` paired with a realistic recent goal that names specific records (so the - keep-set the model filters toward is concrete and realistic). Provenance: - `testdata/fixtures/README.md`. - -## Reproduce - -```sh -# from the repo root; loads ANTHROPIC_BASE_URL + ANTHROPIC_AUTH_TOKEN + the haiku model id -source /tmp/lcx_env.sh -CGO_ENABLED=1 go run ./cmd/labcx-bench --extract # the table below -CGO_ENABLED=1 go run ./cmd/labcx-bench --extract --json # machine-readable rows -``` - -`--extract` requires `ANTHROPIC_BASE_URL` and `ANTHROPIC_AUTH_TOKEN` in the environment; -it fails fast if they are unset. Without `--extract` the harness stays fully offline and -deterministic (see `RESULTS-offline.md`) — no model is called. - -## Results - -These are the exact rows printed by the command above on 2026-06-24. They are not edited. -Because they come from a live model, exact `%saved`, `strategy`, and `latency_ms` will vary -run to run (the model is non-deterministic); the qualitative outcome — which fixtures shrink -and which decline — is stable. - -| fixture | tokens_before | tokens_after | %saved | strategy | contained | latency_ms | model | -| --- | ---: | ---: | ---: | :---: | :---: | ---: | :--- | -| flights_search | 607 | 122 | 79.90 | code | yes | 1921 | claude-haiku-4-5 | -| users_directory | 194 | 194 | 0.00 | none | no | 1 | claude-haiku-4-5 | -| products_inventory | 254 | 93 | 63.39 | code | yes | 2492 | claude-haiku-4-5 | -| oc_pods_slice | 811 | 258 | 68.19 | single | yes | 8533 | claude-haiku-4-5 | -| glab_issue_list | 2106 | 2106 | 0.00 | none | no | 3138 | claude-haiku-4-5 | -| glab_mr_list | 2934 | 1270 | 56.71 | deterministic | yes | 8081 | claude-haiku-4-5 | - -## Analysis (honest) - -**Shrank (4 of 6).** The extractor reduced four fixtures, each via a different strategy: - -- `flights_search` **−79.9%** and `products_inventory` **−63.4%** via the **code** strategy - — the model wrote a Starlark filter that ran locally over the *full* body and kept only - the records named in the goal (FL003/FL004; SKU0001/SKU0004). -- `oc_pods_slice` **−68.2%** via the **single** strategy — a one-shot JSON-return filter - keeping the pod(s) the goal asked about. -- `glab_mr_list` **−56.7%** via the **deterministic** strategy — the model strategies did - not beat the deterministic projection here, so the ordered fallback (a deterministic, - keep-set-driven projection) won. This is still a real run: the model strategies were - attempted first and declined, and the safe deterministic fallback produced the kept set. - -**Declined (2 of 6) — both shown honestly as `strategy=none`, `0% saved`:** - -- `users_directory` (194 tokens) is **below the 200-token floor**, so it never became an - extraction candidate. The 1 ms latency reflects that no model call was made. This is the - floor working as designed, not a model failure. -- `glab_issue_list` (2106 tokens) **did** qualify (above the floor) and the model was - called, but no strategy produced a result that was both strictly smaller AND passed the - containment/validation gate, so the engine kept the original untouched. A decline is a - valid, **safe** outcome: fail-open means the original is forwarded unchanged. - -**Containment / reversibility.** Every spliced result is marked `contained=yes`, verified -by the harness via the public `engine.FindMarkers` + `engine.Expand` round-trip: each -spliced block carries a recovery marker whose stored original is a substring of the -pre-extraction body. So every reduction here is **lossless and reversible** — the engine -only splices a result it can fully recover from its rewind store, and only when the result -is strictly smaller (it never inflates). Declined fixtures show `contained=no` simply -because nothing was spliced (the original is intact). diff --git a/docs/RESULTS-offline.md b/docs/RESULTS-offline.md deleted file mode 100644 index acfcaff..0000000 --- a/docs/RESULTS-offline.md +++ /dev/null @@ -1,144 +0,0 @@ -# Offline measurement results (deterministic, no model) - -- **Date measured:** 2026-06-24 -- **Mode:** OFFLINE / deterministic only. The cheap-model Extract stage is **OFF** for - every row here; nothing in this document calls a model. Token reduction comes entirely - from the deterministic components (command-output filter, format re-encoder, code - skeletonizer, reversible collapse) and the full deterministic pipeline. -- **Tokenizer:** `o200k_base` (the modern GPT-family BPE), via `internal/tokens` — the - same counter the engine uses to gate reductions. Counts are real BPE token counts, not - a chars/4 estimate. -- **Fixtures:** 10 real tool outputs committed under `testdata/fixtures/` (rtk command - outputs + reference-prototype structured tool outputs). Provenance for every file is in - `testdata/fixtures/README.md`. - -## Reproduce - -```sh -# from the repo root -CGO_ENABLED=1 go run ./cmd/labcx-bench # the table below -CGO_ENABLED=1 go run ./cmd/labcx-bench --json # machine-readable rows -``` - -The harness wraps each fixture as a canonical Anthropic-shaped request (a `tool_result` -block, paired with the `tool_use` that produced it so command/file-aware passes can see -the command or path), places it outside the protect-recent window, and runs the engine -with **one component enabled at a time** plus the **full deterministic pipeline**. For -every row it verifies the reduction is **reversible** — either it is a no-op, or the -original is recoverable from the rewind store via `engine.FindMarkers` + `engine.Expand`. - -## Measured table (verbatim harness output) - -The numbers below are pasted directly from `go run ./cmd/labcx-bench`. Rows tagged -`(no-op)` are components that did not apply to that fixture (e.g. the skeletonizer on a -JSON array); they are kept in the table to show the fail-safe — a component never inflates -a fixture it does not understand. - -| fixture | component | tokens_before | tokens_after | %saved | bytes_before | bytes_after | reversible | added_latency_ms | -| --- | --- | ---: | ---: | ---: | ---: | ---: | :---: | ---: | -| pytest_failures | cmdfilter (no-op) | 94 | 94 | 0.00 | 324 | 324 | yes | 13.065 | -| pytest_failures | format_toon (no-op) | 94 | 94 | 0.00 | 324 | 324 | yes | 0.625 | -| pytest_failures | format_best (no-op) | 94 | 94 | 0.00 | 324 | 324 | yes | 0.615 | -| pytest_failures | skeleton (no-op) | 94 | 94 | 0.00 | 324 | 324 | yes | 0.521 | -| pytest_failures | collapse (no-op) | 94 | 94 | 0.00 | 324 | 324 | yes | 0.520 | -| pytest_failures | pipeline_full (no-op) | 94 | 94 | 0.00 | 324 | 324 | yes | 0.979 | -| cargo_test_failure | cmdfilter (no-op) | 95 | 95 | 0.00 | 292 | 292 | yes | 0.983 | -| cargo_test_failure | format_toon (no-op) | 95 | 95 | 0.00 | 292 | 292 | yes | 0.569 | -| cargo_test_failure | format_best (no-op) | 95 | 95 | 0.00 | 292 | 292 | yes | 0.568 | -| cargo_test_failure | skeleton (no-op) | 95 | 95 | 0.00 | 292 | 292 | yes | 0.500 | -| cargo_test_failure | collapse (no-op) | 95 | 95 | 0.00 | 292 | 292 | yes | 0.484 | -| cargo_test_failure | pipeline_full (no-op) | 95 | 95 | 0.00 | 292 | 292 | yes | 0.949 | -| cargo_build | cmdfilter | 2687 | 155 | 94.23 | 6743 | 409 | yes | 11.652 | -| cargo_build | format_toon (no-op) | 2687 | 2687 | 0.00 | 6743 | 6743 | yes | 7.597 | -| cargo_build | format_best (no-op) | 2687 | 2687 | 0.00 | 6743 | 6743 | yes | 7.866 | -| cargo_build | skeleton (no-op) | 2687 | 2687 | 0.00 | 6743 | 6743 | yes | 7.736 | -| cargo_build | collapse | 2687 | 54 | 97.99 | 6743 | 133 | yes | 7.667 | -| cargo_build | pipeline_full | 2687 | 155 | 94.23 | 6743 | 409 | yes | 10.966 | -| flights_search | cmdfilter (no-op) | 607 | 607 | 0.00 | 1356 | 1356 | yes | 2.349 | -| flights_search | format_toon | 607 | 291 | 52.06 | 1356 | 535 | yes | 3.353 | -| flights_search | format_best | 607 | 263 | 56.67 | 1356 | 504 | yes | 4.178 | -| flights_search | skeleton (no-op) | 607 | 607 | 0.00 | 1356 | 1356 | yes | 2.588 | -| flights_search | collapse | 607 | 55 | 90.94 | 1356 | 133 | yes | 2.282 | -| flights_search | pipeline_full | 607 | 55 | 90.94 | 1356 | 133 | yes | 2.285 | -| users_directory | cmdfilter (no-op) | 194 | 194 | 0.00 | 533 | 533 | yes | 0.855 | -| users_directory | format_toon | 194 | 140 | 27.84 | 533 | 382 | yes | 1.228 | -| users_directory | format_best | 194 | 124 | 36.08 | 533 | 362 | yes | 1.577 | -| users_directory | skeleton (no-op) | 194 | 194 | 0.00 | 533 | 533 | yes | 0.860 | -| users_directory | collapse (no-op) | 194 | 194 | 0.00 | 533 | 533 | yes | 0.831 | -| users_directory | pipeline_full | 194 | 124 | 36.08 | 533 | 362 | yes | 2.756 | -| products_inventory | cmdfilter (no-op) | 254 | 254 | 0.00 | 643 | 643 | yes | 1.140 | -| products_inventory | format_toon | 254 | 147 | 42.13 | 643 | 345 | yes | 1.677 | -| products_inventory | format_best | 254 | 136 | 46.46 | 643 | 323 | yes | 1.998 | -| products_inventory | skeleton (no-op) | 254 | 254 | 0.00 | 643 | 643 | yes | 1.097 | -| products_inventory | collapse | 254 | 50 | 80.31 | 643 | 133 | yes | 1.124 | -| products_inventory | pipeline_full | 254 | 50 | 80.31 | 643 | 133 | yes | 1.150 | -| oc_pods_slice | cmdfilter (no-op) | 811 | 811 | 0.00 | 3216 | 3216 | yes | 3.836 | -| oc_pods_slice | format_toon | 811 | 511 | 36.99 | 3216 | 1791 | yes | 5.226 | -| oc_pods_slice | format_best | 811 | 511 | 36.99 | 3216 | 1791 | yes | 5.721 | -| oc_pods_slice | skeleton (no-op) | 811 | 811 | 0.00 | 3216 | 3216 | yes | 3.837 | -| oc_pods_slice | collapse | 811 | 50 | 93.83 | 3216 | 133 | yes | 3.208 | -| oc_pods_slice | pipeline_full | 811 | 50 | 93.83 | 3216 | 133 | yes | 3.556 | -| glab_issue_list | cmdfilter (no-op) | 2106 | 2106 | 0.00 | 6748 | 6748 | yes | 8.076 | -| glab_issue_list | format_toon | 2106 | 1992 | 5.41 | 6748 | 6482 | yes | 14.328 | -| glab_issue_list | format_best | 2106 | 1799 | 14.58 | 6748 | 6180 | yes | 17.478 | -| glab_issue_list | skeleton (no-op) | 2106 | 2106 | 0.00 | 6748 | 6748 | yes | 7.020 | -| glab_issue_list | collapse | 2106 | 54 | 97.44 | 6748 | 133 | yes | 6.747 | -| glab_issue_list | pipeline_full | 2106 | 54 | 97.44 | 6748 | 133 | yes | 7.337 | -| glab_mr_list | cmdfilter (no-op) | 2934 | 2934 | 0.00 | 9463 | 9463 | yes | 10.454 | -| glab_mr_list | format_toon | 2934 | 2713 | 7.53 | 9463 | 9004 | yes | 19.472 | -| glab_mr_list | format_best | 2934 | 2417 | 17.62 | 9463 | 8483 | yes | 22.569 | -| glab_mr_list | skeleton (no-op) | 2934 | 2934 | 0.00 | 9463 | 9463 | yes | 9.973 | -| glab_mr_list | collapse | 2934 | 50 | 98.30 | 9463 | 133 | yes | 9.687 | -| glab_mr_list | pipeline_full | 2934 | 50 | 98.30 | 9463 | 133 | yes | 9.715 | -| runner_py | cmdfilter (no-op) | 1401 | 1401 | 0.00 | 5399 | 5399 | yes | 7.418 | -| runner_py | format_toon (no-op) | 1401 | 1401 | 0.00 | 5399 | 5399 | yes | 6.184 | -| runner_py | format_best (no-op) | 1401 | 1401 | 0.00 | 5399 | 5399 | yes | 6.681 | -| runner_py | skeleton | 1401 | 303 | 78.37 | 5399 | 1099 | yes | 8.803 | -| runner_py | collapse | 1401 | 59 | 95.79 | 5399 | 162 | yes | 6.218 | -| runner_py | pipeline_full | 1401 | 59 | 95.79 | 5399 | 162 | yes | 6.271 | - -(The ~13 ms on the first row is the one-time lazy initialization of the BPE tokenizer -amortized into that measurement; steady-state per-fixture latency is sub-millisecond to a -few milliseconds, scaling with input size.) - -## What this shows - -Each deterministic component reduces tokens on exactly the fixture types it targets, and -is a safe no-op everywhere else: - -- **Command-output filter (`cmdfilter`)** is the biggest single-component win on verbose - CLI noise: on a real `cargo build` of rtk's 203-crate dependency graph it cut **94.2%** - of tokens (2687 → 155) by dropping the `Compiling …` chatter while keeping the - `Finished` line — reversibly. It is a deliberate **no-op** on the two small pytest/cargo - fixtures: those are mostly failure/summary signal already, and below the size where the - recovery-marker overhead is worth paying, so the filter declines to touch them (it never - inflates an output). - -- **Format re-encoder** is the right tool for structured tool/MCP outputs. On flat - uniform record arrays (`flights_search`, `users_directory`, `products_inventory`) the - best-encoding mode saved **36–57%** (TOON alone **28–52%**) by switching JSON to a - delimited/TOON table — fully lossless, the data is identical. On nested record arrays - (`glab_issue_list`, `glab_mr_list`, `oc_pods_slice`) the savings are smaller - (**15–37%**) because the nesting limits the table encoders, but it still helps and never - hurts. - -- **Code skeletonizer** targets file reads: on the real Python source `runner.py` it - dropped function bodies for **78.4%** token savings, keeping every signature. - -- **Reversible collapse** is the universal fallback for an *unused* tool output (one whose - content is not referenced later): it replaces the body with a recovery marker for - **80–98%** savings. It is the strongest per-fixture reducer but the most aggressive — it - hides content behind a marker rather than re-expressing it — so the pipeline prefers a - signal-preserving reducer when one applies (see below). - -- **Full deterministic pipeline** combines them in the engine's real order. Across all 10 - fixtures it took **11,183 → 786 tokens, a 93.0% reduction**, all reversible. Note the - pipeline does **not** simply take the max of the columns: on `cargo_build` it reports - 94.2% (the cmdfilter result, 155 tokens) rather than collapse's 98.0% (54 tokens), - because cmdfilter runs first as a pre-pass and keeps the failure/summary signal a model - would actually need — the engine intentionally trades a few tokens for fidelity. - -The headline: **cmdfilter −94% on verbose command output, format re-encode −35% (best) / -−29% (TOON) on structured outputs, skeleton −78% on a code read, and the full -deterministic pipeline −93% aggregate across 10 real fixtures — every reduction -reversible, with no model in the loop.** diff --git a/docs/RESULTS.md b/docs/RESULTS.md deleted file mode 100644 index ca112c3..0000000 --- a/docs/RESULTS.md +++ /dev/null @@ -1,73 +0,0 @@ -# Results index - -Every number here is real and reproducible — links to the source docs that hold the -verbatim harness output. No numbers are invented; this page only indexes them. - -## Offline deterministic components - -- **Headline:** `cmdfilter` **−94%** on verbose command output, `format` re-encode - **−35%** best / **−29%** TOON on structured output, `skeleton` **−78%** on a code read, - full deterministic pipeline **−93%** aggregate across 10 real fixtures — every reduction - reversible, no model in the loop. -- **Date:** 2026-06-24. **How produced:** `CGO_ENABLED=1 go run ./cmd/labcx-bench` over - the committed `testdata/fixtures/` corpus, `o200k_base` token counts, reversibility - verified per row. -- **Full table + analysis:** [RESULTS-offline.md](RESULTS-offline.md). - -## Cheap-model extractor (online) - -- **Headline:** `claude-haiku-4-5` extractor reduced **4 of 6** structured fixtures - **−56%…−80%** (via `code`/`single`/`deterministic` strategies), with **2 honest - declines** shown as `strategy=none`; every spliced result is `contained=yes` (lossless + - reversible). -- **Date:** 2026-06-24. **How produced:** `CGO_ENABLED=1 go run ./cmd/labcx-bench - --extract` against a live Anthropic-compatible gateway (`LLMCompactFloor=200` for the - small fixtures), real network round-trips, no mocks. -- **Full table + honest analysis:** [RESULTS-extract.md](RESULTS-extract.md). - -## Claude Code integration (real runs) - -- **Headline (real savings):** on a large-file task with `reduce_cached_prefix`, **−50.2% - tokens** (69,683 → 34,729; 34,954 saved; ~$0.035), and the answer was **correct** (42 - top-level funcs — exact — plus an accurate summary). `skeleton` dropped function bodies - while keeping signatures, so the task was preserved → real savings, **no harm**. -- **Headline (trivial task):** `cache_injected: 0` / `tokens_saved: 0` — **do-no-harm on a - self-caching client, as designed** (lab-cx stands down rather than fight Claude Code's - own `cache_control`). -- **Date:** 2026-06-24. **How produced:** `claude -p` routed through `lab-cx proxy` via a - settings file, reading `/stats` (`scripts/cc-demo.sh` for the trivial run). -- **Detail:** [integration/claude-code.md](integration/claude-code.md). - -## Bob (IBM Bob Shell) integration (real run) - -- **Headline:** **5/5 verifiable tasks correct** through the proxy (incl. a file edit and - a 300-record JSON analysis); `cache_injected` on **every** request (Bob is not - self-caching, so the cache lever fires — the one Claude Code declines); 0 stage errors, - ~3ms p50 added latency. Content reduction was 0 because Bob condensed files itself; - content-reduction value is shown by the component results above and the Claude Code - large-file run. -- **Date:** 2026-06-24. **How produced:** real `bob` CLI with `CUSTOM_BASE_URL` → proxy → - Bob backend; `/stats` after a 3-task suite. -- **Detail:** [integration/bob.md](integration/bob.md). - -## SWE-bench / eval-containers integration (real runs) - -- **Headline:** **10 real SWE-bench Verified tasks** (Claude Code agent → `lab-cx` → - gateway, `claude-sonnet-4-6` agent / `claude-haiku-4-5` extractor) reduced the agent's - input traffic by **−35.5% aggregate** (4,405,821 → 2,840,296 tokens; **1,565,525 saved** - over 263 requests), ranging **−10.4%…−51.8%** per task — entirely from the deterministic - reducers (collapse/skeleton/format/cmdfilter, **1,598 blocks reduced**) running on Claude - Code's self-cached prefix via `--reduce-cached-prefix`. 0 stage errors; p95 added latency - ~130–250 ms. -- **Caveats (honest):** the LLM extractor did not fire on these astropy tasks - (`extract_candidates=0` — no large *structured* tool outputs; its value is shown by the - extractor results above). Resolve status is reported per task, but Epoch AI marks the - **arm64** SWE-bench bases (used here for native Apple-Silicon runs) best-effort/untested - for grading, so `not resolved` rows may understate accuracy; **token reduction is - architecture-independent and valid**. `--reduce-cached-prefix` trades the client's - prompt-cache hits for these reductions (the deliberate choice for self-caching agents). -- **Date:** 2026-06-25. **How produced:** per-task eval images built locally - (`combination.Dockerfile` over the public Epoch base + the claude-code agent), run via - the eval-containers compose with [`deploy/eval-containers/compose.override.yaml`](../deploy/eval-containers/compose.override.yaml); - per-task reduction read from `lab-cx`'s `/stats`, resolve status from `result.json`. -- **Detail + per-task table + runbook:** [integration/swe-bench.md](integration/swe-bench.md). diff --git a/docs/RUNNING.md b/docs/RUNNING.md deleted file mode 100644 index 578d174..0000000 --- a/docs/RUNNING.md +++ /dev/null @@ -1,154 +0,0 @@ -# Running and measuring lab-cx - -How to run each component and how to measure it. Every command here is real against the -current flags/targets. CGO is required throughout (`CGO_ENABLED=1`) — the skeletonizer -uses tree-sitter via cgo. - -## Build - -```sh -make build # ./bin/lab-cx (exports CGO_ENABLED=1) -./bin/lab-cx version -``` - -## Run the proxy - -```sh -# preset mode (default :8080), routes by provider -./bin/lab-cx proxy --preset balanced - -# config mode, forwarding all traffic to one upstream gateway, extractor on -./bin/lab-cx proxy --config configs/lab-cx.yaml \ - --upstream https://gateway.example/v1 \ - --extract-model claude-haiku-4-5 --extract-provider anthropic --extract-auth bearer \ - --extract-base https://gateway.example/v1 -``` - -Point an agent at it: - -```sh -ANTHROPIC_BASE_URL=http://localhost:8080 # Anthropic-wire agents -OPENAI_BASE_URL=http://localhost:8080/v1 # OpenAI-wire agents -``` - -Flags: `--addr`, `--preset` (`safe|balanced|aggressive|cache|coding|mcp`), `--config`, -`--upstream`, `--extract-model/-provider/-auth/-base`, -`--summarize-model/-provider/-auth/-base`, `--reduce-cached-prefix` (run the reducers + -extractor on a self-caching client's cached prefix instead of deferring to its cache — -see below), `--max-body-bytes` (default 33554432 = 32 MiB; `0` = no cap), -`--upstream-timeout` (default `0s`; non-zero caps the whole request incl. streamed -responses). - -Self-caching agents (Claude Code): by default lab-cx defers to the client's own -`cache_control` (the cache stage stands down) and reduces little. Pass -`--reduce-cached-prefix` (or `reduce.reduce_cached_prefix: true`) to run the deterministic -reducers and the LLM extractor on the cached prefix too — real token reduction at the cost -of invalidating the client's cached prefix. - -Model credentials: an inline `api_key` (or `key_env`) in the config wins; else the -compactor's env var (`LABCX_EXTRACT_KEY` / `LABCX_SUMMARIZE_KEY`); else the provider -default (`ANTHROPIC_API_KEY` / `ANTHROPIC_AUTH_TOKEN`, or `OPENAI_API_KEY`). Set -`source: incoming` to reuse the proxied request's own model + credentials instead. - -Disable / bypass: - -- `LABCX_DISABLE=1` — transparent passthrough for the whole process. -- `x-labcx-bypass: true` header — skip reduction for one request. - -## Read /stats - -```sh -curl -s http://localhost:8080/stats # raw JSON -./bin/lab-cx stats # JSON + one-line summary (default addr) -./bin/lab-cx stats --addr http://localhost:8080 -``` - -`/stats` is comprehensive — global totals, a per-session breakdown, and a recent-per-call -ring: - -- **Global:** `requests`, `tokens_before/after/saved`, `reduction_ratio`, `cache_injected`, - `extracted`, `reduced_blocks`, `extract_candidates`, `stage_errors`, `cost_saved_usd`, - `sessions`, and a `latency` object (`p50_ms`, `p95_ms`, `p99_ms`, `max_ms`, `mean_ms`; - the flat `added_latency_p50_ms`/`p95_ms` are kept for compatibility). -- **`session_stats[]`** — per conversation: the same token/cache/extract/reduced metrics, - `cost_saved_usd`, `tools_total`, `tool_def_tokens`, `model`, `surface`, and its own - `latency` distribution. -- **`recent_calls[]`** (last 256) — every metric for each call: tokens + ratio, - `cache_injected`, `extracted`, `reduced_blocks`, `extract_candidates`, `frozen_messages`, - `rehydrated`, `at_compaction`, `session_id`, `request_model`, and `added_latency_ms`. - -The same per-call fields are emitted as a structured slog event per request -(`gen_ai.*` + `context_engineering.*`). Recover an omitted block: `GET /labcx/expand?id=`. - -## Measure: offline deterministic harness - -No model in the loop — proves each deterministic component on real fixtures under -`testdata/fixtures/`. Token counts use the same `o200k_base` BPE counter the engine uses. - -```sh -CGO_ENABLED=1 go run ./cmd/labcx-bench # human-readable Markdown table -CGO_ENABLED=1 go run ./cmd/labcx-bench --json # machine-readable rows -``` - -It wraps each fixture as a canonical Anthropic-shaped request, runs the engine with one -component enabled at a time plus the full deterministic pipeline, and verifies every row -is reversible. Recorded run: [RESULTS-offline.md](RESULTS-offline.md). - -## Measure: cheap-model extractor harness (online) - -`--extract` flips the harness into ONLINE mode against a real Anthropic-compatible -gateway (`claude-haiku-4-5`, bearer auth). Requires the gateway env: - -```sh -export ANTHROPIC_BASE_URL=https://gateway.example/v1 # the model gateway -export ANTHROPIC_AUTH_TOKEN= # never printed/committed -CGO_ENABLED=1 go run ./cmd/labcx-bench --extract # Markdown table -CGO_ENABLED=1 go run ./cmd/labcx-bench --extract --json # machine-readable rows -``` - -It fails fast if `ANTHROPIC_BASE_URL` / `ANTHROPIC_AUTH_TOKEN` are unset. Recorded run: -[RESULTS-extract.md](RESULTS-extract.md). - -## Run the Claude Code demo - -```sh -ANTHROPIC_BASE_URL=https://gateway.example/v1 ANTHROPIC_AUTH_TOKEN= \ - scripts/cc-demo.sh -``` - -Starts the proxy with the extractor on, routes a real `claude -p` task through it via a -settings file (so Claude Code's `env.ANTHROPIC_BASE_URL` precedence is handled), and -prints `/stats` before and after. Detail + measured result: -[integration/claude-code.md](integration/claude-code.md). - -## Config-extension guide - -`configs/lab-cx.yaml` selects components purely by **name**. Each list folds onto the base -`preset`; an empty/omitted list means "all built-in defaults"; list order sets -priority/run order. To add a component, register it once in its registry, then list its -name in the config — no other core edit needed. - -| Component | Register it in | Then list it under | -|---|---|---| -| **Compactor** | `engine.(*Engine).Register` / `builtinCompactors` map (`engine/engine.go`) | `compactors:` | -| **Reducer** | `reduce.RegisterReducer(...)` (its `Reducer.Name`) | `reduce.reducers:` | -| **Encoder** (format re-encoder) | `allEncoders` table in `internal/reduce/actions.go` (unique name + rank) | `reduce.encoders:` (order = tie-break priority) | -| **Extract strategy** | `RunExtraction`'s switch + `rawStrategyOrder` in `internal/extract/extract.go` | `extract.strategies:` | - -Every compaction approach (`reduce`, `extract`, `summarize`, `truncate`, `cache`) -implements one `engine.Compactor` interface — given the conversation, return the -transformed conversation. Built-ins: compactors `reduce, extract, summarize, truncate, -cache` (default order `extract, reduce, cache`; summarize/truncate are opt-in); reducers -`collapse, skeleton, format` (`cmdfilter` and `dedup` are separate passes toggled by their -own keys); encoders `json_compact, toon, jsonl, markdown_kv, tsv, csv`; strategies -`code, single, rlm, deterministic`. - -Other `reduce` keys: `protect_recent` (keep N most recent turns at full fidelity), -`provable_only` (never drop merely-predicted-unused content), `cmd_filter` (bool). -`extract` and `summarize` share one LLM transport block: `provider`, `model`, `auth`, -`base`, plus credentials (`api_key` inline or `key_env` naming an env var) and `source` -(`config` = use the configured model; `incoming` = reuse the proxied request's own model + -credentials). `--extract-*` / `--summarize-*` flags override it. `extract` keys: `enabled`, -`mode` (`auto|single|rlm|deterministic`), `floor`. `summarize` keys: `enabled`, `level` -(`concise|regular|highly_detailed`), `keep_last`, `trigger_tokens`. `truncate` keys (no -model): `enabled`, `keep_last`, `trigger_tokens`. `cache` keys: `enabled`, `breakpoints`. diff --git a/docs/components.md b/docs/components.md new file mode 100644 index 0000000..abc2d40 --- /dev/null +++ b/docs/components.md @@ -0,0 +1,247 @@ +# Components + +Every registered component, exactly as it behaves in code. All operate on tool-output +messages (`role:"tool"`; for Anthropic, `tool_result` blocks normalized to that shape by +`apply`). **Reformat** = lossless. **Offload** = drops bytes, stashes the original, leaves a +`<>` marker recoverable via `context_guru_expand` / `GET /expand`. + +## Summary + +| Component | Kind | What it drops | Recoverable | Fires on | Key config (default) | +|---|---|---|---|---|---| +| `format` | Reformat | nothing (compacts JSON) | n/a (lossless) | pretty-printed JSON tool output | `min_tokens` (50) | +| `cacheinject` | Reformat | nothing (adds `cache_control`) | n/a (lossless) | Anthropic-family requests | — | +| `skeleton` | Offload | function/method bodies | via expand | fenced ` ```lang ` code blocks | `min_tokens` (80) | +| `dedup` | Offload | later byte-identical tool outputs | via expand | repeated identical outputs | `min_tokens` (100) | +| `collapse` | Offload | middle of an oversized output | via expand | any large tool output (fallback) | `max_tokens` (2000), `head_lines` (20), `tail_lines` (20) | +| `failed_run` | Offload | earlier superseded test/build runs | via expand | ≥2 run-like outputs | `min_tokens` (100) | +| `cmdfilter` | Offload | lines per declarative DSL filter | via expand | output matching a filter | `filters` ([]), `disable_builtins` (false) | +| `extract` | Offload | query-irrelevant lines | via expand | large output + a recent query | `min_tokens` (300), `head_lines`/`tail_lines` (5), `strategy` (deterministic) | +| `smartcrush` | Offload | middle items of a JSON array | via expand | JSON-array tool output | `min_items` (5), `min_tokens` (200), `keep_first` (3), `keep_last` (2) | +| `mask` | Offload | older tool outputs (age-based) | via expand | more than `keep_recent` outputs | `keep_recent` (3), `min_tokens` (100) | +| `phi_evict` | Offload | lowest-scoring outputs over budget | via expand | transcript over `budget_tokens` | `budget_tokens` (120000), `weights` (balanced) | + +Presets (`config`): `off` `[]` · `safe` `[format, cacheinject]` · `balanced` +`[format, dedup, failed_run, cmdfilter, cacheinject]` · `aggressive` adds `smartcrush, extract` · +`coding` `[format, skeleton, cmdfilter, cacheinject]` · `mcp` `[format, smartcrush, cacheinject]`. + +Common gates every Offload respects: skip non-text (`Rewritable`) messages, skip content already +carrying a marker (no double-offload), and skip if the rewrite (marker + hint included) isn't +actually smaller. + +--- + +## Reformat (lossless) + +### `format` +Re-encodes a pretty-printed JSON tool output as compact JSON — same value, fewer whitespace +tokens. Only acts on tool messages whose trimmed text starts with `{`/`[`, is valid JSON, is +≥ `min_tokens`, and gets smaller. v1 is json-compact only (a TOON encoder is planned). + +``` +before: { "id": 1, after: {"id":1,"name":"ada","tags":["x","y"]} + "name": "ada", + "tags": [ "x", "y" ] } +``` + +- **Lossiness:** none — nothing stashed. **Shines:** verbose pretty-printed JSON/MCP payloads. + **Inert:** already-compact JSON, non-JSON text, small outputs. + +### `cacheinject` +Places an Anthropic `cache_control: {type: ephemeral}` breakpoint on the last content block of +the message just **before** the newest turn (a stable prefix boundary), so the provider KV cache +hits across turns. Adds a control directive, changes no model-visible content. + +- **Lossiness:** none. **Shines:** Anthropic/Bedrock/Vertex agents that don't self-cache (the + savings lever is provider-side cache hits, invisible to `/stats` token counts). **Inert:** + non-cache-aware providers, string-content messages (can't carry a block breakpoint), a + breakpoint already present. `/stats` will list it under `top_passthrough` since it saves no + *content* tokens — that's expected, not dead weight. + +--- + +## Offload (lossy, reversible) + +### `skeleton` +Parses fenced ` ```lang ` code blocks with tree-sitter and replaces function/method/constructor +**bodies** with a placeholder, keeping signatures, imports, types, and class bodies (so method +signatures survive). Stashes the whole original message. + +```mermaid +flowchart LR + A["```go fenced block
full func bodies"] --> B{tree-sitter parse
lang known? body ≥ min_tokens?} + B -->|no| A + B -->|yes| C["signatures + { … }
+ <> marker"] + C --> D[(Store: original)] +``` + +``` +before: func Add(a, b int) int { after: func Add(a, b int) int { … } + return a + b func Sub(a, b int) int { … } + } <> [full source: call context_guru_expand] +``` + +- **Config:** `min_tokens` (80, per body). Grammars: go, python, js/ts/tsx, rust, java, c/cpp, + ruby, php, c#, kotlin, swift, scala. **Shines:** the `coding` preset — the agent reads big + source files but mostly needs the shape. **Inert:** no fenced blocks, unfenced file reads, + unknown language, skeleton not smaller than the body. + +### `dedup` +Replaces a tool output byte-identical to an earlier one in the same request with a short pointer + +marker. Exact match only (near-duplicate is deferred). + +``` +before: … (later, identical) +after: … [identical to an earlier tool output] <> +``` + +- **Config:** `min_tokens` (100). **Shines:** agents that re-read the same file/command output + repeatedly. **Inert:** no exact repeats, small outputs. + +### `collapse` +Content-agnostic fallback for an oversized tool output nothing more specific handled: keep a +`head_lines` + `tail_lines` window, stash the full original. Runs late (after cmdfilter/format); +skips content already marked. + +``` +before: <2,000-line log> +after: + ... (1960 lines omitted) <> [full output: call context_guru_expand] + +``` + +- **Config:** `max_tokens` (2000 threshold), `head_lines` (20), `tail_lines` (20). **Shines:** a + catch-all last stage for huge outputs. **Inert:** output ≤ `max_tokens`, or too few lines for + head/tail to help. + +### `failed_run` +Recognizes test/build run output (regex: `N passed/failed`, `BUILD SUCCESS/FAIL`, `Traceback`, +`FAILED`, `panic:`, `npm ERR!`, pytest session banners). Keeps the **most recent** run in full, +collapses every earlier run to a pointer + marker — a superseded run is safely recoverable. + +``` +before: [run 1] 3 failed, 5 passed … [run 2 after fix] 8 passed +after: [superseded by a later run] <> [full output: …] [run 2] 8 passed +``` + +- **Config:** `min_tokens` (100). Needs ≥2 run-like outputs. **Shines:** iterative fix→re-run + loops. **Inert:** <2 runs detected, small outputs. False positives cost only an expand + round-trip, never data. + +### `cmdfilter` +Shrinks tool output with **declarative DSL filters** (see below). Matches a filter on the output's +first non-empty line, applies its 8-stage pipeline, stashes the original, and appends a recovery +hint only when the filter was actually lossy. Ships builtin `pytest` / `npm-install` / `make` +filters. + +``` +before: pytest … 100 lines of PASSED + warnings + 1 failure +after: <> [full output: …] +``` + +- **Config:** `filters` (inline filter YAML docs, added with no recompile), `disable_builtins`. + `Enabled` only when ≥1 filter is loaded. **Shines:** noisy but structured command/log output + (test runners, package managers, build tools). **Inert:** output whose first line matches no + filter, or where filtering doesn't shrink it. + +### `extract` +Projects a large tool output down to what's relevant to the **most recent user query**. v1 ships +the `deterministic` strategy: keep `head`/`tail` context, every line overlapping the query's +keywords, and every line carrying an error word; collapse the gaps with `…`. Stashes the original. + +``` +before: <300-line API dump> (query mentions "auth timeout") +after: … lines mentioning auth/timeout + any error lines … + <> [full output: call context_guru_expand] +``` + +- **Config:** `min_tokens` (300), `head_lines`/`tail_lines` (5), `strategy` + (`deterministic` | `code` | `rlm`). `NeedsModel()` is true for `code`/`rlm`, but the cheap-LLM + client isn't wired yet — those select-and-fall-back to deterministic. **Shines:** big + query-focused MCP/API outputs. **Inert:** no user query to score against, output < `min_tokens`, + projection not smaller. + +### `smartcrush` +Statistical JSON-**array** compressor: parse the array, keep `keep_first` + `keep_last` items plus +any item whose raw JSON carries an error signal, drop the rest, stash the full original. Kept items +are verbatim (schema-preserving). + +``` +before: [ {…}, {…}, … 200 items … ] +after: [ item0, item1, item2, item198, item199 ] [5 of 200 items shown; full array: call …] <> +``` + +- **Config:** `min_items` (5), `min_tokens` (200), `keep_first` (3), `keep_last` (2). **Shines:** + long homogeneous JSON arrays (list endpoints, search hits) — the `mcp` preset. **Inert:** + non-array output, fewer than `min_items`, nothing to drop. v1 uses fixed anchors (headroom's + Kneedle adaptive-K is a documented refinement). + +### `mask` +Age-based garbage collection: keep the newest `keep_recent` tool outputs verbatim, replace older +ones (≥ `min_tokens`) with a short marker + stash. Complementary to the content-based offloaders. + +``` +after (older): [older tool output masked] <> [full output: call context_guru_expand] +``` + +- **Config:** `keep_recent` (3), `min_tokens` (100). **Shines:** long agent trajectories where old + tool results are unlikely to matter. **Inert:** ≤ `keep_recent` tool outputs, small outputs. + +### `phi_evict` +Ranks tool outputs by a lean-ctx-style context-field score and evicts the lowest until the +transcript fits `budget_tokens`. Never evicts the most recent tool output. + +$$\Phi = w_R\cdot\text{relevance} + w_H\cdot\text{recency} - w_C\cdot\text{cost} - w_D\cdot\text{redundancy}$$ + +- **relevance** = keyword overlap with the last user message; **recency** = position (newest = 1); + **cost** = share of total tool tokens; **redundancy** = 1 if a duplicate seen earlier. +- **Config:** `budget_tokens` (120000), `weights` — `balanced` `{R.40,H.20,C.30,D.10}`, + `aggressive` (punish cost: `{.30,.10,.45,.15}`), `conservative` (`{.45,.20,.05,.05}`). +- **Shines:** very long transcripts that must fit a context budget. **Inert:** total tool tokens ≤ + budget, or ≤1 tool output. The scalar-ranking essence of lean-ctx's Context Field (the full + heat-diffusion/MMR/bandit machinery is a documented refinement). + +--- + +## The DSL filter engine + +`components/dsl` is a declarative, user-extensible text-filter engine (adapted from rtk), wrapped +by `cmdfilter`. Filters are authored in YAML (no recompile), matched first-by-sorted-name, and each +runs a fixed **8-stage** pipeline. Because filters drop lines they are lossy, which is why the +wrapping `cmdfilter` component is an Offload (it stashes the original first). + +```mermaid +flowchart LR + I[input] --> S1[1 strip_ansi] --> S2[2 replace[]] --> S3[3 match_output[] + unless] + S3 --> S4[4 strip / keep lines] --> S5[5 truncate_lines_at] --> S6[6 head / tail] + S6 --> S7[7 max_lines] --> S8[8 on_empty] --> O[output + Lossiness] +``` + +Filter fields (all optional except `match`): `match` (regex vs the selector = first non-empty +line), `strip_ansi`, `replace` (chained `pattern`→`replacement`, `$1` backrefs), `match_output` +(whole-blob short-circuit: `pattern`/`message`/`unless`), `strip_lines_matching` **xor** +`keep_lines_matching`, `truncate_lines_at` (per-line char cap), `head_lines`/`tail_lines`, +`max_lines` (absolute cap with omission marker), `on_empty` (replacement when output is blank). + +`Lossiness` reported back to `cmdfilter` (drives whether a recovery hint is appended): `None` +(nothing dropped / reversible reformat), `Tail` (a clean contiguous tail dropped), `Whole` +(non-contiguous or whole-blob loss). + +```yaml +schema_version: 1 +filters: + pytest: + description: keep failures + summary, drop passing noise + match: "(pytest|=+ test session starts)" + strip_lines_matching: ["^\\s*$", " PASSED", "^\\.+$"] + max_lines: 80 + on_empty: "pytest: all passed" +tests: # inline; run via dsl.RunTests (a `verify` command) + pytest: + - name: all-green + input: "pytest\n....\n" + expected: "pytest: all passed" +``` + +Documents load with `schema_version: 1` and strict unknown-field rejection. Inline `tests` +(input → expected) run via `dsl.RunTests`, so a filter ships with its own regression check. diff --git a/docs/design.md b/docs/design.md new file mode 100644 index 0000000..b31e5da --- /dev/null +++ b/docs/design.md @@ -0,0 +1,207 @@ +# Design + +context-guru is one pure-Go `components` core operating on **bifrost `schemas` types** +(`BifrostChatRequest` / `ChatMessage`), exposing two lossiness-typed interfaces, run in a +**config-ordered, fail-open, never-worse pipeline**, driven unchanged by thin host adapters. +Reversibility, state, session keying, metrics, config, and a filter DSL are the shared +infrastructure the components sit on. + +## Package map + +| Package | Role | +|---|---| +| `components/` | `Component`/`Reformat`/`Offload` interfaces, `Report`, `Ctx`, the `Pipeline`, the registry | +| `components/reformat/` | lossless components: `format`, `cacheinject` | +| `components/offload/` | lossy-reversible components: `skeleton`, `dedup`, `collapse`, `failed_run`, `cmdfilter`, `extract`, `smartcrush`, `mask`, `phi_evict` | +| `components/dsl/` | declarative text-filter engine (wrapped by `cmdfilter`) | +| `components/all/` | blank-imports every component so `init()` registrations run | +| `schema/` | helpers over bifrost's schema: token counting, deep-clone, `MessageText`/`SetMessageText`, `Rewritable` | +| `apply/` | the one place the pipeline meets a raw wire body: extract `messages` → run → byte-lossless splice | +| `expand/` | reversibility: `<>` marker, the `context_guru_expand` tool def, response parsing + continuation | +| `store/` | `Store` interface + in-memory TTL+LRU backend (rewind + sticky ids) | +| `session/` | resolve the session key (explicit id, else content hash) | +| `metrics/` | `Emitter` implementations: `Slog`, `Aggregator` (for `/stats`), `Tee` | +| `config/` | strict YAML loader, presets, pipeline builder | +| `proxy/` | the standalone/gateway HTTP proxy | +| `adapters/bifrost/` | `LLMPlugin` adapter to embed the pipeline in a bifrost deployment | +| `cmd/context-guru-proxy/` | the proxy binary / eval-containers gateway | + +## The component model + +Two interfaces, split by **lossiness**, so reversibility is type-enforced: + +```mermaid +classDiagram + class Component { + <> + Name() string + Enabled(ctx) bool + } + class Reformat { + <> + Reformat(req, rep, ctx) error + } + class Offload { + <> + Offload(req, rep, ctx) (cacheKeys, error) + } + Component <|-- Reformat + Component <|-- Offload + note for Reformat "lossless: repack in place, nothing to stash" + note for Offload "drops bytes: MUST return a cache_key proving the original was stashed" +``` + +Optional capability interfaces a component may also implement: `Configurable` (receives its +YAML block as bytes), `NeedsModel` (declares it calls a cheap LLM — the model client is not +yet wired). + +- **Reformat** = lossless repack (`format` re-encodes JSON compact; `cacheinject` adds + `cache_control`). No information leaves the wire, so nothing is stashed. +- **Offload** = lossy-but-reversible. It drops bytes and returns the `cache_keys` under which + it stashed the originals. If it shrinks the request but returns no keys, the pipeline treats + it as a **failed offload and reverts** — you cannot silently lose data. Returning no keys and + leaving the request unchanged is a legitimate no-op (`rep.Skipped`). + +## The pipeline: fail-open, never-worse + +`Pipeline.Run` walks components in config order. Each is isolated by a snapshot/restore guard +around a per-component `Report`. Token counts are measured on **message content text** (what +the model reads), not the JSON envelope — so `cacheinject` adding `cache_control` bytes never +looks "worse". + +```mermaid +flowchart TD + START([Run]) --> BYP{ctx.Bypass?} + BYP -->|yes| DONE([return, tokens unchanged]) + BYP -->|no| LOOP{next component} + LOOP -->|enabled?| EN{Enabled ctx} + EN -->|no| LOOP + EN -->|yes| SNAP[snapshot messages] + SNAP --> RUN[dispatch by type:
Reformat / Offload] + RUN --> CHK{outcome} + CHK -->|panic or error| REV[restore snapshot
Reverted=true] + CHK -->|offload dropped bytes
but no cache_key| REV + CHK -->|tokens grew| REV + CHK -->|ok| KEEP[keep change
record Saved] + REV --> LOOP + KEEP --> LOOP + LOOP -->|done| EMIT[emit Reports + RunReport] + EMIT --> DONE +``` + +Revert conditions (each reverts only that component; the run continues): +1. the component **panicked or returned an error**; +2. an **Offload dropped content but returned no cache_key** (reversibility would be broken); +3. the component **grew** the request (never-worse). + +A component registered but implementing neither interface is skipped, not failed. + +## Wire ↔ pipeline: `apply.Body` + +Both hosts funnel through `apply.Body(ctx, pipe, store, provider, body, session, bypass)`. +It never mutates fields the pipeline didn't touch — **byte-lossless for everything else**. + +```mermaid +sequenceDiagram + participant Host + participant apply as apply.Body + participant Pipe as Pipeline + Host->>apply: raw body + provider + session + apply->>apply: gjson extract messages[] + apply->>apply: normalize → []ChatMessage + write-back slots + apply->>Pipe: Run(chat, ctx) + Pipe-->>apply: mutated messages + apply->>apply: per message: unchanged → keep bytes;
changed & lossless round-trip → sjson splice + apply-->>Host: rewritten body (or original, fail open) +``` + +**Provider normalization.** Components expect OpenAI-shaped tool outputs (`role:"tool"`, +string content). The Anthropic Messages API carries tool outputs as `tool_result` blocks +*inside* user messages — a shape bifrost's schema cannot represent. So for Anthropic requests +`apply` expands each string `tool_result` block into a synthetic `role:tool` message, runs the +pipeline, then splices each rewritten output back into its exact source block via `sjson`. +Non-string tool_result content is skipped (never lose non-text). A whole-message change is only +spliced back if bifrost round-trips that message losslessly (`jsonEqual`); otherwise the change +is discarded — correctness over the marginal saving. + +If a component changes the message *count* (none of the v1 set does), the slot map no longer +aligns, so `apply` forwards the original untouched. + +Diagnostics: `CONTEXT_GURU_DEBUG=1` logs each tool output's token count + first line; +`CONTEXT_GURU_DUMP=` appends a before→after JSON record per rewritten message. + +## Reversibility: marker + expand loop + +Offload writes a `<>` marker in place of dropped content and calls `store.Put(HASH, original)`. +The host injects a model-callable `context_guru_expand(id)` tool. The **continuation loop** is host +glue (it must re-invoke upstream); the marker format, tool def, response parsing and continuation +builder are shared in `expand/`. + +```mermaid +sequenceDiagram + participant M as Model + participant Host + participant Store + participant Up as Upstream + Host->>Up: request (content replaced by <> + expand tool) + Up-->>Host: response calls context_guru_expand(id=HASH) + Host->>Store: Resolve(HASH) + Store-->>Host: original bytes + Host->>Up: append assistant tool-call + tool_result(original), re-invoke + Up-->>M: final answer with full content in hand + Note over Host,Up: capped at 3 rounds; if the model also calls another tool,
the loop bails and returns the response as-is +``` + +An expired/evicted original resolves to an explicit placeholder rather than being omitted (the +provider requires one `tool_result` per `tool_call_id`). A miss silently turns a lossless offload +lossy — the known TTL edge. + +## State: the Store + +One `Store` interface, in-memory TTL+LRU default (both hosts share it). Defaults: **1800s TTL, +1000 entries, 100 sticky sessions**. It carries, keyed per session: + +- **Rewind** — `cache_key → original bytes` (what the expand loop resolves). +- **Sticky** — the set of content ids already reduced on prior turns (for byte-stable output + across turns; scaffolding for cache stability). + +SQLite/Redis slot in behind the same interface when a durable/multi-replica deployment is real. + +## Session keying + +`session.Resolve(explicit, system, firstUser)`: an explicit host id wins; otherwise a stable +`sha256(system + firstUser)[:16]` so two turns of one conversation land on the same key. +Explicit id sources: proxy header `x-context-guru-session`; AuthBridge `pctx.Session`; +eval-containers stamps it in the gateway. + +## Metrics + +The pipeline depends only on the `Emitter` interface (`Component(Report)` + `Run(RunReport)`), +so it has no telemetry-backend dependency. Implementations: `Slog` (logs in `context_engineering.*` +vocabulary), `Aggregator` (in-process rollups behind `/stats`), `Tee` (fan-out), `NopEmitter`. + +`/stats` savings are **token-weighted** (Σ saved / Σ before), the honest aggregate — not a mean +of per-request percentages. It also reports: +- `wasted_tokens` / `bounces` — content offloaded then re-served via expand (a premature offload); +- `adjusted_saved` = saved − wasted (bounce-adjusted, may be negative); +- `top_passthrough` — components that ran but never changed a request: dead weight to drop. + +## Config & registry + +One strict YAML struct serves both hosts. `pipeline:` is an ordered name-list (order + +enablement); each component's typed block lives under `components:` and is handed to its +constructor verbatim. A `preset` expands to a default pipeline; explicit fields override it. +Unknown keys are rejected. + +```yaml +preset: balanced +pipeline: [format, dedup, failed_run, cmdfilter, cacheinject] # order + enable +components: + collapse: { max_tokens: 2000, head_lines: 20, tail_lines: 20 } + smartcrush: { min_items: 5, keep_first: 3, keep_last: 2 } +store: { ttl_seconds: 1800, max_entries: 1000 } +``` + +A component registers its constructor + config type via `init()`; adding one makes it +YAML-configurable with no core edit. See [components.md](components.md) for presets and every +component's config. diff --git a/docs/integration/authbridge.md b/docs/integration/authbridge.md deleted file mode 100644 index 132e826..0000000 --- a/docs/integration/authbridge.md +++ /dev/null @@ -1,69 +0,0 @@ -# Integrating with Kagenti AuthBridge - -The AuthBridge plugin lives in -[kagenti-extensions](https://github.com/kagenti/kagenti-extensions), **not** in this -repo. It depends on this module and wraps the engine in-process (the `sparc` plugin is -the structural precedent: a thin pipeline plugin delegating to a focused capability). -This repo's job is to expose a clean, importable Go API; it carries no AuthBridge code. - -## The contract - -```go -import ( - "github.com/kagenti/lab-context-engineering/config" - "github.com/kagenti/lab-context-engineering/engine" - "github.com/kagenti/lab-context-engineering/surfaces" -) - -// Construct once (e.g. in the plugin's Init), reuse across requests. -eng := engine.New(config.Default(), nil, nil) // nil → in-memory rewind store + evictions -``` - -Per request, on the **outbound** LLM call (AuthBridge's `OnRequest` for an -`inference`-classified request), feed the body you already have through the matching -surface, transform, and write the rendered bytes back with `pctx.SetBody`: - -```go -surface := surfaces.Anthropic{} // or surfaces.OpenAI{} per the detected wire format -req, token, err := surface.ToInternal(pctx.Body) -if err == surfaces.ErrUnsupported || err != nil { - return // fail open: leave pctx.Body untouched -} -reduced, _ := eng.Transform(ctx, req) // never errors; worst case is a no-op -out, err := surface.Render(reduced, token) -if err == nil { - pctx.SetBody(out) // the only mutation AuthBridge needs -} -``` - -Declare `Capabilities{ReadsBody: true, WritesBody: true}` and -`RequiresAny: ["inference-parser"]` so a parser runs first. Ship it `on_error: observe` -to roll out in shadow mode. - -## Recovering omitted content - -Reductions are reversible. To serve an expand request, or to rehydrate before the -agent's own summarization turn: - -```go -for _, id := range engine.FindMarkers(text) { - if original, ok := eng.Expand(id); ok { - // splice original back in - } -} -``` - -## Notes - -- **Fail open is the contract.** `Transform` never returns an error; on any internal - fault it forwards the request unchanged. `ToInternal` returning `ErrUnsupported` - (e.g. Gemini today) also means "forward untouched". -- **Why raw bytes, not parsed inference?** Feeding `pctx.Body` is the simplest path and - avoids coupling to a parser's types. A `canon.Request` constructor from - already-parsed messages can be added later if double-parsing ever shows up in a - profile — it does not today. -- **State.** The default rewind store is in-memory (per process). For multi-replica - recovery, supply a shared `store.Rewind` to `engine.New` (Redis/SQLite backends are a - planned addition). -- **Observability.** Emit the engine's `Report` (tokens before/after/saved) via - AuthBridge's OpenTelemetry GenAI conventions. diff --git a/docs/integration/bob.md b/docs/integration/bob.md deleted file mode 100644 index 0d1d31f..0000000 --- a/docs/integration/bob.md +++ /dev/null @@ -1,84 +0,0 @@ -# Integrating with Bob (IBM Bob Shell) — real run - -[Bob Shell](https://bob.ibm.com) is an OpenAI-compatible CLI coding agent. It integrates -with `lab-cx` by pointing its `CUSTOM_BASE_URL` at the proxy; the proxy reduces the -OpenAI-shaped traffic and forwards to Bob's backend. Bob is **not self-caching**, so -unlike Claude Code lab-cx's cache-injection lever fires on every request. - -## Setup - -```sh -# Bob's API key (here from ../winnow/.env: BOBSHELL_API_KEY=bob_...) -export BOBSHELL_API_KEY=... - -# Start lab-cx: agent upstream = Bob's backend; cheap-model extractor = your gateway. -./bin/lab-cx proxy --addr 127.0.0.1:8093 --preset balanced \ - --upstream https://api.us-east.bob.ibm.com \ - --extract-model claude-haiku-4-5 --extract-provider anthropic \ - --extract-auth bearer --extract-base "$ANTHROPIC_BASE_URL" -``` - -Run Bob routed through the proxy (Bob's `custom` auth mode treats `CUSTOM_BASE_URL` as a -plain OpenAI provider and does its own auth handshake — drive the real `bob` CLI, a raw -curl with the `bob_` key is rejected by the backend as "invalid jwt"): - -```sh -CUSTOM_BASE_URL=http://127.0.0.1:8093/v1 \ -BOBSHELL_DEFAULT_AUTH_TYPE=custom \ -BOBSHELL_API_KEY="$BOBSHELL_API_KEY" \ -bob "List the function names defined in calc.py, comma-separated." \ - --accept-license --yolo --hide-intermediary-output -``` - -Then read `curl -s http://127.0.0.1:8093/stats`. - -## Results (real, 2026-06-24, Bob → lab-cx → Bob backend) - -**Correctness: 5/5 verifiable tasks correct, including a file edit — no harm.** - -| Task | Expected | Bob's answer | OK | -|---|---|---|---| -| `17 * 23` | 391 | `391` | ✓ | -| function names in calc.py | add, sub | `add, sub` | ✓ | -| add `multiply(a,b)` to calc.py | file edited | `multiply` added to calc.py | ✓ | -| analyze 300-record data.json | 200 active, first inactive id 1000 | `200 active … id 1000` | ✓ | -| functions in calc.py (variant) | add, sub | correct | ✓ | - -`/stats` after the 3-task suite: - -```json -{ "requests": 6, "tokens_before": 2919, "tokens_after": 2919, "tokens_saved": 0, - "reduction_ratio": 0, "cache_injected": 6, "extracted": 0, "stage_errors": 0, - "added_latency_p50_ms": 3, "added_latency_p95_ms": 12 } -``` - -**What this shows:** -- **The integration works end-to-end** and **preserves correctness** (5/5, including a - write) with negligible added latency (~3ms p50, 0 stage errors). -- **`cache_injected: 6/6`** — lab-cx injects `cache_control` breakpoints Bob never sends. - On a multi-turn Bob session this bills the growing transcript prefix at provider - cache-read rates (~10× cheaper) from turn 2 on — the lever a self-caching client like - Claude Code declines. (Our content-token metric doesn't price the provider-side - cache-read discount; `cache_injected` is the signal that it's engaged.) -- **`tokens_saved: 0` (content) here** because Bob *condensed the files itself* (it ran - logic over `data.json` rather than dumping its 43 KB into the model context), so the - Reduce/Extract content levers had no large raw output to act on. - -## Where Bob content-reduction savings come from - -Content reduction (Reduce/Extract) needs large **raw** tool outputs in the model context -— e.g. an MCP tool returning a big JSON array, or a `Read` of a large file kept across -turns. That path is proven: -- Component level on real fixtures: −93% aggregate ([../RESULTS-offline.md](../RESULTS-offline.md)), - cheap-model extractor −56%…−80% ([../RESULTS-extract.md](../RESULTS-extract.md)). -- End-to-end on a self-caching agent reading a large file: - [claude-code.md](claude-code.md) shows **50% token reduction** with correctness - preserved. The same Reduce levers apply to Bob when it surfaces large raw outputs; - enable extraction with a lower `floor` (see `configs/lab-cx.yaml`) to catch - medium-size structured results. - -## Reproduce - -The full flow (proxy + 5-task suite + a large-file read) is scriptable exactly as above; -swap the `bob ""` line for your own. Keep `--extract-*` pointed at a cheap model to -also exercise the extractor on large structured tool outputs. diff --git a/docs/integration/claude-code.md b/docs/integration/claude-code.md deleted file mode 100644 index d831c84..0000000 --- a/docs/integration/claude-code.md +++ /dev/null @@ -1,124 +0,0 @@ -# Integrating with Claude Code (real run) - -`lab-cx` integrates with Claude Code by the standard base-URL swap: point Claude Code's -`ANTHROPIC_BASE_URL` at the proxy, and the proxy forwards upstream (a provider or, here, -an Anthropic-compatible gateway). No Claude Code changes; it works unmodified. - -## How it was actually run (2026-06-24, claude-haiku-4-5 via an Anthropic-compatible gateway) - -1. Start the proxy, forwarding to the model endpoint, extraction on: - -```sh -./bin/lab-cx proxy --addr 127.0.0.1:8090 --preset balanced \ - --upstream "$ANTHROPIC_BASE_URL" \ - --extract-model claude-haiku-4-5 --extract-provider anthropic \ - --extract-auth bearer --extract-base "$ANTHROPIC_BASE_URL" -``` - -2. Run Claude Code routed through the proxy. **Precedence note:** if your - `~/.claude/settings.json` sets `env.ANTHROPIC_BASE_URL`, Claude Code applies it over - an inherited shell variable — so an inline `ANTHROPIC_BASE_URL=...` is ignored. Route - it explicitly with a settings file instead: - -```sh -# /tmp/cc-settings.json: {"env":{"ANTHROPIC_BASE_URL":"http://127.0.0.1:8090", -# "ANTHROPIC_AUTH_TOKEN":"","ANTHROPIC_MODEL":"claude-haiku-4-5", -# "ANTHROPIC_SMALL_FAST_MODEL":"claude-haiku-4-5"}} -claude -p "Read main.go and README.md and summarize each in one line." \ - --settings /tmp/cc-settings.json --dangerously-skip-permissions -``` - -3. Read the proxy's `/stats`: - -```sh -curl -s http://127.0.0.1:8090/stats -``` - -## Result (verbatim, honest) - -The task completed correctly through the proxy. `/stats` after the run: - -```json -{ "requests": 2, "tokens_before": 12246, "tokens_after": 12246, - "tokens_saved": 0, "cache_injected": 0, "extracted": 0, - "stage_errors": 0, "added_latency_p50_ms": 41, "added_latency_p95_ms": 65 } -``` - -**Interpretation (do-no-harm, as designed):** -- **Traffic traversed lab-cx** (`requests: 2`) and the agent's task succeeded — the - integration works. -- **`cache_injected: 0`** — Claude Code is a *self-caching* client (it sends its own - `cache_control` breakpoints), so lab-cx's cache stage correctly **stands down** rather - than fight the client's cache. This matches the reference prototype's finding that on Claude Code the - proxy is cost-neutral by design. -- **`tokens_saved: 0`** — a tiny 2-file, 2-turn task has no large/stale/duplicate tool - outputs to reduce and nothing over the extraction floor. lab-cx safely did nothing. -- **`added_latency_p50_ms: 41`** — the reduction pass adds ~40ms even when it changes - nothing (parse + score + render); acceptable, and dwarfed by model latency. - -**Where Claude Code savings actually come from** (not exercised by this toy task): -long sessions that re-read large files / re-run noisy commands / accumulate big tool -outputs — there the Reduce pre-passes (cmdfilter, dedup, skeleton, format) and, with -`reduce_cached_prefix` enabled, prefix re-caching kick in. For per-component evidence on -real large outputs see [../RESULTS-offline.md](../RESULTS-offline.md) (deterministic, -−93% aggregate) and [../RESULTS-extract.md](../RESULTS-extract.md) (haiku extractor, -−56%…−80% on structured outputs). A non-self-caching tool-calling agent additionally -gets the cache-injection lever that Claude Code declines. - -## Real savings on Claude Code (and a harm check) - -The do-no-harm run above changed nothing because the task was tiny and Claude Code -self-caches. To get **real reduction on a self-caching client**, enable -`reduce_cached_prefix` (lab-cx then reduces even the client's cached prefix — a one-time -re-cache of a smaller prefix) and give it a workload with a large file read. Config: - -```yaml -# /tmp/cc-save-cfg.yaml -preset: balanced -reduce: - protect_recent: 1 - provable_only: true - reduce_cached_prefix: true - reducers: [collapse, skeleton, format] -extract: { enabled: true, mode: auto, floor: 400 } -``` - -Run (a 1,179-line / 32 KB real Go file, 42 top-level funcs), routed through the proxy -with haiku, then read `/stats`: - -```sh -./bin/lab-cx proxy --addr 127.0.0.1:8094 --config /tmp/cc-save-cfg.yaml \ - --upstream "$ANTHROPIC_BASE_URL" --extract-model claude-haiku-4-5 \ - --extract-provider anthropic --extract-auth bearer --extract-base "$ANTHROPIC_BASE_URL" -claude -p "Read bigfile.go, then tell me in two lines: (1) roughly how many top-level - 'func' declarations it has, and (2) what the code does overall." \ - --settings /tmp/cc-save-settings.json --dangerously-skip-permissions -``` - -**Result (real, 2026-06-24, claude-haiku-4-5):** - -```json -{ "requests": 4, "tokens_before": 69683, "tokens_after": 34729, - "tokens_saved": 34954, "reduction_ratio": 0.502, "cost_saved_usd": 0.0350, - "cache_injected": 0, "added_latency_p50_ms": 86, "added_latency_p95_ms": 191 } -``` - -- **−50.2% tokens** (34,954 saved across 4 requests; up to −70% on the turn carrying the - full file read), ~$0.035 saved on this short interaction. -- **No harm — the task was answered correctly:** Claude Code reported **42 top-level - `func` declarations** (exactly matches `grep -c '^func '`) and an accurate summary of - the file. The `skeleton` reducer dropped function *bodies* while keeping every - signature, so the agent could still count and describe the functions — reduction that - preserves the task. -- `cache_injected: 0` because Claude Code self-caches; with `reduce_cached_prefix` lab-cx - reduces content instead of fighting the client's cache. - -So: on a real workload (large file read) lab-cx cuts Claude Code's tokens ~50% with the -answer unchanged; on a trivial task it safely does nothing (above). - -## Metric note - -`/stats` reports `reduction_ratio` = `tokens_saved / tokens_before` (fraction of input -removed; 0 = no savings, higher = more reduction). On this self-caching do-no-harm run -that value is 0, matching `tokens_saved: 0`. (Earlier builds exposed `ratio` as -`after/before`, which read as 1.0 at zero savings — fixed.) diff --git a/docs/integration/swe-bench.md b/docs/integration/swe-bench.md deleted file mode 100644 index f5ae646..0000000 --- a/docs/integration/swe-bench.md +++ /dev/null @@ -1,126 +0,0 @@ -# Integrating with eval-containers (SWE-bench) - -[eval-containers](https://github.com/exgentic/eval-containers) runs `one benchmark + one -agent + one model`, with all agent→model traffic flowing through a **gateway** on port -4000. `lab-cx` inserts as a plain proxy *before* that gateway, so it sees and reduces -exactly what the agent sends — here with **claude-code** as the agent and -**claude-haiku-4-5** as the model. - -## Wiring (validated) - -The before-gateway placement is [`deploy/eval-containers/compose.override.yaml`](../../deploy/eval-containers/compose.override.yaml). -Merging it with the real swe-bench compose was validated structurally (no image pull): - -```sh -cd eval-containers/containers/benchmarks/swe-bench -EVAL_TASK_ID=astropy__astropy-12907 EVAL_AGENT=claude-code EVAL_MODEL=anthropic/claude-haiku-4-5 \ -OPENAI_API_BASE=... OPENAI_API_KEY=... \ -docker compose -f compose.yaml -f /path/to/lab-context-engineering/deploy/eval-containers/compose.override.yaml \ - config --services -# -> otelcol, gateway, lab-cx, runner (lab-cx inserted before the gateway) -``` - -The override adds a `lab-cx` service on the `internal` network, repoints the runner's -`ANTHROPIC_BASE_URL` (`http://gateway:4000/anthropic`) to `http://lab-cx:8080/anthropic`, -and sets `lab-cx --upstream http://gateway:4000`. The agent still holds only `sk-proxy`; -the real key stays in the gateway. lab-cx's suffix routing reduces the `/anthropic/...` -prefixed path and forwards the full path on. - -## Runbook (full run) - -1. Build the lab-cx image (CGO; final image is `distroless/base`): - ```sh - cd lab-context-engineering && docker build -t ghcr.io/kagenti/lab-context-engineering:latest . - ``` -2. Point the eval **gateway** at your model provider. For a standard provider set - `OPENAI_API_BASE`/`OPENAI_API_KEY` + `EVAL_MODEL` per the eval-containers docs. For an - **Anthropic-compatible gateway** (e.g. the IBM LiteLLM endpoint used in this repo's - other tests), either configure the eval gateway's provider to that endpoint, or set - `lab-cx --upstream` directly to it (bypassing the eval gateway) — lab-cx forwards the - agent's `Authorization` header through. Use `--extract-auth bearer` for bearer-token - gateways. -3. Run a task (or a slice from `tasks.txt`) with and without the override and compare: - ```sh - # with lab-cx - EVAL_TASK_ID=astropy__astropy-12907 EVAL_AGENT=claude-code EVAL_MODEL=anthropic/claude-haiku-4-5 \ - docker compose -f compose.yaml -f .../compose.override.yaml up -y --abort-on-container-exit - ``` -4. Read the metrics: - - **eval-containers**: `result.json` (resolve status) + `trajectory.jsonl` (per-call - `total_tokens`, `cost_usd`) in the shared `output` volume. - - **lab-cx**: `curl http://lab-cx:8080/stats` (tokens_before/after/saved, cache, - extracted, added latency) — run from inside the compose network, or expose the port. - - Compare with-vs-without lab-cx on the same `EVAL_TASK_ID` for the token/cost delta; - `result.json` resolve status confirms accuracy is preserved. - -## Results (10 real tasks, 2026-06-25) - -Ten SWE-bench Verified tasks run Claude Code (`claude-sonnet-4-6`) → `lab-cx` → gateway, -with the **deterministic reducers + extractor on the cached prefix** (`--reduce-cached-prefix`) -and the extractor pinned to `claude-haiku-4-5`. Per-task input-token reduction from -`lab-cx`'s `/stats`, resolve from `result.json`: - -| task | reqs | tokens before | tokens after | saved | % | blocks | resolve | -|------|-----:|--------------:|-------------:|------:|-----:|-------:|---------| -| astropy-12907 | 16 | 210,638 | 101,472 | 109,166 | 51.8 | — | passed | -| astropy-13033 | 29 | 659,220 | 573,936 | 85,284 | 12.9 | 133 | — | -| astropy-13236 | 45 | 416,300 | 237,675 | 178,625 | 42.9 | 350 | not resolved | -| astropy-13398 | 28 | 792,753 | 401,839 | 390,914 | 49.3 | 176 | not resolved | -| astropy-13453 | 41 | 1,129,296 | 567,732 | 561,564 | 49.7 | 425 | passed | -| astropy-13579 | 9 | 52,283 | 26,409 | 25,874 | 49.5 | 7 | not resolved | -| astropy-13977 | 27 | 354,322 | 282,680 | 71,642 | 20.2 | 154 | not resolved | -| astropy-14096 | 19 | 259,925 | 223,951 | 35,974 | 13.8 | 58 | passed | -| astropy-14182 | 36 | 452,919 | 354,583 | 98,336 | 21.7 | 286 | passed | -| astropy-14309 | 10 | 78,165 | 70,019 | 8,146 | 10.4 | 9 | passed | -| **TOTAL** | **263** | **4,405,821** | **2,840,296** | **1,565,525** | **35.5** | **1,598** | | - -- **Reduction is real and substantial: −35.5% aggregate** (−10.4%…−51.8% per task), all - from the deterministic reducers (1,598 blocks). 0 stage errors; p95 added latency - ~130–250 ms. -- `extract_candidates=0` on every task: these astropy tasks emit no large *structured* - tool outputs, so the LLM extractor had nothing to claim — its value is the separate - −56%…−80% on structured fixtures ([../RESULTS-extract.md](../RESULTS-extract.md)). -- **Honest caveats:** runs used the **arm64** Epoch bases for native Apple-Silicon - execution; Epoch marks arm64 grading best-effort/untested, so `not resolved` rows may - understate accuracy (token reduction is architecture-independent and valid). 12907/13033 - predate per-task `result.json` capture. `--reduce-cached-prefix` trades the client's - prompt-cache hits for these reductions. - -### How these were produced (reproduce) - -Most of the 500 `tasks.txt` instances have **no prebuilt** `evals/--claude-code` -image; only a demo (12907) is published. Build per-task images locally from the **public** -Epoch base, then stitch with the agent: - -```sh -# 1. benchmark image (public Epoch base; arm64 for native Apple Silicon, x86_64 otherwise) -docker build --platform linux/arm64 -f containers/benchmarks/swe-bench/Dockerfile \ - --build-arg EVAL_TASK_ID= --build-arg EVAL_BASE_ARCH=arm64 \ - --build-context "ghcr.io/exgentic/core/entrypoint=docker-image://ghcr.io/exgentic/core/entrypoint:latest" \ - -t localhost:5001/swe-bench-:latest containers/benchmarks/swe-bench -docker push localhost:5001/swe-bench-:latest # local registry: BuildKit resolves FROM via a registry -# 2. stitch benchmark + agent + gosu into the runner image -docker build --platform linux/arm64 -f containers/core/combination.Dockerfile \ - --build-arg BENCHMARK_IMAGE=localhost:5001/swe-bench-:latest \ - --build-arg AGENT_IMAGE=ghcr.io/exgentic/agents/claude-code:latest \ - --build-arg GOSU_IMAGE=ghcr.io/exgentic/core/gosu:latest \ - -t ghcr.io/exgentic/evals/swe-bench---claude-code:latest containers/core -``` - -Gotchas learned: (a) build for the host arch — an amd64-only image won't run `node` -without emulation on arm64; (b) Claude Code 2.1.x sends adaptive **thinking** that some -gateways reject for haiku — run the agent on a thinking-capable model -(`ANTHROPIC_MODEL=claude-sonnet-4-6`) or cap `ANTHROPIC_MODEL_SUPPORTED_CAPABILITIES=effort`; -(c) the eval gateway force-maps to `EVAL_MODEL`, so to keep the extractor on haiku point -`--extract-base` at the model endpoint directly (the override does this). - -## Note for self-caching agents - -Claude Code self-caches, so by default lab-cx defers to its `cache_control` (cache stage -stands down) and reduces little. To run the **Reduce** pre-passes -(cmdfilter/dedup/skeleton/format) and the **extractor** on the cached prefix anyway — the -configuration that produced the results above — pass `--reduce-cached-prefix` (or -`reduce.reduce_cached_prefix: true`). This re-reduces (and so invalidates) the client's -cached prefix: you trade its cheap cached-token reads for the reductions, which is the -right call when measuring the components or when reduction matters more than the client -cache. diff --git a/docs/integrations.md b/docs/integrations.md new file mode 100644 index 0000000..0d61da8 --- /dev/null +++ b/docs/integrations.md @@ -0,0 +1,91 @@ +# Integrations + +The same `components` core runs behind three host adapters. All of them ultimately call the +same pipeline over the same bifrost schema — hosts differ only in how they obtain the body, +provider, and session id, and how they run the expand loop. + +| Option | Host code | Body source | Expand loop | Status | +|---|---|---|---|---| +| Proxy / gateway | `proxy/` + `cmd/context-guru-proxy/` | HTTP request body | server-side, wraps the chat route | shipped; the eval-containers gateway | +| AuthBridge plugin | `kagenti-extensions` (imports this module) | `pctx.Body` | response path (`OnResponse`) | plugin lives externally | +| bifrost `LLMPlugin` | `adapters/bifrost/` | `req.ChatRequest` | transport wrapper | adapter shipped | + +## Option A — proxy / gateway + +`context-guru-proxy` is a direct HTTP proxy (it reuses bifrost's `ChatMessage` **type**, not +its transport). One port serves both dialects and forwards to the configured upstream, injecting +the real provider key in gateway mode. + +```mermaid +sequenceDiagram + participant Agent + participant Proxy as proxy.Handler + participant Apply as apply.Body + participant Up as Upstream + Agent->>Proxy: POST /anthropic/v1/messages (or /openai/v1/chat/completions) + Proxy->>Proxy: FORCE_MODEL? overwrite "model" + Proxy->>Apply: body, provider, x-context-guru-session, bypass? + Apply-->>Proxy: rewritten body (fail open → original) + Proxy->>Up: forward (drop placeholder auth, inject real key) + Up-->>Proxy: response + loop up to 3 rounds + Proxy->>Proxy: expand tool called (only)? resolve from Store, re-invoke + end + Proxy-->>Agent: response (streaming passes straight through) +``` + +Key behaviors (`proxy/proxy.go`): +- **Routes** — `POST /openai/v1/chat/completions`, `POST /anthropic/v1/messages`, `GET /healthz`, + `GET /stats`, `GET /expand?id=`. +- **Gateway credential model** — when `OPENAI_API_KEY`/`ANTHROPIC_API_KEY` are set, the client's + auth is dropped and the real key injected on forward (the agent holds only a placeholder). + Empty keys → client auth passes through (local/dev). +- **`FORCE_MODEL`** pins every call's model (eval-containers `EVAL_MODEL`). +- **Expand loop** runs server-side before returning, capped at `maxExpandRounds = 3`. Streaming + (`event-stream`) responses skip the loop and pass through with flushing. +- `x-context-guru-*` headers are stripped before forwarding upstream. + +Run it: see the [README](../README.md) flag table and [setup.md](setup.md). + +## Option B — AuthBridge in-process plugin + +The plugin lives in **kagenti-extensions** (`authlib/plugins/contextguru/`) and imports only this +module — not bifrost internals. It is an outbound `WritesBody` plugin gated to inference paths. + +```mermaid +sequenceDiagram + participant Agent + participant AB as AuthBridge plugin + participant Apply as apply.Body + participant Up as Upstream + Agent->>AB: outbound request (pctx.Body) + AB->>Apply: pctx.Body, provider, pctx.Session + Apply-->>AB: rewritten bytes + AB->>AB: pctx.SetBody(rewritten) + AB->>Up: forward + Up-->>AB: response + AB->>AB: OnResponse: expand markers via expand/ + Store, continue + AB-->>Agent: response +``` + +The plugin reuses `apply.Body` (bytes in → pipeline → bytes out), `session.Resolve` (from +`pctx.Session`), the shared `Store`, and `expand/` for the response-path continuation. Config +arrives as `json.RawMessage` into the same `config` struct the proxy uses; metrics surface via +AuthBridge's `StatsSource`. + +## Option C — bifrost LLMPlugin (embed in a bifrost deployment) + +`adapters/bifrost.Plugin` implements bifrost's `schemas.LLMPlugin`. Registering it via +`BifrostConfig.LLMPlugins` runs the pipeline as a `PreRequestHook` inside any bifrost proxy. + +```go +p := bifrost.New(pipe, store) // pipe from config.Build, store from config.NewStore +// register p in BifrostConfig.LLMPlugins +``` + +- `PreRequestHook` runs the pipeline over `req.ChatRequest` in place (the canonical mutate phase); + non-chat requests pass through. It never aborts a request (fail-open is inside the pipeline). +- Session id comes from the `context-guru-session` context value (set by the transport from the + header / Anthropic `metadata.user_id`), else the content-hash fallback. +- `PreLLMHook`/`PostLLMHook` are pass-throughs — the expand loop belongs in a transport wrapper + (it must re-invoke upstream, which a hook cannot). diff --git a/docs/setup.md b/docs/setup.md new file mode 100644 index 0000000..085d352 --- /dev/null +++ b/docs/setup.md @@ -0,0 +1,120 @@ +# Setup & example run + +Build the binary/image, then run context-guru as the eval-containers gateway for a real +SWE-bench task driven by Claude Code. + +## Prerequisites + +- **Go 1.26** and a **C toolchain** — `CGO_ENABLED=1` is required (the `skeleton` component + binds tree-sitter via cgo). +- The **bifrost** repo checked out beside this one — `go.mod` pins it with + `replace github.com/maximhq/bifrost/core => ../bifrost/core`. +- **Docker** (for the gateway image / eval-containers), and the **eval-containers** repo. + +## Build + +Local binary (build from the parent dir so the `replace` resolves): + +```sh +cd .../context-engineering +CGO_ENABLED=1 go build -o bin/context-guru-proxy \ + ./lab-context-engineering/cmd/context-guru-proxy +``` + +Gateway image (the build context is the parent dir that holds both repos): + +```sh +cd .../context-engineering +docker build -f lab-context-engineering/Dockerfile -t context-guru:local . +``` + +The image entrypoint is `deploy/eval-containers/start`, which reads `EVAL_MODEL`, targets the +upstream, injects the real key, and selects the pipeline. It exposes `:4000` with +`/openai/v1/chat/completions`, `/anthropic/v1/messages`, `/healthz`, `/stats`, `/expand`. + +## Quick local smoke test + +```sh +context-guru-proxy --preset balanced +# then, from an agent or curl, against http://localhost:4000/anthropic/v1/messages +curl -s localhost:4000/stats | jq # token-weighted savings rollup +``` + +## Example: SWE-bench + Claude Code through the gateway + +This uses the committed compose override +[`deploy/eval-containers/compose.contextguru.yaml`](../deploy/eval-containers/compose.contextguru.yaml), +which swaps the eval-containers gateway for `context-guru:local` and wires it to an +Anthropic-native upstream (IBM litellm). Model: **`anthropic/claude-sonnet-4-6`**. + +```mermaid +flowchart LR + R[SWE-bench runner
claude-code agent] -->|sk-proxy| G[context-guru:local gateway
:4000 /anthropic] + G -->|real token| U[litellm upstream
claude-sonnet-4-6] + G -->|/stats| CSV[sweep-results.csv] + G -->|CONTEXT_GURU_DUMP| V[(output volume)] +``` + +### 1. Set env and run one task + +```sh +cd .../eval-containers/containers/benchmarks/swe-bench + +export EVAL_TASK_ID=django__django-11820 +export EVAL_MODEL=anthropic/claude-sonnet-4-6 +export EVAL_AGENT=claude-code +export ANTHROPIC_API_BASE= +export ANTHROPIC_API_KEY= +export OPENAI_API_KEY=unused OPENAI_API_BASE=http://unused.invalid/v1 # base service marks them required +export CONTEXT_GURU_PRESET=balanced # or `off` for the passthrough baseline + +docker compose \ + -f compose.yaml \ + -f .../lab-context-engineering/deploy/eval-containers/compose.contextguru.yaml \ + up --abort-on-container-exit +``` + +- `EVAL_MODEL` = `/`: the model pins every call (`FORCE_MODEL`), the provider + selects the upstream. +- The agent is handed a placeholder `sk-proxy`; the gateway injects `ANTHROPIC_API_KEY` on forward. +- **Pipeline selection**: `CONTEXT_GURU_PIPELINE` (comma-separated names) wins if non-empty, else + `CONTEXT_GURU_PRESET`. Use `CONTEXT_GURU_PRESET=off` for the baseline (empty pipeline = passthrough). +- Optional: `CONTEXT_GURU_DUMP=/output/cg-dump.jsonl` writes a before→after record per rewritten + message to the shared output volume; `CONTEXT_GURU_DEBUG=1` logs tool-output token counts. + +### 2. Where results land + +- **Task reward / pass** — the runner writes `task/result.json` (and `agent/result.json`) to the + compose **`output` volume**. +- **Token savings** — the gateway's `/stats`: + ```sh + docker exec -gateway-1 sh -c 'curl -s localhost:4000/stats' + ``` + Reports `tokens_before/after`, `saved_tokens`, `savings_pct` (token-weighted), plus + `wasted_tokens`/`bounces` and per-component rollups. + +### 3. Sweep many task × config cells + +[`deploy/eval-containers/sweep.py`](../deploy/eval-containers/sweep.py) automates the matrix +(baseline vs each component alone vs the `balanced` preset vs competitors) over a task list. It +runs each cell, waits for the runner to exit, and appends reward + wall-clock + `/stats` savings +to `deploy/eval-containers/sweep-results.csv`. It is **resumable** — cells already in the CSV are +skipped. + +```sh +python3 deploy/eval-containers/sweep.py # built-in 10 tasks × all configs +python3 deploy/eval-containers/sweep.py --only baseline cg-dedup +python3 deploy/eval-containers/sweep.py --one-task django__django-11820 +``` + +The config selector grammar (CSV `config` column): `cg:off` (passthrough), `cg:` (pin a +pipeline), `cg:preset=`, or a competitor label like `headroom`. + +### Measuring the effect of one component + +Run the same task twice — once with the component in the pipeline, once with +`CONTEXT_GURU_PRESET=off` — and compare reward + `/stats`. Or per-request, have the agent send +`x-context-guru-bypass: true` to skip the pipeline for that call. + +> Credentials note: `sweep.py` reads the litellm base + token from `~/.claude/settings.json` +> (`env.ANTHROPIC_BASE_URL` / `env.ANTHROPIC_AUTH_TOKEN`). diff --git a/engine/compactor_test.go b/engine/compactor_test.go deleted file mode 100644 index 19622cb..0000000 --- a/engine/compactor_test.go +++ /dev/null @@ -1,137 +0,0 @@ -package engine - -import ( - "context" - "strings" - "testing" - - "github.com/kagenti/lab-context-engineering/canon" - "github.com/kagenti/lab-context-engineering/config" -) - -// fakeModel returns a fixed completion — enough to drive summarize without a network. -type fakeModel struct{ out string } - -func (f fakeModel) Complete(_ context.Context, _ string) (string, error) { return f.out, nil } - -// sixMessages builds a request with one system field and six alternating turns. -func sixMessages(t *testing.T) canon.Request { - t.Helper() - body := []byte(`{"system":"be helpful","messages":[ - {"role":"user","content":[{"type":"text","text":"task: fix the bug"}]}, - {"role":"assistant","content":[{"type":"text","text":"looking"}]}, - {"role":"user","content":[{"type":"text","text":"more context"}]}, - {"role":"assistant","content":[{"type":"text","text":"investigating"}]}, - {"role":"user","content":[{"type":"text","text":"any progress"}]}, - {"role":"assistant","content":[{"type":"text","text":"almost done"}]} - ]}`) - req, err := canon.Decode(body) - if err != nil { - t.Fatalf("decode: %v", err) - } - return req -} - -// TestTruncateKeepsLastAndRecovers: naive truncate keeps the last N messages behind one -// recoverable note. -func TestTruncateKeepsLastAndRecovers(t *testing.T) { - s := config.Default() - s.Compactors = []string{truncateName} - s.TruncateEnabled = true - s.TruncateKeepLast = 2 - e := New(s, nil, nil) - - out, _ := e.Transform(context.Background(), sixMessages(t)) - msgs := out.Messages() - if len(msgs) != 3 { // 1 note + last 2 - t.Fatalf("want 3 messages (note + last 2), got %d", len(msgs)) - } - note, _ := msgs[0]["content"].(string) - if !strings.Contains(note, "Truncated history") { - t.Fatalf("first message is not the truncation note: %q", note) - } - body, _ := out.Encode() - ids := FindMarkers(string(body)) - if len(ids) == 0 { - t.Fatalf("truncation left no recoverable marker") - } - if _, ok := e.Expand(ids[0]); !ok { - t.Fatalf("Expand could not recover the truncated span %s", ids[0]) - } -} - -// TestSummarizeReplacesHistory: summarize replaces older turns with one and -// keeps the last KeepLast verbatim; the dropped span is recoverable. -func TestSummarizeReplacesHistory(t *testing.T) { - s := config.Default() - s.Compactors = []string{summarizeName} - s.SummarizeKeepLast = 1 - e := New(s, nil, nil) - e.EnableSummarize(fakeModel{out: "KEYFACTS about the bug"}, DefaultSummarizeConfig()) - - out, _ := e.Transform(context.Background(), sixMessages(t)) - msgs := out.Messages() - if len(msgs) != 2 { // 1 summary note + last 1 - t.Fatalf("want 2 messages (summary + last 1), got %d", len(msgs)) - } - note, _ := msgs[0]["content"].(string) - if !strings.Contains(note, "") || !strings.Contains(note, "KEYFACTS") { - t.Fatalf("summary note missing summary/content: %q", note) - } - body, _ := out.Encode() - if ids := FindMarkers(string(body)); len(ids) == 0 { - t.Fatalf("summarize left no recoverable marker") - } -} - -// taggerCompactor is a trivial custom Compactor used to prove a new approach plugs in -// purely by name, with no engine edits. -type taggerCompactor struct{} - -func (taggerCompactor) Name() string { return "tagger" } -func (taggerCompactor) Enabled(*Context) bool { return true } -func (taggerCompactor) Compact(req canon.Request, _ *Report, _ *Context) (canon.Request, error) { - msgs := req.Messages() - msgs = append(msgs, map[string]any{"role": "user", "content": "TAGGED"}) - req.SetMessages(msgs) - return req, nil -} - -// TestCustomCompactorByName: registering a Compactor by name and listing it in -// Settings.Compactors runs it — the generic-abstraction extension path. -func TestCustomCompactorByName(t *testing.T) { - s := config.Default() - s.Compactors = []string{"tagger"} - e := New(s, nil, nil) - e.Register("tagger", taggerCompactor{}) - - out, _ := e.Transform(context.Background(), sixMessages(t)) - msgs := out.Messages() - last, _ := msgs[len(msgs)-1]["content"].(string) - if last != "TAGGED" { - t.Fatalf("custom compactor did not run; last message = %q", last) - } -} - -// TestIncomingModelSpec: a compactor with ModelSpec{UseIncoming:true} no-ops without a -// request model and runs when the host supplies one via WithRequestModel. -func TestIncomingModelSpec(t *testing.T) { - s := config.Default() - s.Compactors = []string{summarizeName} - s.SummarizeKeepLast = 1 - e := New(s, nil, nil) - e.EnableSummarizeSpec(ModelSpec{UseIncoming: true}, DefaultSummarizeConfig()) - - // No request model → fail-open no-op (all six messages survive). - out, _ := e.Transform(context.Background(), sixMessages(t)) - if len(out.Messages()) != 6 { - t.Fatalf("without a request model, summarize should no-op; got %d messages", len(out.Messages())) - } - - // With a request model → summarized down to note + last 1. - ctx := WithRequestModel(context.Background(), fakeModel{out: "incoming"}) - out2, _ := e.Transform(ctx, sixMessages(t)) - if len(out2.Messages()) != 2 { - t.Fatalf("with a request model, summarize should run; got %d messages", len(out2.Messages())) - } -} diff --git a/engine/engine.go b/engine/engine.go deleted file mode 100644 index 1fc7990..0000000 --- a/engine/engine.go +++ /dev/null @@ -1,203 +0,0 @@ -// Package engine is the public entry point: it runs a pipeline of Compactors over a -// canonical request and exposes Transform (reduce a request, fail-open) and Expand -// (recover a collapsed block). A host — the proxy binary, an eval-containers adapter, -// or a Kagenti AuthBridge plugin — wraps an Engine; the engine has no transport code. -package engine - -import ( - "context" - - "github.com/kagenti/lab-context-engineering/canon" - "github.com/kagenti/lab-context-engineering/config" - "github.com/kagenti/lab-context-engineering/internal/cache" - "github.com/kagenti/lab-context-engineering/internal/markers" - "github.com/kagenti/lab-context-engineering/internal/reduce" - "github.com/kagenti/lab-context-engineering/internal/store" -) - -// FindMarkers returns the rewind ids of any reversible markers in text. A host uses -// this to serve an expand request or to rehydrate collapsed blocks before forwarding -// (e.g. ahead of a summarization turn). Pair with Engine.Expand to recover originals. -func FindMarkers(text string) []string { return markers.FindIDs(text) } - -// Model is the LLM client an LLM-using Compactor calls. The host (the proxy, or a -// Kagenti AuthBridge plugin) injects a concrete implementation; the engine core has no -// model client of its own. -type Model interface { - Complete(ctx context.Context, prompt string) (string, error) -} - -// ModelSpec resolves to the Model a Compactor should use for a request. This is the -// ONE credential mechanism shared by every LLM compactor (extract, summarize, and any -// future approach): Source "config" uses Static — a client built once from the -// configured transport + credentials; Source "incoming" (UseIncoming) uses the -// per-request model the host built from the proxied request's own model + credentials -// (Context.RequestModel). -type ModelSpec struct { - Static Model - UseIncoming bool -} - -// Resolve returns the Model for this request, or nil if none is available (fail-open: -// the compactor then leaves the conversation untouched). -func (s ModelSpec) Resolve(c *Context) Model { - if s.UseIncoming { - return c.RequestModel - } - return s.Static -} - -type reqModelKey struct{} - -// WithRequestModel returns a context carrying the model built from the incoming -// request's own model + credentials. The proxy sets this per request; compactors with -// a ModelSpec{UseIncoming:true} resolve to it. -func WithRequestModel(ctx context.Context, m Model) context.Context { - return context.WithValue(ctx, reqModelKey{}, m) -} - -func requestModel(ctx context.Context) Model { - m, _ := ctx.Value(reqModelKey{}).(Model) - return m -} - -// Context is the per-request state a compactor may read. -type Context struct { - Settings config.Settings - Store store.Rewind - Evictions *store.Eviction - ClientCacheFloor int // cache.FloorIndex of the incoming request - StickyIDs map[string]struct{} // ids reduced on prior turns (cache stability) - RequestModel Model // model built from the incoming request (nil if none) - GoCtx context.Context -} - -// Compactor is the single abstraction every compaction approach implements: given the -// canonical request (messages + tools) it returns the transformed request. reduce, -// extract, summarize, truncate and cache all implement it. Add a new approach by -// implementing Compactor and registering it by name (Engine.Register), then listing -// that name in Settings.Compactors. -type Compactor interface { - Name() string - Enabled(*Context) bool - // Compact transforms the conversation and returns the new request. Fail-open: the - // engine snapshots and restores the request if Compact returns an error or panics. - Compact(req canon.Request, agg *Report, c *Context) (canon.Request, error) -} - -// Report aggregates what the pipeline did. Embeds the reduce report and adds -// cross-compactor info. -type Report struct { - Skipped bool - StageErrors int - Candidates []reduce.Candidate - CacheInjected bool - Reduce reduce.Report -} - -// Engine runs a name-resolved pipeline of Compactors over requests. Construct with New. -type Engine struct { - settings config.Settings - store store.Rewind - evict *store.Eviction - registry map[string]Compactor -} - -// Compactor names. defaultPipeline runs extract BEFORE reduce so the extractor sees -// large structured tool outputs intact; reduce then applies lossless actions to -// whatever remains, and cache injects breakpoints last. -const ( - reduceName = "reduce" - extractName = "extract" - summarizeName = "summarize" - truncateName = "truncate" - cacheName = "cache" -) - -var defaultPipeline = []string{extractName, reduceName, cacheName} - -// New builds an engine with the built-in deterministic compactors registered -// (reduce, truncate, cache). LLM compactors (extract, summarize) are added by the host -// via EnableExtract / EnableSummarize. The pipeline order is Settings.Compactors (by -// name), or defaultPipeline when empty; names with no registered compactor are skipped. -func New(settings config.Settings, st store.Rewind, ev *store.Eviction) *Engine { - if st == nil { - st = store.NewMemory(0) - } - if ev == nil { - ev = store.NewEviction() - } - e := &Engine{settings: settings, store: st, evict: ev, registry: map[string]Compactor{}} - e.Register(reduceName, Reducer{}) - e.Register(truncateName, Truncator{}) - e.Register(cacheName, Cacher{}) - return e -} - -// Register adds (or overrides) a compactor by name. It participates in the pipeline -// only when its name appears in the resolved order (Settings.Compactors or default). -func (e *Engine) Register(name string, c Compactor) { e.registry[name] = c } - -func (e *Engine) order() []string { - if len(e.settings.Compactors) > 0 { - return e.settings.Compactors - } - return defaultPipeline -} - -// Store exposes the rewind store so the host can serve expand requests. -func (e *Engine) Store() store.Rewind { return e.store } - -// Expand recovers the original content for a rewind id. -func (e *Engine) Expand(id string) (string, bool) { return e.store.Get(id) } - -// Transform runs the compactor pipeline over req and returns a Report. It never returns -// an error: any compactor failure is isolated (the request is restored to its pre-step -// state) and counted, so the worst case is a no-op forward. The returned request is the -// one the host should forward. -func (e *Engine) Transform(goctx context.Context, req canon.Request) (canon.Request, Report) { - if e.settings.Disabled { - return req, Report{Skipped: true} - } - ctx := &Context{ - Settings: e.settings, Store: e.store, Evictions: e.evict, - ClientCacheFloor: cache.FloorIndex(req.Root), - RequestModel: requestModel(goctx), GoCtx: goctx, - } - var agg Report - for _, name := range e.order() { - c, ok := e.registry[name] - if !ok { - continue - } - if !c.Enabled(ctx) { - continue - } - out, err := safeRun(c, req, &agg, ctx) - if err != nil { - agg.StageErrors++ - continue - } - req = out - } - return req, agg -} - -// safeRun isolates a compactor: it snapshots the request and restores it if Compact -// returns an error or panics, so a faulty compactor cannot corrupt the forwarded -// request. -func safeRun(c Compactor, req canon.Request, agg *Report, ctx *Context) (out canon.Request, err error) { - snapshot := req.Clone() - out = req - defer func() { - if r := recover(); r != nil { - out = canon.Request{Root: snapshot.Root} - err = errFromPanic(r) - } - }() - res, e := c.Compact(req, agg, ctx) - if e != nil { - return canon.Request{Root: snapshot.Root}, e - } - return res, nil -} diff --git a/engine/engine_test.go b/engine/engine_test.go deleted file mode 100644 index 8b7591c..0000000 --- a/engine/engine_test.go +++ /dev/null @@ -1,81 +0,0 @@ -package engine - -import ( - "context" - "strings" - "testing" - - "github.com/kagenti/lab-context-engineering/canon" - "github.com/kagenti/lab-context-engineering/config" - "github.com/kagenti/lab-context-engineering/internal/markers" -) - -func bigText(n int) string { - return strings.Repeat("a.go some reasonably long line of file content here\n", n) -} - -// TestTransformReducesAndCaches: a stale read is collapsed (reduce) and the request -// gets cache breakpoints (cache stage, since the client didn't self-cache). -func TestTransformReducesAndCaches(t *testing.T) { - body := []byte(`{"system":"be helpful","tools":[{"name":"read"}],"messages":[ - {"role":"user","content":[{"type":"text","text":"fix bug"}]}, - {"role":"assistant","content":[{"type":"tool_use","id":"u1","name":"read","input":{"file_path":"a.go"}}]}, - {"role":"user","content":[{"type":"tool_result","tool_use_id":"u1","content":"` + strings.ReplaceAll(bigText(12), "\n", "\\n") + `"}]}, - {"role":"assistant","content":[{"type":"tool_use","id":"u2","name":"edit","input":{"file_path":"a.go"}}]}, - {"role":"user","content":[{"type":"tool_result","tool_use_id":"u2","content":"success: edited"}]}, - {"role":"user","content":[{"type":"text","text":"thanks"}]} - ]}`) - req, err := canon.Decode(body) - if err != nil { - t.Fatalf("decode: %v", err) - } - s := config.Default() - s.ProtectRecent = 1 - e := New(s, nil, nil) - - out, rep := e.Transform(context.Background(), req) - - if rep.Reduce.TokensSaved <= 0 { - t.Fatalf("expected reduction; before=%d after=%d", rep.Reduce.TokensBefore, rep.Reduce.TokensAfter) - } - if !rep.CacheInjected { - t.Fatalf("expected cache stage to run on a non-self-caching client") - } - // The collapsed block carries a marker recoverable via Expand. - out2, _ := out.Encode() - ids := markers.FindIDs(string(out2)) - if len(ids) == 0 { - t.Fatalf("expected a lab-cx marker in the reduced request") - } - if _, ok := e.Expand(ids[0]); !ok { - t.Fatalf("Expand could not recover marker %s", ids[0]) - } -} - -// TestTransformDisabledIsNoop verifies the global kill switch forwards untouched. -func TestTransformDisabledIsNoop(t *testing.T) { - body := []byte(`{"messages":[{"role":"user","content":[{"type":"text","text":"hi"}]}]}`) - req, _ := canon.Decode(body) - s := config.Default() - s.Disabled = true - e := New(s, nil, nil) - _, rep := e.Transform(context.Background(), req) - if !rep.Skipped { - t.Fatalf("disabled engine should skip") - } -} - -// TestSelfCachingClientSkipsCacheStage: when the client already set cache_control, -// the cache stage stands down (ClientCacheFloor >= 0). -func TestSelfCachingClientSkipsCacheStage(t *testing.T) { - body := []byte(`{"messages":[ - {"role":"user","content":[{"type":"text","text":"hi","cache_control":{"type":"ephemeral"}}]}, - {"role":"assistant","content":[{"type":"text","text":"hello"}]} - ]}`) - req, _ := canon.Decode(body) - e := New(config.Default(), nil, nil) - _, rep := e.Transform(context.Background(), req) - if rep.CacheInjected { - t.Fatalf("cache stage should stand down for a self-caching client") - } -} diff --git a/engine/extract_cache_test.go b/engine/extract_cache_test.go deleted file mode 100644 index b59b13a..0000000 --- a/engine/extract_cache_test.go +++ /dev/null @@ -1,111 +0,0 @@ -package engine - -import ( - "context" - "encoding/json" - "strings" - "sync" - "testing" - - "github.com/kagenti/lab-context-engineering/canon" - "github.com/kagenti/lab-context-engineering/config" -) - -// recordingModel returns the first two records of the JSON array embedded in the -// prompt (a contained subset, so extraction succeeds and is cached) and records -// every prompt it is asked to complete. The recorded count makes a wrongly-shared -// cache observable: a body-only key calls the model once across two goals; a -// goal-aware key calls it once per goal. -type recordingModel struct { - mu sync.Mutex - prompts []string -} - -func (m *recordingModel) Complete(_ context.Context, prompt string) (string, error) { - m.mu.Lock() - m.prompts = append(m.prompts, prompt) - m.mu.Unlock() - - const marker = "INPUT (return a smaller value of this same shape):\n" - i := strings.Index(prompt, marker) - if i < 0 { - return "", nil - } - rest := prompt[i+len(marker):] - if e := strings.Index(rest, "\n\n"); e >= 0 { - rest = rest[:e] - } - var arr []any - if json.Unmarshal([]byte(rest), &arr) == nil && len(arr) >= 2 { - b, _ := json.Marshal(arr[:2]) - return string(b), nil - } - return "", nil -} - -func (m *recordingModel) calls() int { - m.mu.Lock() - defer m.mu.Unlock() - return len(m.prompts) -} - -// reqWithGoal builds a request whose tool_result body is the shared large array -// and whose trailing user text is the goal. Using the "single" strategy forces the -// model to receive the body inline (so recordingModel can echo a contained subset) -// and keeps the per-request model-call count deterministic at one. -func reqWithGoal(t *testing.T, arr, goal string) canon.Request { - t.Helper() - body := []byte(`{"messages":[ - {"role":"assistant","content":[{"type":"tool_use","id":"u1","name":"list_items","input":{}}]}, - {"role":"user","content":[{"type":"tool_result","tool_use_id":"u1","content":` + jsonStr(arr) + `}]}, - {"role":"user","content":[{"type":"text","text":` + jsonStr(goal) + `}]} - ]}`) - req, err := canon.Decode(body) - if err != nil { - t.Fatalf("decode: %v", err) - } - return req -} - -// TestExtractionCacheIsGoalAware asserts that the same tool-output body re-read under -// a DIFFERENT agent goal does NOT reuse the first goal's filtered result. Observed at -// the cache level: with a body-only key the model is called once (goal B reuses goal -// A's entry); with the goal-aware key it is called twice (once per goal). -func TestExtractionCacheIsGoalAware(t *testing.T) { - var recs []string - for i := 0; i < 400; i++ { - recs = append(recs, `{"id":`+itoa(i)+`,"name":"item_`+itoa(i)+`","detail":"some detail text for this record"}`) - } - arr := "[" + strings.Join(recs, ",") + "]" - - model := &recordingModel{} - s := config.Default() - s.ProtectRecent = 1 - eng := New(s, nil, nil) - // "single" forces the inline-body strategy so each goal makes exactly one model - // call; the cache key is what decides whether goal B reuses goal A. - cfg := DefaultExtractConfig() - cfg.Mode = "single" - eng.EnableExtract(model, cfg) - - reqA := reqWithGoal(t, arr, "goal A: inspect the items for duplicate ids") - reqB := reqWithGoal(t, arr, "goal B: summarize the names of every record") - - if _, rep := eng.Transform(context.Background(), reqA); len(rep.Candidates) == 0 { - t.Fatalf("goal A: expected at least one extraction candidate") - } - afterA := model.calls() - if afterA == 0 { - t.Fatalf("goal A: expected the model to be called") - } - - if _, rep := eng.Transform(context.Background(), reqB); len(rep.Candidates) == 0 { - t.Fatalf("goal B: expected at least one extraction candidate") - } - afterB := model.calls() - - if afterB <= afterA { - t.Fatalf("goal B reused goal A's cached result (cache is body-only, not goal-aware): "+ - "model calls after A=%d, after B=%d", afterA, afterB) - } -} diff --git a/engine/extract_engine_test.go b/engine/extract_engine_test.go deleted file mode 100644 index 94c73f6..0000000 --- a/engine/extract_engine_test.go +++ /dev/null @@ -1,84 +0,0 @@ -package engine - -import ( - "context" - "encoding/json" - "strings" - "testing" - - "github.com/kagenti/lab-context-engineering/canon" - "github.com/kagenti/lab-context-engineering/config" -) - -// firstTwoModel returns the first two records of the JSON array embedded in the -// prompt — a content-aware, always-contained selection. -type firstTwoModel struct{} - -func (firstTwoModel) Complete(_ context.Context, prompt string) (string, error) { - const marker = "INPUT (return a smaller value of this same shape):\n" - i := strings.Index(prompt, marker) - if i < 0 { - return "", nil - } - rest := prompt[i+len(marker):] - if e := strings.Index(rest, "\n\n"); e >= 0 { - rest = rest[:e] - } - var arr []any - if json.Unmarshal([]byte(rest), &arr) == nil && len(arr) >= 2 { - b, _ := json.Marshal(arr[:2]) - return string(b), nil - } - return "", nil -} - -func TestExtractionEndToEnd(t *testing.T) { - // A large structured tool output (well over the 3000-token floor) that the - // deterministic path would only re-encode; extraction selects 2 records. - var recs []string - for i := 0; i < 400; i++ { - recs = append(recs, `{"id":`+itoa(i)+`,"name":"item_`+itoa(i)+`","detail":"some detail text for this record"}`) - } - arr := "[" + strings.Join(recs, ",") + "]" - - body := []byte(`{"messages":[ - {"role":"assistant","content":[{"type":"tool_use","id":"u1","name":"list_items","input":{}}]}, - {"role":"user","content":[{"type":"tool_result","tool_use_id":"u1","content":` + jsonStr(arr) + `}]}, - {"role":"user","content":[{"type":"text","text":"now inspect the results"}]} - ]}`) - req, err := canon.Decode(body) - if err != nil { - t.Fatalf("decode: %v", err) - } - - s := config.Default() - s.ProtectRecent = 1 - eng := New(s, nil, nil) - eng.EnableExtract(firstTwoModel{}, DefaultExtractConfig()) - - out, rep := eng.Transform(context.Background(), req) - if len(rep.Candidates) == 0 { - t.Fatalf("expected at least one extraction candidate") - } - outBody, _ := out.Encode() - ids := FindMarkers(string(outBody)) - if len(ids) == 0 { - t.Fatalf("expected extracted block to carry a recoverable marker") - } - if _, ok := eng.Expand(ids[0]); !ok { - t.Fatalf("Expand failed for extracted marker") - } - if len(outBody) >= len(body) { - t.Fatalf("extraction did not shrink the request: %d vs %d", len(outBody), len(body)) - } -} - -func itoa(n int) string { - b, _ := json.Marshal(n) - return string(b) -} - -func jsonStr(s string) string { - b, _ := json.Marshal(s) - return string(b) -} diff --git a/engine/extractfunc.go b/engine/extractfunc.go deleted file mode 100644 index 7211d8f..0000000 --- a/engine/extractfunc.go +++ /dev/null @@ -1,202 +0,0 @@ -package engine - -import ( - "crypto/sha256" - "encoding/hex" - "strings" - - "github.com/kagenti/lab-context-engineering/canon" - "github.com/kagenti/lab-context-engineering/internal/extract" - "github.com/kagenti/lab-context-engineering/internal/markers" - "github.com/kagenti/lab-context-engineering/internal/reduce" - "github.com/kagenti/lab-context-engineering/internal/store" - "github.com/kagenti/lab-context-engineering/internal/tokens" -) - -const goalTurns = 6 // recent turns whose text conditions extraction + keep-set - -// ExtractConfig configures cheap-model extraction (public mirror of the internal cfg). -type ExtractConfig struct { - Mode string // "auto" | "single" | "rlm" | "deterministic" - Floor int // token floor; rlm kicks in at max(floor*4,8000) in auto - MinKeepRatio float64 // 0 disables the blunt ratio backstop - AllowDeterministic bool - MaxChars int - // Strategies, when non-empty, restricts the strategy order to these names by-name - // (code | single | rlm | deterministic). Empty means "all". - Strategies []string -} - -// DefaultExtractConfig returns sensible extraction defaults. -func DefaultExtractConfig() ExtractConfig { - c := extract.DefaultCfg() - return ExtractConfig{Mode: c.Mode, Floor: c.Floor, AllowDeterministic: c.AllowDeterministic, MaxChars: c.MaxChars} -} - -func (c ExtractConfig) internal() extract.Cfg { - return extract.Cfg{Mode: c.Mode, Floor: c.Floor, MinKeepRatio: c.MinKeepRatio, - AllowDeterministic: c.AllowDeterministic, MaxChars: c.MaxChars, - AllowedStrategies: c.Strategies} -} - -// Extractor is the cheap-model extraction compactor. It does its OWN detection of large -// structured tool outputs in the conversation (independent of the reduce pass), projects -// each to a smaller faithful subset via the model, and splices it back with a reversible -// recovery marker. Fail-open per candidate. Implements Compactor. -type Extractor struct { - spec ModelSpec - icfg extract.Cfg - cache *extract.Cache -} - -// NewExtractor builds the extract compactor with the given model spec + config. -func NewExtractor(spec ModelSpec, cfg ExtractConfig) *Extractor { - return &Extractor{spec: spec, icfg: cfg.internal(), cache: extract.NewCache()} -} - -func (*Extractor) Name() string { return extractName } -func (*Extractor) Enabled(c *Context) bool { return c.Settings.ExtractEnabled } - -func (x *Extractor) Compact(req canon.Request, agg *Report, c *Context) (canon.Request, error) { - model := x.spec.Resolve(c) - if model == nil { - return req, nil // no model available (e.g. incoming creds missing) — fail-open - } - opts := reduce.DefaultOpts() - opts.ProtectRecent = c.Settings.ProtectRecent - opts.ProtectRecentToolUses = c.Settings.ProtectRecentToolUses - opts.ProvableOnly = c.Settings.ProvableOnly - opts.ContextLimit = c.Settings.ContextLimit - opts.ReduceCachedPrefix = c.Settings.ReduceCachedPrefix - opts.CacheFloor = c.ClientCacheFloor - opts.LLMCompactFloor = c.Settings.LLMCompactFloor - opts.LLMCompactStructuredOnly = c.Settings.LLMCompactStructuredOnly - - cands := reduce.SelectLLMCandidates(req, opts) - agg.Candidates = cands - goal := recentGoalText(req, goalTurns) - keep := extract.HarvestIdentifiers(goal, 60) - for _, cand := range cands { - func() { - defer func() { _ = recover() }() // fail-open per candidate - key := goalKey(extract.ContentKey(cand.Text), goal, keep) - result, ok := x.cache.Get(key) - if !ok { - var strat string - result, strat = extract.RunExtraction(c.GoCtx, cand.Text, goal, keep, cand.TokenEst, x.icfg, model) - if strat == "none" || result == "" { - return - } - x.cache.Put(key, result) - } - splice(req, cand, result, c.Store) - }() - } - return req, nil -} - -// EnableExtract registers the extract compactor backed by a static model (config -// source). Settings.ExtractMode / ExtractStrategies override cfg when set, so a config -// file fully drives the run. -func (e *Engine) EnableExtract(model Model, cfg ExtractConfig) { - e.EnableExtractSpec(ModelSpec{Static: model}, cfg) -} - -// EnableExtractSpec registers the extract compactor with an explicit ModelSpec — use -// ModelSpec{UseIncoming:true} to reuse the proxied request's own model + credentials. -func (e *Engine) EnableExtractSpec(spec ModelSpec, cfg ExtractConfig) { - if e.settings.ExtractMode != "" { - cfg.Mode = e.settings.ExtractMode - } - if len(e.settings.ExtractStrategies) > 0 { - cfg.Strategies = e.settings.ExtractStrategies - } - e.settings.ExtractEnabled = true - e.Register(extractName, NewExtractor(spec, cfg)) -} - -// goalKey makes the extraction cache goal-aware: the cache value is the FILTERED -// result, which depends on the goal and keep-set, not just the body. The key composites -// the body's content key with the goal and the keep-set so a different goal is a miss. -func goalKey(contentKey, goal string, keep []string) string { - h := sha256.New() - h.Write([]byte(contentKey)) - h.Write([]byte{0}) - h.Write([]byte(goal)) - for _, k := range keep { - h.Write([]byte{0}) - h.Write([]byte(k)) - } - return hex.EncodeToString(h.Sum(nil))[:24] -} - -// splice replaces the candidate block with the extracted result plus a reversible -// recovery marker (original stored), only if that is strictly smaller. -func splice(req canon.Request, c reduce.Candidate, result string, st store.Rewind) { - block := blockAt(req, c.MsgIndex, c.BlockIndex) - if block == nil { - return - } - rid := st.Put(c.Text) - label := c.FilePath - if label == "" { - label = c.ToolName - } - if label == "" { - label = "tool output" - } - newText := strings.TrimRight(result, "\n") + "\n" + markers.RecoveryNote(label, "extracted", rid) - if tokens.Count(newText) >= tokens.Count(c.Text) { - return // never inflate - } - switch block["type"] { - case "tool_result": - block["content"] = newText - case "text": - block["text"] = newText - } -} - -func blockAt(req canon.Request, mi, bi int) map[string]any { - msgs := req.Messages() - if mi < 0 || mi >= len(msgs) { - return nil - } - list, ok := msgs[mi]["content"].([]any) - if !ok || bi < 0 || bi >= len(list) { - return nil - } - blk, _ := list[bi].(map[string]any) - return blk -} - -// recentGoalText concatenates the text of the last k turns — the agent's current -// focus — excluding tool_result content (harvesting ids from the very output being -// extracted would defeat extraction). -func recentGoalText(req canon.Request, k int) string { - msgs := req.Messages() - start := len(msgs) - k - if start < 0 { - start = 0 - } - var out []string - for _, m := range msgs[start:] { - switch c := m["content"].(type) { - case string: - out = append(out, c) - case []any: - for _, b := range c { - if bb, ok := b.(map[string]any); ok && bb["type"] == "text" { - if t, ok := bb["text"].(string); ok { - out = append(out, t) - } - } - } - } - } - s := strings.Join(out, "\n") - if len(s) > 6000 { - s = s[:6000] - } - return s -} diff --git a/engine/integration_test.go b/engine/integration_test.go deleted file mode 100644 index a42b063..0000000 --- a/engine/integration_test.go +++ /dev/null @@ -1,63 +0,0 @@ -package engine_test - -// This file imports ONLY the public packages, exactly as an external consumer (e.g. -// the Kagenti AuthBridge plugin in kagenti-extensions) would. It stands in for that -// plugin: take the raw request body the host already holds, reduce it, forward the -// rendered bytes, and recover an omitted block via Expand. If this compiles and -// passes, the public integration surface is intact. - -import ( - "context" - "strings" - "testing" - - "github.com/kagenti/lab-context-engineering/canon" - "github.com/kagenti/lab-context-engineering/config" - "github.com/kagenti/lab-context-engineering/engine" - "github.com/kagenti/lab-context-engineering/surfaces" -) - -func TestPublicAPI_AuthBridgeStyleWrap(t *testing.T) { - // What a host plugin does, end to end. - s := config.Default() - s.ProtectRecent = 1 - eng := engine.New(s, nil, nil) - surface := surfaces.Anthropic{} // pick by the request's wire format - - rawBody := []byte(`{"messages":[ - {"role":"assistant","content":[{"type":"tool_use","id":"u1","name":"read","input":{"file_path":"a.go"}}]}, - {"role":"user","content":[{"type":"tool_result","tool_use_id":"u1","content":"` + - strings.Repeat("a.go a fairly long line of read content goes here\\n", 12) + `"}]}, - {"role":"assistant","content":[{"type":"tool_use","id":"u2","name":"edit","input":{"file_path":"a.go"}}]}, - {"role":"user","content":[{"type":"tool_result","tool_use_id":"u2","content":"success: edited"}]}, - {"role":"user","content":[{"type":"text","text":"thanks"}]} - ]}`) - - req, token, err := surface.ToInternal(rawBody) - if err != nil { - t.Fatalf("ToInternal: %v", err) - } - reduced, _ := eng.Transform(context.Background(), req) - outBody, err := surface.Render(reduced, token) - if err != nil { - t.Fatalf("Render: %v", err) - } - if len(outBody) >= len(rawBody) { - t.Fatalf("expected reduced body to be smaller: %d vs %d", len(outBody), len(rawBody)) - } - - // The host can recover any omitted block from the marker (e.g. to serve an - // expand tool call, or to rehydrate before a summarization turn). - ids := engine.FindMarkers(string(outBody)) - if len(ids) == 0 { - t.Fatalf("expected a recoverable marker in the reduced body") - } - if _, ok := eng.Expand(ids[0]); !ok { - t.Fatalf("Expand failed for %s", ids[0]) - } - - // A re-decode of the forwarded bytes is still a valid canonical request. - if _, err := canon.Decode(outBody); err != nil { - t.Fatalf("forwarded body is not valid JSON: %v", err) - } -} diff --git a/engine/live_test.go b/engine/live_test.go deleted file mode 100644 index 66a7449..0000000 --- a/engine/live_test.go +++ /dev/null @@ -1,92 +0,0 @@ -package engine - -import ( - "context" - "os" - "strings" - "testing" - - "github.com/kagenti/lab-context-engineering/canon" - "github.com/kagenti/lab-context-engineering/config" - "github.com/kagenti/lab-context-engineering/internal/cheapmodel" -) - -// These are opt-in live checks of the summarize/truncate compactors against a real -// claude-haiku-4-5. They skip unless LABCX_LIVE=1 (+ gateway env), so CI never calls out. -// -// LABCX_LIVE=1 CGO_ENABLED=1 go test ./engine -run TestLive -v - -func liveModel(t *testing.T) Model { - t.Helper() - base, key := os.Getenv("ANTHROPIC_BASE_URL"), os.Getenv("ANTHROPIC_AUTH_TOKEN") - if os.Getenv("LABCX_LIVE") != "1" || base == "" || key == "" { - t.Skip("set LABCX_LIVE=1 + ANTHROPIC_BASE_URL + ANTHROPIC_AUTH_TOKEN to run the live check") - } - return cheapmodel.Anthropic{BaseURL: base, APIKey: key, Model: "claude-haiku-4-5", AuthScheme: "bearer"} -} - -func multiTurn(t *testing.T) canon.Request { - t.Helper() - body := []byte(`{"system":"You are a coding agent.","messages":[ - {"role":"user","content":[{"type":"text","text":"Bug: parse_config crashes on empty files. Repo myapp, file src/config.py."}]}, - {"role":"assistant","content":[{"type":"text","text":"I will read src/config.py."}]}, - {"role":"user","content":[{"type":"text","text":"src/config.py defines parse_config(path): it opens the file and calls json.load without handling empty content."}]}, - {"role":"assistant","content":[{"type":"text","text":"I will add an empty-file guard returning {}."}]}, - {"role":"user","content":[{"type":"text","text":"Tests live in tests/test_config.py; add a case for the empty file."}]}, - {"role":"assistant","content":[{"type":"text","text":"Patch drafted; running tests."}]}, - {"role":"user","content":[{"type":"text","text":"All tests pass now. Summarize what changed."}]} - ]}`) - req, err := canon.Decode(body) - if err != nil { - t.Fatalf("decode: %v", err) - } - return req -} - -func TestLiveSummarize(t *testing.T) { - model := liveModel(t) - s := config.Default() - s.Compactors = []string{summarizeName} - s.SummarizeKeepLast = 2 - e := New(s, nil, nil) - e.EnableSummarize(model, DefaultSummarizeConfig()) - - out, _ := e.Transform(context.Background(), multiTurn(t)) - msgs := out.Messages() - if len(msgs) != 3 { // summary note + last 2 - t.Fatalf("want 3 messages (summary + last 2), got %d", len(msgs)) - } - note, _ := msgs[0]["content"].(string) - if !strings.Contains(note, "") { - t.Fatalf("summary note missing : %q", note) - } - body, _ := out.Encode() - ids := FindMarkers(string(body)) - if len(ids) == 0 { - t.Fatal("no recovery marker on summarized request") - } - if _, ok := e.Expand(ids[0]); !ok { - t.Fatal("Expand failed for summarized span") - } - t.Logf("7 messages -> %d (1 summary + last 2); dropped span recoverable via id %s", len(msgs), ids[0]) - t.Logf("LIVE SUMMARY NOTE:\n%s", note) -} - -func TestLiveTruncate(t *testing.T) { - if os.Getenv("LABCX_LIVE") != "1" { - t.Skip("set LABCX_LIVE=1 to run") - } - s := config.Default() - s.Compactors = []string{truncateName} - s.TruncateEnabled = true - s.TruncateKeepLast = 2 - e := New(s, nil, nil) - - out, _ := e.Transform(context.Background(), multiTurn(t)) - msgs := out.Messages() - if len(msgs) != 3 { - t.Fatalf("want 3 messages (note + last 2), got %d", len(msgs)) - } - t.Logf("7 messages -> %d (1 note + last 2)", len(msgs)) - t.Logf("TRUNCATE NOTE:\n%s", msgs[0]["content"].(string)) -} diff --git a/engine/stages.go b/engine/stages.go deleted file mode 100644 index 9ab6ef4..0000000 --- a/engine/stages.go +++ /dev/null @@ -1,102 +0,0 @@ -package engine - -import ( - "encoding/json" - "fmt" - - "github.com/kagenti/lab-context-engineering/canon" - "github.com/kagenti/lab-context-engineering/internal/cache" - "github.com/kagenti/lab-context-engineering/internal/markers" - "github.com/kagenti/lab-context-engineering/internal/reduce" - "github.com/kagenti/lab-context-engineering/internal/tokens" -) - -func errFromPanic(r any) error { return fmt.Errorf("compactor panic: %v", r) } - -// Reducer runs the deterministic, lossless-first reduction pass (collapse / skeleton / -// format, cmdfilter, dedup). No model. Large structured tool outputs are left for the -// extract compactor (which runs first in the default pipeline); whatever it does not -// claim, Reducer reduces losslessly here. -type Reducer struct{} - -func (Reducer) Name() string { return reduceName } -func (Reducer) Enabled(c *Context) bool { return c.Settings.ReduceEnabled } - -func (Reducer) Compact(req canon.Request, agg *Report, c *Context) (canon.Request, error) { - s := c.Settings - opts := reduce.DefaultOpts() - opts.ProtectRecent = s.ProtectRecent - opts.ProtectRecentToolUses = s.ProtectRecentToolUses - opts.ProvableOnly = s.ProvableOnly - opts.CollapseOutputs = s.CollapseOutputs - opts.ContextLimit = s.ContextLimit - opts.ReduceCachedPrefix = s.ReduceCachedPrefix - opts.CmdFilter = s.CmdFilter - opts.RehydrateOnCompaction = s.RehydrateOnCompaction - opts.EnabledReducers = s.Reducers - opts.EnabledEncoders = s.Encoders - opts.CacheFloor = c.ClientCacheFloor - opts.StickyIDs = c.StickyIDs - - rep := reduce.ReduceRequest(req, c.Store, c.Evictions, opts) - agg.Reduce = rep - return req, nil -} - -// Cacher injects ephemeral cache_control breakpoints on the stable prefix. Only active -// when the client did not already self-cache (ClientCacheFloor < 0): a self-caching -// client (e.g. Claude Code) keeps its own breakpoints. -type Cacher struct{} - -func (Cacher) Name() string { return cacheName } -func (Cacher) Enabled(c *Context) bool { - return c.Settings.CacheEnabled && c.ClientCacheFloor < 0 -} - -func (Cacher) Compact(req canon.Request, agg *Report, c *Context) (canon.Request, error) { - s := c.Settings - anchor := -1 - if msgs, ok := req.Root["messages"].([]any); ok { - anchor = cache.ProtectedAnchorIndex(msgs, s.ProtectRecent, s.ProtectRecentToolUses) - } - cache.Inject(req.Root, s.CacheBreakpoints, s.CacheStableGap, anchor, s.CacheToolsBreakpoint) - agg.CacheInjected = true - return req, nil -} - -// Truncator is the naive baseline: it keeps the last KeepLast messages and drops the -// older ones, replacing them with one recoverable note. No relevance scoring, no model. -// -// ponytail: naive by design — it does not enforce user/assistant role alternation after -// the drop. It is the no-LLM control to measure smarter compactors against. -type Truncator struct{} - -func (Truncator) Name() string { return truncateName } -func (Truncator) Enabled(c *Context) bool { return c.Settings.TruncateEnabled } - -func (Truncator) Compact(req canon.Request, agg *Report, c *Context) (canon.Request, error) { - s := c.Settings - keep := s.TruncateKeepLast - if keep <= 0 { - keep = 3 - } - msgs := req.Messages() - if len(msgs) <= keep { - return req, nil - } - if s.TruncateTriggerTokens > 0 { - if b, err := json.Marshal(msgs); err == nil && tokens.Count(string(b)) < s.TruncateTriggerTokens { - return req, nil - } - } - dropped := msgs[:len(msgs)-keep] - b, _ := json.Marshal(dropped) - rid := c.Store.Put(string(b)) - note := map[string]any{ - "role": "user", - "content": "=== Truncated history ===\n" + - markers.RecoveryNote(fmt.Sprintf("%d earlier message(s)", len(dropped)), "truncated", rid), - } - req.SetMessages(append([]map[string]any{note}, msgs[len(msgs)-keep:]...)) - return req, nil -} diff --git a/engine/summarizefunc.go b/engine/summarizefunc.go deleted file mode 100644 index 7f046d1..0000000 --- a/engine/summarizefunc.go +++ /dev/null @@ -1,133 +0,0 @@ -package engine - -import ( - "encoding/json" - "fmt" - "strings" - - "github.com/kagenti/lab-context-engineering/canon" - "github.com/kagenti/lab-context-engineering/internal/markers" - "github.com/kagenti/lab-context-engineering/internal/summarize" - "github.com/kagenti/lab-context-engineering/internal/tokens" -) - -// SummarizeConfig configures the summarize compactor. -type SummarizeConfig struct { - Level string // concise | regular | highly_detailed - KeepLast int // recent messages kept verbatim after the summary - TriggerTokens int // skip summarizing below this whole-conversation token count -} - -// DefaultSummarizeConfig returns sensible summarizer defaults. -func DefaultSummarizeConfig() SummarizeConfig { - return SummarizeConfig{Level: summarize.LevelRegular, KeepLast: 3} -} - -// Summarizer is the trajectory-summarization compactor (ported from CE-Manager): it -// replaces the older messages with one LLM-produced summary and keeps the last KeepLast -// messages verbatim, storing the dropped span for recovery. Implements Compactor. -type Summarizer struct { - spec ModelSpec - cfg SummarizeConfig -} - -// NewSummarizer builds the summarize compactor with the given model spec + config. -func NewSummarizer(spec ModelSpec, cfg SummarizeConfig) *Summarizer { - if cfg.KeepLast <= 0 { - cfg.KeepLast = 3 - } - if cfg.Level == "" { - cfg.Level = summarize.LevelRegular - } - return &Summarizer{spec: spec, cfg: cfg} -} - -func (*Summarizer) Name() string { return summarizeName } -func (*Summarizer) Enabled(c *Context) bool { return c.Settings.SummarizeEnabled } - -func (x *Summarizer) Compact(req canon.Request, agg *Report, c *Context) (canon.Request, error) { - model := x.spec.Resolve(c) - if model == nil { - return req, nil // no model available — fail-open - } - msgs := req.Messages() - if len(msgs) <= x.cfg.KeepLast { - return req, nil - } - if x.cfg.TriggerTokens > 0 { - if b, err := json.Marshal(msgs); err == nil && tokens.Count(string(b)) < x.cfg.TriggerTokens { - return req, nil - } - } - dropped := msgs[:len(msgs)-x.cfg.KeepLast] - summary, err := summarize.Summarize(c.GoCtx, trajectoryString(dropped), x.cfg.Level, model) - if err != nil { - return req, err - } - b, _ := json.Marshal(dropped) - rid := c.Store.Put(string(b)) - note := map[string]any{ - "role": "user", - "content": "=== History Summary ===\nThe earlier trajectory is summarized below.\n\n" + - summary + "\n\n" + - markers.RecoveryNote(fmt.Sprintf("%d earlier message(s)", len(dropped)), "summarized", rid), - } - req.SetMessages(append([]map[string]any{note}, msgs[len(msgs)-x.cfg.KeepLast:]...)) - return req, nil -} - -// EnableSummarize registers the summarize compactor backed by a static model (config -// source). Settings summarize knobs override cfg when set. -func (e *Engine) EnableSummarize(model Model, cfg SummarizeConfig) { - e.EnableSummarizeSpec(ModelSpec{Static: model}, cfg) -} - -// EnableSummarizeSpec registers the summarize compactor with an explicit ModelSpec — -// use ModelSpec{UseIncoming:true} to reuse the proxied request's own model + creds. -func (e *Engine) EnableSummarizeSpec(spec ModelSpec, cfg SummarizeConfig) { - if e.settings.SummarizeLevel != "" { - cfg.Level = e.settings.SummarizeLevel - } - if e.settings.SummarizeKeepLast > 0 { - cfg.KeepLast = e.settings.SummarizeKeepLast - } - if e.settings.SummarizeTriggerTokens > 0 { - cfg.TriggerTokens = e.settings.SummarizeTriggerTokens - } - e.settings.SummarizeEnabled = true - e.Register(summarizeName, NewSummarizer(spec, cfg)) -} - -// trajectoryString flattens a message list into a "[role]\ncontent" block transcript -// for the summarizer prompt. -func trajectoryString(msgs []map[string]any) string { - var b strings.Builder - for _, m := range msgs { - role, _ := m["role"].(string) - b.WriteString("[" + role + "]\n") - for _, blk := range canon.Blocks(m) { - switch canon.BlockType(blk) { - case "text": - if t, ok := blk["text"].(string); ok { - b.WriteString(t) - } - case "tool_use": - if name, ok := blk["name"].(string); ok { - b.WriteString("(tool_use: " + name + ")") - } - case "tool_result": - switch cc := blk["content"].(type) { - case string: - b.WriteString(cc) - default: - if bb, err := json.Marshal(cc); err == nil { - b.Write(bb) - } - } - } - b.WriteString("\n") - } - b.WriteString("\n") - } - return b.String() -} diff --git a/expand/expand.go b/expand/expand.go new file mode 100644 index 0000000..8d9a6cd --- /dev/null +++ b/expand/expand.go @@ -0,0 +1,80 @@ +// Package expand holds the host-agnostic half of reversibility (design D6, +// after headroom's CCR): the marker format Offload components write, the +// expand(id) tool definition injected per provider, and resolution of a stashed +// original from the Store. +// +// The continuation LOOP that actually answers an expand tool call is host glue +// (the bifrost proxy wraps its chat route; the AuthBridge plugin does it in +// OnResponse) — but every host reuses ParseMarkers, ToolDef, and Resolve from +// here so the wire contract stays identical across integrations. +package expand + +import ( + "regexp" + + "github.com/kagenti/context-guru/store" +) + +// ToolName is the model-callable tool that retrieves offloaded content. +const ToolName = "context_guru_expand" + +// Marker is the sentinel an Offload component writes in place of dropped +// content. HASH is the store key. Sticky-on per session (headroom's golden +// tool-bytes rule) so injecting the tool never busts the provider prefix cache. +// +// <> +var markerRe = regexp.MustCompile(`<>`) + +// Marker renders the sentinel for a given store key. +func Marker(key string) string { return "<>" } + +// ParseMarkers returns the distinct store keys referenced by any markers in s, +// in first-seen order. +func ParseMarkers(s string) []string { + m := markerRe.FindAllStringSubmatch(s, -1) + seen := map[string]struct{}{} + var out []string + for _, g := range m { + k := g[1] + if _, dup := seen[k]; dup { + continue + } + seen[k] = struct{}{} + out = append(out, k) + } + return out +} + +// Resolve returns the original stashed under key, or ("",false) if it's gone +// (expired/evicted). Callers must handle the miss gracefully — an expired +// original silently turns a lossless offload lossy (headroom's known TTL edge). +func Resolve(s store.Store, key string) (string, bool) { + b, ok := s.Get(key) + if !ok { + return "", false + } + return string(b), true +} + +// ToolDef returns the expand tool definition shaped for the given provider +// dialect. OpenAI/Anthropic differ (parameters vs input_schema); the returned +// map is ready to append to the request's tools array. +func ToolDef(provider string) map[string]any { + params := map[string]any{ + "type": "object", + "properties": map[string]any{ + "id": map[string]any{"type": "string", "description": "The HASH from a <> marker to retrieve in full."}, + }, + "required": []string{"id"}, + } + desc := "Retrieve the full original content that was compressed and replaced by a <> marker." + switch provider { + case "anthropic": + return map[string]any{"name": ToolName, "description": desc, "input_schema": params} + default: // openai and compatibles + return map[string]any{ + "type": "function", + "function": map[string]any{"name": ToolName, "description": desc, "parameters": params}, + } + } +} diff --git a/expand/expand_test.go b/expand/expand_test.go new file mode 100644 index 0000000..e763ced --- /dev/null +++ b/expand/expand_test.go @@ -0,0 +1,63 @@ +package expand + +import ( + "testing" + + "github.com/kagenti/context-guru/store" +) + +func TestToolDefShape(t *testing.T) { + an := ToolDef("anthropic") + if an["name"] != ToolName { + t.Fatalf("anthropic tool def name=%v", an["name"]) + } + if _, ok := an["input_schema"].(map[string]any)["properties"]; !ok { + t.Fatalf("anthropic def missing input_schema.properties: %v", an) + } + oa := ToolDef("openai") + if oa["type"] != "function" { + t.Fatalf("openai def should be a function tool: %v", oa) + } + fn, ok := oa["function"].(map[string]any) + if !ok || fn["name"] != ToolName || fn["parameters"] == nil { + t.Fatalf("openai function shape wrong: %v", oa) + } +} + +func TestParseMarkersDistinctInOrder(t *testing.T) { + got := ParseMarkers("a <> b <> c <>") + if len(got) != 2 || got[0] != "K1" || got[1] != "K2" { + t.Fatalf("ParseMarkers=%v want [K1 K2] first-seen, deduped", got) + } + if ParseMarkers("no markers here") != nil { + t.Fatal("text without markers must return nil") + } +} + +func TestResolve(t *testing.T) { + st := store.NewMemory(store.Options{}) + if _, ok := Resolve(st, "absent"); ok { + t.Fatal("absent key must miss") + } + st.Put("k", []byte("orig")) + if v, ok := Resolve(st, "k"); !ok || v != "orig" { + t.Fatalf("Resolve=%q ok=%v", v, ok) + } +} + +func TestContinuationFailsOpenOnBadShape(t *testing.T) { + if _, ok := Continuation("anthropic", []byte(`{"messages":[]}`), []byte(`{}`), nil); ok { + t.Fatal("anthropic response with no content must fail open (ok=false)") + } + if _, ok := Continuation("openai", []byte(`{"messages":[]}`), []byte(`{}`), nil); ok { + t.Fatal("openai response with no message must fail open (ok=false)") + } +} + +func TestResponseCallsNoExpandCall(t *testing.T) { + // A plain assistant answer with no tool calls => no expand calls, no other tools. + calls, other := ResponseCalls("openai", []byte(`{"choices":[{"message":{"content":"hi"}}]}`)) + if len(calls) != 0 || other { + t.Fatalf("plain answer => no calls; got calls=%v other=%v", calls, other) + } +} diff --git a/expand/response.go b/expand/response.go new file mode 100644 index 0000000..4924700 --- /dev/null +++ b/expand/response.go @@ -0,0 +1,98 @@ +package expand + +import ( + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" +) + +// Call is one model request to expand offloaded content: CallID identifies the +// tool call (for the continuation's tool_result), HashID is the store key. +type Call struct { + CallID string + HashID string +} + +// ResponseCalls parses a provider response for expand tool calls. otherTools is +// true if the model also called some OTHER tool — in that case the host cannot +// safely auto-continue (the client must resolve the other tools), so the loop +// bails and returns the response as-is. +func ResponseCalls(provider string, resp []byte) (calls []Call, otherTools bool) { + switch provider { + case "anthropic": + gjson.GetBytes(resp, "content").ForEach(func(_, blk gjson.Result) bool { + if blk.Get("type").String() != "tool_use" { + return true + } + if blk.Get("name").String() == ToolName { + calls = append(calls, Call{CallID: blk.Get("id").String(), HashID: blk.Get("input.id").String()}) + } else { + otherTools = true + } + return true + }) + default: // openai and compatibles + gjson.GetBytes(resp, "choices.0.message.tool_calls").ForEach(func(_, tc gjson.Result) bool { + if tc.Get("function.name").String() == ToolName { + hash := gjson.Get(tc.Get("function.arguments").String(), "id").String() + calls = append(calls, Call{CallID: tc.Get("id").String(), HashID: hash}) + } else { + otherTools = true + } + return true + }) + } + return calls, otherTools +} + +// Continuation builds the next request body: it appends the assistant's +// tool-call turn and a tool_result turn carrying each resolved original, so the +// model can finish with the full content in hand. resolved maps CallID -> +// original text. ok=false if the shapes weren't as expected (caller returns the +// response unchanged — fail open). +func Continuation(provider string, reqBody, resp []byte, resolved map[string]string) ([]byte, bool) { + switch provider { + case "anthropic": + content := gjson.GetBytes(resp, "content") + if !content.Exists() { + return nil, false + } + asst, err := sjson.SetRaw(`{"role":"assistant"}`, "content", content.Raw) + if err != nil { + return nil, false + } + out, err := sjson.SetRawBytes(reqBody, "messages.-1", []byte(asst)) + if err != nil { + return nil, false + } + user := `{"role":"user","content":[]}` + for callID, orig := range resolved { + blk, _ := sjson.Set(`{"type":"tool_result"}`, "tool_use_id", callID) + blk, _ = sjson.Set(blk, "content", orig) + user, _ = sjson.SetRaw(user, "content.-1", blk) + } + out, err = sjson.SetRawBytes(out, "messages.-1", []byte(user)) + if err != nil { + return nil, false + } + return out, true + + default: // openai + msg := gjson.GetBytes(resp, "choices.0.message") + if !msg.Exists() { + return nil, false + } + out, err := sjson.SetRawBytes(reqBody, "messages.-1", []byte(msg.Raw)) + if err != nil { + return nil, false + } + for callID, orig := range resolved { + tool, _ := sjson.Set(`{"role":"tool"}`, "tool_call_id", callID) + tool, _ = sjson.Set(tool, "content", orig) + out, err = sjson.SetRawBytes(out, "messages.-1", []byte(tool)) + if err != nil { + return nil, false + } + } + return out, true + } +} diff --git a/expand/response_test.go b/expand/response_test.go new file mode 100644 index 0000000..6271523 --- /dev/null +++ b/expand/response_test.go @@ -0,0 +1,73 @@ +package expand + +import ( + "strings" + "testing" + + "github.com/tidwall/gjson" +) + +func TestResponseCallsOpenAI(t *testing.T) { + resp := `{"choices":[{"message":{"role":"assistant","tool_calls":[ + {"id":"call_1","type":"function","function":{"name":"context_guru_expand","arguments":"{\"id\":\"HASH1\"}"}} + ]}}]}` + calls, other := ResponseCalls("openai", []byte(resp)) + if other || len(calls) != 1 || calls[0].CallID != "call_1" || calls[0].HashID != "HASH1" { + t.Fatalf("bad parse: %+v other=%v", calls, other) + } +} + +func TestResponseCallsOtherToolBails(t *testing.T) { + resp := `{"choices":[{"message":{"tool_calls":[ + {"id":"c1","function":{"name":"context_guru_expand","arguments":"{\"id\":\"H\"}"}}, + {"id":"c2","function":{"name":"do_something_else","arguments":"{}"}} + ]}}]}` + calls, other := ResponseCalls("openai", []byte(resp)) + if !other || len(calls) != 1 { + t.Fatalf("expected otherTools=true with one expand call, got %+v other=%v", calls, other) + } +} + +func TestResponseCallsAnthropic(t *testing.T) { + resp := `{"role":"assistant","content":[ + {"type":"text","text":"let me look"}, + {"type":"tool_use","id":"toolu_1","name":"context_guru_expand","input":{"id":"HASH2"}} + ]}` + calls, other := ResponseCalls("anthropic", []byte(resp)) + if other || len(calls) != 1 || calls[0].CallID != "toolu_1" || calls[0].HashID != "HASH2" { + t.Fatalf("bad anthropic parse: %+v other=%v", calls, other) + } +} + +func TestContinuationOpenAIAppendsTurns(t *testing.T) { + req := `{"model":"gpt","messages":[{"role":"user","content":"hi"}]}` + resp := `{"choices":[{"message":{"role":"assistant","tool_calls":[{"id":"call_1","function":{"name":"context_guru_expand","arguments":"{\"id\":\"H\"}"}}]}}]}` + out, ok := Continuation("openai", []byte(req), []byte(resp), map[string]string{"call_1": "THE ORIGINAL"}) + if !ok { + t.Fatal("continuation failed") + } + msgs := gjson.GetBytes(out, "messages") + if msgs.Get("#").Int() != 3 { + t.Fatalf("expected 3 messages (user, assistant, tool), got %d: %s", msgs.Get("#").Int(), out) + } + if msgs.Get("2.role").String() != "tool" || msgs.Get("2.tool_call_id").String() != "call_1" || + !strings.Contains(msgs.Get("2.content").String(), "THE ORIGINAL") { + t.Fatalf("tool result turn wrong: %s", out) + } +} + +func TestContinuationAnthropicAppendsTurns(t *testing.T) { + req := `{"messages":[{"role":"user","content":"hi"}]}` + resp := `{"content":[{"type":"tool_use","id":"toolu_1","name":"context_guru_expand","input":{"id":"H"}}]}` + out, ok := Continuation("anthropic", []byte(req), []byte(resp), map[string]string{"toolu_1": "ORIG"}) + if !ok { + t.Fatal("continuation failed") + } + msgs := gjson.GetBytes(out, "messages") + if msgs.Get("#").Int() != 3 || msgs.Get("1.role").String() != "assistant" || msgs.Get("2.role").String() != "user" { + t.Fatalf("expected user,assistant,user turns: %s", out) + } + if msgs.Get("2.content.0.tool_use_id").String() != "toolu_1" || !strings.Contains(msgs.Get("2.content.0.content").String(), "ORIG") { + t.Fatalf("tool_result wrong: %s", out) + } +} diff --git a/go.mod b/go.mod index 67720d1..242cc9d 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,10 @@ -module github.com/kagenti/lab-context-engineering +module github.com/kagenti/context-guru -go 1.25.0 +go 1.26.4 + +// Bifrost provides the provider-agnostic message/request schema our components operate on, +// plus the proxy engine the context-guru-proxy binary embeds. +require github.com/maximhq/bifrost/core v1.7.0 require ( github.com/alexaandru/go-sitter-forest/c v1.9.4 @@ -18,15 +22,38 @@ require ( github.com/alexaandru/go-sitter-forest/swift v1.9.5 github.com/alexaandru/go-sitter-forest/tsx v1.9.2 github.com/alexaandru/go-sitter-forest/typescript v1.9.4 + github.com/tidwall/gjson v1.18.0 + github.com/tidwall/sjson v1.2.5 github.com/tiktoken-go/tokenizer v0.7.0 - github.com/toon-format/toon-go v0.0.0-20251202084852-7ca0e27c4e8c github.com/tree-sitter/go-tree-sitter v0.25.0 - go.starlark.net v0.0.0-20260613233743-8ba36ccb83fb gopkg.in/yaml.v3 v3.0.1 ) require ( + github.com/andybalholm/brotli v1.2.2 // indirect + github.com/bahlo/generic-list-go v0.2.0 // indirect + github.com/buger/jsonparser v1.1.2 // indirect + github.com/bytedance/gopkg v0.1.3 // indirect + github.com/bytedance/sonic v1.15.1 // indirect + github.com/bytedance/sonic/loader v0.5.1 // indirect + github.com/cloudwego/base64x v0.1.6 // indirect github.com/dlclark/regexp2 v1.11.5 // indirect + github.com/google/go-cmp v0.7.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/invopop/jsonschema v0.13.0 // indirect + github.com/klauspost/compress v1.18.6 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/mailru/easyjson v0.9.1 // indirect + github.com/mark3labs/mcp-go v0.43.2 // indirect github.com/mattn/go-pointer v0.0.1 // indirect - golang.org/x/sys v0.42.0 // indirect + github.com/spf13/cast v1.10.0 // indirect + github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/pretty v1.2.1 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasthttp v1.71.0 // indirect + github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect + golang.org/x/arch v0.23.0 // indirect + golang.org/x/sys v0.45.0 // indirect ) diff --git a/go.sum b/go.sum index 8ff308d..7179a7a 100644 --- a/go.sum +++ b/go.sum @@ -28,22 +28,79 @@ github.com/alexaandru/go-sitter-forest/tsx v1.9.2 h1:R6CIvxcs6zGF/nwI7YbHzRM1HuR github.com/alexaandru/go-sitter-forest/tsx v1.9.2/go.mod h1:JIGhuMRPOeeUQbqrNiDh3YZV9iMtWTHUmTgSH8WMTIk= github.com/alexaandru/go-sitter-forest/typescript v1.9.4 h1:k+zE1JbmcDjgqPxO0fVnCnsCFj0yWmRaLpp2sbC4MoA= github.com/alexaandru/go-sitter-forest/typescript v1.9.4/go.mod h1:fzlkFeml5odd1gUkYOgiNXK4bF2M6hBcfTitiJPlso8= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/andybalholm/brotli v1.2.2 h1:HzTuoo2ErYQqf5qvcJInB8uvqSVxRttzkFexPWtnceM= +github.com/andybalholm/brotli v1.2.2/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= +github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= +github.com/buger/jsonparser v1.1.2 h1:frqHqw7otoVbk5M8LlE/L7HTnIq2v9RX6EJ48i9AxJk= +github.com/buger/jsonparser v1.1.2/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= +github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= +github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= +github.com/bytedance/sonic v1.15.1 h1:nJD5PmM0vY7J8CT6MxoqbVAAMhkSmV2HgRAUrrpLoOw= +github.com/bytedance/sonic v1.15.1/go.mod h1:mT2NbXunuaEbnZ+mRIX/vYqKISmgEuHFDI4UzmKx2SA= +github.com/bytedance/sonic/loader v0.5.1 h1:Ygpfa9zwRCCKSlrp5bBP/b/Xzc3VxsAW+5NIYXrOOpI= +github.com/bytedance/sonic/loader v0.5.1/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= +github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= +github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E= +github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0= +github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= +github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8= +github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= +github.com/mark3labs/mcp-go v0.43.2 h1:21PUSlWWiSbUPQwXIJ5WKlETixpFpq+WBpbMGDSVy/I= +github.com/mark3labs/mcp-go v0.43.2/go.mod h1:YnJfOL382MIWDx1kMY+2zsRHU/q78dBg9aFb8W6Thdw= github.com/mattn/go-pointer v0.0.1 h1:n+XhsuGeVO6MEAp7xyEukFINEa+Quek5psIR/ylA6o0= github.com/mattn/go-pointer v0.0.1/go.mod h1:2zXcozF6qYGgmsG+SeTZz3oAbFLdD3OWqnUbNvJZAlc= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/maximhq/bifrost/core v1.7.0 h1:iUmSfqXwVdDbyJXWIO+S8AYz6JOTWoyhGON4Z4a/p7Q= +github.com/maximhq/bifrost/core v1.7.0/go.mod h1:jjdqJc0+fCNl3irgUGfSDzgZupMSRLNm4E/2Q7KZKks= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= +github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= github.com/tiktoken-go/tokenizer v0.7.0 h1:VMu6MPT0bXFDHr7UPh9uii7CNItVt3X9K90omxL54vw= github.com/tiktoken-go/tokenizer v0.7.0/go.mod h1:6UCYI/DtOallbmL7sSy30p6YQv60qNyU/4aVigPOx6w= -github.com/toon-format/toon-go v0.0.0-20251202084852-7ca0e27c4e8c h1:D8lDFovBMZywze1eh9iwMLcYor5f11mHBocLhO7cBe8= -github.com/toon-format/toon-go v0.0.0-20251202084852-7ca0e27c4e8c/go.mod h1:j/BOnpF2ihnz4lELs99h9mwGJBx/zdleOUCnLLRPCsc= github.com/tree-sitter/go-tree-sitter v0.25.0 h1:sx6kcg8raRFCvc9BnXglke6axya12krCJF5xJ2sftRU= github.com/tree-sitter/go-tree-sitter v0.25.0/go.mod h1:r77ig7BikoZhHrrsjAnv8RqGti5rtSyvDHPzgTPsUuU= github.com/tree-sitter/tree-sitter-c v0.23.4 h1:nBPH3FV07DzAD7p0GfNvXM+Y7pNIoPenQWBpvM++t4c= @@ -70,13 +127,24 @@ github.com/tree-sitter/tree-sitter-ruby v0.23.1 h1:T/NKHUA+iVbHM440hFx+lzVOzS4dV github.com/tree-sitter/tree-sitter-ruby v0.23.1/go.mod h1:kUS4kCCQloFcdX6sdpr8p6r2rogbM6ZjTox5ZOQy8cA= github.com/tree-sitter/tree-sitter-rust v0.23.2 h1:6AtoooCW5GqNrRpfnvl0iUhxTAZEovEmLKDbyHlfw90= github.com/tree-sitter/tree-sitter-rust v0.23.2/go.mod h1:hfeGWic9BAfgTrc7Xf6FaOAguCFJRo3RBbs7QJ6D7MI= -go.starlark.net v0.0.0-20260613233743-8ba36ccb83fb h1:NGUBN0jbH0IR3msRslALnoxlySm+6YvVKvVDjdDJrlA= -go.starlark.net v0.0.0-20260613233743-8ba36ccb83fb/go.mod h1:Iue6g6iirlfLoVi/DYCi5/x0h/bAOuWF3dULTKpt2Vo= -golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= -golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= -google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasthttp v1.71.0 h1:tepR7H+Guh9VUqxxcPggYi8R3lGUu2Rsdh+z7/FCY3k= +github.com/valyala/fasthttp v1.71.0/go.mod h1:z1sDUvOShhXq/C9mwH/fSm1Vb71tUJwmQdgkBrBNwnA= +github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= +github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= +github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= +github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= +golang.org/x/arch v0.23.0 h1:lKF64A2jF6Zd8L0knGltUnegD62JMFBiCPBmQpToHhg= +golang.org/x/arch v0.23.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/cache/cache.go b/internal/cache/cache.go deleted file mode 100644 index 9c4127b..0000000 --- a/internal/cache/cache.go +++ /dev/null @@ -1,202 +0,0 @@ -// Package cache injects Anthropic ephemeral cache_control breakpoints on the stable -// prefix of a request. Lossless: nothing is dropped or rewritten, only annotated -// where the provider may cache. Ported from the reference prototype's cache.py. -// -// All functions operate on the canonical Root map (Anthropic shape) in place. -package cache - -const maxBreakpoints = 4 - -func ephemeral() map[string]any { return map[string]any{"type": "ephemeral"} } - -// asBlocks returns msg["content"] as a []any of block maps, or nil. -func msgContentList(msg map[string]any) ([]any, bool) { - c, ok := msg["content"].([]any) - return c, ok -} - -func hasCacheControl(block any) bool { - b, ok := block.(map[string]any) - if !ok { - return false - } - _, ok = b["cache_control"] - return ok -} - -// FloorIndex returns the message index of the last message carrying a cache_control -// breakpoint (-1 if none). Reducing any message at or before this index would bust -// the client's prompt cache, so reduction must stay below it. -1 means unrestricted. -func FloorIndex(root map[string]any) int { - floor := -1 - msgs, _ := root["messages"].([]any) - for i, m := range msgs { - mm, ok := m.(map[string]any) - if !ok { - continue - } - c, ok := msgContentList(mm) - if !ok { - continue - } - for _, b := range c { - if hasCacheControl(b) { - floor = i - break - } - } - } - return floor -} - -// ProtectedAnchorIndex returns the message index just behind the reducer's protected -// working set — the deepest place a stable breakpoint can sit and still be byte- -// identical next turn. Returns -1 if there's nothing to anchor. -func ProtectedAnchorIndex(msgs []any, protectRecent, protectRecentToolUses int) int { - n := len(msgs) - if n == 0 { - return -1 - } - protectFrom := n - protectRecent - if protectRecentToolUses > 0 { - var tu []int - for i, m := range msgs { - mm, ok := m.(map[string]any) - if !ok { - continue - } - c, ok := msgContentList(mm) - if !ok { - continue - } - for _, b := range c { - if bb, ok := b.(map[string]any); ok && bb["type"] == "tool_use" { - tu = append(tu, i) - break - } - } - } - if len(tu) >= protectRecentToolUses { - if cand := tu[len(tu)-protectRecentToolUses]; cand < protectFrom { - protectFrom = cand - } - } - } - if protectFrom-1 < 0 { - return 0 - } - return protectFrom - 1 -} - -func countBreakpoints(root map[string]any) int { - n := 0 - if sys, ok := root["system"].([]any); ok { - for _, b := range sys { - if hasCacheControl(b) { - n++ - } - } - } - msgs, _ := root["messages"].([]any) - for _, m := range msgs { - mm, ok := m.(map[string]any) - if !ok { - continue - } - if c, ok := msgContentList(mm); ok { - for _, b := range c { - if hasCacheControl(b) { - n++ - } - } - } - } - return n -} - -// markLastBlock adds cache_control to the last block of a message content, -// normalising a bare string into a single text block. Returns the new content. -func markLastBlock(content any) any { - switch c := content.(type) { - case string: - if c != "" { - return []any{map[string]any{"type": "text", "text": c, "cache_control": ephemeral()}} - } - case []any: - if len(c) > 0 { - if last, ok := c[len(c)-1].(map[string]any); ok { - if _, has := last["cache_control"]; !has { - last["cache_control"] = ephemeral() - } - } - } - } - return content -} - -// Inject annotates root in place with ephemeral cache breakpoints on the stable -// prefix. Safe/no-op once the provider cap (4) is reached. stableAnchorIdx (or -1 -// to fall back to a tail offset) pins the deep-stable breakpoint to recurring bytes. -func Inject(root map[string]any, breakpoints, stableGap, stableAnchorIdx int, toolsBreakpoint bool) { - budget := maxBreakpoints - countBreakpoints(root) - if budget <= 0 { - return - } - - // 0) tools: biggest static segment. - if toolsBreakpoint && budget > 0 { - if tools, ok := root["tools"].([]any); ok && len(tools) > 0 { - if last, ok := tools[len(tools)-1].(map[string]any); ok { - if _, has := last["cache_control"]; !has { - last["cache_control"] = ephemeral() - budget-- - } - } - } - } - - // 1) end of system: caches tools + system. - if budget > 0 { - switch sys := root["system"].(type) { - case string: - if sys != "" { - root["system"] = []any{map[string]any{"type": "text", "text": sys, "cache_control": ephemeral()}} - budget-- - } - case []any: - if len(sys) > 0 { - if last, ok := sys[len(sys)-1].(map[string]any); ok { - if _, has := last["cache_control"]; !has { - last["cache_control"] = ephemeral() - budget-- - } - } - } - } - } - - msgs, _ := root["messages"].([]any) - - // 2) deep-stable breakpoint, anchored to the protected-set boundary when given. - stableIdx := stableAnchorIdx - if !(stableAnchorIdx >= 0 && stableAnchorIdx < len(msgs)-1) { - gap := stableGap - if gap < 1 { - gap = 1 - } - stableIdx = len(msgs) - 1 - gap - } - if breakpoints >= 3 && budget > 0 && stableIdx >= 0 && stableIdx < len(msgs)-1 { - if m, ok := msgs[stableIdx].(map[string]any); ok { - m["content"] = markLastBlock(m["content"]) - budget-- - } - } - - // 3) rolling breakpoint on the last message. - if budget > 0 && len(msgs) > 0 { - if m, ok := msgs[len(msgs)-1].(map[string]any); ok { - m["content"] = markLastBlock(m["content"]) - } - } -} diff --git a/internal/cache/cache_test.go b/internal/cache/cache_test.go deleted file mode 100644 index ccd4689..0000000 --- a/internal/cache/cache_test.go +++ /dev/null @@ -1,52 +0,0 @@ -package cache - -import ( - "testing" - - "github.com/kagenti/lab-context-engineering/canon" -) - -func TestInjectAddsBreakpointsAndRespectsCap(t *testing.T) { - req, _ := canon.Decode([]byte(`{ - "system":"you are helpful", - "tools":[{"name":"read"}], - "messages":[ - {"role":"user","content":[{"type":"text","text":"a"}]}, - {"role":"assistant","content":[{"type":"text","text":"b"}]}, - {"role":"user","content":[{"type":"text","text":"c"}]}, - {"role":"assistant","content":[{"type":"text","text":"d"}]} - ] - }`)) - Inject(req.Root, 4, 2, -1, true) - - if got := countBreakpoints(req.Root); got == 0 || got > maxBreakpoints { - t.Fatalf("breakpoints = %d, want 1..%d", got, maxBreakpoints) - } - // tools and system should each be marked. - if !hasCacheControl(req.Root["tools"].([]any)[0]) { - t.Fatal("tools breakpoint missing") - } - if _, ok := req.Root["system"].([]any); !ok { - t.Fatal("string system should be normalised to a marked block list") - } - - // Re-injecting must not exceed the cap (idempotent-ish, never over 4). - Inject(req.Root, 4, 2, -1, true) - if got := countBreakpoints(req.Root); got > maxBreakpoints { - t.Fatalf("breakpoints = %d exceeds cap", got) - } -} - -func TestFloorIndex(t *testing.T) { - req, _ := canon.Decode([]byte(`{"messages":[ - {"role":"user","content":[{"type":"text","text":"a","cache_control":{"type":"ephemeral"}}]}, - {"role":"assistant","content":[{"type":"text","text":"b"}]} - ]}`)) - if got := FloorIndex(req.Root); got != 0 { - t.Fatalf("FloorIndex = %d, want 0", got) - } - empty, _ := canon.Decode([]byte(`{"messages":[{"role":"user","content":[{"type":"text","text":"a"}]}]}`)) - if got := FloorIndex(empty.Root); got != -1 { - t.Fatalf("FloorIndex (no cache) = %d, want -1", got) - } -} diff --git a/internal/cheapmodel/anthropic.go b/internal/cheapmodel/anthropic.go deleted file mode 100644 index f237c53..0000000 --- a/internal/cheapmodel/anthropic.go +++ /dev/null @@ -1,87 +0,0 @@ -// Package cheapmodel provides a minimal Anthropic Messages client used as the -// engine's injected extraction model. It implements engine.Model. (An OpenAI variant -// is a straightforward follow-up; the canonical model is Anthropic-shaped, so the -// Anthropic client is the natural first.) -package cheapmodel - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "net/http" - "strings" -) - -// Anthropic calls a small Anthropic model with a single user prompt and returns the -// text of the first content block. -type Anthropic struct { - BaseURL string // default https://api.anthropic.com - APIKey string - Model string // e.g. claude-haiku-4-5 - MaxTokens int // default 2048 - Client *http.Client - // AuthScheme selects how the API key is sent. "" or "x-api-key" sends the - // x-api-key header (Anthropic default). "bearer" sends - // Authorization: Bearer instead, for gateways (e.g. an IBM LiteLLM - // Anthropic-compatible endpoint) that authenticate with a bearer token. The - // anthropic-version header is sent in both cases. - AuthScheme string -} - -func (a Anthropic) Complete(ctx context.Context, prompt string) (string, error) { - base := a.BaseURL - if base == "" { - base = "https://api.anthropic.com" - } - maxTok := a.MaxTokens - if maxTok == 0 { - maxTok = 2048 - } - client := a.Client - if client == nil { - client = http.DefaultClient - } - reqBody, _ := json.Marshal(map[string]any{ - "model": a.Model, - "max_tokens": maxTok, - "messages": []any{map[string]any{"role": "user", "content": prompt}}, - }) - req, err := http.NewRequestWithContext(ctx, http.MethodPost, - strings.TrimRight(base, "/")+"/v1/messages", bytes.NewReader(reqBody)) - if err != nil { - return "", err - } - req.Header.Set("content-type", "application/json") - if a.AuthScheme == "bearer" { - req.Header.Set("Authorization", "Bearer "+a.APIKey) - } else { - req.Header.Set("x-api-key", a.APIKey) - } - req.Header.Set("anthropic-version", "2023-06-01") - - resp, err := client.Do(req) - if err != nil { - return "", err - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - return "", fmt.Errorf("cheapmodel: status %d", resp.StatusCode) - } - var out struct { - Content []struct { - Text string `json:"text"` - } `json:"content"` - } - if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { - return "", err - } - // Return the first content block that carries text. A leading non-text block - // (e.g. "thinking") has an empty Text, so we skip it rather than returning "". - for _, c := range out.Content { - if c.Text != "" { - return c.Text, nil - } - } - return "", nil -} diff --git a/internal/cheapmodel/cheapmodel_test.go b/internal/cheapmodel/cheapmodel_test.go deleted file mode 100644 index 06df247..0000000 --- a/internal/cheapmodel/cheapmodel_test.go +++ /dev/null @@ -1,75 +0,0 @@ -package cheapmodel - -import ( - "context" - "io" - "net/http" - "net/http/httptest" - "testing" -) - -func TestAnthropicSkipsNonTextLeadingBlock(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - _, _ = io.WriteString(w, `{"content":[{"type":"thinking","text":""},{"type":"text","text":"RESULT"}]}`) - })) - defer srv.Close() - got, err := Anthropic{BaseURL: srv.URL, Model: "m"}.Complete(context.Background(), "p") - if err != nil || got != "RESULT" { - t.Fatalf("got %q err %v", got, err) - } -} - -func TestAnthropicBearerAuth(t *testing.T) { - var gotAuth, gotKey, gotVersion string - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - gotAuth = r.Header.Get("Authorization") - gotKey = r.Header.Get("x-api-key") - gotVersion = r.Header.Get("anthropic-version") - _, _ = io.WriteString(w, `{"content":[{"type":"text","text":"OK"}]}`) - })) - defer srv.Close() - _, err := Anthropic{BaseURL: srv.URL, APIKey: "tok", Model: "m", AuthScheme: "bearer"}.Complete(context.Background(), "p") - if err != nil { - t.Fatalf("err %v", err) - } - if gotAuth != "Bearer tok" { - t.Fatalf("Authorization = %q, want %q", gotAuth, "Bearer tok") - } - if gotKey != "" { - t.Fatalf("x-api-key = %q, want empty", gotKey) - } - if gotVersion != "2023-06-01" { - t.Fatalf("anthropic-version = %q, want 2023-06-01", gotVersion) - } -} - -func TestAnthropicDefaultAuthUsesAPIKey(t *testing.T) { - var gotAuth, gotKey string - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - gotAuth = r.Header.Get("Authorization") - gotKey = r.Header.Get("x-api-key") - _, _ = io.WriteString(w, `{"content":[{"type":"text","text":"OK"}]}`) - })) - defer srv.Close() - _, err := Anthropic{BaseURL: srv.URL, APIKey: "tok", Model: "m"}.Complete(context.Background(), "p") - if err != nil { - t.Fatalf("err %v", err) - } - if gotKey != "tok" { - t.Fatalf("x-api-key = %q, want %q", gotKey, "tok") - } - if gotAuth != "" { - t.Fatalf("Authorization = %q, want empty", gotAuth) - } -} - -func TestOpenAIComplete(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - _, _ = io.WriteString(w, `{"choices":[{"message":{"content":"OUT"}}]}`) - })) - defer srv.Close() - got, err := OpenAI{BaseURL: srv.URL, Model: "m"}.Complete(context.Background(), "p") - if err != nil || got != "OUT" { - t.Fatalf("got %q err %v", got, err) - } -} diff --git a/internal/cheapmodel/openai.go b/internal/cheapmodel/openai.go deleted file mode 100644 index fe05a18..0000000 --- a/internal/cheapmodel/openai.go +++ /dev/null @@ -1,70 +0,0 @@ -package cheapmodel - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "net/http" - "strings" -) - -// OpenAI calls a small OpenAI chat-completions model with a single user prompt and -// returns the content of the first choice. It implements engine.Model. -type OpenAI struct { - BaseURL string // default https://api.openai.com - APIKey string - Model string // e.g. gpt-4o-mini - MaxTokens int // default 2048 - Client *http.Client -} - -func (o OpenAI) Complete(ctx context.Context, prompt string) (string, error) { - base := o.BaseURL - if base == "" { - base = "https://api.openai.com" - } - maxTok := o.MaxTokens - if maxTok == 0 { - maxTok = 2048 - } - client := o.Client - if client == nil { - client = http.DefaultClient - } - reqBody, _ := json.Marshal(map[string]any{ - "model": o.Model, - "max_tokens": maxTok, - "messages": []any{map[string]any{"role": "user", "content": prompt}}, - }) - req, err := http.NewRequestWithContext(ctx, http.MethodPost, - strings.TrimRight(base, "/")+"/v1/chat/completions", bytes.NewReader(reqBody)) - if err != nil { - return "", err - } - req.Header.Set("content-type", "application/json") - req.Header.Set("authorization", "Bearer "+o.APIKey) - - resp, err := client.Do(req) - if err != nil { - return "", err - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - return "", fmt.Errorf("cheapmodel: status %d", resp.StatusCode) - } - var out struct { - Choices []struct { - Message struct { - Content string `json:"content"` - } `json:"message"` - } `json:"choices"` - } - if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { - return "", err - } - if len(out.Choices) == 0 { - return "", nil - } - return out.Choices[0].Message.Content, nil -} diff --git a/internal/extract/cache.go b/internal/extract/cache.go deleted file mode 100644 index f128ded..0000000 --- a/internal/extract/cache.go +++ /dev/null @@ -1,27 +0,0 @@ -package extract - -import "sync" - -// Cache memoizes extraction results by ContentKey, so the same large output re-sent -// on a later turn skips the paid model call. In-memory, process-local. -// -// ponytail: a plain map+mutex, unbounded. Add an LRU/TTL only if memory ever bites. -type Cache struct { - mu sync.Mutex - m map[string]string -} - -func NewCache() *Cache { return &Cache{m: map[string]string{}} } - -func (c *Cache) Get(key string) (string, bool) { - c.mu.Lock() - defer c.mu.Unlock() - v, ok := c.m[key] - return v, ok -} - -func (c *Cache) Put(key, val string) { - c.mu.Lock() - c.m[key] = val - c.mu.Unlock() -} diff --git a/internal/extract/contain.go b/internal/extract/contain.go deleted file mode 100644 index 08d6f9f..0000000 --- a/internal/extract/contain.go +++ /dev/null @@ -1,101 +0,0 @@ -// Package extract is the cheap-model tool-output extractor. A small model returns a -// SMALLER value of the same shape (selecting records/fields, never paraphrasing); the -// containment validator here PROVES the result is a lossless projection of the -// original, so a chatty model can never corrupt a value the agent relies on. On any -// failure it falls back to a deterministic projection, then to the original -// (fail-open). Ported from the reference prototype's actions/llm_compact.py. -// -// ponytail: the model returns the selected JSON directly and containment verifies it — -// no model-generated code (the reference prototype's Python sandbox) and no custom spec language. The -// containment proof is what makes the mechanism safe, whatever produced the subset. -package extract - -import ( - "encoding/json" - "strings" -) - -// IsContained reports whether result is a lossless projection of original (both -// parsed values): string → substring; list → order-preserving subsequence of -// contained items; dict → keys subset with contained values; numbers/bools/nil → -// equal where present; nil result → always allowed (dropping is fine). -func IsContained(result, original any) bool { - return checkContained(result, original) -} - -func checkContained(out, in any) bool { - if out == nil { - return true // dropping content is always allowed - } - if in == nil { - return false - } - switch o := out.(type) { - case string: - s, ok := in.(string) - return ok && strings.Contains(s, o) - case bool: - b, ok := in.(bool) - return ok && b == o - case json.Number: - return numbersEqual(o, in) - case float64: - return numbersEqual(o, in) - case []any: - il, ok := in.([]any) - if !ok || len(o) > len(il) { - return false - } - i := 0 - for _, item := range o { - matched := false - for i < len(il) { - if checkContained(item, il[i]) { - matched = true - i++ - break - } - i++ - } - if !matched { - return false - } - } - return true - case map[string]any: - im, ok := in.(map[string]any) - if !ok { - return false - } - for k, v := range o { - iv, present := im[k] - if !present { - return false // extra key not in input - } - if !checkContained(v, iv) { - return false - } - } - return true - default: - return out == in - } -} - -// numbersEqual compares two JSON-decoded numbers (json.Number or float64) by value. -func numbersEqual(a, b any) bool { - return numStr(a) == numStr(b) -} - -func numStr(v any) string { - switch n := v.(type) { - case json.Number: - return n.String() - case float64: - // json.Number-style rendering via json.Marshal keeps integers integral. - b, _ := json.Marshal(n) - return string(b) - default: - return "" - } -} diff --git a/internal/extract/deterministic.go b/internal/extract/deterministic.go deleted file mode 100644 index 21b569f..0000000 --- a/internal/extract/deterministic.go +++ /dev/null @@ -1,180 +0,0 @@ -package extract - -import ( - "regexp" - "strings" -) - -// Deterministic, model-free projection: filter a parsed value to the parts that match -// the keep-set (plus the important-key "spine"). Every leaf it emits is an unchanged -// value, a string prefix, or a contiguous window, so its output always passes -// IsContained by construction and is never empty. Ported from the reference prototype's -// deterministic_project. - -var importantKeyTokens = []string{"id", "status", "state", "name", "error", "reason", "date", "time"} - -var jsonKeyRe = regexp.MustCompile(`"([A-Za-z_][A-Za-z0-9_\-]{1,})"\s*:`) - -func isImportantKey(keyLower string) bool { - for _, tok := range importantKeyTokens { - if strings.Contains(keyLower, tok) { - return true - } - } - return false -} - -func termMatches(term, keyLower string) bool { - return term == keyLower || strings.Contains(keyLower, term) || strings.Contains(term, keyLower) -} - -func truncateValue(value any, maxChars int) any { - if value == nil || maxChars <= 0 { - return value - } - switch v := value.(type) { - case string: - if r := []rune(v); len(r) > maxChars { - return string(r[:maxChars]) - } - return v - case []any: - out := make([]any, len(v)) - for i, x := range v { - out[i] = truncateValue(x, maxChars) - } - return out - case map[string]any: - out := make(map[string]any, len(v)) - for k, x := range v { - out[k] = truncateValue(x, maxChars) - } - return out - } - return value -} - -func extractTextWindow(text string, terms []string, maxChars int) string { - runes := []rune(text) - if maxChars <= 0 || len(runes) <= maxChars { - return text - } - loweredRunes := []rune(strings.ToLower(text)) - for _, term := range terms { - t := strings.TrimSpace(strings.ToLower(term)) - if t == "" { - continue - } - // Index into the rune slice (not bytes) so the window math stays rune-aligned. - if idx := runeIndex(loweredRunes, []rune(t)); idx >= 0 { - start := idx - maxChars/4 - if start < 0 { - start = 0 - } - if start > len(runes)-maxChars { - start = len(runes) - maxChars - } - return string(runes[start : start+maxChars]) - } - } - return string(runes[:maxChars]) -} - -// runeIndex returns the index (in runes) of the first occurrence of sub in s, or -1. -func runeIndex(s, sub []rune) int { - if len(sub) == 0 { - return 0 - } - for i := 0; i+len(sub) <= len(s); i++ { - match := true - for j := range sub { - if s[i+j] != sub[j] { - match = false - break - } - } - if match { - return i - } - } - return -1 -} - -func projectMapping(m map[string]any, terms map[string]struct{}, maxChars int) map[string]any { - out := map[string]any{} - for key, value := range m { - kl := strings.ToLower(key) - keep := isImportantKey(kl) - if !keep { - for t := range terms { - if termMatches(t, kl) { - keep = true - break - } - } - } - if !keep { - if s, ok := value.(string); ok { - vl := strings.ToLower(s) - for t := range terms { - if strings.Contains(vl, t) { - keep = true - break - } - } - } - } - if keep { - out[key] = truncateValue(value, maxChars) - } - } - return out -} - -// DeterministicProject filters a parsed value to the keep-set + important-key spine. -// Never empties: a value that projects to nothing falls back to a truncated copy. -func DeterministicProject(value any, keepIDs []string, maxChars int) any { - terms := map[string]struct{}{} - for _, k := range keepIDs { - if k != "" { - terms[strings.ToLower(k)] = struct{}{} - } - } - switch v := value.(type) { - case string: - winTerms := append([]string{}, keepIDs...) - for i, m := range jsonKeyRe.FindAllStringSubmatch(v, -1) { - if i >= 16 { - break - } - winTerms = append(winTerms, m[1]) - } - return extractTextWindow(v, winTerms, maxChars) - case map[string]any: - if len(terms) > 0 { - if p := projectMapping(v, terms, maxChars); len(p) > 0 { - return p - } - } - return truncateValue(v, maxChars) - case []any: - var kept []any - for _, item := range v { - m, ok := item.(map[string]any) - if !ok { - kept = append(kept, truncateValue(item, maxChars)) - continue - } - if len(terms) > 0 { - if p := projectMapping(m, terms, maxChars); len(p) > 0 { - kept = append(kept, p) - } - } - } - if len(kept) > 0 { - return kept - } - return truncateValue(v, maxChars) - } - return truncateValue(value, maxChars) -} diff --git a/internal/extract/extract.go b/internal/extract/extract.go deleted file mode 100644 index 660e5d5..0000000 --- a/internal/extract/extract.go +++ /dev/null @@ -1,350 +0,0 @@ -package extract - -import ( - "context" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "fmt" - "regexp" - "strings" - "sync" - - "github.com/kagenti/lab-context-engineering/internal/markers" - "github.com/kagenti/lab-context-engineering/internal/tokens" -) - -const sampleChars = 4000 - -// Model is the cheap-model client the extractor calls. The host (proxy / plugin) -// injects a concrete implementation; the extractor core stays transport-free. -type Model interface { - Complete(ctx context.Context, prompt string) (string, error) -} - -// Cfg configures extraction. -type Cfg struct { - Mode string // auto | single | rlm | deterministic - Floor int // token floor; rlm kicks in at max(floor*4, 8000) in auto - MinKeepRatio float64 // 0 disables the blunt ratio backstop (keep-set check governs) - AllowDeterministic bool - MaxChars int // deterministic projection window - // AllowedStrategies, when non-empty, restricts strategyOrder to these strategy names - // (code | single | rlm | deterministic) preserving the computed order. Empty means - // "all" — prior behavior. Lets config enable/disable strategies purely by name. - AllowedStrategies []string -} - -// DefaultCfg mirrors the reference prototype's ExtractCfg defaults. -func DefaultCfg() Cfg { - return Cfg{Mode: "auto", Floor: 3000, AllowDeterministic: true, MaxChars: sampleChars} -} - -var wsRe = regexp.MustCompile(`\s+`) - -// ContentKey is a stable, marker- and whitespace-insensitive key for a body, so the -// same output re-sent on a later turn hits the extraction cache. -func ContentKey(text string) string { - s := wsRe.ReplaceAllString(markers.Strip(text), " ") - s = strings.TrimSpace(s) - sum := sha256.Sum256([]byte(s)) - return hex.EncodeToString(sum[:])[:24] -} - -var identRe = regexp.MustCompile(`[A-Za-z_][\w./-]{3,}|\b\d{3,}\b`) - -// HarvestIdentifiers pulls distinctive identifiers (paths, symbols, ids, numbers) -// from the agent's recent turns — the keep-set the extractor must retain. -func HarvestIdentifiers(text string, cap int) []string { - var seen []string - idx := map[string]struct{}{} - for _, m := range identRe.FindAllString(text, -1) { - if _, ok := idx[m]; ok { - continue - } - idx[m] = struct{}{} - seen = append(seen, m) - if len(seen) >= cap { - break - } - } - return seen -} - -// parseBody returns the parsed value handed to the extractor: JSON if possible, then -// NDJSON (as a list), else the raw string. -func parseBody(text string) any { - var v any - dec := json.NewDecoder(strings.NewReader(text)) - dec.UseNumber() - if err := dec.Decode(&v); err == nil { - return v - } - if recs := parseNDJSON(text); recs != nil { - return recs - } - return text -} - -func parseNDJSON(text string) []any { - var lines []string - for _, ln := range strings.Split(text, "\n") { - if strings.TrimSpace(ln) != "" { - lines = append(lines, ln) - } - } - if len(lines) < 2 { - return nil - } - var recs []any - for _, ln := range lines { - var v any - dec := json.NewDecoder(strings.NewReader(ln)) - dec.UseNumber() - if err := dec.Decode(&v); err != nil { - return nil - } - switch v.(type) { - case map[string]any, []any: - recs = append(recs, v) - default: - return nil - } - } - return recs -} - -func resultToText(v any) string { - switch v.(type) { - case map[string]any, []any: - // Compact, not indented: this is the reduced tool-output value, and - // indentation whitespace inflates the BPE token count (it can exceed the - // original), which trips the never-inflate gate and silently drops the - // extraction. Compact JSON keeps the projection a real reduction. - b, err := json.Marshal(v) - if err == nil { - return string(b) - } - } - return fmt.Sprint(v) -} - -func stripFences(s string) string { - c := strings.TrimSpace(s) - if !strings.HasPrefix(c, "```") { - return c - } - lines := strings.Split(c, "\n") - if strings.HasPrefix(lines[0], "```") { - lines = lines[1:] - } - if len(lines) > 0 && strings.TrimSpace(lines[len(lines)-1]) == "```" { - lines = lines[:len(lines)-1] - } - return strings.Join(lines, "\n") -} - -// --- sanity + validation gate --- - -func extractionIsSane(bodyText, resultText string, keepIDs []string, minKeepRatio float64) bool { - if resultText == "" { - return false - } - bodyN, resN := tokens.Count(bodyText), tokens.Count(resultText) - switch strings.TrimSpace(resultText) { - case "", "[]", "{}", "null", `""`: - if bodyN > 0 { - return false - } - } - if minKeepRatio > 0 && float64(resN) < minKeepRatio*float64(bodyN) { - return false - } - for _, kid := range keepIDs { - if len(kid) >= 5 && strings.ContainsFunc(kid, isLetter) && - strings.Contains(bodyText, kid) && !strings.Contains(resultText, kid) { - return false - } - } - return true -} - -func isLetter(r rune) bool { - return (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') -} - -func validateExtraction(resultText, bodyText string, keepIDs []string, cfg Cfg) bool { - if !extractionIsSane(bodyText, resultText, keepIDs, cfg.MinKeepRatio) { - return false - } - return IsContained(parseBody(resultText), parseBody(bodyText)) -} - -// intersectAllowed filters order to the allowed set (non-empty), preserving order. -// Empty allowed means no filtering. -func intersectAllowed(order, allowed []string) []string { - if len(allowed) == 0 { - return order - } - allow := make(map[string]struct{}, len(allowed)) - for _, a := range allowed { - allow[a] = struct{}{} - } - out := make([]string, 0, len(order)) - for _, s := range order { - if _, ok := allow[s]; ok { - out = append(out, s) - } - } - return out -} - -func strategyOrder(tokenEst int, cfg Cfg) []string { - return intersectAllowed(rawStrategyOrder(tokenEst, cfg), cfg.AllowedStrategies) -} - -func rawStrategyOrder(tokenEst int, cfg Cfg) []string { - switch cfg.Mode { - case "deterministic": - return []string{"deterministic"} - case "single", "rlm": - order := []string{cfg.Mode} - if cfg.AllowDeterministic { - order = append(order, "deterministic") - } - return order - case "code": - order := []string{"code"} - if cfg.AllowDeterministic { - order = append(order, "deterministic") - } - return order - default: // auto - // "code" (model-written Starlark filter over the full body) is primary for - // mid-size bodies; "rlm" (chunked) above the floor. "single" (JSON-return) and - // "deterministic" are ordered fallbacks behind the primary. - floor4 := cfg.Floor * 4 - if floor4 < 8000 { - floor4 = 8000 - } - var order []string - if tokenEst >= floor4 { - order = []string{"rlm", "code", "single"} - } else { - order = []string{"code", "single"} - } - if cfg.AllowDeterministic { - order = append(order, "deterministic") - } - return order - } -} - -// RunExtraction tries strategies in order, returning the first candidate that is -// strictly smaller AND passes the validation gate, else ("", "none"). Fail-open: the -// caller keeps the original on "none". -func RunExtraction(ctx context.Context, body, goal string, keepIDs []string, tokenEst int, cfg Cfg, model Model) (string, string) { - base := tokens.Count(body) - for _, name := range strategyOrder(tokenEst, cfg) { - var cand string - switch name { - case "code": - cand = runStarlark(ctx, body, goal, keepIDs, model) - case "single": - cand = runSingle(ctx, body, goal, keepIDs, model) - case "rlm": - cand = runRLMBatched(ctx, body, goal, keepIDs, model) - case "deterministic": - cand = resultToText(DeterministicProject(parseBody(body), keepIDs, cfg.MaxChars)) - } - if cand == "" || tokens.Count(cand) >= base { - continue - } - if validateExtraction(cand, body, keepIDs, cfg) { - return cand, name - } - } - return "", "none" -} - -// runSingle asks the model for the filtered subset in one call. It is a FALLBACK -// behind the primary full-body strategies: "code" (a model-written Starlark filter) -// and "rlm" (chunked) both run over the FULL body, so they never truncate. runSingle -// inlines the body into the prompt via buildPrompt, which truncates to sampleChars to -// bound prompt cost — acceptable for a fallback, since whatever the model returns is -// still containment-checked against the full body before it can be spliced in. -func runSingle(ctx context.Context, body, goal string, keepIDs []string, model Model) string { - if model == nil { - return "" - } - out, err := model.Complete(ctx, buildPrompt(body, goal, keepIDs)) - if err != nil { - return "" - } - return stripFences(out) -} - -const rlmChunkSize = 20 -const rlmConcurrency = 6 - -// runRLMBatched chunks a large list body and asks the model to filter each chunk -// concurrently, then merges the kept records (order-preserving). Containment over the -// merged result is checked by the caller. Non-list bodies fall back to a single call. -func runRLMBatched(ctx context.Context, body, goal string, keepIDs []string, model Model) string { - if model == nil { - return "" - } - parsed := parseBody(body) - list, ok := parsed.([]any) - if !ok || len(list) <= rlmChunkSize { - return runSingle(ctx, body, goal, keepIDs, model) - } - var chunks [][]any - for i := 0; i < len(list); i += rlmChunkSize { - end := i + rlmChunkSize - if end > len(list) { - end = len(list) - } - chunks = append(chunks, list[i:end]) - } - - results := make([][]any, len(chunks)) - sem := make(chan struct{}, rlmConcurrency) - var wg sync.WaitGroup - for ci, chunk := range chunks { - wg.Add(1) - sem <- struct{}{} - go func(ci int, chunk []any) { - defer wg.Done() - defer func() { <-sem }() - defer func() { _ = recover() }() - chunkJSON, err := json.Marshal(chunk) - if err != nil { - return - } - out, err := model.Complete(ctx, buildPrompt(string(chunkJSON), goal, keepIDs)) - if err != nil { - return - } - var kept []any - if json.Unmarshal([]byte(stripFences(out)), &kept) == nil { - results[ci] = kept - } - // On parse failure the chunk contributes nothing (safe drop; containment holds). - }(ci, chunk) - } - wg.Wait() - - var merged []any - for _, r := range results { - merged = append(merged, r...) - } - if len(merged) == 0 { - return "" - } - b, err := json.Marshal(merged) - if err != nil { - return "" - } - return string(b) -} diff --git a/internal/extract/extract_test.go b/internal/extract/extract_test.go deleted file mode 100644 index edf839d..0000000 --- a/internal/extract/extract_test.go +++ /dev/null @@ -1,156 +0,0 @@ -package extract - -import ( - "context" - "encoding/json" - "strings" - "testing" - "unicode/utf8" -) - -func parse(s string) any { return parseBody(s) } - -func TestContainment(t *testing.T) { - original := parse(`{"id":1,"name":"alpha","tags":["x","y","z"],"note":"hello world"}`) - cases := []struct { - name string - out string - want bool - }{ - {"subset keys", `{"id":1,"name":"alpha"}`, true}, - {"substring value", `{"note":"hello"}`, true}, - {"subsequence list", `{"tags":["x","z"]}`, true}, - {"drop everything (nil-ish)", `{}`, true}, - {"extra key", `{"id":1,"missing":true}`, false}, - {"paraphrased string", `{"name":"ALPHA"}`, false}, - {"changed number", `{"id":2}`, false}, - {"invented value", `{"name":"omega"}`, false}, - } - for _, c := range cases { - t.Run(c.name, func(t *testing.T) { - if got := IsContained(parse(c.out), original); got != c.want { - t.Fatalf("IsContained(%s) = %v, want %v", c.out, got, c.want) - } - }) - } -} - -func TestContainmentListSubsequenceOrder(t *testing.T) { - orig := parse(`[{"id":1},{"id":2},{"id":3}]`) - if !IsContained(parse(`[{"id":1},{"id":3}]`), orig) { - t.Fatal("order-preserving subsequence should be contained") - } - if IsContained(parse(`[{"id":3},{"id":1}]`), orig) { - t.Fatal("out-of-order subsequence must NOT be contained") - } -} - -func TestDeterministicProjectIsAlwaysContained(t *testing.T) { - body := `[{"id":1,"name":"alpha","blob":"` + strings.Repeat("x", 200) + `"}, - {"id":2,"name":"beta","blob":"` + strings.Repeat("y", 200) + `"}]` - proj := DeterministicProject(parseBody(body), []string{"alpha"}, 4000) - if !IsContained(proj, parseBody(body)) { - t.Fatalf("deterministic projection must be contained: %v", proj) - } -} - -// fakeModel parses the INPUT embedded in the prompt and returns its first element -// (for arrays) — a content-aware, always-contained selection, like a well-behaved -// cheap model. Used to exercise the single + RLM paths. -type firstElemModel struct{} - -func (firstElemModel) Complete(_ context.Context, prompt string) (string, error) { - i := strings.Index(prompt, sampleMarker) - if i < 0 { - return "", nil - } - rest := prompt[i+len(sampleMarker):] - end := strings.Index(rest, "\n\n") - if end >= 0 { - rest = rest[:end] - } - var arr []any - if json.Unmarshal([]byte(rest), &arr) == nil && len(arr) > 0 { - b, _ := json.Marshal(arr[:1]) - return string(b), nil - } - return rest, nil -} - -// paraphraseModel always returns an invented value (fails containment). -type paraphraseModel struct{} - -func (paraphraseModel) Complete(_ context.Context, _ string) (string, error) { - return `[{"id":999,"name":"INVENTED"}]`, nil -} - -func TestRunExtractionSingleSelects(t *testing.T) { - body := `[{"id":1,"name":"alpha"},{"id":2,"name":"beta"},{"id":3,"name":"gamma"}]` - cfg := DefaultCfg() - cfg.Mode = "single" - cfg.AllowDeterministic = false - out, strat := RunExtraction(context.Background(), body, "find alpha", []string{"alpha"}, 100, cfg, firstElemModel{}) - if strat != "single" { - t.Fatalf("strategy = %q, want single", strat) - } - if !IsContained(parseBody(out), parseBody(body)) { - t.Fatalf("accepted result not contained: %s", out) - } - if len(out) >= len(body) { - t.Fatalf("result not smaller: %d vs %d", len(out), len(body)) - } -} - -func TestRunExtractionRejectsParaphraseFallsToDeterministic(t *testing.T) { - // Records carry a bulky non-important "detail" field that deterministic drops. - blob := strings.Repeat("noise ", 30) - body := `[{"id":1,"detail":"` + blob + `"},{"id":2,"detail":"` + blob + `"}]` - cfg := DefaultCfg() - cfg.Mode = "single" // single fails containment, then deterministic runs - out, strat := RunExtraction(context.Background(), body, "find alpha", []string{"alpha"}, 100, cfg, paraphraseModel{}) - if strat != "deterministic" { - t.Fatalf("strategy = %q, want deterministic (paraphrase must be rejected)", strat) - } - if !IsContained(parseBody(out), parseBody(body)) { - t.Fatalf("deterministic fallback not contained: %s", out) - } -} - -func TestRunExtractionRLMBatchedMergesChunks(t *testing.T) { - // 50 uniform records -> chunked (size 20) -> 3 chunks -> first of each merged. - var recs []string - for i := 0; i < 50; i++ { - recs = append(recs, `{"id":`+itoa(i)+`,"v":"rec`+itoa(i)+`"}`) - } - body := "[" + strings.Join(recs, ",") + "]" - cfg := DefaultCfg() - cfg.Mode = "rlm" - cfg.AllowDeterministic = false - out, strat := RunExtraction(context.Background(), body, "anything", nil, 100, cfg, firstElemModel{}) - if strat != "rlm" { - t.Fatalf("strategy = %q, want rlm", strat) - } - if !IsContained(parseBody(out), parseBody(body)) { - t.Fatalf("merged RLM result not contained: %s", out) - } - var merged []any - if err := json.Unmarshal([]byte(out), &merged); err != nil { - t.Fatalf("rlm output not a JSON array: %v", err) - } - if len(merged) != 3 { // one per chunk - t.Fatalf("expected 3 merged records, got %d", len(merged)) - } -} - -func TestTruncateValueDoesNotSplitRunes(t *testing.T) { - s := strings.Repeat("é", 10) // 2 bytes each - out := truncateValue(s, 5).(string) - if !utf8.ValidString(out) { - t.Fatalf("truncation split a rune: %q", out) - } -} - -func itoa(n int) string { - b, _ := json.Marshal(n) - return string(b) -} diff --git a/internal/extract/prompt.go b/internal/extract/prompt.go deleted file mode 100644 index 18b33e8..0000000 --- a/internal/extract/prompt.go +++ /dev/null @@ -1,147 +0,0 @@ -package extract - -import ( - "encoding/json" - "strings" -) - -// Prompt building. Because the model returns the filtered VALUE (which containment -// then verifies), it must SEE the values — so the prompt shows the actual JSON/text -// (truncated). For very large lists the RLM strategy chunks the body so each chunk is -// shown in full. The rule set is the reference prototype's "select, never summarize, recall-first" -// contract, retargeted from "write a function" to "return the JSON". - -// sampleMarker precedes the body in the prompt; tests and the (future) model both -// locate the payload after it. -const sampleMarker = "INPUT (return a smaller value of this same shape):\n" - -const rules = `Return ONLY a JSON value (or, for raw text input, the kept text): a SMALLER value -of the SAME shape, selecting only what the agent needs next. Rules, in priority order: -1. RECALL FIRST. When unsure whether a record/field is relevant, KEEP IT. -2. SELECT, NEVER SUMMARIZE. Return whole records/objects/values byte-for-byte. Never - paraphrase, truncate, round, reformat, or invent values. -3. PRESERVE EXACTLY: ids, numbers, names, paths, timestamps, error messages, stack - traces — and anything matching the KEEP list. -4. Only drop records that are CLEARLY irrelevant boilerplate, duplicates, or noise. -5. If you cannot identify clearly-irrelevant content, RETURN THE INPUT UNCHANGED. -6. Keep the natural shape and types. Output ONLY the value — no prose, no markdown.` - -const example = `EXAMPLE -Goal: "Fix failing test test_auth_expiry; find the relevant hit." -KEEP: ["test_auth_expiry","auth/session.py"] -INPUT: [{"path":"auth/session.py","snippet":"def test_auth_expiry()..."},{"path":"README.md","snippet":"intro"}] -OUTPUT: [{"path":"auth/session.py","snippet":"def test_auth_expiry()..."}]` - -func buildPrompt(bodyText, goal string, keepIDs []string) string { - g := strings.TrimSpace(goal) - if g == "" { - g = "(no explicit goal stated)" - } - if len(g) > 2000 { - g = g[:2000] - } - keep := keepIDs - if len(keep) > 60 { - keep = keep[:60] - } - keepBlock := "" - if len(keep) > 0 { - kb, _ := json.Marshal(keep) - keepBlock = "IDENTIFIERS THE AGENT REFERENCED RECENTLY — keep every record or field\n" + - "whose value matches any of these, verbatim:\n" + string(kb) + "\n\n" - } - - // Show the actual value (pretty-printed JSON if it parses), truncated. - sample := bodyText - if v := parseBody(bodyText); !isRawString(v) { - if b, err := json.MarshalIndent(v, "", " "); err == nil { - sample = string(b) - } - } - sample = truncate(sample, sampleChars) - - return "You filter ONE tool output down to only what the agent needs next.\n\n" + - "WHAT THE AGENT IS DOING NOW (filter toward this):\n" + g + "\n\n" + - keepBlock + sampleMarker + sample + "\n\n" + rules + "\n\n" + example -} - -// codeRules is the Starlark code-writing contract: the model writes a program that -// runs over the real INPUT (it is NOT shown the full body), so the prompt stays cheap. -// Same "select, never summarize, recall-first" discipline as buildPrompt, retargeted -// from "return the value" to "write the filter". -const codeRules = `Write a Starlark program (a safe Python subset) that filters this ONE tool output -down to only what the agent needs next. Contract: -- The global string INPUT holds the FULL tool output. The module ` + "`json`" + ` is available. -- Start: data = json.decode(INPUT) -- Select a SMALLER value of the SAME shape (e.g. drop irrelevant list records / fields). -- End: OUTPUT = json.encode(result) # OUTPUT must be a string -Rules, in priority order: -1. RECALL FIRST. When unsure whether a record/field is relevant, KEEP IT. -2. SELECT, NEVER SUMMARIZE. Keep whole records/values byte-for-byte. Never paraphrase, - truncate, round, reformat, or invent values — only DROP clearly-irrelevant ones. -3. PRESERVE EXACTLY: ids, numbers, names, paths, timestamps, errors, stack traces — - and anything matching the KEEP list. -4. NO imports (no load()), NO I/O, NO network. Use only json.decode/json.encode and - plain Starlark (list comprehensions, dict/list ops, string ops). -5. If you cannot identify clearly-irrelevant content, set OUTPUT = INPUT. -Output ONLY the Starlark program — no prose, no markdown fences.` - -const codeExample = `EXAMPLE -Goal: "Fix failing test test_auth_expiry; find the relevant hit." -KEEP: ["test_auth_expiry","auth/session.py"] -INPUT schema: list of {"path": str, "snippet": str} -PROGRAM: -data = json.decode(INPUT) -result = [r for r in data if "auth" in r["path"] or "test_auth_expiry" in r["snippet"]] -OUTPUT = json.encode(result)` - -// buildCodePrompt builds the prompt for the Starlark code-writing strategy. Unlike -// buildPrompt it does NOT inline the full body — the program runs over the real INPUT. -// It shows the parsed shape and a small sample so the model knows the schema cheaply. -func buildCodePrompt(bodyText, goal string, keepIDs []string) string { - g := strings.TrimSpace(goal) - if g == "" { - g = "(no explicit goal stated)" - } - if len(g) > 2000 { - g = g[:2000] - } - keep := keepIDs - if len(keep) > 60 { - keep = keep[:60] - } - keepBlock := "" - if len(keep) > 0 { - kb, _ := json.Marshal(keep) - keepBlock = "IDENTIFIERS THE AGENT REFERENCED RECENTLY — keep every record or field\n" + - "whose value matches any of these, verbatim:\n" + string(kb) + "\n\n" - } - - // A small sample of the parsed shape — enough to infer the schema, not the full body. - sample := bodyText - if v := parseBody(bodyText); !isRawString(v) { - if b, err := json.MarshalIndent(v, "", " "); err == nil { - sample = string(b) - } - } - sample = truncate(sample, codeSampleChars) - - return "You write a Starlark filter for ONE tool output, keeping only what the agent needs next.\n\n" + - "WHAT THE AGENT IS DOING NOW (filter toward this):\n" + g + "\n\n" + - keepBlock + "INPUT SAMPLE (the real INPUT at runtime is the FULL output of this same shape):\n" + - sample + "\n\n" + codeRules + "\n\n" + codeExample -} - -const codeSampleChars = 1500 - -func isRawString(v any) bool { - _, ok := v.(string) - return ok -} - -func truncate(s string, n int) string { - if len(s) <= n { - return s - } - return s[:n] -} diff --git a/internal/extract/starlark.go b/internal/extract/starlark.go deleted file mode 100644 index 96709ae..0000000 --- a/internal/extract/starlark.go +++ /dev/null @@ -1,63 +0,0 @@ -package extract - -import ( - "context" - "time" - - starjson "go.starlark.net/lib/json" - "go.starlark.net/starlark" -) - -const ( - starlarkMaxSteps = 50_000_000 - starlarkTimeout = 2 * time.Second -) - -// runStarlark asks the model for a Starlark program whose contract is: read the -// global string INPUT (the full tool output), assign a string global OUTPUT (the -// filtered value). It runs sandboxed over the FULL body — no imports, no I/O, step + -// time limits — and returns OUTPUT, or "" on any failure (fail-open). Containment is -// verified by the caller (RunExtraction). -func runStarlark(ctx context.Context, body, goal string, keepIDs []string, model Model) (out string) { - defer func() { - if recover() != nil { - out = "" - } - }() - if model == nil { - return "" - } - src, err := model.Complete(ctx, buildCodePrompt(body, goal, keepIDs)) - if err != nil { - return "" - } - src = stripFences(src) - - ctx, cancel := context.WithTimeout(ctx, starlarkTimeout) - defer cancel() - thread := &starlark.Thread{Name: "extract"} // Load==nil => load() disabled - thread.SetMaxExecutionSteps(starlarkMaxSteps) - done := make(chan struct{}) - go func() { - select { - case <-ctx.Done(): - thread.Cancel(ctx.Err().Error()) - case <-done: - } - }() - defer close(done) - - predeclared := starlark.StringDict{ - "json": starjson.Module, - "INPUT": starlark.String(body), - } - globals, err := starlark.ExecFile(thread, "extract.star", src, predeclared) - if err != nil { - return "" - } - res, ok := globals["OUTPUT"].(starlark.String) - if !ok { - return "" - } - return string(res) -} diff --git a/internal/extract/starlark_test.go b/internal/extract/starlark_test.go deleted file mode 100644 index 6ae2f0f..0000000 --- a/internal/extract/starlark_test.go +++ /dev/null @@ -1,57 +0,0 @@ -package extract - -import ( - "context" - "strings" - "testing" -) - -// starlarkModel returns a fixed Starlark program that keeps records whose name -// contains "keep" — exercises real code execution over the full input. -type starlarkModel struct{} - -func (starlarkModel) Complete(_ context.Context, _ string) (string, error) { - return ` -data = json.decode(INPUT) -kept = [r for r in data if "keep" in r["name"]] -OUTPUT = json.encode(kept) -`, nil -} - -func TestRunStarlarkFiltersFullBody(t *testing.T) { - var recs []string - for i := 0; i < 100; i++ { - name := "drop" - if i%10 == 0 { - name = "keep" - } - recs = append(recs, `{"id":`+itoa(i)+`,"name":"`+name+`"}`) - } - body := "[" + strings.Join(recs, ",") + "]" - out := runStarlark(context.Background(), body, "find keep", nil, starlarkModel{}) - if out == "" { - t.Fatal("expected a Starlark result") - } - if !IsContained(parseBody(out), parseBody(body)) { - t.Fatalf("Starlark output must be a contained subset: %s", out) - } - if strings.Contains(out, "drop") { - t.Fatal("filter should have dropped non-keep records") - } - if !strings.Contains(out, "keep") { - t.Fatal("filter should have kept the keep records (recall, not truncation)") - } -} - -// malicious program must fail-open (no panic, returns ""). -type evilModel struct{} - -func (evilModel) Complete(_ context.Context, _ string) (string, error) { - return `load("os", "x")`, nil // imports disabled -} - -func TestRunStarlarkFailsOpenOnDisallowed(t *testing.T) { - if out := runStarlark(context.Background(), `[{"a":1}]`, "", nil, evilModel{}); out != "" { - t.Fatalf("disallowed program must fail open to \"\", got %q", out) - } -} diff --git a/internal/markers/markers.go b/internal/markers/markers.go deleted file mode 100644 index 6e8169e..0000000 --- a/internal/markers/markers.go +++ /dev/null @@ -1,49 +0,0 @@ -// Package markers handles reversible, namespaced content markers. lab-cx's marker -// is «labcx:HEXID»; foreign markers from other reducers (headroom, claw) are left -// alone so lab-cx stacks cleanly on top of them. Ported from the reference prototype's -// markers.py. -package markers - -import ( - "fmt" - "regexp" -) - -var ( - labcxRe = regexp.MustCompile(`«labcx:([0-9a-f]{4,64})»`) - foreignRe = regexp.MustCompile(`<>|\[rewind:[0-9a-zA-Z]{4,64}\]`) -) - -// Make returns the marker for a rewind id. -func Make(rewindID string) string { return fmt.Sprintf("«labcx:%s»", rewindID) } - -// RecoveryNote is a self-advertising, model-readable note appended to a reduced -// block so the omission is a known unknown the model can recover, not a silent drop. -func RecoveryNote(label, what, rewindID string) string { - return fmt.Sprintf("[labcx: %s %s; call labcx_expand(%q) to restore] %s", - label, what, rewindID, Make(rewindID)) -} - -// FindIDs returns all lab-cx rewind ids referenced in text. -func FindIDs(text string) []string { - m := labcxRe.FindAllStringSubmatch(text, -1) - out := make([]string, 0, len(m)) - for _, g := range m { - out = append(out, g[1]) - } - return out -} - -// Has reports whether text already carries a lab-cx marker (i.e. the block was -// already reduced/extracted on this or a prior turn). Compactors use it to avoid -// re-processing an already-reduced block. -func Has(text string) bool { return labcxRe.MatchString(text) } - -// HasForeign reports whether text carries another reducer's marker. -func HasForeign(text string) bool { return foreignRe.MatchString(text) } - -// Strip removes lab-cx and foreign markers from text (used for a marker-insensitive -// content key). -func Strip(text string) string { - return foreignRe.ReplaceAllString(labcxRe.ReplaceAllString(text, ""), "") -} diff --git a/internal/proxyhttp/proxy.go b/internal/proxyhttp/proxy.go deleted file mode 100644 index 3eed989..0000000 --- a/internal/proxyhttp/proxy.go +++ /dev/null @@ -1,395 +0,0 @@ -// Package proxyhttp is the HTTP shell around the engine: a transparent proxy that -// reduces /v1/messages and /v1/chat/completions requests and forwards everything -// (including streaming responses and non-model endpoints) upstream unchanged. This -// is the "any agent" and eval-containers integration — agents point their -// *_BASE_URL at it. Fail-open: any error forwards the original request. -package proxyhttp - -import ( - "bytes" - "context" - "encoding/json" - "errors" - "io" - "net/http" - "strings" - "sync/atomic" - "time" - - "github.com/kagenti/lab-context-engineering/engine" - "github.com/kagenti/lab-context-engineering/observability" - "github.com/kagenti/lab-context-engineering/surfaces" -) - -// Mode is the proxy's process-wide runtime reduction mode, settable without a -// restart via POST /labcx/mode. It is the new-stack equivalent of the old -// CE-Manager activation: a KV-pressure monitor flips it on/off live. -type Mode string - -const ( - ModeOn Mode = "on" // reduce with the startup-configured method - ModeOff Mode = "off" // bypass: forward the original request untouched - ModeDeterministic Mode = "deterministic" // reserved; currently behaves like ModeOn -) - -// ParseMode validates a mode string (case/space-insensitive). ok=false for anything -// outside the enum, so callers can reject it with HTTP 400. -func ParseMode(s string) (Mode, bool) { - switch Mode(strings.ToLower(strings.TrimSpace(s))) { - case ModeOn: - return ModeOn, true - case ModeOff: - return ModeOff, true - case ModeDeterministic: - return ModeDeterministic, true - default: - return "", false - } -} - -// Config configures the proxy handler. -type Config struct { - Engine *engine.Engine - // Upstream, if set, is the base URL every request is forwarded to (the - // eval-containers gateway case). If empty, requests route to the provider - // default by path (api.anthropic.com / api.openai.com). - Upstream string - AnthropicDefault string // default "https://api.anthropic.com" - OpenAIDefault string // default "https://api.openai.com" - Client *http.Client - Emitter observability.Emitter // default observability.Nop - // Aggregator, if set, is served at GET /stats (Snapshot as JSON). It is - // independent of Emitter: a host may stream via Emitter and also expose - // process-wide stats here, or pass the same *observability.Aggregator as both. - Aggregator *observability.Aggregator - // MaxBodyBytes caps the request body read from clients; 0 means no cap. A body - // exceeding the cap yields HTTP 413. - MaxBodyBytes int64 - // UpstreamTimeout bounds each upstream request when Client is not set; 0 means - // no timeout (http.DefaultClient). - UpstreamTimeout time.Duration - // BuildRequestModel, when set, builds an engine.Model from the incoming request's - // own model + credentials (surface name, model id, resolved upstream base, request - // headers) for LLM compactors configured with source "incoming". Returning nil means - // "no per-request model" (those compactors then no-op). The host (proxy binary) - // supplies this so cheapmodel construction stays out of this package. - BuildRequestModel func(surfaceName, model, base string, h http.Header) engine.Model - // InitialMode is the reduction mode at startup (on|off|deterministic). Empty or - // invalid defaults to ModeOn, preserving the always-reduce default. Settable at - // runtime via POST /labcx/mode. - InitialMode Mode -} - -type proxy struct { - cfg Config - mode atomic.Value // holds Mode; runtime-settable via POST /labcx/mode -} - -// New returns the proxy http.Handler. Routing is by path SUFFIX so it works both for -// direct agents (POST /v1/messages) and for eval-containers, which prefix the wire -// path (POST /anthropic/v1/messages, /openai/v1/chat/completions). The full original -// path is preserved when forwarding upstream. -func New(cfg Config) http.Handler { - if cfg.Client == nil { - if cfg.UpstreamTimeout > 0 { - cfg.Client = &http.Client{Timeout: cfg.UpstreamTimeout} - } else { - cfg.Client = http.DefaultClient - } - } - if cfg.AnthropicDefault == "" { - cfg.AnthropicDefault = "https://api.anthropic.com" - } - if cfg.OpenAIDefault == "" { - cfg.OpenAIDefault = "https://api.openai.com" - } - if cfg.Emitter == nil { - cfg.Emitter = observability.Nop{} - } - p := &proxy{cfg: cfg} - initial := cfg.InitialMode - if _, ok := ParseMode(string(initial)); !ok { - initial = ModeOn - } - p.mode.Store(initial) - return p -} - -func (p *proxy) ServeHTTP(w http.ResponseWriter, r *http.Request) { - path := r.URL.Path - switch { - case path == "/health" || path == "/ready": - p.ok(w, r) - case path == "/labcx/expand": - p.expand(w, r) - case path == "/labcx/mode": - p.modeControl(w, r) - case path == "/stats": - p.stats(w, r) - case strings.HasSuffix(path, "/v1/messages"): - p.model(surfaces.Anthropic{}, p.cfg.AnthropicDefault)(w, r) - case strings.HasSuffix(path, "/chat/completions"): - p.model(surfaces.OpenAI{}, p.cfg.OpenAIDefault)(w, r) - default: - p.passthrough(p.cfg.AnthropicDefault)(w, r) - } -} - -func (p *proxy) ok(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) } - -// stats serves the process-wide reduction Snapshot as JSON. Returns 404 when no -// Aggregator is configured. -func (p *proxy) stats(w http.ResponseWriter, _ *http.Request) { - if p.cfg.Aggregator == nil { - http.Error(w, "stats not enabled", http.StatusNotFound) - return - } - w.Header().Set("Content-Type", "application/json") - _ = p.cfg.Aggregator.WriteJSON(w) -} - -func (p *proxy) expand(w http.ResponseWriter, r *http.Request) { - id := r.URL.Query().Get("id") - original, ok := p.cfg.Engine.Expand(id) - if !ok { - http.Error(w, "not found", http.StatusNotFound) - return - } - w.Header().Set("Content-Type", "text/plain; charset=utf-8") - _, _ = io.WriteString(w, original) -} - -// currentMode returns the live reduction mode (defaults to ModeOn if unset). -func (p *proxy) currentMode() Mode { - if m, ok := p.mode.Load().(Mode); ok { - return m - } - return ModeOn -} - -// modeControl gets or sets the process-wide reduction mode at runtime — no restart. -// GET → {"mode":""}. POST/PUT reads {"mode":"on|off|deterministic"} (or the -// ?mode= query) and stores it; an unknown value is rejected with HTTP 400. This is the -// switch a KV-pressure monitor flips to activate/deactivate context engineering live. -func (p *proxy) modeControl(w http.ResponseWriter, r *http.Request) { - switch r.Method { - case http.MethodGet: - // fall through and report the current mode - case http.MethodPost, http.MethodPut: - want := r.URL.Query().Get("mode") - if want == "" { - var body struct { - Mode string `json:"mode"` - } - if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&body); err == nil { - want = body.Mode - } - } - m, ok := ParseMode(want) - if !ok { - http.Error(w, "invalid mode (want on|off|deterministic)", http.StatusBadRequest) - return - } - p.mode.Store(m) - default: - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - return - } - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]string{"mode": string(p.currentMode())}) -} - -// upstreamBase returns the base URL to forward to: the configured single upstream, -// or the provider default. -func (p *proxy) upstreamBase(providerDefault string) string { - if p.cfg.Upstream != "" { - return p.cfg.Upstream - } - return providerDefault -} - -func (p *proxy) model(surface surfaces.Surface, providerDefault string) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - base := p.upstreamBase(providerDefault) - body, err := p.readBody(w, r) - if err != nil { - return // readBody already wrote the response - } - // Reduce unless bypassed (per-request header or process-wide mode=off) or the - // surface can't map this format. - outBody := body - if !bypassed(r) && p.currentMode() != ModeOff { - start := time.Now() - ctx := r.Context() - if p.cfg.BuildRequestModel != nil { - mb := modelBase(base, r.URL.Path, surface.Name()) - if m := p.cfg.BuildRequestModel(surface.Name(), modelOf(body), mb, r.Header); m != nil { - ctx = engine.WithRequestModel(ctx, m) - } - } - reduced, rep, ok := p.reduce(ctx, surface, body) - latencyMs := int(time.Since(start).Milliseconds()) - if ok { - outBody = reduced - p.cfg.Emitter.Emit(r.Context(), observability.Event{ - System: surface.Name(), Surface: surface.Name(), - RequestModel: modelOf(body), SessionID: rep.Reduce.SessionID, - TokensBefore: rep.Reduce.TokensBefore, TokensAfter: rep.Reduce.TokensAfter, - TokensSaved: rep.Reduce.TokensSaved, Ratio: rep.Reduce.Ratio, - CacheInject: rep.CacheInjected, Extracted: len(rep.Candidates) > 0, - StageErrors: rep.StageErrors, - ToolsTotal: rep.Reduce.ToolsTotal, - ToolDefTokens: rep.Reduce.ToolDefTokens, - ReducedCount: len(rep.Reduce.ReducedIDs), - CandidatesCount: len(rep.Candidates), - FrozenCount: rep.Reduce.FrozenCount, - Rehydrated: rep.Reduce.Rehydrated, - AtCompaction: rep.Reduce.AtCompaction, - LatencyMillis: latencyMs, - }) - } - } - p.forward(w, r, base, outBody) - } -} - -// reduce maps → transforms → renders, returning the reduced body and report. ok=false -// means forward the original (unsupported surface or any failure — fail-open). -func (p *proxy) reduce(ctx context.Context, surface surfaces.Surface, body []byte) (out []byte, rep engine.Report, ok bool) { - defer func() { - if recover() != nil { - out, ok = nil, false - } - }() - req, token, err := surface.ToInternal(body) - if err != nil { - return nil, engine.Report{}, false - } - transformed, report := p.cfg.Engine.Transform(ctx, req) - rendered, err := surface.Render(transformed, token) - if err != nil { - return nil, engine.Report{}, false - } - return rendered, report, true -} - -// modelBase returns the base URL a per-request (source: incoming) model client should -// use so that appending the surface's fixed path (/v1/messages or /v1/chat/completions) -// reproduces the SAME upstream URL the main request forwards to — preserving any route -// prefix (e.g. /anthropic when the agent points at http://gateway:4000/anthropic). -func modelBase(upstream, reqPath, surfaceName string) string { - suffix := "/v1/messages" - if surfaceName == "openai" { - suffix = "/v1/chat/completions" - } - prefix := strings.TrimSuffix(reqPath, suffix) - if prefix == reqPath { // suffix not present; fall back to the bare upstream - prefix = "" - } - return strings.TrimRight(upstream, "/") + prefix -} - -// modelOf best-effort reads the "model" field from a request body for telemetry. -func modelOf(body []byte) string { - var m struct { - Model string `json:"model"` - } - _ = json.Unmarshal(body, &m) - return m.Model -} - -func (p *proxy) passthrough(providerDefault string) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - body, err := p.readBody(w, r) - if err != nil { - return // readBody already wrote the response - } - p.forward(w, r, p.upstreamBase(providerDefault), body) - } -} - -// readBody reads the full request body, enforcing cfg.MaxBodyBytes when set. On a -// cap overflow it writes HTTP 413; on any other read error it writes HTTP 400. In -// both error cases the response is already written and the caller must just return. -func (p *proxy) readBody(w http.ResponseWriter, r *http.Request) ([]byte, error) { - if p.cfg.MaxBodyBytes > 0 { - r.Body = http.MaxBytesReader(w, r.Body, p.cfg.MaxBodyBytes) - } - body, err := io.ReadAll(r.Body) - if err != nil { - var maxErr *http.MaxBytesError - if errors.As(err, &maxErr) { - http.Error(w, "request body too large", http.StatusRequestEntityTooLarge) - } else { - http.Error(w, "read body", http.StatusBadRequest) - } - return nil, err - } - return body, nil -} - -// hop-by-hop headers that must not be forwarded. -var hopHeaders = map[string]struct{}{ - "Connection": {}, "Keep-Alive": {}, "Proxy-Authenticate": {}, "Proxy-Authorization": {}, - "Te": {}, "Trailer": {}, "Transfer-Encoding": {}, "Upgrade": {}, -} - -// forward proxies the request to base+path with the given body and streams the -// response back (flushing per chunk so SSE streams pass through live). -func (p *proxy) forward(w http.ResponseWriter, r *http.Request, base string, body []byte) { - url := strings.TrimRight(base, "/") + r.URL.Path - if r.URL.RawQuery != "" { - url += "?" + r.URL.RawQuery - } - req, err := http.NewRequestWithContext(r.Context(), r.Method, url, bytes.NewReader(body)) - if err != nil { - http.Error(w, "bad upstream request", http.StatusBadGateway) - return - } - for k, vs := range r.Header { - if _, hop := hopHeaders[http.CanonicalHeaderKey(k)]; hop || k == "Host" { - continue - } - for _, v := range vs { - req.Header.Add(k, v) - } - } - req.ContentLength = int64(len(body)) - - resp, err := p.cfg.Client.Do(req) - if err != nil { - http.Error(w, "upstream error: "+err.Error(), http.StatusBadGateway) - return - } - defer resp.Body.Close() - - for k, vs := range resp.Header { - if _, hop := hopHeaders[http.CanonicalHeaderKey(k)]; hop { - continue - } - for _, v := range vs { - w.Header().Add(k, v) - } - } - w.WriteHeader(resp.StatusCode) - flusher, _ := w.(http.Flusher) - buf := make([]byte, 16*1024) - for { - n, rerr := resp.Body.Read(buf) - if n > 0 { - if _, werr := w.Write(buf[:n]); werr != nil { - return - } - if flusher != nil { - flusher.Flush() - } - } - if rerr != nil { - return - } - } -} - -func bypassed(r *http.Request) bool { - return strings.EqualFold(r.Header.Get("x-labcx-bypass"), "true") -} diff --git a/internal/proxyhttp/proxy_test.go b/internal/proxyhttp/proxy_test.go deleted file mode 100644 index d3a390c..0000000 --- a/internal/proxyhttp/proxy_test.go +++ /dev/null @@ -1,335 +0,0 @@ -package proxyhttp - -import ( - "context" - "encoding/json" - "io" - "net/http" - "net/http/httptest" - "strings" - "testing" - - "github.com/kagenti/lab-context-engineering/config" - "github.com/kagenti/lab-context-engineering/engine" - "github.com/kagenti/lab-context-engineering/internal/markers" - "github.com/kagenti/lab-context-engineering/observability" -) - -// captureUpstream records the last body it received and returns a fixed response. -func captureUpstream(t *testing.T, got *string) *httptest.Server { - t.Helper() - return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - b, _ := io.ReadAll(r.Body) - *got = string(b) - w.Header().Set("Content-Type", "application/json") - _, _ = io.WriteString(w, `{"ok":true}`) - })) -} - -func newProxy(t *testing.T, upstream string) http.Handler { - t.Helper() - s := config.Default() - s.ProtectRecent = 1 - return New(Config{Engine: engine.New(s, nil, nil), Upstream: upstream}) -} - -func TestProxyReducesBeforeForwarding(t *testing.T) { - var upstreamGot string - up := captureUpstream(t, &upstreamGot) - defer up.Close() - h := newProxy(t, up.URL) - - big := strings.Repeat("a.go long file content line for the read result here\\n", 12) - body := `{"messages":[ - {"role":"assistant","content":[{"type":"tool_use","id":"u1","name":"read","input":{"file_path":"a.go"}}]}, - {"role":"user","content":[{"type":"tool_result","tool_use_id":"u1","content":"` + big + `"}]}, - {"role":"assistant","content":[{"type":"tool_use","id":"u2","name":"edit","input":{"file_path":"a.go"}}]}, - {"role":"user","content":[{"type":"tool_result","tool_use_id":"u2","content":"success: edited"}]}, - {"role":"user","content":[{"type":"text","text":"thanks"}]} - ]}` - - req := httptest.NewRequest(http.MethodPost, "/v1/messages", strings.NewReader(body)) - rec := httptest.NewRecorder() - h.ServeHTTP(rec, req) - - if rec.Code != http.StatusOK { - t.Fatalf("status = %d", rec.Code) - } - if len(upstreamGot) >= len(body) { - t.Fatalf("upstream body not reduced: got %d, original %d", len(upstreamGot), len(body)) - } - if len(markers.FindIDs(upstreamGot)) == 0 { - t.Fatalf("expected a lab-cx marker in the forwarded body") - } -} - -func TestBypassForwardsOriginal(t *testing.T) { - var upstreamGot string - up := captureUpstream(t, &upstreamGot) - defer up.Close() - h := newProxy(t, up.URL) - - body := `{"messages":[{"role":"user","content":[{"type":"text","text":"hi"}]}]}` - req := httptest.NewRequest(http.MethodPost, "/v1/messages", strings.NewReader(body)) - req.Header.Set("x-labcx-bypass", "true") - rec := httptest.NewRecorder() - h.ServeHTTP(rec, req) - - if upstreamGot != body { - t.Fatalf("bypass should forward the original body verbatim:\n got: %s\nwant: %s", upstreamGot, body) - } -} - -// TestRuntimeModeToggle: POST /labcx/mode flips reduction on/off without a restart. -// mode=off → forward the original untouched; mode=on → reduce again. Bad value → 400. -func TestRuntimeModeToggle(t *testing.T) { - var upstreamGot string - up := captureUpstream(t, &upstreamGot) - defer up.Close() - h := newProxy(t, up.URL) - - big := strings.Repeat("a.go long file content line for the read result here\\n", 12) - body := `{"messages":[ - {"role":"assistant","content":[{"type":"tool_use","id":"u1","name":"read","input":{"file_path":"a.go"}}]}, - {"role":"user","content":[{"type":"tool_result","tool_use_id":"u1","content":"` + big + `"}]}, - {"role":"assistant","content":[{"type":"tool_use","id":"u2","name":"edit","input":{"file_path":"a.go"}}]}, - {"role":"user","content":[{"type":"tool_result","tool_use_id":"u2","content":"success: edited"}]}, - {"role":"user","content":[{"type":"text","text":"thanks"}]} - ]}` - - post := func(payload string) string { - t.Helper() - req := httptest.NewRequest(http.MethodPost, "/labcx/mode", strings.NewReader(payload)) - rec := httptest.NewRecorder() - h.ServeHTTP(rec, req) - if rec.Code != http.StatusOK { - t.Fatalf("POST /labcx/mode %s = %d", payload, rec.Code) - } - var got struct{ Mode string } - _ = json.Unmarshal(rec.Body.Bytes(), &got) - return got.Mode - } - - // Default is "on": a reduce-eligible request gets reduced. - { - req := httptest.NewRequest(http.MethodPost, "/v1/messages", strings.NewReader(body)) - rec := httptest.NewRecorder() - h.ServeHTTP(rec, req) - if len(upstreamGot) >= len(body) { - t.Fatalf("default mode should reduce: got %d, original %d", len(upstreamGot), len(body)) - } - } - - // Flip to off → the next request is forwarded verbatim. - if m := post(`{"mode":"off"}`); m != "off" { - t.Fatalf("mode after off = %q", m) - } - upstreamGot = "" - { - req := httptest.NewRequest(http.MethodPost, "/v1/messages", strings.NewReader(body)) - rec := httptest.NewRecorder() - h.ServeHTTP(rec, req) - if upstreamGot != body { - t.Fatalf("mode=off should forward original verbatim:\n got: %s\nwant: %s", upstreamGot, body) - } - } - - // Flip back to on → reduction resumes. - if m := post(`{"mode":"on"}`); m != "on" { - t.Fatalf("mode after on = %q", m) - } - upstreamGot = "" - { - req := httptest.NewRequest(http.MethodPost, "/v1/messages", strings.NewReader(body)) - rec := httptest.NewRecorder() - h.ServeHTTP(rec, req) - if len(upstreamGot) >= len(body) { - t.Fatalf("mode=on should reduce again: got %d, original %d", len(upstreamGot), len(body)) - } - } - - // Unknown value → 400, mode unchanged. - req := httptest.NewRequest(http.MethodPost, "/labcx/mode", strings.NewReader(`{"mode":"bogus"}`)) - rec := httptest.NewRecorder() - h.ServeHTTP(rec, req) - if rec.Code != http.StatusBadRequest { - t.Fatalf("bad mode should be 400, got %d", rec.Code) - } - - // GET reports the current mode. - greq := httptest.NewRequest(http.MethodGet, "/labcx/mode", nil) - grec := httptest.NewRecorder() - h.ServeHTTP(grec, greq) - var got struct{ Mode string } - _ = json.Unmarshal(grec.Body.Bytes(), &got) - if got.Mode != "on" { - t.Fatalf("GET mode = %q, want on", got.Mode) - } -} - -func TestPassthroughForwardsUnknownPaths(t *testing.T) { - var upstreamGot string - up := captureUpstream(t, &upstreamGot) - defer up.Close() - h := newProxy(t, up.URL) - - req := httptest.NewRequest(http.MethodGet, "/v1/models", nil) - rec := httptest.NewRecorder() - h.ServeHTTP(rec, req) - if rec.Code != http.StatusOK { - t.Fatalf("passthrough status = %d", rec.Code) - } -} - -// TestEvalContainersPrefixedPath: eval-containers posts to /anthropic/v1/messages; -// the proxy must still reduce it and forward the full prefixed path to the gateway. -func TestEvalContainersPrefixedPath(t *testing.T) { - var gotPath, gotBody string - up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - gotPath = r.URL.Path - b, _ := io.ReadAll(r.Body) - gotBody = string(b) - _, _ = io.WriteString(w, `{"ok":true}`) - })) - defer up.Close() - h := newProxy(t, up.URL) - - big := strings.Repeat("a.go long file content line for the read result here\\n", 12) - body := `{"messages":[ - {"role":"assistant","content":[{"type":"tool_use","id":"u1","name":"read","input":{"file_path":"a.go"}}]}, - {"role":"user","content":[{"type":"tool_result","tool_use_id":"u1","content":"` + big + `"}]}, - {"role":"assistant","content":[{"type":"tool_use","id":"u2","name":"edit","input":{"file_path":"a.go"}}]}, - {"role":"user","content":[{"type":"tool_result","tool_use_id":"u2","content":"success: edited"}]}, - {"role":"user","content":[{"type":"text","text":"thanks"}]} - ]}` - req := httptest.NewRequest(http.MethodPost, "/anthropic/v1/messages", strings.NewReader(body)) - rec := httptest.NewRecorder() - h.ServeHTTP(rec, req) - - if gotPath != "/anthropic/v1/messages" { - t.Fatalf("upstream path = %q, want the prefix preserved", gotPath) - } - if len(gotBody) >= len(body) { - t.Fatalf("prefixed request was not reduced") - } -} - -type captureEmitter struct{ events []observability.Event } - -func (c *captureEmitter) Emit(_ context.Context, e observability.Event) { - c.events = append(c.events, e) -} - -func TestProxyEmitsSavingsEvent(t *testing.T) { - var upstreamGot string - up := captureUpstream(t, &upstreamGot) - defer up.Close() - cap := &captureEmitter{} - s := config.Default() - s.ProtectRecent = 1 - h := New(Config{Engine: engine.New(s, nil, nil), Upstream: up.URL, Emitter: cap}) - - big := strings.Repeat("a.go long file content line for the read result here\\n", 12) - body := `{"model":"claude-x","messages":[ - {"role":"assistant","content":[{"type":"tool_use","id":"u1","name":"read","input":{"file_path":"a.go"}}]}, - {"role":"user","content":[{"type":"tool_result","tool_use_id":"u1","content":"` + big + `"}]}, - {"role":"assistant","content":[{"type":"tool_use","id":"u2","name":"edit","input":{"file_path":"a.go"}}]}, - {"role":"user","content":[{"type":"tool_result","tool_use_id":"u2","content":"success: edited"}]}, - {"role":"user","content":[{"type":"text","text":"thanks"}]} - ]}` - req := httptest.NewRequest(http.MethodPost, "/v1/messages", strings.NewReader(body)) - h.ServeHTTP(httptest.NewRecorder(), req) - - if len(cap.events) != 1 { - t.Fatalf("expected 1 emitted event, got %d", len(cap.events)) - } - e := cap.events[0] - if e.System != "anthropic" || e.RequestModel != "claude-x" || e.TokensSaved <= 0 { - t.Fatalf("unexpected event: %+v", e) - } -} - -func TestRequestBodyCap(t *testing.T) { - h := New(Config{Engine: engine.New(config.Default(), nil, nil), Upstream: "http://unused", MaxBodyBytes: 10}) - req := httptest.NewRequest(http.MethodPost, "/v1/messages", strings.NewReader(strings.Repeat("x", 100))) - rec := httptest.NewRecorder() - h.ServeHTTP(rec, req) - if rec.Code != http.StatusRequestEntityTooLarge { - t.Fatalf("status=%d want 413", rec.Code) - } -} - -func TestStatsExposesSavings(t *testing.T) { - var upstreamGot string - up := captureUpstream(t, &upstreamGot) - defer up.Close() - - s := config.Default() - s.ProtectRecent = 1 - agg := observability.NewAggregator(map[string]observability.CostRate{ - observability.DefaultCostKey: {InputPerMTok: 1.0}, - }) - h := New(Config{Engine: engine.New(s, nil, nil), Upstream: up.URL, Emitter: agg, Aggregator: agg}) - - big := strings.Repeat("a.go long file content line for the read result here\\n", 12) - body := `{"model":"claude-x","messages":[ - {"role":"assistant","content":[{"type":"tool_use","id":"u1","name":"read","input":{"file_path":"a.go"}}]}, - {"role":"user","content":[{"type":"tool_result","tool_use_id":"u1","content":"` + big + `"}]}, - {"role":"assistant","content":[{"type":"tool_use","id":"u2","name":"edit","input":{"file_path":"a.go"}}]}, - {"role":"user","content":[{"type":"tool_result","tool_use_id":"u2","content":"success: edited"}]}, - {"role":"user","content":[{"type":"text","text":"thanks"}]} - ]}` - req := httptest.NewRequest(http.MethodPost, "/v1/messages", strings.NewReader(body)) - h.ServeHTTP(httptest.NewRecorder(), req) - - srec := httptest.NewRecorder() - h.ServeHTTP(srec, httptest.NewRequest(http.MethodGet, "/stats", nil)) - if srec.Code != http.StatusOK { - t.Fatalf("/stats status = %d", srec.Code) - } - var snap observability.Snapshot - if err := json.Unmarshal(srec.Body.Bytes(), &snap); err != nil { - t.Fatalf("unmarshal /stats: %v", err) - } - if snap.Requests != 1 { - t.Fatalf("Requests = %d, want 1", snap.Requests) - } - if snap.TokensSaved <= 0 { - t.Fatalf("TokensSaved = %d, want > 0", snap.TokensSaved) - } -} - -func TestStatsDisabledWhenNoAggregator(t *testing.T) { - h := newProxy(t, "http://unused") - rec := httptest.NewRecorder() - h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/stats", nil)) - if rec.Code != http.StatusNotFound { - t.Fatalf("/stats without aggregator = %d, want 404", rec.Code) - } -} - -func TestHealth(t *testing.T) { - h := newProxy(t, "http://unused") - req := httptest.NewRequest(http.MethodGet, "/health", nil) - rec := httptest.NewRecorder() - h.ServeHTTP(rec, req) - if rec.Code != http.StatusOK { - t.Fatalf("health = %d", rec.Code) - } -} - -// TestModelBasePreservesPrefix: the per-request (source: incoming) model base must -// reproduce the main request's upstream URL, including any route prefix. -func TestModelBasePreservesPrefix(t *testing.T) { - cases := []struct{ upstream, path, surface, want string }{ - {"http://gateway:4000", "/anthropic/v1/messages", "anthropic", "http://gateway:4000/anthropic"}, - {"http://gateway:4000/", "/v1/messages", "anthropic", "http://gateway:4000"}, - {"https://api.anthropic.com", "/v1/messages", "anthropic", "https://api.anthropic.com"}, - {"http://gateway:4000", "/openai/v1/chat/completions", "openai", "http://gateway:4000/openai"}, - } - for _, c := range cases { - if got := modelBase(c.upstream, c.path, c.surface); got != c.want { - t.Errorf("modelBase(%q,%q,%q) = %q, want %q", c.upstream, c.path, c.surface, got, c.want) - } - } -} diff --git a/internal/reduce/actions.go b/internal/reduce/actions.go deleted file mode 100644 index 0ad547f..0000000 --- a/internal/reduce/actions.go +++ /dev/null @@ -1,560 +0,0 @@ -package reduce - -import ( - "bytes" - "encoding/csv" - "encoding/json" - "fmt" - "regexp" - "sort" - "strings" - - "github.com/kagenti/lab-context-engineering/internal/markers" - "github.com/kagenti/lab-context-engineering/internal/store" - "github.com/kagenti/lab-context-engineering/internal/tokens" - "github.com/kagenti/lab-context-engineering/internal/treesitter" - toon "github.com/toon-format/toon-go" - sitter "github.com/tree-sitter/go-tree-sitter" -) - -// ---------- collapse ---------- - -// collapse replaces text with a reversible marker, storing the original. -func collapse(text, filePath, reason string, st store.Rewind, terse bool) (string, string) { - rid := st.Put(text) - marker := markers.Make(rid) - label := filePath - if label == "" { - label = "tool output" - } - if terse { - return fmt.Sprintf("[labcx %s: %s] %s", reason, label, marker), rid - } - return fmt.Sprintf("[labcx: %s omitted (%s); call labcx_expand(%q) to restore] %s", - label, reason, rid, marker), rid -} - -// ---------- failed_run ---------- - -var ( - failRe = regexp.MustCompile(`(?i)\b(fail(ed|ures?)?|error|exception|traceback|assert|panic|FAILED|✗|✖)\b`) - passRe = regexp.MustCompile(`(?i)\b(pass(ed)?|ok|success(ful)?|0 failed|all tests passed|✓|✔)\b`) -) - -func isFailure(text string) bool { return failRe.MatchString(text) && !passRe.MatchString(text) } -func isSuccess(text string) bool { return passRe.MatchString(text) && !failRe.MatchString(text) } - -// supersededFailedRuns returns indices of failed runs followed by a later success. -func supersededFailedRuns(texts []string) []int { - lastSuccess := -1 - for i := len(texts) - 1; i >= 0; i-- { - if isSuccess(texts[i]) { - lastSuccess = i - break - } - } - if lastSuccess < 0 { - return nil - } - var out []int - for i := 0; i < lastSuccess; i++ { - if isFailure(texts[i]) { - out = append(out, i) - } - } - return out -} - -// ---------- dedup ---------- - -const shingleK = 5 - -func shingles(text string) map[string]struct{} { - toks := strings.Fields(text) - out := map[string]struct{}{} - if len(toks) < shingleK { - out[text] = struct{}{} - return out - } - for i := 0; i+shingleK <= len(toks); i++ { - out[strings.Join(toks[i:i+shingleK], " ")] = struct{}{} - } - return out -} - -func jaccard(a, b map[string]struct{}) float64 { - if len(a) == 0 && len(b) == 0 { - return 1 - } - inter := 0 - for s := range a { - if _, ok := b[s]; ok { - inter++ - } - } - union := len(a) + len(b) - inter - if union == 0 { - return 0 - } - return float64(inter) / float64(union) -} - -// nearDuplicateEarlier returns indices of earlier outputs that a later one -// near-duplicates (Jaccard >= threshold over 5-gram shingles). -// -// ponytail: exact O(n²) Jaccard instead of MinHash — candidate sets are small -// (sizeable tool outputs only); add MinHash if a transcript ever makes this hot. -func nearDuplicateEarlier(texts []string, threshold float64) []int { - sh := make([]map[string]struct{}, len(texts)) - for i, t := range texts { - sh[i] = shingles(t) - } - drop := map[int]struct{}{} - for i := 0; i < len(texts); i++ { - for j := i + 1; j < len(texts); j++ { - if jaccard(sh[i], sh[j]) >= threshold { - drop[i] = struct{}{} - break - } - } - } - out := make([]int, 0, len(drop)) - for i := range drop { - out = append(out, i) - } - sort.Ints(out) - return out -} - -// ---------- format re-encoding ---------- - -// encoder is one named, ranked format re-encoder. The rank is the tie-break priority -// when two encoders produce the same token count (lower wins). -type encoder struct { - name string - rank int - fn func(any) (string, bool) -} - -// allEncoders is the built-in re-encoder table, keyed by name so config can enable, -// disable, and reorder them purely by NAME. To ADD an encoder: append an entry here -// (give it a unique name and rank), then list that name under reduce.encoders in the -// config file — no other code change is needed. -var allEncoders = []encoder{ - {"json_compact", 0, encCompact}, - {"toon", 1, encTOON}, - {"jsonl", 2, encJSONL}, - {"markdown_kv", 3, encMarkdownKV}, - {"tsv", 4, func(d any) (string, bool) { return encDelimited(d, '\t') }}, - {"csv", 5, func(d any) (string, bool) { return encDelimited(d, ',') }}, -} - -// selectEncoders returns the encoders allowed by the named set, in the order the names -// were given (a config-controlled priority). An empty/nil set means "all built-ins" in -// their default order, preserving prior behavior. Unknown names are ignored. -func selectEncoders(enabled []string) []encoder { - if len(enabled) == 0 { - return allEncoders - } - byName := make(map[string]encoder, len(allEncoders)) - for _, e := range allEncoders { - byName[e.name] = e - } - out := make([]encoder, 0, len(enabled)) - for i, name := range enabled { - if e, ok := byName[name]; ok { - // Honor the config-given order as the tie-break rank: the first listed - // encoder wins ties, regardless of its built-in rank. - e.rank = i - out = append(out, e) - } - } - return out -} - -// bestEncoding returns the smallest faithful re-encoding strictly smaller than text, -// plus its format name, or ("","") if none helps / text isn't structured. enabled is an -// allowed-encoder set referenced by name; empty/nil means all built-in encoders. -// -// ponytail: object keys re-encode in alphabetical order (Go maps don't preserve -// source order). Lossless to the DATA — the original is stored for exact recovery. -func bestEncoding(text string, enabled []string) (string, string) { - var data any - dec := json.NewDecoder(strings.NewReader(text)) - dec.UseNumber() - if err := dec.Decode(&data); err != nil { - recs := parseNDJSON(text) - if recs == nil { - return "", "" - } - data = recs - } - orig := tokens.Count(text) - type cand struct { - enc, name string - rank, tok int - } - var best *cand - for _, e := range selectEncoders(enabled) { - enc, ok := e.fn(data) - if !ok { - continue - } - t := tokens.Count(enc) - if t < orig && (best == nil || t < best.tok || (t == best.tok && e.rank < best.rank)) { - best = &cand{enc, e.name, e.rank, t} - } - } - if best == nil { - return "", "" - } - return best.enc, best.name -} - -func parseNDJSON(text string) []any { - var lines []string - for _, ln := range strings.Split(text, "\n") { - if strings.TrimSpace(ln) != "" { - lines = append(lines, ln) - } - } - if len(lines) < 2 { - return nil - } - var recs []any - for _, ln := range lines { - var v any - dec := json.NewDecoder(strings.NewReader(ln)) - dec.UseNumber() - if err := dec.Decode(&v); err != nil { - return nil - } - switch v.(type) { - case map[string]any, []any: - recs = append(recs, v) - default: - return nil - } - } - return recs -} - -func isScalar(v any) bool { - switch v.(type) { - case nil, string, bool, float64, int, json.Number: - return true - } - return false -} - -// uniformFlat returns sorted column names if data is a non-empty list of objects -// sharing the same key set with only scalar values; else nil. -func uniformFlat(data any) []string { - list, ok := data.([]any) - if !ok || len(list) == 0 { - return nil - } - first, ok := list[0].(map[string]any) - if !ok { - return nil - } - cols := make([]string, 0, len(first)) - for k := range first { - cols = append(cols, k) - } - sort.Strings(cols) - colset := map[string]struct{}{} - for _, c := range cols { - colset[c] = struct{}{} - } - for _, r := range list { - row, ok := r.(map[string]any) - if !ok || len(row) != len(colset) { - return nil - } - for k, v := range row { - if _, ok := colset[k]; !ok || !isScalar(v) { - return nil - } - } - } - return cols -} - -func scalarStr(v any) string { - if v == nil { - return "" - } - return fmt.Sprint(v) -} - -func encDelimited(data any, delim rune) (string, bool) { - cols := uniformFlat(data) - if cols == nil { - return "", false - } - var buf bytes.Buffer - w := csv.NewWriter(&buf) - w.Comma = delim - _ = w.Write(cols) - for _, r := range data.([]any) { - row := r.(map[string]any) - rec := make([]string, len(cols)) - for i, c := range cols { - rec[i] = scalarStr(row[c]) - } - _ = w.Write(rec) - } - w.Flush() - return strings.TrimRight(buf.String(), "\n"), true -} - -func encJSONL(data any) (string, bool) { - list, ok := data.([]any) - if !ok || len(list) == 0 { - return "", false - } - for _, x := range list { - if _, ok := x.(map[string]any); !ok { - return "", false - } - } - var lines []string - for _, x := range list { - b, err := json.Marshal(x) - if err != nil { - return "", false - } - lines = append(lines, string(b)) - } - return strings.Join(lines, "\n"), true -} - -func encMarkdownKV(data any) (string, bool) { - m, ok := data.(map[string]any) - if !ok || len(m) == 0 { - return "", false - } - keys := make([]string, 0, len(m)) - for k, v := range m { - if !isScalar(v) { - return "", false - } - keys = append(keys, k) - } - sort.Strings(keys) - var lines []string - for _, k := range keys { - lines = append(lines, fmt.Sprintf("%s: %s", k, scalarStr(m[k]))) - } - return strings.Join(lines, "\n"), true -} - -// encTOON re-encodes data as Token-Oriented Object Notation. The decoded-JSON -// value may carry json.Number; toon-go renders those natively. Returns false on -// error or empty output so the candidate is simply skipped. -func encTOON(data any) (string, bool) { - out, err := toon.MarshalString(data, toon.WithLengthMarkers(true)) - if err != nil || out == "" { - return "", false - } - return out, true -} - -func encCompact(data any) (string, bool) { - b, err := json.Marshal(data) - if err != nil { - return "", false - } - return string(b), true -} - -// ---------- skeleton ---------- - -var bodyDefKinds = map[string]bool{ - "function_declaration": true, "function_definition": true, "function_item": true, - "method_declaration": true, "method_definition": true, "method": true, - "constructor_declaration": true, -} - -// skeletonize keeps signatures and drops function/method BODIES (the "body" field), -// language-agnostic via tree-sitter. Returns ok=false for non-code, parse failure, -// no bodies, or no token savings. -func skeletonize(source, filePath string) (string, bool) { - lang := treesitter.LangForExt(filePath) - if lang == "" { - return "", false - } - src := []byte(source) - tree, _, ok := treesitter.Parse(lang, src) - if !ok { - return "", false - } - defer tree.Close() - type span struct{ start, end uint } - var bodies []span - var walk func(n *sitter.Node) - walk = func(n *sitter.Node) { - if bodyDefKinds[n.Kind()] { - if b := n.ChildByFieldName("body"); b != nil { - bodies = append(bodies, span{b.StartByte(), b.EndByte()}) - return // don't recurse into a body we're dropping (nested fns) - } - } - for i := uint(0); i < n.NamedChildCount(); i++ { - walk(n.NamedChild(i)) - } - } - walk(tree.RootNode()) - if len(bodies) == 0 { - return "", false - } - sort.Slice(bodies, func(i, j int) bool { return bodies[i].start > bodies[j].start }) - out := append([]byte(nil), src...) - for _, b := range bodies { - out = append(out[:b.start], append([]byte("{ ... }"), out[b.end:]...)...) - } - result := string(out) - if tokens.Count(result) >= tokens.Count(source) { - return "", false - } - return result, true -} - -// ---------- is_structured ---------- - -// IsStructured reports whether text is machine-structured (a JSON object, a JSON -// array of length >= 2, or NDJSON) — the safe target for filter-style extraction. -func IsStructured(text string) bool { - var v any - if err := json.Unmarshal([]byte(text), &v); err == nil { - switch t := v.(type) { - case map[string]any: - return len(t) > 0 - case []any: - return len(t) >= 2 - } - } - return parseNDJSON(text) != nil -} - -// ---------- router ---------- - -var collapseReasons = set("stale", "superseded_dup", "empty", "duplicate", "failed_run", "unused") - -// Reducer is a pluggable reduction strategy. -type Reducer struct { - Name string - Applies func(ContextItem, Verdict) bool - Reduce func(ContextItem, Verdict, store.Rewind) *Reduced -} - -func keepItem(item ContextItem) *Reduced { return &Reduced{ItemID: item.ID, Action: "keep"} } - -// reversible makes a lossy reduction reversible and self-advertising; falls back to -// keep if the marker overhead eats the savings. -func reversible(item ContextItem, reduced, action, what string, st store.Rewind) *Reduced { - rid := st.Put(item.Text) - label := item.FilePath - if label == "" { - label = "tool output" - } - newText := strings.TrimRight(reduced, "\n") + "\n" + markers.RecoveryNote(label, what, rid) - if tokens.Count(newText) < tokens.Count(item.Text) { - return &Reduced{ItemID: item.ID, Action: action, NewText: &newText, RewindID: rid} - } - return keepItem(item) -} - -func collapseReduce(item ContextItem, v Verdict, st store.Rewind) *Reduced { - newText, rid := collapse(item.Text, item.FilePath, v.Reason, st, false) - if tokens.Count(newText) < tokens.Count(item.Text) { - return &Reduced{ItemID: item.ID, Action: "collapse", NewText: &newText, RewindID: rid} - } - return keepItem(item) -} - -func skeletonReduce(item ContextItem, v Verdict, st store.Rewind) *Reduced { - if !isCodePath(item.FilePath) { - return nil - } - sk, ok := skeletonize(item.Text, item.FilePath) - if !ok { - return nil - } - return reversible(item, sk, "skeleton", "code body skeletonized", st) -} - -func formatReduce(item ContextItem, v Verdict, st store.Rewind, enabledEncoders []string) *Reduced { - fr, fmtName := bestEncoding(item.Text, enabledEncoders) - if fr == "" { - return keepItem(item) - } - return reversible(item, fr, "format", "reformatted as "+fmtName, st) -} - -// formatReducerName is the built-in name of the lossless format re-encoder. route -// applies it specially (it threads the config-selected encoder set into bestEncoding), -// so the table entry below is a marker whose Reduce is never invoked directly. -const formatReducerName = "format" - -var reducers = []Reducer{ - {"collapse", func(i ContextItem, v Verdict) bool { _, ok := collapseReasons[v.Reason]; return ok }, collapseReduce}, - {"skeleton", func(i ContextItem, v Verdict) bool { return isCodePath(i.FilePath) }, skeletonReduce}, - {formatReducerName, func(i ContextItem, v Verdict) bool { return true }, - func(i ContextItem, v Verdict, st store.Rewind) *Reduced { return formatReduce(i, v, st, nil) }}, -} - -// RegisterReducer inserts a custom strategy just before the format fallback. A custom -// reducer is selectable by config purely by its Reducer.Name — list it under -// reduce.reducers and it is honored; omit it and it is filtered out. -func RegisterReducer(r Reducer) { - idx := len(reducers) - 1 - if idx < 0 { - idx = 0 - } - reducers = append(reducers[:idx], append([]Reducer{r}, reducers[idx:]...)...) -} - -// reducerAllowed reports whether a reducer name is enabled by the named set. An -// empty/nil set means "all built-ins", preserving prior behavior. -func reducerAllowed(name string, enabled []string) bool { - if len(enabled) == 0 { - return true - } - for _, n := range enabled { - if n == name { - return true - } - } - return false -} - -// route picks the cheapest faithful action for an item. enabledReducers/enabledEncoders -// are config-selected allow-lists referenced by name; empty means "all built-ins". -func route(item ContextItem, v Verdict, st store.Rewind, enabledReducers, enabledEncoders []string) *Reduced { - if v.Protected { - // Recent content keeps full fidelity for lossy ops, but a lossless format - // re-encode is safe even here — when the format reducer is enabled. - if reducerAllowed(formatReducerName, enabledReducers) { - return formatReduce(item, v, st, enabledEncoders) - } - return keepItem(item) - } - for _, r := range reducers { - if !reducerAllowed(r.Name, enabledReducers) || !r.Applies(item, v) { - continue - } - if r.Name == formatReducerName { - // Thread the config-selected encoder set into the format re-encoder. - if res := formatReduce(item, v, st, enabledEncoders); res != nil { - return res - } - continue - } - if res := r.Reduce(item, v, st); res != nil { - return res - } - } - return keepItem(item) -} diff --git a/internal/reduce/candidates.go b/internal/reduce/candidates.go deleted file mode 100644 index fd22ed4..0000000 --- a/internal/reduce/candidates.go +++ /dev/null @@ -1,63 +0,0 @@ -package reduce - -import ( - "github.com/kagenti/lab-context-engineering/canon" - "github.com/kagenti/lab-context-engineering/internal/markers" -) - -// SelectLLMCandidates returns the large structured tool outputs eligible for -// cheap-model extraction, WITHOUT mutating req. The extract compactor owns this -// detection so it is fully independent of the reduce pass (no candidate hand-off). -// It mirrors the candidate predicate the reduce loop previously applied: structured, -// not frozen, not protected, above the token floor, and not already marked. -func SelectLLMCandidates(req canon.Request, opts Opts) []Candidate { - msgs := req.Messages() - items := ExtractItems(req) - inputTokens := 0 - for _, it := range items { - inputTokens += it.TokenEst - } - zones := ComputeZones(len(msgs), inputTokens, opts.ContextLimit, 0) - frozen := zones.FrozenCount - if !opts.ReduceCachedPrefix && opts.CacheFloor+1 > frozen { - frozen = opts.CacheFloor + 1 - } - - scoreOpts := DefaultScoreOpts(opts.ProtectRecent) - scoreOpts.CollapseOutputs = opts.CollapseOutputs - scoreOpts.ProtectRecentToolUses = opts.ProtectRecentToolUses - scoreOpts.ProvableOnly = opts.ProvableOnly - verdicts := map[string]Verdict{} - for _, v := range ScoreRelevance(items, scoreOpts) { - verdicts[v.ItemID] = v - } - - var out []Candidate - for _, item := range items { - if item.MsgIndex < frozen || markers.HasForeign(item.Text) || markers.Has(item.Text) { - continue - } - v, ok := verdicts[item.ID] - if !ok { - continue - } - structured := IsStructured(item.Text) - reasonOK := false - switch { - case opts.LLMCompactStructuredOnly && !structured: - reasonOK = false - case structured: - reasonOK = v.Reason == "unused" || v.Reason == "lossy_candidate" || v.Reason == "kept_default" - default: - reasonOK = v.Reason == "unused" || v.Reason == "lossy_candidate" - } - if reasonOK && item.TokenEst >= opts.LLMCompactFloor { - out = append(out, Candidate{ - ID: item.ID, MsgIndex: item.MsgIndex, BlockIndex: item.BlockIndex, - Text: item.Text, FilePath: item.FilePath, ToolName: item.ToolName, - TokenEst: item.TokenEst, - }) - } - } - return out -} diff --git a/internal/reduce/cmdfilter.go b/internal/reduce/cmdfilter.go deleted file mode 100644 index d36847f..0000000 --- a/internal/reduce/cmdfilter.go +++ /dev/null @@ -1,130 +0,0 @@ -package reduce - -import ( - "fmt" - "regexp" - "strings" -) - -// Deterministic, LLM-free compaction of KNOWN command outputs (rtk-style): keep the -// signal (failures, errors, summary) and drop routine noise. Lossy but reversible — -// the caller stores the original and applies only when strictly smaller. Ported from -// the reference prototype's cmdfilter.py. - -type commandRule struct { - name string - command *regexp.Regexp - mode string // "keep" | "strip" - keep []*regexp.Regexp - drop []*regexp.Regexp - head int - tail int -} - -func ci(pats ...string) []*regexp.Regexp { - out := make([]*regexp.Regexp, len(pats)) - for i, p := range pats { - out[i] = regexp.MustCompile("(?i)" + p) - } - return out -} - -var ( - failPats = []string{`\berror\b`, `\bfailed\b`, `\bfailure`, `\bpanic`, `traceback`, - `\bexception\b`, `assert`, `\bFAIL\b`, `^E\s`, `error\[`, `:\d+:\d+`} - summaryPats = []string{`test result:`, `^\s*=+.*(passed|failed|error)`, `^tests?:\s`, - `^ok\s`, `^---\s*(FAIL|PASS)`, `in \d+\.\d+\s*s`} -) - -func failAndSummary(extra ...string) []*regexp.Regexp { - return ci(append(append(append([]string{}, failPats...), summaryPats...), extra...)...) -} - -var commandRules = []commandRule{ - {"pytest", regexp.MustCompile(`pytest|python -m pytest`), "keep", - failAndSummary(`warnings? summary`), nil, 1, 6}, - {"cargo", regexp.MustCompile(`\bcargo\s+(test|build|check|clippy|run)`), "keep", - failAndSummary(), - ci(`^\s*Compiling\b`, `^\s*Finished\b`, `^\s*Running\b`, `^\s*Downloading\b`, - `^\s*Updating\b`, `test .* \.\.\. ok`), 1, 6}, - {"gotest", regexp.MustCompile(`\bgo\s+(test|build|vet)`), "keep", - failAndSummary(`no test files`), nil, 1, 6}, - {"jsnode", regexp.MustCompile(`\b(npm|pnpm|yarn)\s+(run\s+)?test|jest|vitest|mocha`), "keep", - failAndSummary(`✕|✗|×|✘`), ci(`✓|√|PASS\b|passing`), 1, 6}, - {"tsc_lint", regexp.MustCompile(`\btsc\b|eslint|ruff|mypy|flake8`), "keep", - ci(append(append([]string{}, failPats...), `warning`, `\d+\s+problems?`)...), nil, 1, 6}, - {"install", regexp.MustCompile(`\b(npm|pnpm|yarn)\s+(install|i|ci|add)\b|pip3?\s+install|apt-get|brew\s+install`), "strip", - ci(failPats...), - ci(`already satisfied`, `requirement already`, `^\s*Downloading`, `^\s*Collecting`, - `added \d+ packages`, `^npm warn`, `^\s*\|`, `^\s*[-\\|/]\s*$`, - `packages are looking for funding`), 1, 6}, - {"gitstatus", regexp.MustCompile(`\bgit\s+status`), "keep", - ci(`modified|new file|deleted|renamed|untracked|both modified`, `branch|ahead|behind|up to date`), - ci(`use "git`, `^\s*$`), 1, 6}, -} - -const minCmdFilterLines = 8 - -func matchesAny(line string, pats []*regexp.Regexp) bool { - for _, p := range pats { - if p.MatchString(line) { - return true - } - } - return false -} - -func ruleFor(command string) *commandRule { - if command == "" { - return nil - } - for i := range commandRules { - if commandRules[i].command.MatchString(command) { - return &commandRules[i] - } - } - return nil -} - -// compactCommandOutput filters output per the rule matching command; returns ("", -// false) if no rule matches, the output is too small, or nothing was removed. -func compactCommandOutput(command, output string) (string, bool) { - rule := ruleFor(command) - if rule == nil || output == "" { - return "", false - } - lines := strings.Split(output, "\n") - n := len(lines) - if n < minCmdFilterLines { - return "", false - } - var kept []string - for i, line := range lines { - if i < rule.head || i >= n-rule.tail { - kept = append(kept, line) - continue - } - isKeep := matchesAny(line, rule.keep) - if rule.mode == "keep" { - if isKeep { - kept = append(kept, line) - } - } else { // strip - if matchesAny(line, rule.drop) && !isKeep { - continue - } - kept = append(kept, line) - } - } - if len(kept) >= n { - return "", false - } - dropped := n - len(kept) - result := strings.Join(kept, "\n") - cmd := command - if len(cmd) > 60 { - cmd = cmd[:60] - } - result += fmt.Sprintf("\n[labcx: %d routine line(s) filtered from `%s`]", dropped, cmd) - return result, true -} diff --git a/internal/reduce/compaction.go b/internal/reduce/compaction.go deleted file mode 100644 index 046b18b..0000000 --- a/internal/reduce/compaction.go +++ /dev/null @@ -1,102 +0,0 @@ -package reduce - -import ( - "strings" - - "github.com/kagenti/lab-context-engineering/canon" - "github.com/kagenti/lab-context-engineering/internal/markers" - "github.com/kagenti/lab-context-engineering/internal/store" -) - -// Phrases characterizing a known agent compaction prompt (Claude Code / Codex / -// Gemini CLI). A false positive only forgoes one turn's savings. Ported from -// the reference prototype's compaction.py. -var compactionPhrases = []string{ - "this session is being continued from a previous conversation", - "create a detailed summary of the conversation", - "context checkpoint compaction", - "your task is to create a summary", - "create a handoff summary", - "summarize the conversation", - "summary of the conversation so far", - "continue from where we left off", -} - -func textOf(content any) string { - switch c := content.(type) { - case string: - return c - case []any: - var parts []string - for _, b := range c { - if bb, ok := b.(map[string]any); ok { - if t, ok := bb["text"].(string); ok { - parts = append(parts, t) - } else if cs, ok := bb["content"].(string); ok { - parts = append(parts, cs) - } - } - } - return strings.Join(parts, "\n") - } - return "" -} - -// IsCompactionRequest reports whether the request looks like the agent asking the -// model to summarize the conversation (so reduction should pass it through). -func IsCompactionRequest(req canon.Request) bool { - haystack := strings.ToLower(textOf(req.Root["system"])) - msgs := req.Messages() - for i := len(msgs) - 1; i >= 0; i-- { - if msgs[i]["role"] == "user" { - haystack += "\n" + strings.ToLower(textOf(msgs[i]["content"])) - break - } - } - for _, p := range compactionPhrases { - if strings.Contains(haystack, p) { - return true - } - } - return false -} - -// RehydrateMarkers replaces any lab-cx-collapsed block with its stored original, in -// place. Returns the number of blocks restored. -func RehydrateMarkers(req canon.Request, st store.Rewind) int { - restored := 0 - for _, m := range req.Messages() { - content, ok := m["content"].([]any) - if !ok { - continue - } - for _, bRaw := range content { - blk, ok := bRaw.(map[string]any) - if !ok { - continue - } - var key string - switch blk["type"] { - case "tool_result": - key = "content" - case "text": - key = "text" - default: - continue - } - s, ok := blk[key].(string) - if !ok { - continue - } - ids := markers.FindIDs(s) - if len(ids) == 0 { - continue - } - if original, ok := st.Get(ids[0]); ok { - blk[key] = original - restored++ - } - } - } - return restored -} diff --git a/internal/reduce/encoderfilter_test.go b/internal/reduce/encoderfilter_test.go deleted file mode 100644 index 9278dcb..0000000 --- a/internal/reduce/encoderfilter_test.go +++ /dev/null @@ -1,52 +0,0 @@ -package reduce - -import ( - "strconv" - "strings" - "testing" -) - -// TestBestEncodingHonorsAllowedSet verifies named encoder selection: when only "toon" -// is allowed, bestEncoding must return either a toon result or "" — never csv/tsv/jsonl, -// even on a flat uniform array where a delimited encoder would otherwise win on tokens. -func TestBestEncodingHonorsAllowedSet(t *testing.T) { - // A wide flat uniform array: csv/tsv normally beat toon here by dropping repeated - // keys entirely, so this fixture is exactly where a delimited encoder "would win". - var recs []string - for i := 0; i < 50; i++ { - recs = append(recs, `{"id":`+strconv.Itoa(i)+`,"name":"item","status":"active","kind":"row"}`) - } - body := "[" + strings.Join(recs, ",") + "]" - - // Sanity: with no filter, a delimited encoder is allowed to win. - encAll, _ := bestEncoding(body, nil) - if encAll == "" { - t.Fatal("unfiltered bestEncoding returned nothing on a flat array") - } - - enc, name := bestEncoding(body, []string{"toon"}) - if enc == "" { - // Allowed: toon may not beat the original; "" is a valid result. - return - } - if name != "toon" { - t.Fatalf("Encoders=[toon] but bestEncoding returned %q", name) - } - if name == "csv" || name == "tsv" || name == "jsonl" { - t.Fatalf("disallowed encoder %q leaked through filter", name) - } -} - -// TestBestEncodingEmptyFilterPreservesBehavior: an empty/nil allowed set behaves -// exactly like before (no filtering). -func TestBestEncodingEmptyFilterPreservesBehavior(t *testing.T) { - var recs []string - for i := 0; i < 40; i++ { - recs = append(recs, `{"id":`+strconv.Itoa(i)+`,"name":"item","status":"active"}`) - } - body := "[" + strings.Join(recs, ",") + "]" - enc, _ := bestEncoding(body, nil) - if enc == "" || len(enc) >= len(body) { - t.Fatalf("empty-filter bestEncoding should still reduce: enc=%q", enc) - } -} diff --git a/internal/reduce/extract.go b/internal/reduce/extract.go deleted file mode 100644 index 854c671..0000000 --- a/internal/reduce/extract.go +++ /dev/null @@ -1,118 +0,0 @@ -package reduce - -import ( - "crypto/sha256" - "encoding/hex" - "encoding/json" - "fmt" - "strings" - - "github.com/kagenti/lab-context-engineering/canon" - "github.com/kagenti/lab-context-engineering/internal/tokens" -) - -func hashParts(parts ...any) string { - h := sha256.New() - for _, p := range parts { - h.Write([]byte(fmt.Sprint(p))) - h.Write([]byte{0}) - } - return hex.EncodeToString(h.Sum(nil))[:24] -} - -func toolUseText(input any) string { - if b, err := json.Marshal(input); err == nil { - return string(b) - } - return fmt.Sprint(input) -} - -// resultText flattens tool_result content (string, or list of text blocks/strings). -func resultText(content any) string { - switch c := content.(type) { - case string: - return c - case []any: - var out []string - for _, b := range c { - switch bb := b.(type) { - case map[string]any: - if bb["type"] == "text" { - if t, ok := bb["text"].(string); ok { - out = append(out, t) - } - } - case string: - out = append(out, bb) - } - } - return strings.Join(out, "\n") - } - return "" -} - -func asString(v any) string { s, _ := v.(string); return s } - -// ExtractItems parses a canonical request into a flat list of ContextItem. -func ExtractItems(req canon.Request) []ContextItem { - var items []ContextItem - tooluseFile := map[string]string{} - tooluseRange := map[string][2]*int{} - - for mi, msg := range req.Messages() { - raw, isList := msg["content"].([]any) - if !isList { - if s, ok := msg["content"].(string); ok && s != "" { - items = append(items, ContextItem{ - ID: hashParts(mi, 0, s), MsgIndex: mi, BlockIndex: 0, - Kind: "text", Text: s, TokenEst: tokens.Count(s), - }) - } - continue - } - for bi, bRaw := range raw { - blk, ok := bRaw.(map[string]any) - if !ok { - continue - } - switch blk["type"] { - case "tool_use": - input, _ := blk["input"].(map[string]any) - if input == nil { - input = map[string]any{} - } - fp := fileArg(input) - off, lim := readRange(input) - tuID := asString(blk["id"]) - if tuID != "" { - tooluseFile[tuID] = fp - tooluseRange[tuID] = [2]*int{off, lim} - } - text := toolUseText(blk["input"]) - items = append(items, ContextItem{ - ID: hashParts(mi, bi, blk["name"], text), MsgIndex: mi, BlockIndex: bi, - Kind: "tool_use", ToolName: asString(blk["name"]), ToolUseID: tuID, - FilePath: fp, Text: text, TokenEst: tokens.Count(text), - ReadOffset: off, ReadLimit: lim, - }) - case "tool_result": - tuID := asString(blk["tool_use_id"]) - text := resultText(blk["content"]) - rng := tooluseRange[tuID] - items = append(items, ContextItem{ - ID: hashParts(mi, bi, tuID, text), MsgIndex: mi, BlockIndex: bi, - Kind: "tool_result", ToolUseID: tuID, FilePath: tooluseFile[tuID], - Text: text, TokenEst: tokens.Count(text), - ReadOffset: rng[0], ReadLimit: rng[1], - }) - case "text": - text := asString(blk["text"]) - items = append(items, ContextItem{ - ID: hashParts(mi, bi, text), MsgIndex: mi, BlockIndex: bi, - Kind: "text", Text: text, TokenEst: tokens.Count(text), - }) - } - } - } - return items -} diff --git a/internal/reduce/format_test.go b/internal/reduce/format_test.go deleted file mode 100644 index 7501a7b..0000000 --- a/internal/reduce/format_test.go +++ /dev/null @@ -1,42 +0,0 @@ -package reduce - -import ( - "strconv" - "strings" - "testing" -) - -func itoaTest(n int) string { return strconv.Itoa(n) } - -func TestBestEncodingConsidersTOON(t *testing.T) { - // A flat uniform array is where TOON's tabular header shines; bestEncoding - // must return a non-empty, strictly-smaller encoding. We don't hard-assert - // the winning format name (TOON, tsv, or csv may win on token count). - var recs []string - for i := 0; i < 40; i++ { - recs = append(recs, `{"id":`+itoaTest(i)+`,"name":"item","status":"active"}`) - } - body := "[" + strings.Join(recs, ",") + "]" - enc, name := bestEncoding(body, nil) - if enc == "" { - t.Fatal("expected a smaller encoding") - } - if len(enc) >= len(body) { - t.Fatal("encoding not smaller") - } - _ = name // may be toon, tsv, or csv depending on token counts; just assert it ran -} - -func TestEncTOON(t *testing.T) { - data := []any{ - map[string]any{"id": "1", "name": "a"}, - map[string]any{"id": "2", "name": "b"}, - } - out, ok := encTOON(data) - if !ok || out == "" { - t.Fatalf("encTOON failed: ok=%v out=%q", ok, out) - } - if !strings.Contains(out, "name") { - t.Fatalf("TOON output missing field: %q", out) - } -} diff --git a/internal/reduce/pipeline.go b/internal/reduce/pipeline.go deleted file mode 100644 index 540f524..0000000 --- a/internal/reduce/pipeline.go +++ /dev/null @@ -1,350 +0,0 @@ -package reduce - -import ( - "encoding/json" - "math" - "sort" - - "github.com/kagenti/lab-context-engineering/canon" - "github.com/kagenti/lab-context-engineering/internal/markers" - "github.com/kagenti/lab-context-engineering/internal/store" - "github.com/kagenti/lab-context-engineering/internal/tokens" -) - -// Candidate is a large output left verbatim for the async Extract stage. -type Candidate struct { - ID string - MsgIndex int - BlockIndex int - Text string - FilePath string - ToolName string - TokenEst int -} - -// Report summarizes a reduction pass. -// -// ponytail: per-item "where did tokens go" breakdown omitted — counts + reduced ids -// are enough for metrics and cross-turn stickiness. Add breakdown if a dashboard needs it. -type Report struct { - SessionID string - AtCompaction bool - FrozenCount int - TokensBefore int - TokensAfter int - TokensSaved int - Ratio float64 - ReducerErrors int - ToolDefTokens int - ToolsTotal int - ReducedIDs []string - CompactionPassthrough bool - Rehydrated int -} - -// Opts configures a reduction pass. Use DefaultOpts and adjust. -type Opts struct { - ProtectRecent int - ContextLimit int - StickyIDs map[string]struct{} - CollapseOutputs bool - CacheFloor int - LLMCompactFloor int - LLMCompactStructuredOnly bool - RehydrateOnCompaction bool - ProtectRecentToolUses int - ProvableOnly bool - ReduceCachedPrefix bool - CmdFilter bool - - // EnabledReducers / EnabledEncoders are config-selected allow-lists referenced by - // NAME (Reducer.Name / encoder name). Empty means "all built-ins" — prior behavior. - // They let config enable, disable, and (for encoders) reorder components without a - // core edit; see config.Settings.Reducers / .Encoders. - EnabledReducers []string - EnabledEncoders []string -} - -// DefaultOpts mirrors the reference prototype's reduce_request defaults. -func DefaultOpts() Opts { - return Opts{ - CollapseOutputs: true, CacheFloor: -1, LLMCompactFloor: 3000, - LLMCompactStructuredOnly: true, CmdFilter: true, - } -} - -func measure(before, after string) (int, int, int, float64) { - b, a := tokens.Count(before), tokens.Count(after) - saved := b - a - ratio := 0.0 - if b > 0 { - ratio = math.Round(float64(saved)/float64(b)*10000) / 10000 - } - return b, a, saved, ratio -} - -func firstUserText(msgs []map[string]any) string { - for _, m := range msgs { - if m["role"] != "user" { - continue - } - switch c := m["content"].(type) { - case string: - return c - case []any: - for _, b := range c { - if bb, ok := b.(map[string]any); ok && bb["type"] == "text" { - if t, ok := bb["text"].(string); ok { - return t - } - } - } - } - } - return "" -} - -func serialize(msgs []map[string]any) string { - b, _ := json.Marshal(msgs) - return string(b) -} - -func setBlockText(block map[string]any, newText string) { - switch block["type"] { - case "tool_result": - block["content"] = newText - case "text": - block["text"] = newText - } -} - -func blockAt(msgs []map[string]any, mi, bi int) map[string]any { - if mi < 0 || mi >= len(msgs) { - return nil - } - list, ok := msgs[mi]["content"].([]any) - if !ok || bi < 0 || bi >= len(list) { - return nil - } - blk, _ := list[bi].(map[string]any) - return blk -} - -// ReduceRequest reduces req in place and returns a Report. Fail-open: a per-item -// reducer error is counted and leaves that item verbatim. Ported from the reference prototype's -// reduce_request. -func ReduceRequest(req canon.Request, st store.Rewind, ev *store.Eviction, opts Opts) Report { - msgs := req.Messages() - beforeText := serialize(msgs) - systemStr := textOf(req.Root["system"]) - sid := store.SessionID(systemStr, firstUserText(msgs)) - - toolsList, _ := req.Root["tools"].([]any) - toolDefTokens := 0 - if len(toolsList) > 0 { - if b, err := json.Marshal(toolsList); err == nil { - toolDefTokens = tokens.Count(string(b)) - } - } - - if opts.RehydrateOnCompaction && IsCompactionRequest(req) { - restored := RehydrateMarkers(req, st) - b, a, saved, ratio := measure(beforeText, serialize(req.Messages())) - return Report{ - SessionID: sid, AtCompaction: true, TokensBefore: b, TokensAfter: a, - TokensSaved: saved, Ratio: ratio, ToolDefTokens: toolDefTokens, - ToolsTotal: len(toolsList), CompactionPassthrough: true, Rehydrated: restored, - } - } - - items := ExtractItems(req) - inputTokens := 0 - for _, it := range items { - inputTokens += it.TokenEst - } - zones := ComputeZones(len(msgs), inputTokens, opts.ContextLimit, 0) - frozen := zones.FrozenCount - if !opts.ReduceCachedPrefix && opts.CacheFloor+1 > frozen { - frozen = opts.CacheFloor + 1 - } - - scoreOpts := DefaultScoreOpts(opts.ProtectRecent) - scoreOpts.CollapseOutputs = opts.CollapseOutputs - scoreOpts.ProtectRecentToolUses = opts.ProtectRecentToolUses - scoreOpts.ProvableOnly = opts.ProvableOnly - verdicts := map[string]Verdict{} - for _, v := range ScoreRelevance(items, scoreOpts) { - verdicts[v.ItemID] = v - } - - byID := map[string]ContextItem{} - for _, it := range items { - byID[it.ID] = it - } - - handled := map[string]struct{}{} - reducedIDs := map[string]struct{}{} - reducerErrors := 0 - - // Batch collapse pre-pass. - batchPass := func(selectFn func(ContextItem) bool, detect func([]string) []int, reason string, minTokens int) { - var cands []ContextItem - for _, it := range items { - if it.Kind != "tool_result" { - continue - } - if _, done := handled[it.ID]; done { - continue - } - if it.MsgIndex < frozen || markers.HasForeign(it.Text) { - continue - } - if v, ok := verdicts[it.ID]; ok && v.Protected { - continue - } - if it.TokenEst < minTokens || !selectFn(it) { - continue - } - cands = append(cands, it) - } - texts := make([]string, len(cands)) - for i, c := range cands { - texts[i] = c.Text - } - for _, idx := range detect(texts) { - it := cands[idx] - block := blockAt(msgs, it.MsgIndex, it.BlockIndex) - if block == nil { - continue - } - newText, _ := collapse(it.Text, it.FilePath, reason, st, false) - if tokens.Count(newText) < tokens.Count(it.Text) { - setBlockText(block, newText) - handled[it.ID] = struct{}{} - reducedIDs[it.ID] = struct{}{} - } - } - } - - batchPass(func(it ContextItem) bool { return isFailure(it.Text) || isSuccess(it.Text) }, - supersededFailedRuns, "failed_run", 0) - batchPass(func(ContextItem) bool { return true }, - func(t []string) []int { return nearDuplicateEarlier(t, 0.85) }, "duplicate", 80) - - // Command-filter pre-pass. - if opts.CmdFilter { - cmdByID := map[string]string{} - for _, m := range msgs { - if list, ok := m["content"].([]any); ok { - for _, bRaw := range list { - b, ok := bRaw.(map[string]any) - if !ok || b["type"] != "tool_use" { - continue - } - input, _ := b["input"].(map[string]any) - id, _ := b["id"].(string) - if input != nil && id != "" { - if cmd, ok := input["command"].(string); ok { - cmdByID[id] = cmd - } - } - } - } - } - for _, it := range items { - if it.Kind != "tool_result" { - continue - } - if _, done := handled[it.ID]; done { - continue - } - if it.MsgIndex < frozen || markers.HasForeign(it.Text) { - continue - } - if v, ok := verdicts[it.ID]; ok && v.Protected { - continue - } - cmd := cmdByID[it.ToolUseID] - if cmd == "" { - continue - } - filtered, ok := compactCommandOutput(cmd, it.Text) - if !ok || tokens.Count(filtered) >= tokens.Count(it.Text) { - continue - } - block := blockAt(msgs, it.MsgIndex, it.BlockIndex) - if block == nil { - continue - } - rid := st.Put(it.Text) - label := cmd - if len(label) > 48 { - label = label[:48] - } - newText := filtered + "\n" + markers.RecoveryNote(label, "command output filtered", rid) - if tokens.Count(newText) < tokens.Count(it.Text) { - setBlockText(block, newText) - handled[it.ID] = struct{}{} - reducedIDs[it.ID] = struct{}{} - } - } - } - - for _, item := range items { - if _, done := handled[item.ID]; done { - continue - } - // Skip frozen, foreign-marked, and already-lab-cx-marked blocks. The last guard - // keeps reduce off blocks the extract compactor already rewrote (extract runs - // first in the default pipeline), so they are not double-processed. - if item.MsgIndex < frozen || markers.HasForeign(item.Text) || markers.Has(item.Text) { - continue - } - block := blockAt(msgs, item.MsgIndex, item.BlockIndex) - if block == nil { - continue - } - - evictKey := item.ToolUseID - if evictKey == "" { - evictKey = item.FilePath - } - if evictKey != "" && ev != nil && ev.IsEvicted(sid, evictKey) { - newText, _ := collapse(item.Text, item.FilePath, "pruned", st, true) - setBlockText(block, newText) - continue - } - - v, ok := verdicts[item.ID] - if !ok { - continue - } - - reduced := route(byID[item.ID], v, st, opts.EnabledReducers, opts.EnabledEncoders) - if reduced.NewText != nil { - setBlockText(block, *reduced.NewText) - reducedIDs[item.ID] = struct{}{} - } else if opts.StickyIDs != nil { - if _, sticky := opts.StickyIDs[item.ID]; sticky { - newText, _ := collapse(item.Text, item.FilePath, "sticky", st, false) - if tokens.Count(newText) < tokens.Count(item.Text) { - setBlockText(block, newText) - reducedIDs[item.ID] = struct{}{} - } - } - } - } - - b, a, saved, ratio := measure(beforeText, serialize(req.Messages())) - ids := make([]string, 0, len(reducedIDs)) - for id := range reducedIDs { - ids = append(ids, id) - } - sort.Strings(ids) - return Report{ - SessionID: sid, AtCompaction: zones.AtCompaction, FrozenCount: frozen, - TokensBefore: b, TokensAfter: a, TokensSaved: saved, Ratio: ratio, - ReducerErrors: reducerErrors, ToolDefTokens: toolDefTokens, ToolsTotal: len(toolsList), - ReducedIDs: ids, - } -} diff --git a/internal/reduce/pipeline_test.go b/internal/reduce/pipeline_test.go deleted file mode 100644 index 11fe9f2..0000000 --- a/internal/reduce/pipeline_test.go +++ /dev/null @@ -1,146 +0,0 @@ -package reduce - -import ( - "encoding/json" - "fmt" - "strings" - "testing" - - "github.com/kagenti/lab-context-engineering/canon" - "github.com/kagenti/lab-context-engineering/internal/markers" - "github.com/kagenti/lab-context-engineering/internal/store" -) - -// jsonStr returns text as a JSON string literal for embedding in a request body. -func jsonStr(text string) string { - b, _ := json.Marshal(text) - return string(b) -} - -// bigUniformArray builds a uniform flat JSON array large enough to clear the -// reducer's min-output gate and where CSV is strictly smaller than JSON. -func bigUniformArray(n int) string { - var recs []string - for i := 0; i < n; i++ { - recs = append(recs, fmt.Sprintf( - `{"id":%d,"name":"item_%d","status":"active","note":"a short note here"}`, 1001+i, i)) - } - return "[" + strings.Join(recs, ",") + "]" -} - -func bigText(prefix string, n int) string { - var b strings.Builder - for i := 0; i < n; i++ { - b.WriteString(prefix) - b.WriteString(" line of content that is reasonably long to exceed the marker\n") - } - return b.String() -} - -func reduceFixture(t *testing.T, body string, opts Opts) (canon.Request, Report, store.Rewind) { - t.Helper() - req, err := canon.Decode([]byte(body)) - if err != nil { - t.Fatalf("decode: %v", err) - } - st := store.NewMemory(0) - rep := ReduceRequest(req, st, store.NewEviction(), opts) - return req, rep, st -} - -// markerOriginal returns the stored original for the first lab-cx marker found in text. -func markerOriginal(t *testing.T, st store.Rewind, text string) (string, bool) { - ids := markers.FindIDs(text) - if len(ids) == 0 { - return "", false - } - return st.Get(ids[0]) -} - -func TestStaleReadCollapsesAndIsReversible(t *testing.T) { - original := bigText("a.go", 12) - body := `{"messages":[ - {"role":"user","content":[{"type":"text","text":"fix the bug"}]}, - {"role":"assistant","content":[{"type":"tool_use","id":"u1","name":"read","input":{"file_path":"a.go"}}]}, - {"role":"user","content":[{"type":"tool_result","tool_use_id":"u1","content":` + jsonStr(original) + `}]}, - {"role":"assistant","content":[{"type":"tool_use","id":"u2","name":"edit","input":{"file_path":"a.go"}}]}, - {"role":"user","content":[{"type":"tool_result","tool_use_id":"u2","content":"success: file edited"}]}, - {"role":"user","content":[{"type":"text","text":"thanks"}]} - ]}` - opts := DefaultOpts() - opts.ProtectRecent = 1 - req, rep, st := reduceFixture(t, body, opts) - - if rep.TokensSaved <= 0 { - t.Fatalf("expected token savings, got %d (before=%d after=%d)", rep.TokensSaved, rep.TokensBefore, rep.TokensAfter) - } - // The stale read block should now hold a marker recoverable to the original. - block := blockAt(req.Messages(), 2, 0) - got, ok := markerOriginal(t, st, block["content"].(string)) - if !ok { - t.Fatalf("stale read was not collapsed to a recoverable marker: %v", block["content"]) - } - if got != original { - t.Fatalf("recovered content mismatch") - } -} - -func TestEmptyResultCollapsed(t *testing.T) { - body := `{"messages":[ - {"role":"assistant","content":[{"type":"tool_use","id":"u1","name":"grep","input":{}}]}, - {"role":"user","content":[{"type":"tool_result","tool_use_id":"u1","content":"[]"}]}, - {"role":"user","content":[{"type":"text","text":"next"}]} - ]}` - opts := DefaultOpts() - opts.ProtectRecent = 1 - req, _, _ := reduceFixture(t, body, opts) - block := blockAt(req.Messages(), 1, 0) - // "[]" is tiny so the marker can't beat it (never-inflate); verdict is still - // "empty" — assert the scorer agrees rather than forcing an inflating collapse. - items := ExtractItems(req) - var emptyVerdict bool - for _, v := range ScoreRelevance(items, DefaultScoreOpts(1)) { - if v.Reason == "empty" { - emptyVerdict = true - } - } - if !emptyVerdict { - t.Fatalf("expected an 'empty' verdict for the [] result; block=%v", block["content"]) - } -} - -func TestRecentReadProtected(t *testing.T) { - original := bigText("hot.go", 12) - body := `{"messages":[ - {"role":"assistant","content":[{"type":"tool_use","id":"u1","name":"read","input":{"file_path":"hot.go"}}]}, - {"role":"user","content":[{"type":"tool_result","tool_use_id":"u1","content":` + jsonStr(original) + `}]} - ]}` - opts := DefaultOpts() - opts.ProtectRecent = 5 // protects everything - req, _, _ := reduceFixture(t, body, opts) - block := blockAt(req.Messages(), 1, 0) - if block["content"].(string) != original { - t.Fatalf("recent read should be untouched") - } -} - -func TestProvableFormatReencode(t *testing.T) { - // A uniform JSON array as generic (non-file) output, never referenced later. - arr := bigUniformArray(30) - body := `{"messages":[ - {"role":"assistant","content":[{"type":"tool_use","id":"u1","name":"list_items","input":{}}]}, - {"role":"user","content":[{"type":"tool_result","tool_use_id":"u1","content":` + jsonStr(arr) + `}]}, - {"role":"user","content":[{"type":"text","text":"ok"}]} - ]}` - opts := DefaultOpts() - opts.ProtectRecent = 1 - opts.ProvableOnly = true // predicted-unused becomes a lossless re-encode, not a drop - req, rep, st := reduceFixture(t, body, opts) - if rep.TokensSaved <= 0 { - t.Fatalf("expected savings from re-encoding; before=%d after=%d", rep.TokensBefore, rep.TokensAfter) - } - block := blockAt(req.Messages(), 1, 0) - if _, ok := markerOriginal(t, st, block["content"].(string)); !ok { - t.Fatalf("re-encoded block should carry a recoverable marker: %v", block["content"]) - } -} diff --git a/internal/reduce/relevance.go b/internal/reduce/relevance.go deleted file mode 100644 index 208df89..0000000 --- a/internal/reduce/relevance.go +++ /dev/null @@ -1,248 +0,0 @@ -package reduce - -import ( - "regexp" - "strings" - "unicode" -) - -// Relevance scoring — the core contribution. For each reducible read/tool_result -// item, decide whether it is still relevant using deterministic transcript signals. -// Ported from the reference prototype's relevance.py. - -var ( - salientRe = regexp.MustCompile(`[A-Za-z_][A-Za-z0-9_./-]{7,}`) - idTokenRe = regexp.MustCompile(`[A-Za-z0-9_][A-Za-z0-9_.\-/]*`) -) - -// ScoreOpts configures relevance scoring. Zero value is not the default; callers -// pass DefaultScoreOpts() and adjust. -type ScoreOpts struct { - ProtectRecent int - CollapseOutputs bool - MinOutputTokens int - ProtectRecentToolUses int - ProvableOnly bool - LiteralSignal bool -} - -// DefaultScoreOpts mirrors the reference prototype's score_relevance defaults. -func DefaultScoreOpts(protectRecent int) ScoreOpts { - return ScoreOpts{ - ProtectRecent: protectRecent, CollapseOutputs: true, - MinOutputTokens: 200, LiteralSignal: true, - } -} - -func salientTokens(text string) map[string]struct{} { - return tokenSet(salientRe, text) -} - -func shortIDTokens(text string) map[string]struct{} { - out := map[string]struct{}{} - for _, t := range idTokenRe.FindAllString(text, -1) { - if len(t) < 2 || len(t) > 7 { - continue - } - hasDigit := strings.ContainsFunc(t, unicode.IsDigit) - if hasDigit || t == strings.ToUpper(t) { - out[t] = struct{}{} - } - } - return out -} - -func outputReferencedLater(item ContextItem, items []ContextItem) bool { - salient := salientTokens(item.Text) - ids := shortIDTokens(item.Text) - if len(salient) == 0 && len(ids) == 0 { - return true // nothing distinctive -> don't risk collapsing - } - for _, t := range items { - if t.MsgIndex <= item.MsgIndex { - continue - } - for s := range salient { - if strings.Contains(t.Text, s) { - return true - } - } - if len(ids) > 0 { - for tok := range shortIDTokens(t.Text) { - if _, ok := ids[tok]; ok { - return true - } - } - } - } - return false -} - -func isEmptyResult(text string) bool { - t := strings.ToLower(strings.TrimSpace(text)) - switch t { - case "", "none", "null", "[]", "{}", `""`, "{ }", "[ ]": - return true - } - return false -} - -// ScoreRelevance returns one verdict per reducible item. -func ScoreRelevance(items []ContextItem, opts ScoreOpts) []Verdict { - if len(items) == 0 { - return nil - } - maxMI := 0 - for _, i := range items { - if i.MsgIndex > maxMI { - maxMI = i.MsgIndex - } - } - protectFrom := maxMI - opts.ProtectRecent + 1 - if opts.ProtectRecentToolUses > 0 { - var tuMIs []int - seen := map[int]struct{}{} - for _, i := range items { - if i.Kind == "tool_use" { - if _, ok := seen[i.MsgIndex]; !ok { - seen[i.MsgIndex] = struct{}{} - tuMIs = append(tuMIs, i.MsgIndex) - } - } - } - sortInts(tuMIs) - if len(tuMIs) >= opts.ProtectRecentToolUses { - if cand := tuMIs[len(tuMIs)-opts.ProtectRecentToolUses]; cand < protectFrom { - protectFrom = cand - } - } - } - - // Hot path: the file the agent is touching right now is never predictively dropped. - hotPath := "" - lastMI := -1 - for _, i := range items { - if i.Kind == "tool_use" && i.FilePath != "" && i.MsgIndex >= lastMI { - lastMI = i.MsgIndex - hotPath = i.FilePath - } - } - unusedReason := "unused" - if opts.ProvableOnly { - unusedReason = "lossy_candidate" - } - - resultText := map[string]string{} - for _, it := range items { - if it.Kind == "tool_result" && it.ToolUseID != "" { - resultText[it.ToolUseID] = it.Text - } - } - - fullReadMIs := map[string][]int{} - mutateMIs := map[string][]int{} - for _, it := range items { - if it.FilePath != "" && isMutateTool(it.ToolName) { - if !isFailure(resultText[it.ToolUseID]) { - mutateMIs[it.FilePath] = append(mutateMIs[it.FilePath], it.MsgIndex) - } - } - isReadLike := it.FilePath != "" && (isReadTool(it.ToolName) || it.Kind == "tool_result") - if isReadLike && it.ReadOffset == nil && it.ReadLimit == nil { - fullReadMIs[it.FilePath] = append(fullReadMIs[it.FilePath], it.MsgIndex) - } - } - - referencedLater := func(item ContextItem) bool { - fp := item.FilePath - if fp == "" { - return false - } - for _, mi := range mutateMIs[fp] { - if mi > item.MsgIndex { - return true - } - } - syms := definedSymbols(item.Text, fp) - var lits map[string]struct{} - if opts.LiteralSignal { - lits = salientLiterals(item.Text) - } - for _, t := range items { - if t.MsgIndex <= item.MsgIndex { - continue - } - if pathReferenced(fp, t.Text) || symbolsUsed(syms, t.Text) { - return true - } - if len(lits) > 0 && literalsUsed(lits, t.Text) { - return true - } - } - return false - } - - var verdicts []Verdict - for _, it := range items { - protected := it.MsgIndex >= protectFrom - onHotPath := opts.ProvableOnly && hotPath != "" && it.FilePath == hotPath - unusedHere := unusedReason - if onHotPath { - unusedHere = "kept_default" - } - isFileRead := it.FilePath != "" && (isReadTool(it.ToolName) || it.Kind == "tool_result") - - if protected { - verdicts = append(verdicts, Verdict{it.ID, 1.0, "protected_recent", true}) - continue - } - if opts.CollapseOutputs && it.Kind == "tool_result" && isEmptyResult(it.Text) { - verdicts = append(verdicts, Verdict{it.ID, 0.1, "empty", false}) - continue - } - if !isFileRead { - if opts.CollapseOutputs && it.Kind == "tool_result" && - it.TokenEst >= opts.MinOutputTokens && !outputReferencedLater(it, items) { - verdicts = append(verdicts, Verdict{it.ID, 0.2, unusedHere, false}) - } else { - verdicts = append(verdicts, Verdict{it.ID, 0.7, "kept_default", false}) - } - continue - } - - fp := it.FilePath - laterMutate := false - for _, mi := range mutateMIs[fp] { - if mi > it.MsgIndex { - laterMutate = true - break - } - } - laterFullRead := false - for _, mi := range fullReadMIs[fp] { - if mi > it.MsgIndex { - laterFullRead = true - break - } - } - switch { - case laterMutate: - verdicts = append(verdicts, Verdict{it.ID, 0.1, "stale", false}) - case laterFullRead: - verdicts = append(verdicts, Verdict{it.ID, 0.15, "superseded_dup", false}) - case referencedLater(it): - verdicts = append(verdicts, Verdict{it.ID, 0.9, "referenced", false}) - default: - verdicts = append(verdicts, Verdict{it.ID, 0.2, unusedHere, false}) - } - } - return verdicts -} - -func sortInts(a []int) { - for i := 1; i < len(a); i++ { - for j := i; j > 0 && a[j-1] > a[j]; j-- { - a[j-1], a[j] = a[j], a[j-1] - } - } -} diff --git a/internal/reduce/signals.go b/internal/reduce/signals.go deleted file mode 100644 index 792fcfc..0000000 --- a/internal/reduce/signals.go +++ /dev/null @@ -1,172 +0,0 @@ -package reduce - -import ( - "path" - "regexp" - "strings" - "sync" - - "github.com/kagenti/lab-context-engineering/internal/treesitter" - sitter "github.com/tree-sitter/go-tree-sitter" -) - -// Code-reference signals. The question is not "was the basename restated" but: was -// the read file's PATH referenced later, or a SYMBOL it DEFINES used later, or a -// distinctive LITERAL it contains reused later? Every signal biases toward KEEP (the -// safe direction; reductions are reversible). Ported from the reference prototype's signals/refs.py. -// -// definedSymbols uses tree-sitter (go-tree-sitter + per-grammar forest), which catches -// methods the old regex missed and ignores commented-out definitions. It still biases -// toward KEEP and fails open (empty on non-code paths or parse failure). - -var ( - identRe = regexp.MustCompile(`[A-Za-z_][A-Za-z0-9_]*`) - constRe = regexp.MustCompile(`\b[A-Z][A-Z0-9_]{2,}\b`) - numRe = regexp.MustCompile(`(?:^|[^\w.])(\d{3,})(?:[^\w.]|$)`) - quotedRe = regexp.MustCompile(`['"]([^'"\n]{3,40})['"]`) -) - -var stopLiterals = set( - "todo", "fixme", "note", "xxx", "hack", "true", "false", "null", "none", - "get", "post", "put", "delete", "patch", "and", "or", "not", "the", "for", - "error", "warning", "info", "debug", "string", "number", "object", "array", -) - -func isCodePath(fp string) bool { return treesitter.LangForExt(fp) != "" } - -// defNodeKinds: definition node kinds whose "name" field (or identifier child) -// names a symbol, across grammars. -var defNodeKinds = map[string]bool{ - "function_declaration": true, "function_definition": true, "function_item": true, - "method_declaration": true, "method_definition": true, "method": true, - "class_declaration": true, "class_definition": true, "class": true, - "struct_item": true, "enum_item": true, "trait_item": true, - "type_spec": true, "interface_declaration": true, "module": true, -} - -// definedSymbols returns names of functions/classes/methods/types defined in code -// text. Empty for non-code paths or on any parse failure (fail-open). Drops names -// shorter than 3 chars. -func definedSymbols(text, filePath string) map[string]struct{} { - lang := treesitter.LangForExt(filePath) - if lang == "" { - return nil - } - src := []byte(text) - tree, _, ok := treesitter.Parse(lang, src) - if !ok { - return nil - } - defer tree.Close() - out := map[string]struct{}{} - var walk func(n *sitter.Node) - walk = func(n *sitter.Node) { - if defNodeKinds[n.Kind()] { - if name := n.ChildByFieldName("name"); name != nil { - if s := name.Utf8Text(src); len(s) >= 3 { - out[s] = struct{}{} - } - } - } - for i := uint(0); i < n.NamedChildCount(); i++ { - walk(n.NamedChild(i)) - } - } - walk(tree.RootNode()) - return out -} - -// salientLiterals returns distinctive literal values (ALL_CAPS, 3+ digit numbers, -// short quoted strings) whose later standalone reuse implies the content was used. -func salientLiterals(text string) map[string]struct{} { - out := map[string]struct{}{} - add := func(s string) { - if len(s) >= 3 { - if _, stop := stopLiterals[strings.ToLower(s)]; !stop { - out[s] = struct{}{} - } - } - } - for _, m := range constRe.FindAllString(text, -1) { - add(m) - } - for _, m := range numRe.FindAllStringSubmatch(text, -1) { - add(m[1]) - } - for _, m := range quotedRe.FindAllStringSubmatch(text, -1) { - add(strings.TrimSpace(m[1])) - } - return out -} - -func isIdentifier(s string) bool { - return s != "" && identRe.FindString(s) == s -} - -// literalsUsed reports whether any salient literal reappears in text. -func literalsUsed(literals map[string]struct{}, text string) bool { - if len(literals) == 0 || text == "" { - return false - } - idents := tokenSet(identRe, text) - for lit := range literals { - if _, ok := idents[lit]; ok { - return true - } - if !isIdentifier(lit) && strings.Contains(text, lit) { - return true - } - } - return false -} - -// pathReferenced reports whether the full path or basename appears as a standalone -// path token (boundary-aware, not a naive substring). -func pathReferenced(filePath, text string) bool { - if filePath == "" || text == "" { - return false - } - // Compile the boundary patterns once per call (the caller scans this in an - // O(items²) loop, so building the regex per token inside the loop was hot). - for _, tok := range []string{filePath, path.Base(filePath)} { - re := pathTokenRe(tok) - if re.MatchString(text) { - return true - } - } - return false -} - -// pathTokenCache memoizes the boundary-aware regex for each path token across calls. -var pathTokenCache sync.Map // string -> *regexp.Regexp - -func pathTokenRe(tok string) *regexp.Regexp { - if v, ok := pathTokenCache.Load(tok); ok { - return v.(*regexp.Regexp) - } - re := regexp.MustCompile(`(?:^|[^\w./-])` + regexp.QuoteMeta(tok) + `(?:[^\w]|$)`) - pathTokenCache.Store(tok, re) - return re -} - -// symbolsUsed reports whether any defined symbol appears as a standalone identifier. -func symbolsUsed(symbols map[string]struct{}, text string) bool { - if len(symbols) == 0 || text == "" { - return false - } - idents := tokenSet(identRe, text) - for s := range symbols { - if _, ok := idents[s]; ok { - return true - } - } - return false -} - -func tokenSet(re *regexp.Regexp, text string) map[string]struct{} { - out := map[string]struct{}{} - for _, t := range re.FindAllString(text, -1) { - out[t] = struct{}{} - } - return out -} diff --git a/internal/reduce/signals_test.go b/internal/reduce/signals_test.go deleted file mode 100644 index b633714..0000000 --- a/internal/reduce/signals_test.go +++ /dev/null @@ -1,27 +0,0 @@ -package reduce - -import "testing" - -func TestDefinedSymbolsCatchesGoMethod(t *testing.T) { - src := "package p\ntype T struct{}\nfunc (r *T) DoThing() {}\nfunc Helper() {}\n" - syms := definedSymbols(src, "x.go") - for _, want := range []string{"DoThing", "Helper"} { - if _, ok := syms[want]; !ok { - t.Fatalf("missing symbol %q in %v", want, syms) - } - } -} - -func TestDefinedSymbolsIgnoresComments(t *testing.T) { - src := "package p\n// func Ghost() {}\nfunc Real() {}\n" - syms := definedSymbols(src, "x.go") - if _, ok := syms["Ghost"]; ok { - t.Fatal("commented-out func must not be a defined symbol") - } -} - -func TestDefinedSymbolsNonCodeEmpty(t *testing.T) { - if len(definedSymbols("hello", "notes.txt")) != 0 { - t.Fatal("non-code path must yield no symbols") - } -} diff --git a/internal/reduce/skeleton_test.go b/internal/reduce/skeleton_test.go deleted file mode 100644 index a9c981e..0000000 --- a/internal/reduce/skeleton_test.go +++ /dev/null @@ -1,26 +0,0 @@ -package reduce - -import ( - "strings" - "testing" -) - -func TestSkeletonizeDropsGoBodies(t *testing.T) { - src := "package p\nfunc Big() int {\n\t" + strings.Repeat("x := 1\n\t", 50) + "return x\n}\n" - sk, ok := skeletonize(src, "big.go") - if !ok { - t.Fatal("expected skeletonization to apply") - } - if !strings.Contains(sk, "func Big()") { - t.Fatal("signature must be kept") - } - if strings.Contains(sk, "x := 1\n\tx := 1") { - t.Fatal("body should have been elided") - } -} - -func TestSkeletonizeNonCodeFails(t *testing.T) { - if _, ok := skeletonize("hello", "n.txt"); ok { - t.Fatal("non-code must return ok=false") - } -} diff --git a/internal/reduce/taxonomy.go b/internal/reduce/taxonomy.go deleted file mode 100644 index ecdce32..0000000 --- a/internal/reduce/taxonomy.go +++ /dev/null @@ -1,86 +0,0 @@ -package reduce - -import ( - "encoding/json" - "strings" -) - -// Tool-name taxonomy covering common coding agents (Claude Code, Cursor, Aider, -// Cline, Continue, Codex). Ported from the reference prototype's taxonomy.py. -// -// ponytail: defaults only; LABCX_TOOLS-style env overrides land with the config -// package rather than being re-parsed here. -var ( - readTools = set( - "read", "cat", "view", "open", "notebookread", "fetch_file", - "read_file", "readfile", "open_file", "view_file", "get_file", "cat_file", - ) - mutateTools = set( - "edit", "write", "create", "apply_patch", "str_replace", "notebookedit", - "edit_file", "write_file", "create_file", "str_replace_editor", "apply_diff", - "replace_in_file", "insert", "search_replace", - ) - fileKeys = []string{"file_path", "path", "filename", "file", "target_file", "filepath"} - offsetKeys = []string{"offset", "start_line", "line_start", "from_line"} - limitKeys = []string{"limit", "end_line", "line_end", "to_line"} -) - -func set(xs ...string) map[string]struct{} { - m := make(map[string]struct{}, len(xs)) - for _, x := range xs { - m[x] = struct{}{} - } - return m -} - -func isReadTool(name string) bool { - if name == "" { - return false - } - _, ok := readTools[strings.ToLower(name)] - return ok -} - -func isMutateTool(name string) bool { - if name == "" { - return false - } - _, ok := mutateTools[strings.ToLower(name)] - return ok -} - -// fileArg pulls the file path out of a tool input. -func fileArg(input map[string]any) string { - for _, k := range fileKeys { - if v, ok := input[k].(string); ok && v != "" { - return v - } - } - return "" -} - -// readRange extracts an (offset, limit) read range; nil pointers mean a full read. -func readRange(input map[string]any) (*int, *int) { - return firstInt(input, offsetKeys), firstInt(input, limitKeys) -} - -func firstInt(input map[string]any, keys []string) *int { - for _, k := range keys { - switch v := input[k].(type) { - case bool: - continue // bool excluded - case float64: - n := int(v) - return &n - case int: - n := v - return &n - case json.Number: - if i, err := v.Int64(); err == nil { - n := int(i) - return &n - } - } - } - return nil -} diff --git a/internal/reduce/types.go b/internal/reduce/types.go deleted file mode 100644 index dfe5bb6..0000000 --- a/internal/reduce/types.go +++ /dev/null @@ -1,43 +0,0 @@ -// Package reduce is the deterministic, lossless-first reduction core ported from -// the reference prototype: parse a canonical request into items, score each item's relevance from -// transcript signals, and apply the cheapest faithful, reversible action. No I/O, -// clock, or randomness beyond the injected rewind store. -package reduce - -// ContextItem is one reducible block of the request. Ids are content hashes so they -// are stable turn-to-turn (required for cache stability). -type ContextItem struct { - ID string - MsgIndex int - BlockIndex int - Kind string // "tool_result" | "text" | "tool_use" - ToolName string - ToolUseID string - FilePath string - Text string - TokenEst int - ReadOffset *int - ReadLimit *int -} - -// Verdict is the relevance decision for an item. -type Verdict struct { - ItemID string - Score float64 - Reason string // referenced|stale|unused|lossy_candidate|superseded_dup|empty|protected_recent|kept_default - Protected bool -} - -// Reduced is the outcome of routing an item to an action. -type Reduced struct { - ItemID string - Action string // keep|collapse|skeleton|format - NewText *string // nil means keep verbatim - RewindID string -} - -// Zones is the frozen-prefix / live-zone split. -type Zones struct { - FrozenCount int - AtCompaction bool -} diff --git a/internal/reduce/zones.go b/internal/reduce/zones.go deleted file mode 100644 index a97a308..0000000 --- a/internal/reduce/zones.go +++ /dev/null @@ -1,19 +0,0 @@ -package reduce - -const compactionRatio = 0.8 - -// ComputeZones derives the frozen-prefix / live-zone split. At compaction (input -// near the limit) frozen_count is forced to 0 since the cache is rebuilt regardless. -func ComputeZones(numMessages, inputTokens, contextLimit, frozenCount int) Zones { - atCompaction := contextLimit > 0 && float64(inputTokens) >= compactionRatio*float64(contextLimit) - if atCompaction { - frozenCount = 0 - } - if frozenCount < 0 { - frozenCount = 0 - } - if frozenCount > numMessages { - frozenCount = numMessages - } - return Zones{FrozenCount: frozenCount, AtCompaction: atCompaction} -} diff --git a/internal/store/store.go b/internal/store/store.go deleted file mode 100644 index cdf43b9..0000000 --- a/internal/store/store.go +++ /dev/null @@ -1,108 +0,0 @@ -// Package store holds reversible reduction state: the content-addressed rewind -// store (original text keyed by content hash, so a collapsed/reduced block can be -// expanded back), the per-session eviction set, and session identity. -// -// The default Rewind implementation is in-memory — correct for a single proxy or -// sidecar process. A Redis/SQLite-backed implementation can satisfy the same -// interface later for multi-replica recovery (the plan's deferred option). -// Ported from the reference prototype's rewind.py / session.py. -package store - -import ( - "crypto/sha256" - "encoding/hex" - "sync" - "time" -) - -// ContentID is the deterministic id for a piece of content (first 24 hex chars of -// its SHA-256). Exported because item ids elsewhere use the same scheme. -func ContentID(content string) string { - sum := sha256.Sum256([]byte(content)) - return hex.EncodeToString(sum[:])[:24] -} - -// Rewind stores originals so reductions are reversible. -type Rewind interface { - // Put stores original and returns its content id. - Put(original string) string - // Get returns the original for id, or ("", false) if absent/expired. - Get(id string) (string, bool) -} - -type entry struct { - original string - createdAt time.Time -} - -// Memory is an in-memory Rewind with TTL and touch-on-sight (a marker still being -// recovered must not expire mid-session). -type Memory struct { - mu sync.Mutex - m map[string]entry - ttl time.Duration - now func() time.Time // injectable for tests -} - -// NewMemory returns an in-memory store. ttl <= 0 means no expiry. -func NewMemory(ttl time.Duration) *Memory { - return &Memory{m: map[string]entry{}, ttl: ttl, now: time.Now} -} - -func (s *Memory) Put(original string) string { - id := ContentID(original) - s.mu.Lock() - s.m[id] = entry{original: original, createdAt: s.now()} - s.mu.Unlock() - return id -} - -func (s *Memory) Get(id string) (string, bool) { - s.mu.Lock() - defer s.mu.Unlock() - e, ok := s.m[id] - if !ok { - return "", false - } - if s.ttl > 0 && s.now().Sub(e.createdAt) > s.ttl { - delete(s.m, id) - return "", false - } - e.createdAt = s.now() // touch-on-sight - s.m[id] = e - return e.original, true -} - -// Eviction tracks user-directed prune targets per session. -type Eviction struct { - mu sync.Mutex - sets map[string]map[string]struct{} -} - -// NewEviction returns an empty eviction store. -func NewEviction() *Eviction { return &Eviction{sets: map[string]map[string]struct{}{}} } - -func (e *Eviction) Evict(sid, target string) { - e.mu.Lock() - defer e.mu.Unlock() - if e.sets[sid] == nil { - e.sets[sid] = map[string]struct{}{} - } - e.sets[sid][target] = struct{}{} -} - -func (e *Eviction) IsEvicted(sid, target string) bool { - e.mu.Lock() - defer e.mu.Unlock() - _, ok := e.sets[sid][target] - return ok -} - -// SessionID is a stable hash of (system, first user text). -func SessionID(system, firstUserText string) string { - h := sha256.New() - h.Write([]byte(system)) - h.Write([]byte{0}) - h.Write([]byte(firstUserText)) - return hex.EncodeToString(h.Sum(nil))[:16] -} diff --git a/internal/summarize/summarize.go b/internal/summarize/summarize.go deleted file mode 100644 index d479d64..0000000 --- a/internal/summarize/summarize.go +++ /dev/null @@ -1,113 +0,0 @@ -// Package summarize produces a compact, factual summary of an agent trajectory. The -// prompts are ported verbatim from the CE-Manager / ACE "ReSum" summarizer. The -// engine's summarize compactor flattens the conversation to a trajectory string, calls -// Summarize, and rebuilds the message list around the result. -package summarize - -import ( - "context" - "errors" - "strings" -) - -// Model is the LLM client the summarizer calls. The host injects a concrete -// implementation; this core stays transport-free. -type Model interface { - Complete(ctx context.Context, prompt string) (string, error) -} - -// Summary verbosity levels. -const ( - LevelConcise = "concise" - LevelRegular = "regular" - LevelHighlyDetailed = "highly_detailed" -) - -// systemPrompt and userPrompt are verbatim from the ReSum summarizer (prompts/summarizer.py). -const systemPrompt = "You analyze long agent trajectories with tool calls and produce compact, factual summaries. Do not guess or invent information." - -const userPrompt = `You are an expert at analyzing conversation history and extracting relevant information. Your task is -to thoroughly evaluate the conversation history and current question to provide a comprehensive -summary that will help answer the question. - -Task Guidelines: -1. Information Analysis -• Carefully analyze the conversation history to identify truly useful information. -• Focus on information that directly contributes to answering the question. -• Do NOT make assumptions, guesses, or inferences beyond what is explicitly stated in -the conversation. -• If information is missing or unclear, do NOT include it in your summary. - -2. Summary Requirements -• Extract only the most relevant information that is explicitly present in the conversation. -• Synthesize information from multiple exchanges when relevant. Only include information that is certain and clearly stated in the conversation. -• Do NOT output or mention any information that is uncertain, insufficient, or cannot be -confirmed from the conversation. - -3. Output Format Your response should be structured as follows: - -• Essential Information: [Organize the relevant and certain information from the conversation history that helps address the question.] - - -Strictly avoid fabricating, inferring, or exaggerating any information not present in the conversation. -Only output information that is certain and explicitly stated. - -Trajectory: {trajectory} -` - -const ( - suffixConcise = "Please generate a concise summary" - suffixRegular = "Please generate a comprehensive and useful summary" - suffixDetailed = "Please generate a highly detailed, fully comprehensive, explicitly grounded summary " + - "that includes every relevant and certain piece of information from the conversation." -) - -func suffixFor(level string) string { - switch level { - case LevelConcise: - return suffixConcise - case LevelHighlyDetailed: - return suffixDetailed - default: // regular (and unknown) - return suffixRegular - } -} - -// Prompt builds the (system, user) prompt pair for a trajectory at a verbosity level. -func Prompt(trajectory, level string) (system, user string) { - user = strings.ReplaceAll(userPrompt, "{trajectory}", trajectory) - if sfx := suffixFor(level); sfx != "" { - user = user + "\n" + sfx - } - return systemPrompt, user -} - -// Summarize asks the model to summarize the trajectory and returns the result wrapped -// in .... The Model interface takes a single prompt, so the system -// instruction is prepended to the user prompt. -func Summarize(ctx context.Context, trajectory, level string, model Model) (string, error) { - if model == nil { - return "", errors.New("summarize: no model") - } - system, user := Prompt(trajectory, level) - out, err := model.Complete(ctx, system+"\n\n"+user) - if err != nil { - return "", err - } - return EnsureFormat(out), nil -} - -// EnsureFormat guarantees the summary is wrapped in ... tags. -func EnsureFormat(s string) string { - s = strings.TrimSpace(s) - if s == "" { - return "Empty trajectory" - } - if !strings.Contains(s, "") { - s = "\n" + s - } - if !strings.Contains(s, "") { - s = s + "\n" - } - return s -} diff --git a/metrics/metrics.go b/metrics/metrics.go new file mode 100644 index 0000000..09202be --- /dev/null +++ b/metrics/metrics.go @@ -0,0 +1,175 @@ +// Package metrics turns component/run reports into telemetry. It provides +// concrete components.Emitter implementations; the pipeline depends only on the +// Emitter interface, so this package imports components and not vice versa. +// +// v1 ships: Nop (in components), Slog (structured logs in OTel gen_ai.* +// vocabulary), and Aggregator (in-process rollups behind /stats). An OTel-SDK +// emitter and honest-metrics extras (bounce-adjusted savings, waste signals) +// land in P5. Each host surfaces these natively (proxy -> bifrost Prometheus/ +// OTel + /metrics; AuthBridge -> StatsSource). +package metrics + +import ( + "log/slog" + "sort" + "sync" + + "github.com/kagenti/context-guru/components" +) + +// Tee fans a report out to several emitters. +type Tee []components.Emitter + +func (t Tee) Component(r components.Report) { + for _, e := range t { + e.Component(r) + } +} +func (t Tee) Run(r components.RunReport) { + for _, e := range t { + e.Run(r) + } +} + +// Slog logs each component and run in the GenAI semantic-convention vocabulary. +type Slog struct{ L *slog.Logger } + +func (s Slog) logger() *slog.Logger { + if s.L != nil { + return s.L + } + return slog.Default() +} + +func (s Slog) Component(r components.Report) { + s.logger().Info("context_engineering.component", + "context_engineering.component", r.Component, + "context_engineering.kind", r.Kind, + "context_engineering.tokens.before", r.TokensBefore, + "context_engineering.tokens.after", r.TokensAfter, + "context_engineering.tokens.saved", r.Saved(), + "context_engineering.reverted", r.Reverted, + "context_engineering.duration_ms", r.DurationMs, + ) +} + +func (s Slog) Run(r components.RunReport) { + s.logger().Info("context_engineering.run", + "context_engineering.session", r.Session, + "context_engineering.tokens.before", r.TokensBefore, + "context_engineering.tokens.after", r.TokensAfter, + "context_engineering.tokens.saved", r.Saved(), + "context_engineering.duration_ms", r.DurationMs, + ) +} + +// Aggregator keeps process-wide and per-component rollups for /stats. Savings +// are token-weighted (SUM saved / SUM before), the honest aggregate — not a +// mean of per-request percentages (rtk's lesson). +type Aggregator struct { + mu sync.Mutex + requests int64 + before int64 + after int64 + wasted int64 // tokens re-served via expand (offloaded then needed back) + bounces int64 + perComp map[string]*compStat +} + +type compStat struct { + Runs int64 `json:"runs"` + Acted int64 `json:"acted"` // runs that actually saved tokens + Mutated int64 `json:"mutated"` // runs that changed the request at all (may save 0 content tokens, e.g. cacheinject) + Reverted int64 `json:"reverted"` + Saved int64 `json:"saved_tokens"` +} + +// NewAggregator returns an empty aggregator. +func NewAggregator() *Aggregator { return &Aggregator{perComp: map[string]*compStat{}} } + +func (a *Aggregator) Component(r components.Report) { + a.mu.Lock() + defer a.mu.Unlock() + cs := a.perComp[r.Component] + if cs == nil { + cs = &compStat{} + a.perComp[r.Component] = cs + } + cs.Runs++ + cs.Saved += int64(r.Saved()) + if r.Reverted { + cs.Reverted++ + } + if !r.Reverted && !r.Skipped { + cs.Mutated++ // did something, even if it saved no content tokens + } + if r.Saved() > 0 && !r.Reverted && !r.Skipped { + cs.Acted++ + } +} + +// RecordExpand notes that `tokens` of previously-offloaded content had to be +// re-served (the model called expand). This is the bounce signal: it means an +// offload was premature, so the honest savings figure subtracts it (lean-ctx's +// adjusted savings). +func (a *Aggregator) RecordExpand(tokens int) { + a.mu.Lock() + a.wasted += int64(tokens) + a.bounces++ + a.mu.Unlock() +} + +func (a *Aggregator) Run(r components.RunReport) { + a.mu.Lock() + defer a.mu.Unlock() + a.requests++ + a.before += int64(r.TokensBefore) + a.after += int64(r.TokensAfter) +} + +// Snapshot is the JSON served at /stats. It reports both gross savings and the +// honest, bounce-adjusted figure, plus quality signals naming components that +// never earned their place (rtk's top_passthrough idea). +type Snapshot struct { + Requests int64 `json:"requests"` + TokensBefore int64 `json:"tokens_before"` + TokensAfter int64 `json:"tokens_after"` + SavedTokens int64 `json:"saved_tokens"` + SavingsPct float64 `json:"savings_pct"` + WastedTokens int64 `json:"wasted_tokens"` // re-served via expand + Bounces int64 `json:"bounces"` // expand events + AdjustedSaved int64 `json:"adjusted_saved"` // saved - wasted (may be negative) + Components map[string]compStat `json:"components"` + // TopPassthrough names components that ran but never saved a token — dead + // weight in the pipeline, candidates to drop from the config. + TopPassthrough []string `json:"top_passthrough"` +} + +// Snapshot returns a point-in-time copy of the rollups. +func (a *Aggregator) Snapshot() Snapshot { + a.mu.Lock() + defer a.mu.Unlock() + saved := a.before - a.after + var pct float64 + if a.before > 0 { + pct = float64(saved) / float64(a.before) * 100 + } + comps := make(map[string]compStat, len(a.perComp)) + var passthrough []string + for k, v := range a.perComp { + comps[k] = *v + // Dead weight = ran but never changed the request at all (always skipped + // or reverted). A component that mutated but saved no content tokens (e.g. + // cacheinject adds provider cache_control) is NOT passthrough. + if v.Runs > 0 && v.Mutated == 0 { + passthrough = append(passthrough, k) + } + } + sort.Strings(passthrough) + return Snapshot{ + Requests: a.requests, TokensBefore: a.before, TokensAfter: a.after, + SavedTokens: saved, SavingsPct: pct, + WastedTokens: a.wasted, Bounces: a.bounces, AdjustedSaved: saved - a.wasted, + Components: comps, TopPassthrough: passthrough, + } +} diff --git a/metrics/metrics_test.go b/metrics/metrics_test.go new file mode 100644 index 0000000..26b259f --- /dev/null +++ b/metrics/metrics_test.go @@ -0,0 +1,52 @@ +package metrics + +import ( + "testing" + + "github.com/kagenti/context-guru/components" +) + +func TestAggregatorHonestMetrics(t *testing.T) { + a := NewAggregator() + a.Component(components.Report{Component: "dedup", TokensBefore: 100, TokensAfter: 60}) // saved 40, acted + a.Component(components.Report{Component: "format", TokensBefore: 50, TokensAfter: 50, Skipped: true}) + a.Run(components.RunReport{TokensBefore: 100, TokensAfter: 60}) + a.RecordExpand(25) // 25 tokens had to be re-served + + s := a.Snapshot() + if s.SavedTokens != 40 { + t.Fatalf("saved=%d want 40", s.SavedTokens) + } + if s.WastedTokens != 25 || s.Bounces != 1 { + t.Fatalf("wasted=%d bounces=%d want 25/1", s.WastedTokens, s.Bounces) + } + if s.AdjustedSaved != 15 { + t.Fatalf("adjusted=%d want 15 (40-25)", s.AdjustedSaved) + } + // format never saved a token -> flagged; dedup did -> not flagged. + if len(s.TopPassthrough) != 1 || s.TopPassthrough[0] != "format" { + t.Fatalf("top_passthrough=%v want [format]", s.TopPassthrough) + } + if s.Components["dedup"].Acted != 1 { + t.Fatalf("dedup should have acted once, got %+v", s.Components["dedup"]) + } +} + +// TestMutatedZeroSavingsNotPassthrough locks the fix for cacheinject-style +// components: they change the request (add cache_control) but save no content +// tokens, so they must NOT be flagged as dead weight in top_passthrough. +func TestMutatedZeroSavingsNotPassthrough(t *testing.T) { + a := NewAggregator() + // cacheinject: ran, not skipped, not reverted, but 0 content tokens saved. + a.Component(components.Report{Component: "cacheinject", Kind: "reformat", TokensBefore: 100, TokensAfter: 100}) + // skeleton: always skipped this workload -> genuine dead weight. + a.Component(components.Report{Component: "skeleton", Kind: "offload", TokensBefore: 100, TokensAfter: 100, Skipped: true}) + + s := a.Snapshot() + if len(s.TopPassthrough) != 1 || s.TopPassthrough[0] != "skeleton" { + t.Fatalf("top_passthrough=%v want [skeleton] (cacheinject mutated, not dead weight)", s.TopPassthrough) + } + if s.Components["cacheinject"].Mutated != 1 { + t.Fatalf("cacheinject should record a mutation, got %+v", s.Components["cacheinject"]) + } +} diff --git a/observability/aggregator.go b/observability/aggregator.go deleted file mode 100644 index 5ed95db..0000000 --- a/observability/aggregator.go +++ /dev/null @@ -1,380 +0,0 @@ -package observability - -import ( - "context" - "encoding/json" - "fmt" - "io" - "sort" - "sync" -) - -// DefaultCostKey is the CostRates key used to price a model whose own rate is not -// listed. Lets a host configure one generic fallback (e.g. an average input rate) -// while still naming specific models. -const DefaultCostKey = "default" - -// CostRate is the per-million-token price for a model, split by direction. Only the -// input rate is used to estimate savings, since reduction removes input tokens. -type CostRate struct { - InputPerMTok float64 `json:"input_per_mtok"` - OutputPerMTok float64 `json:"output_per_mtok"` -} - -const ( - // maxLatencySamples bounds retained latency samples (per scope) so memory stays - // flat over a long-running process — most recent samples kept (ring buffer). - maxLatencySamples = 1024 - // maxRecentCalls bounds the retained per-call records served at /stats. - maxRecentCalls = 256 - // maxSessions bounds the per-session breakdown; the least-recently-seen session is - // evicted past this. - maxSessions = 512 -) - -// LatencyStats summarizes a set of added-latency samples (milliseconds). -type LatencyStats struct { - P50Millis int `json:"p50_ms"` - P95Millis int `json:"p95_ms"` - P99Millis int `json:"p99_ms"` - MaxMillis int `json:"max_ms"` - MeanMillis int `json:"mean_ms"` -} - -// latRing is a bounded ring of latency samples with quantile/aggregate helpers. -type latRing struct { - buf []int - next int - filled int -} - -func newLatRing(n int) latRing { return latRing{buf: make([]int, n)} } - -func (r *latRing) add(v int) { - r.buf[r.next] = v - r.next = (r.next + 1) % len(r.buf) - if r.filled < len(r.buf) { - r.filled++ - } -} - -func (r *latRing) stats() LatencyStats { - if r.filled == 0 { - return LatencyStats{} - } - xs := make([]int, r.filled) - copy(xs, r.buf[:r.filled]) - sort.Ints(xs) - sum := 0 - for _, x := range xs { - sum += x - } - q := func(p float64) int { - idx := int(p*float64(r.filled)+0.999999) - 1 - if idx < 0 { - idx = 0 - } - if idx >= r.filled { - idx = r.filled - 1 - } - return xs[idx] - } - return LatencyStats{ - P50Millis: q(0.50), P95Millis: q(0.95), P99Millis: q(0.99), - MaxMillis: xs[r.filled-1], MeanMillis: sum / r.filled, - } -} - -// sessionAgg accumulates one conversation's metrics. -type sessionAgg struct { - requests int64 - tokensBefore int64 - tokensAfter int64 - tokensSaved int64 - cacheInjected int64 - extracted int64 - stageErrors int64 - reducedTotal int64 - candidates int64 - costSavedUSD float64 - toolsTotal int - toolDefTokens int - lastModel string - lastSurface string - lastSeq int64 - lat latRing -} - -// Aggregator is an Emitter that accumulates Events into process-wide reduction stats, a -// per-session breakdown, and a recent-per-call ring — safe for concurrent use. A host -// installs it as the proxy Emitter and serves its Snapshot/WriteJSON on /stats. -type Aggregator struct { - rates map[string]CostRate - - mu sync.Mutex - seq int64 - requests int64 - tokensBefore int64 - tokensAfter int64 - tokensSaved int64 - cacheInjected int64 - extracted int64 - stageErrors int64 - reducedTotal int64 - candidates int64 - costSavedUSD float64 - lat latRing - - sessions map[string]*sessionAgg - recent []CallRecord // ring of recent per-call records - rNext int - rFilled int -} - -// NewAggregator returns an Aggregator pricing savings with rates (keyed by model, plus -// an optional DefaultCostKey fallback). A nil rates map disables cost estimation. -func NewAggregator(rates map[string]CostRate) *Aggregator { - return &Aggregator{ - rates: rates, - lat: newLatRing(maxLatencySamples), - sessions: make(map[string]*sessionAgg), - recent: make([]CallRecord, maxRecentCalls), - } -} - -// CallRecord is the full per-call metric set retained for /stats (recent_calls). -type CallRecord struct { - Seq int64 `json:"seq"` - System string `json:"system"` - RequestModel string `json:"request_model"` - Surface string `json:"surface"` - SessionID string `json:"session_id"` - TokensBefore int `json:"tokens_before"` - TokensAfter int `json:"tokens_after"` - TokensSaved int `json:"tokens_saved"` - Ratio float64 `json:"reduction_ratio"` - CacheInjected bool `json:"cache_injected"` - Extracted bool `json:"extracted"` - StageErrors int `json:"stage_errors"` - ToolsTotal int `json:"tools_total"` - ToolDefTokens int `json:"tool_def_tokens"` - ReducedCount int `json:"reduced_blocks"` - CandidatesCount int `json:"extract_candidates"` - FrozenCount int `json:"frozen_messages"` - Rehydrated int `json:"rehydrated"` - AtCompaction bool `json:"at_compaction"` - AddedLatencyMs int `json:"added_latency_ms"` -} - -// Emit folds one Event into the global totals, its session, and the recent-calls ring. -func (a *Aggregator) Emit(_ context.Context, e Event) { - a.mu.Lock() - defer a.mu.Unlock() - - a.seq++ - cost := float64(e.TokensSaved) / 1e6 * a.inputRate(e.RequestModel) - - a.requests++ - a.tokensBefore += int64(e.TokensBefore) - a.tokensAfter += int64(e.TokensAfter) - a.tokensSaved += int64(e.TokensSaved) - if e.CacheInject { - a.cacheInjected++ - } - if e.Extracted { - a.extracted++ - } - a.stageErrors += int64(e.StageErrors) - a.reducedTotal += int64(e.ReducedCount) - a.candidates += int64(e.CandidatesCount) - a.costSavedUSD += cost - a.lat.add(e.LatencyMillis) - - // Per-session. - if e.SessionID != "" { - s := a.sessions[e.SessionID] - if s == nil { - a.evictSessionsLocked() - s = &sessionAgg{lat: newLatRing(maxLatencySamples)} - a.sessions[e.SessionID] = s - } - s.requests++ - s.tokensBefore += int64(e.TokensBefore) - s.tokensAfter += int64(e.TokensAfter) - s.tokensSaved += int64(e.TokensSaved) - if e.CacheInject { - s.cacheInjected++ - } - if e.Extracted { - s.extracted++ - } - s.stageErrors += int64(e.StageErrors) - s.reducedTotal += int64(e.ReducedCount) - s.candidates += int64(e.CandidatesCount) - s.costSavedUSD += cost - s.toolsTotal = e.ToolsTotal - s.toolDefTokens = e.ToolDefTokens - s.lastModel = e.RequestModel - s.lastSurface = e.Surface - s.lastSeq = a.seq - s.lat.add(e.LatencyMillis) - } - - // Recent per-call ring. - a.recent[a.rNext] = CallRecord{ - Seq: a.seq, System: e.System, RequestModel: e.RequestModel, Surface: e.Surface, - SessionID: e.SessionID, TokensBefore: e.TokensBefore, TokensAfter: e.TokensAfter, - TokensSaved: e.TokensSaved, Ratio: e.Ratio, CacheInjected: e.CacheInject, - Extracted: e.Extracted, StageErrors: e.StageErrors, ToolsTotal: e.ToolsTotal, - ToolDefTokens: e.ToolDefTokens, ReducedCount: e.ReducedCount, - CandidatesCount: e.CandidatesCount, FrozenCount: e.FrozenCount, - Rehydrated: e.Rehydrated, AtCompaction: e.AtCompaction, AddedLatencyMs: e.LatencyMillis, - } - a.rNext = (a.rNext + 1) % maxRecentCalls - if a.rFilled < maxRecentCalls { - a.rFilled++ - } -} - -// evictSessionsLocked drops the least-recently-seen session when at capacity. -func (a *Aggregator) evictSessionsLocked() { - if len(a.sessions) < maxSessions { - return - } - var oldestID string - var oldestSeq int64 = 1<<63 - 1 - for id, s := range a.sessions { - if s.lastSeq < oldestSeq { - oldestSeq, oldestID = s.lastSeq, id - } - } - delete(a.sessions, oldestID) -} - -// inputRate resolves the input price for a model, falling back to DefaultCostKey, then 0. -func (a *Aggregator) inputRate(model string) float64 { - if a.rates == nil { - return 0 - } - if r, ok := a.rates[model]; ok { - return r.InputPerMTok - } - if r, ok := a.rates[DefaultCostKey]; ok { - return r.InputPerMTok - } - return 0 -} - -// SessionSnapshot is one conversation's aggregated metrics. -type SessionSnapshot struct { - SessionID string `json:"session_id"` - Requests int64 `json:"requests"` - TokensBefore int64 `json:"tokens_before"` - TokensAfter int64 `json:"tokens_after"` - TokensSaved int64 `json:"tokens_saved"` - Ratio float64 `json:"reduction_ratio"` - CacheInjected int64 `json:"cache_injected"` - Extracted int64 `json:"extracted"` - StageErrors int64 `json:"stage_errors"` - ReducedBlocks int64 `json:"reduced_blocks"` - Candidates int64 `json:"extract_candidates"` - CostSavedUSD float64 `json:"cost_saved_usd"` - ToolsTotal int `json:"tools_total"` - ToolDefTokens int `json:"tool_def_tokens"` - Model string `json:"model"` - Surface string `json:"surface"` - Latency LatencyStats `json:"latency"` -} - -// Snapshot is a point-in-time, JSON-serializable view of the aggregated stats: global -// totals, a per-session breakdown, and the most recent per-call records. -type Snapshot struct { - Requests int64 `json:"requests"` - TokensBefore int64 `json:"tokens_before"` - TokensAfter int64 `json:"tokens_after"` - TokensSaved int64 `json:"tokens_saved"` - Ratio float64 `json:"reduction_ratio"` // tokens_saved / tokens_before (0 = no savings) - CacheInjected int64 `json:"cache_injected"` - Extracted int64 `json:"extracted"` - StageErrors int64 `json:"stage_errors"` - ReducedBlocks int64 `json:"reduced_blocks"` - Candidates int64 `json:"extract_candidates"` - CostSavedUSD float64 `json:"cost_saved_usd"` - Sessions int `json:"sessions"` - - // Flat latency fields (kept for compatibility) + full distribution. - AddedLatencyP50Millis int `json:"added_latency_p50_ms"` - AddedLatencyP95Millis int `json:"added_latency_p95_ms"` - Latency LatencyStats `json:"latency"` - - SessionStats []SessionSnapshot `json:"session_stats,omitempty"` - RecentCalls []CallRecord `json:"recent_calls,omitempty"` -} - -// Snapshot returns the current totals plus per-session and recent-call detail. -func (a *Aggregator) Snapshot() Snapshot { - a.mu.Lock() - defer a.mu.Unlock() - - lat := a.lat.stats() - s := Snapshot{ - Requests: a.requests, TokensBefore: a.tokensBefore, TokensAfter: a.tokensAfter, - TokensSaved: a.tokensSaved, CacheInjected: a.cacheInjected, Extracted: a.extracted, - StageErrors: a.stageErrors, ReducedBlocks: a.reducedTotal, Candidates: a.candidates, - CostSavedUSD: a.costSavedUSD, Sessions: len(a.sessions), - AddedLatencyP50Millis: lat.P50Millis, AddedLatencyP95Millis: lat.P95Millis, - Latency: lat, - } - if a.tokensBefore > 0 { - s.Ratio = float64(a.tokensSaved) / float64(a.tokensBefore) - } - - for id, sa := range a.sessions { - ss := SessionSnapshot{ - SessionID: id, Requests: sa.requests, TokensBefore: sa.tokensBefore, - TokensAfter: sa.tokensAfter, TokensSaved: sa.tokensSaved, - CacheInjected: sa.cacheInjected, Extracted: sa.extracted, StageErrors: sa.stageErrors, - ReducedBlocks: sa.reducedTotal, Candidates: sa.candidates, CostSavedUSD: sa.costSavedUSD, - ToolsTotal: sa.toolsTotal, ToolDefTokens: sa.toolDefTokens, - Model: sa.lastModel, Surface: sa.lastSurface, Latency: sa.lat.stats(), - } - if sa.tokensBefore > 0 { - ss.Ratio = float64(sa.tokensSaved) / float64(sa.tokensBefore) - } - s.SessionStats = append(s.SessionStats, ss) - } - // Stable order: most recently active session first. - sort.Slice(s.SessionStats, func(i, j int) bool { - return a.sessions[s.SessionStats[i].SessionID].lastSeq > a.sessions[s.SessionStats[j].SessionID].lastSeq - }) - - // Recent calls in chronological order (oldest retained → newest). - for i := 0; i < a.rFilled; i++ { - idx := (a.rNext - a.rFilled + i + maxRecentCalls*2) % maxRecentCalls - s.RecentCalls = append(s.RecentCalls, a.recent[idx]) - } - return s -} - -// WriteJSON writes the current Snapshot as indented JSON. -func (a *Aggregator) WriteJSON(w io.Writer) error { - enc := json.NewEncoder(w) - enc.SetIndent("", " ") - return enc.Encode(a.Snapshot()) -} - -// Summary returns a one-line human-readable digest of the current stats. -func (a *Aggregator) Summary() string { - return SummaryOf(a.Snapshot()) -} - -// SummaryOf renders a Snapshot as a one-line human-readable digest. Useful for a -// client (e.g. `lab-cx stats`) that holds only a decoded Snapshot. -func SummaryOf(s Snapshot) string { - return fmt.Sprintf( - "requests=%d sessions=%d tokens %d→%d saved=%d (reduction=%.1f%%) cost_saved=$%.4f cache=%d extract=%d reduced=%d errors=%d latency p50=%dms p95=%dms p99=%dms max=%dms", - s.Requests, s.Sessions, s.TokensBefore, s.TokensAfter, s.TokensSaved, s.Ratio*100, - s.CostSavedUSD, s.CacheInjected, s.Extracted, s.ReducedBlocks, s.StageErrors, - s.AddedLatencyP50Millis, s.AddedLatencyP95Millis, s.Latency.P99Millis, s.Latency.MaxMillis, - ) -} diff --git a/observability/aggregator_test.go b/observability/aggregator_test.go deleted file mode 100644 index 7a6ce82..0000000 --- a/observability/aggregator_test.go +++ /dev/null @@ -1,148 +0,0 @@ -package observability - -import ( - "bytes" - "context" - "encoding/json" - "testing" -) - -func TestAggregatorSnapshot(t *testing.T) { - // A cost model with a known input rate for the model under test: $1 per MTok. - a := NewAggregator(map[string]CostRate{ - "claude-haiku-4-5": {InputPerMTok: 1.0, OutputPerMTok: 5.0}, - }) - - ctx := context.Background() - a.Emit(ctx, Event{ - RequestModel: "claude-haiku-4-5", - TokensBefore: 1000, TokensAfter: 600, TokensSaved: 400, Ratio: 0.6, - CacheInject: true, Extracted: true, StageErrors: 0, LatencyMillis: 10, - }) - a.Emit(ctx, Event{ - RequestModel: "claude-haiku-4-5", - TokensBefore: 2000, TokensAfter: 1400, TokensSaved: 600, Ratio: 0.7, - CacheInject: false, Extracted: true, StageErrors: 1, LatencyMillis: 30, - }) - a.Emit(ctx, Event{ - RequestModel: "claude-haiku-4-5", - TokensBefore: 1000, TokensAfter: 500, TokensSaved: 500, Ratio: 0.5, - CacheInject: true, Extracted: false, StageErrors: 0, LatencyMillis: 20, - }) - - s := a.Snapshot() - - if s.Requests != 3 { - t.Fatalf("Requests = %d, want 3", s.Requests) - } - if s.TokensBefore != 4000 { - t.Fatalf("TokensBefore = %d, want 4000", s.TokensBefore) - } - if s.TokensAfter != 2500 { - t.Fatalf("TokensAfter = %d, want 2500", s.TokensAfter) - } - if s.TokensSaved != 1500 { - t.Fatalf("TokensSaved = %d, want 1500", s.TokensSaved) - } - // Reduction ratio = saved/before = 1500/4000 = 0.375 (fraction of input removed). - if s.Ratio < 0.374 || s.Ratio > 0.376 { - t.Fatalf("Ratio = %v, want ~0.375 (saved/before)", s.Ratio) - } - if s.CacheInjected != 2 { - t.Fatalf("CacheInjected = %d, want 2", s.CacheInjected) - } - if s.Extracted != 2 { - t.Fatalf("Extracted = %d, want 2", s.Extracted) - } - if s.StageErrors != 1 { - t.Fatalf("StageErrors = %d, want 1", s.StageErrors) - } - // cost_saved = tokens_saved/1e6 * inputRate = 1500/1e6 * 1.0 = 0.0015 - if s.CostSavedUSD < 0.00149 || s.CostSavedUSD > 0.00151 { - t.Fatalf("CostSavedUSD = %v, want ~0.0015", s.CostSavedUSD) - } - // p50 of {10,20,30} is 20; p95 should be >= p50 and <= max. - if s.AddedLatencyP50Millis < 10 || s.AddedLatencyP50Millis > 30 { - t.Fatalf("AddedLatencyP50Millis = %d, want in [10,30]", s.AddedLatencyP50Millis) - } - if s.AddedLatencyP95Millis < s.AddedLatencyP50Millis { - t.Fatalf("p95 (%d) < p50 (%d)", s.AddedLatencyP95Millis, s.AddedLatencyP50Millis) - } -} - -func TestAggregatorWriteJSONAndSummary(t *testing.T) { - a := NewAggregator(nil) - a.Emit(context.Background(), Event{ - RequestModel: "x", TokensBefore: 100, TokensAfter: 60, TokensSaved: 40, Ratio: 0.6, LatencyMillis: 5, - }) - - var buf bytes.Buffer - if err := a.WriteJSON(&buf); err != nil { - t.Fatalf("WriteJSON: %v", err) - } - var s Snapshot - if err := json.Unmarshal(buf.Bytes(), &s); err != nil { - t.Fatalf("unmarshal: %v", err) - } - if s.TokensSaved != 40 { - t.Fatalf("round-trip TokensSaved = %d, want 40", s.TokensSaved) - } - if a.Summary() == "" { - t.Fatal("Summary() empty") - } -} - -// Snapshot uses the default cost rate when a model is unknown. -func TestAggregatorDefaultCostRate(t *testing.T) { - a := NewAggregator(map[string]CostRate{ - DefaultCostKey: {InputPerMTok: 2.0}, - }) - a.Emit(context.Background(), Event{ - RequestModel: "unknown-model", TokensBefore: 1_000_000, TokensAfter: 0, TokensSaved: 1_000_000, Ratio: 0, - }) - s := a.Snapshot() - // 1e6/1e6 * 2.0 = 2.0 - if s.CostSavedUSD < 1.99 || s.CostSavedUSD > 2.01 { - t.Fatalf("CostSavedUSD = %v, want ~2.0", s.CostSavedUSD) - } -} - -// TestAggregatorPerSessionAndRecentCalls verifies the per-session breakdown, the -// recent-call ring, and the richer latency distribution. -func TestAggregatorPerSessionAndRecentCalls(t *testing.T) { - a := NewAggregator(nil) - ctx := context.Background() - // Two sessions, one with two calls. - a.Emit(ctx, Event{SessionID: "s1", RequestModel: "m", Surface: "anthropic", - TokensBefore: 1000, TokensAfter: 600, TokensSaved: 400, ReducedCount: 2, - CandidatesCount: 1, ToolsTotal: 3, ToolDefTokens: 50, LatencyMillis: 10}) - a.Emit(ctx, Event{SessionID: "s1", RequestModel: "m", Surface: "anthropic", - TokensBefore: 500, TokensAfter: 400, TokensSaved: 100, ReducedCount: 1, LatencyMillis: 30}) - a.Emit(ctx, Event{SessionID: "s2", RequestModel: "m", Surface: "openai", - TokensBefore: 200, TokensAfter: 200, TokensSaved: 0, LatencyMillis: 50}) - - s := a.Snapshot() - if s.Requests != 3 || s.Sessions != 2 { - t.Fatalf("requests=%d sessions=%d, want 3/2", s.Requests, s.Sessions) - } - if len(s.RecentCalls) != 3 { - t.Fatalf("recent_calls=%d, want 3", len(s.RecentCalls)) - } - if len(s.SessionStats) != 2 { - t.Fatalf("session_stats=%d, want 2", len(s.SessionStats)) - } - // Find s1 and check its aggregate (2 calls, 500 saved, reduced 3). - var s1 *SessionSnapshot - for i := range s.SessionStats { - if s.SessionStats[i].SessionID == "s1" { - s1 = &s.SessionStats[i] - } - } - if s1 == nil || s1.Requests != 2 || s1.TokensSaved != 500 || s1.ReducedBlocks != 3 { - t.Fatalf("s1 aggregate wrong: %+v", s1) - } - // Latency distribution populated. - if s.Latency.MaxMillis != 50 || s.Latency.P50Millis == 0 { - t.Fatalf("global latency wrong: %+v", s.Latency) - } -} diff --git a/observability/observability.go b/observability/observability.go deleted file mode 100644 index 735dd4c..0000000 --- a/observability/observability.go +++ /dev/null @@ -1,99 +0,0 @@ -// Package observability emits per-request reduction telemetry in OpenTelemetry GenAI -// vocabulary (gen_ai.*) plus this component's savings attributes. The Emitter -// interface lets a host plug its own sink; the default emits structured logs via -// stdlib slog so the core stays dependency-free. -// -// ponytail: slog in gen_ai.* field names, not the OTel SDK — keeps zero deps. A real -// OTLP span exporter is a drop-in Emitter implementation (the named upgrade); Kagenti -// hosts typically already run a collector and can map these fields, or supply an -// Emitter that opens spans. -package observability - -import ( - "context" - "log/slog" -) - -// Event is one reduction's telemetry — every field a single call exposes. Field -// comments give the OTel attribute key the default emitter uses. The Aggregator folds -// these into process-wide totals, a per-session breakdown, and a recent-calls ring, -// all served at /stats. -type Event struct { - System string // gen_ai.system (e.g. "anthropic", "openai") - RequestModel string // gen_ai.request.model - Surface string // context_engineering.surface - SessionID string // context_engineering.session_id (stable per conversation) - - TokensBefore int // context_engineering.tokens.before - TokensAfter int // context_engineering.tokens.after - TokensSaved int // context_engineering.tokens.saved - Ratio float64 // context_engineering.tokens.ratio - - CacheInject bool // context_engineering.cache_injected - Extracted bool // context_engineering.extracted - StageErrors int // context_engineering.stage_errors - - // Richer per-call reduction detail (from the engine Report). - ToolsTotal int // context_engineering.tools.total (tool definitions in the request) - ToolDefTokens int // context_engineering.tools.def_tokens (their token cost) - ReducedCount int // context_engineering.reduced_blocks (blocks the deterministic pass reduced) - CandidatesCount int // context_engineering.extract_candidates (large outputs handed to the extractor) - FrozenCount int // context_engineering.frozen_messages (prefix left untouched) - Rehydrated int // context_engineering.rehydrated (markers restored on a compaction turn) - AtCompaction bool // context_engineering.at_compaction - - LatencyMillis int // context_engineering.added_latency_ms (time the reduce path added) -} - -// Emitter records reduction events. Implementations must be safe for concurrent use. -type Emitter interface { - Emit(ctx context.Context, e Event) -} - -// Nop discards events. -type Nop struct{} - -func (Nop) Emit(context.Context, Event) {} - -// Tee fans an event out to several Emitters in order (e.g. stream via slog AND -// accumulate in an Aggregator). nil entries are skipped. -type Tee []Emitter - -func (t Tee) Emit(ctx context.Context, e Event) { - for _, em := range t { - if em != nil { - em.Emit(ctx, e) - } - } -} - -// SlogEmitter logs events as structured records in gen_ai.* vocabulary. -type SlogEmitter struct{ Logger *slog.Logger } - -func (s SlogEmitter) Emit(ctx context.Context, e Event) { - l := s.Logger - if l == nil { - l = slog.Default() - } - l.LogAttrs(ctx, slog.LevelInfo, "context.reduction", - slog.String("gen_ai.system", e.System), - slog.String("gen_ai.request.model", e.RequestModel), - slog.String("context_engineering.surface", e.Surface), - slog.String("context_engineering.session_id", e.SessionID), - slog.Int("context_engineering.tokens.before", e.TokensBefore), - slog.Int("context_engineering.tokens.after", e.TokensAfter), - slog.Int("context_engineering.tokens.saved", e.TokensSaved), - slog.Float64("context_engineering.tokens.ratio", e.Ratio), - slog.Bool("context_engineering.cache_injected", e.CacheInject), - slog.Bool("context_engineering.extracted", e.Extracted), - slog.Int("context_engineering.stage_errors", e.StageErrors), - slog.Int("context_engineering.tools.total", e.ToolsTotal), - slog.Int("context_engineering.tools.def_tokens", e.ToolDefTokens), - slog.Int("context_engineering.reduced_blocks", e.ReducedCount), - slog.Int("context_engineering.extract_candidates", e.CandidatesCount), - slog.Int("context_engineering.frozen_messages", e.FrozenCount), - slog.Int("context_engineering.rehydrated", e.Rehydrated), - slog.Bool("context_engineering.at_compaction", e.AtCompaction), - slog.Int("context_engineering.added_latency_ms", e.LatencyMillis), - ) -} diff --git a/proxy/proxy.go b/proxy/proxy.go new file mode 100644 index 0000000..f77d9e9 --- /dev/null +++ b/proxy/proxy.go @@ -0,0 +1,271 @@ +// Package proxy is the context-guru HTTP proxy: it runs the component pipeline +// on inbound chat requests, then forwards them to the configured upstream +// provider. It is the eval-containers gateway (exposes /openai + /anthropic on +// one port) and the standalone LLM-proxy integration. +// +// It reuses bifrost's ChatMessage type but not its transport: the transport +// can't inject an in-process Go plugin, so we drive the request path directly. +// Message rewriting is byte-lossless (headroom invariant I1) — only the +// `messages` array is re-serialized; every other field of the original body is +// preserved verbatim via sjson. +package proxy + +import ( + "encoding/json" + "errors" + "io" + "net/http" + "strings" + "time" + + "github.com/kagenti/context-guru/apply" + "github.com/kagenti/context-guru/components" + "github.com/kagenti/context-guru/expand" + "github.com/kagenti/context-guru/metrics" + "github.com/kagenti/context-guru/schema" + "github.com/kagenti/context-guru/store" + bschemas "github.com/maximhq/bifrost/core/schemas" + "github.com/tidwall/sjson" +) + +// Options configures upstreams and credential injection. Each upstream is a base +// URL the matching route forwards to (the incoming path is appended). When a key +// is set it replaces the client's auth on forward — this is the eval-containers +// gateway model, where the agent holds only a placeholder key and the real +// provider key lives in the gateway env. Leave keys empty to pass the incoming +// auth through unchanged (local/dev use). +type Options struct { + OpenAIUpstream string // e.g. https://api.openai.com + AnthropicUpstream string + OpenAIKey string // injected as Authorization: Bearer + AnthropicKey string // injected as x-api-key: + // ForceModel, when set, overwrites the request's "model" field. eval-containers + // uses this to pin every call to EVAL_MODEL regardless of what the agent asked for. + ForceModel string + Client *http.Client +} + +// upstream binds a provider to its base URL, the canonical provider path to POST +// to (decoupled from the gateway's incoming /openai|/anthropic namespace), and a +// credential injector. +type upstream struct { + base string + path string + setKey func(http.Header) +} + +// Handler serves the proxy + management routes. +type Handler struct { + pipe *components.Pipeline + store store.Store + agg *metrics.Aggregator + opts Options + client *http.Client +} + +// New builds the proxy handler. agg may be nil (no /stats rollups). +func New(pipe *components.Pipeline, st store.Store, agg *metrics.Aggregator, opts Options) *Handler { + c := opts.Client + if c == nil { + c = &http.Client{Timeout: 5 * time.Minute} + } + return &Handler{pipe: pipe, store: st, agg: agg, opts: opts, client: c} +} + +// Mux wires the routes: chat proxying + health/stats/expand management. +func (h *Handler) Mux() *http.ServeMux { + m := http.NewServeMux() + m.HandleFunc("POST /openai/v1/chat/completions", h.chat(bschemas.OpenAI, upstream{ + base: h.opts.OpenAIUpstream, + path: "/v1/chat/completions", + setKey: bearerKey(h.opts.OpenAIKey), + })) + m.HandleFunc("POST /anthropic/v1/messages", h.chat(bschemas.Anthropic, upstream{ + base: h.opts.AnthropicUpstream, + path: "/v1/messages", + setKey: headerKey("x-api-key", h.opts.AnthropicKey), + })) + m.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) { w.Write([]byte("ok")) }) + m.HandleFunc("GET /stats", h.stats) + m.HandleFunc("GET /expand", h.expand) + return m +} + +func bearerKey(key string) func(http.Header) { + if key == "" { + return nil + } + return func(h http.Header) { h.Set("Authorization", "Bearer "+key) } +} + +func headerKey(name, key string) func(http.Header) { + if key == "" { + return nil + } + return func(h http.Header) { h.Set(name, key) } +} + +func (h *Handler) chat(provider bschemas.ModelProvider, up upstream) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, "read body", http.StatusBadRequest) + return + } + // Pin the model if configured (eval-containers EVAL_MODEL). + if h.opts.ForceModel != "" { + if out, err := sjson.SetBytes(body, "model", h.opts.ForceModel); err == nil { + body = out + } + } + // Rewrite the messages via the shared apply path; fail open. + body, _ = apply.Body( + r.Context(), h.pipe, h.store, provider, body, + r.Header.Get("x-context-guru-session"), + strings.EqualFold(r.Header.Get("x-context-guru-bypass"), "true"), + ) + h.serve(w, r, provider, up, body) + } +} + +// maxExpandRounds caps the expand continuation loop (headroom's default). +const maxExpandRounds = 3 + +// TODO(review): expand.ToolDef is never injected into the outgoing request's +// tools array, so the model is only told to "call context_guru_expand" by the +// marker text but the tool is not actually advertised. The continuation loop +// below therefore fires only if the client itself declared the tool. Wiring +// ToolDef in requires per-provider tools-array injection kept byte-stable across +// turns (sticky) to avoid busting the provider prefix cache — a feature, not a +// minimal fix, so it is left for a human decision. + +var errNoUpstream = errors.New("no upstream configured") + +// serve forwards the request and runs the expand continuation loop: if the model +// calls the expand tool (and only that tool), resolve the originals from the +// store, append the tool-result turn, and re-invoke upstream — up to a few +// rounds. Streaming responses skip the loop and pass straight through. +func (h *Handler) serve(w http.ResponseWriter, r *http.Request, provider bschemas.ModelProvider, up upstream, body []byte) { + for round := 0; ; round++ { + resp, err := h.doUpstream(r, up, body) + if err != nil { + http.Error(w, "upstream: "+err.Error(), http.StatusBadGateway) + return + } + if round >= maxExpandRounds || strings.Contains(resp.Header.Get("Content-Type"), "event-stream") { + h.stream(w, resp) + return + } + respBody, _ := io.ReadAll(resp.Body) + resp.Body.Close() + + calls, otherTools := expand.ResponseCalls(string(provider), respBody) + if len(calls) == 0 || otherTools { + writeBuffered(w, resp, respBody) + return + } + // Build a tool_result for EVERY expand call — the provider requires one per + // tool_call_id or the continuation is malformed. Expired/unknown ids get an + // explicit placeholder rather than being omitted. + resolved := map[string]string{} + got := 0 + for _, c := range calls { + if orig, ok := expand.Resolve(h.store, c.HashID); ok { + resolved[c.CallID] = orig + got++ + if h.agg != nil { + h.agg.RecordExpand(schema.TextTokens(orig)) // bounce: offload had to come back + } + } else { + resolved[c.CallID] = "[expand: original for id " + c.HashID + " is no longer available]" + } + } + next, ok := expand.Continuation(string(provider), body, respBody, resolved) + if got == 0 || !ok { + writeBuffered(w, resp, respBody) // nothing recovered; return the model's own call + return + } + body = next // loop: re-invoke with the originals in hand + } +} + +func (h *Handler) doUpstream(r *http.Request, up upstream, body []byte) (*http.Response, error) { + if up.base == "" { + return nil, errNoUpstream + } + req, err := http.NewRequestWithContext(r.Context(), http.MethodPost, up.base+up.path, strings.NewReader(string(body))) + if err != nil { + return nil, err + } + copyHeaders(req.Header, r.Header) + if up.setKey != nil { + // Gateway mode: drop the client's placeholder auth, inject the real key. + req.Header.Del("Authorization") + req.Header.Del("x-api-key") + up.setKey(req.Header) + } + return h.client.Do(req) +} + +// stream copies an upstream response through with flushing (SSE-friendly). +func (h *Handler) stream(w http.ResponseWriter, resp *http.Response) { + defer resp.Body.Close() + copyHeaders(w.Header(), resp.Header) + w.WriteHeader(resp.StatusCode) + flush, _ := w.(http.Flusher) + buf := make([]byte, 16*1024) + for { + n, rerr := resp.Body.Read(buf) + if n > 0 { + w.Write(buf[:n]) + if flush != nil { + flush.Flush() + } + } + if rerr != nil { + break + } + } +} + +func writeBuffered(w http.ResponseWriter, resp *http.Response, body []byte) { + copyHeaders(w.Header(), resp.Header) + w.WriteHeader(resp.StatusCode) + w.Write(body) +} + +func (h *Handler) stats(w http.ResponseWriter, _ *http.Request) { + if h.agg == nil { + w.Write([]byte("{}")) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(h.agg.Snapshot()) +} + +// expand resolves a stashed original by id — the HTTP side of reversibility (the +// model-callable tool loop is a separate concern, added with response handling). +func (h *Handler) expand(w http.ResponseWriter, r *http.Request) { + id := r.URL.Query().Get("id") + orig, ok := expand.Resolve(h.store, id) + if !ok { + http.Error(w, "expired or unknown id", http.StatusNotFound) + return + } + w.Write([]byte(orig)) +} + +// copyHeaders copies headers except hop-by-hop ones; the caller's provider auth +// (Authorization / x-api-key) passes straight through to the upstream. +func copyHeaders(dst, src http.Header) { + for k, vs := range src { + switch http.CanonicalHeaderKey(k) { + case "Connection", "Keep-Alive", "Transfer-Encoding", "Content-Length", "Host": + continue + } + if strings.HasPrefix(strings.ToLower(k), "x-context-guru-") { + continue + } + dst[k] = append([]string(nil), vs...) + } +} diff --git a/proxy/proxy_test.go b/proxy/proxy_test.go new file mode 100644 index 0000000..8f2f354 --- /dev/null +++ b/proxy/proxy_test.go @@ -0,0 +1,313 @@ +package proxy_test + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + _ "github.com/kagenti/context-guru/components/all" + "github.com/kagenti/context-guru/config" + "github.com/kagenti/context-guru/metrics" + "github.com/kagenti/context-guru/proxy" + "github.com/kagenti/context-guru/store" + "github.com/tidwall/gjson" +) + +// buildHandler wires a real config->pipeline->proxy against a mock upstream that +// records the body it receives. +func buildHandler(t *testing.T, yaml string, upstream string) (*proxy.Handler, store.Store) { + t.Helper() + cfg, err := config.LoadBytes([]byte(yaml)) + if err != nil { + t.Fatal(err) + } + agg := metrics.NewAggregator() + pipe, err := cfg.Build(agg) + if err != nil { + t.Fatal(err) + } + st := store.NewMemory(store.Options{}) + return proxy.New(pipe, st, agg, proxy.Options{OpenAIUpstream: upstream, AnthropicUpstream: upstream}), st +} + +func openAIBody(msgs ...map[string]any) []byte { + b, _ := json.Marshal(map[string]any{"model": "gpt-x", "temperature": 0.2, "messages": msgs}) + return b +} + +func TestProxyReducesThenForwards(t *testing.T) { + var got []byte + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + got, _ = io.ReadAll(r.Body) + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"ok":true}`)) + })) + defer upstream.Close() + + h, _ := buildHandler(t, "pipeline: [dedup]\n", upstream.URL) + srv := httptest.NewServer(h.Mux()) + defer srv.Close() + + dump := strings.Repeat("a verbose repeated tool output line\n", 60) + body := openAIBody( + map[string]any{"role": "user", "content": "do the thing"}, + map[string]any{"role": "tool", "tool_call_id": "a", "content": dump}, + map[string]any{"role": "tool", "tool_call_id": "b", "content": dump}, + ) + resp, err := http.Post(srv.URL+"/openai/v1/chat/completions", "application/json", strings.NewReader(string(body))) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + + // Upstream must have received a SMALLER messages array (dedup collapsed the dup), + // while non-message fields (model, temperature) survive verbatim (I1). + if len(got) == 0 { + t.Fatal("upstream received nothing") + } + if gjson.GetBytes(got, "model").String() != "gpt-x" || gjson.GetBytes(got, "temperature").Float() != 0.2 { + t.Fatalf("non-message fields not preserved: %s", got) + } + third := gjson.GetBytes(got, "messages.2.content").String() + if !strings.Contains(third, "identical to an earlier") { + t.Fatalf("dedup did not run through the proxy: %q", third) + } + if len(got) >= len(body) { + t.Fatalf("proxy did not shrink the request (before=%d after=%d)", len(body), len(got)) + } +} + +// TestAnthropicRouteReducesToolResult drives the real /anthropic/v1/messages +// gateway route with a Claude-Code-shaped body (tool outputs as tool_result +// blocks in user messages) and asserts the offloader fires end-to-end. +func TestAnthropicRouteReducesToolResult(t *testing.T) { + var got []byte + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + got, _ = io.ReadAll(r.Body) + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"type":"message","content":[{"type":"text","text":"ok"}]}`)) + })) + defer upstream.Close() + + h, _ := buildHandler(t, "pipeline: [dedup]\ncomponents:\n dedup: {min_tokens: 20}\n", upstream.URL) + srv := httptest.NewServer(h.Mux()) + defer srv.Close() + + dump := strings.Repeat("verbose repeated anthropic tool output line\n", 40) + body, _ := json.Marshal(map[string]any{ + "model": "claude-sonnet-4-6", + "messages": []any{ + map[string]any{"role": "user", "content": "do it"}, + map[string]any{"role": "user", "content": []any{ + map[string]any{"type": "tool_result", "tool_use_id": "t1", "content": dump}, + }}, + map[string]any{"role": "user", "content": []any{ + map[string]any{"type": "tool_result", "tool_use_id": "t2", "content": dump}, + }}, + }, + }) + resp, err := http.Post(srv.URL+"/anthropic/v1/messages", "application/json", strings.NewReader(string(body))) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + + if len(got) == 0 { + t.Fatal("upstream received nothing") + } + if gjson.GetBytes(got, "model").String() != "claude-sonnet-4-6" { + t.Fatalf("model not preserved: %s", got) + } + if !strings.Contains(gjson.GetBytes(got, "messages.2.content.0.content").String(), "identical to an earlier") { + t.Fatalf("dedup did not run on the anthropic tool_result via the proxy: %s", got) + } + if len(got) >= len(body) { + t.Fatalf("proxy did not shrink the anthropic request (before=%d after=%d)", len(body), len(got)) + } +} + +func TestBypassHeaderForwardsUnchanged(t *testing.T) { + var got []byte + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + got, _ = io.ReadAll(r.Body) + w.Write([]byte(`{}`)) + })) + defer upstream.Close() + h, _ := buildHandler(t, "pipeline: [dedup]\n", upstream.URL) + srv := httptest.NewServer(h.Mux()) + defer srv.Close() + + dump := strings.Repeat("repeated line\n", 60) + body := openAIBody( + map[string]any{"role": "tool", "content": dump}, + map[string]any{"role": "tool", "content": dump}, + ) + req, _ := http.NewRequest("POST", srv.URL+"/openai/v1/chat/completions", strings.NewReader(string(body))) + req.Header.Set("x-context-guru-bypass", "true") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if gjson.GetBytes(got, "messages.1.content").String() != dump { + t.Fatal("bypass should forward messages unchanged") + } +} + +func TestGatewayInjectsRealKey(t *testing.T) { + var gotAuth, gotXAPI string + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAuth = r.Header.Get("Authorization") + gotXAPI = r.Header.Get("x-api-key") + w.Write([]byte(`{}`)) + })) + defer upstream.Close() + + cfg, _ := config.LoadBytes([]byte("pipeline: []\n")) + pipe, _ := cfg.Build(nil) + h := proxy.New(pipe, store.NewMemory(store.Options{}), nil, proxy.Options{ + OpenAIUpstream: upstream.URL, OpenAIKey: "real-openai-key", + }) + srv := httptest.NewServer(h.Mux()) + defer srv.Close() + + body := openAIBody(map[string]any{"role": "user", "content": "hi"}) + req, _ := http.NewRequest("POST", srv.URL+"/openai/v1/chat/completions", strings.NewReader(string(body))) + req.Header.Set("Authorization", "Bearer sk-proxy") // placeholder from the agent + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if gotAuth != "Bearer real-openai-key" { + t.Fatalf("gateway should inject the real key, upstream saw %q", gotAuth) + } + _ = gotXAPI +} + +func TestExpandToolLoop(t *testing.T) { + var calls int + var secondBody []byte + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + b, _ := io.ReadAll(r.Body) + calls++ + w.Header().Set("Content-Type", "application/json") + if calls == 1 { + // model asks to expand the offloaded content + w.Write([]byte(`{"choices":[{"message":{"role":"assistant","tool_calls":[` + + `{"id":"call_1","type":"function","function":{"name":"context_guru_expand","arguments":"{\"id\":\"HASH\"}"}}` + + `]},"finish_reason":"tool_calls"}]}`)) + return + } + secondBody = b + w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":"done"}}]}`)) + })) + defer upstream.Close() + + h, st := buildHandler(t, "pipeline: []\n", upstream.URL) + st.Put("HASH", []byte("THE ORIGINAL CONTENT")) // as if a prior turn offloaded it + srv := httptest.NewServer(h.Mux()) + defer srv.Close() + + body := openAIBody(map[string]any{"role": "user", "content": "go"}) + resp, err := http.Post(srv.URL+"/openai/v1/chat/completions", "application/json", strings.NewReader(string(body))) + if err != nil { + t.Fatal(err) + } + final, _ := io.ReadAll(resp.Body) + resp.Body.Close() + + if calls != 2 { + t.Fatalf("expected 2 upstream calls (initial + continuation), got %d", calls) + } + if !strings.Contains(string(final), "done") { + t.Fatalf("proxy should return the final answer, got %s", final) + } + if !strings.Contains(string(secondBody), "THE ORIGINAL CONTENT") { + t.Fatalf("continuation must carry the resolved original, got %s", secondBody) + } + if gjson.GetBytes(secondBody, "messages.#").Int() != 3 { + t.Fatalf("continuation should append assistant + tool turns: %s", secondBody) + } +} + +// TestExpandPartialResolutionWellFormed guards the malformed-continuation bug: +// the model makes TWO expand calls but only one id resolves. The continuation +// must still carry a tool_result for BOTH call ids (the missing one gets a +// placeholder) or the provider rejects the request. +func TestExpandPartialResolutionWellFormed(t *testing.T) { + var calls int + var secondBody []byte + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + b, _ := io.ReadAll(r.Body) + calls++ + w.Header().Set("Content-Type", "application/json") + if calls == 1 { + w.Write([]byte(`{"choices":[{"message":{"role":"assistant","tool_calls":[` + + `{"id":"call_1","type":"function","function":{"name":"context_guru_expand","arguments":"{\"id\":\"GOOD\"}"}},` + + `{"id":"call_2","type":"function","function":{"name":"context_guru_expand","arguments":"{\"id\":\"GONE\"}"}}` + + `]},"finish_reason":"tool_calls"}]}`)) + return + } + secondBody = b + w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":"ok"}}]}`)) + })) + defer upstream.Close() + + h, st := buildHandler(t, "pipeline: []\n", upstream.URL) + st.Put("GOOD", []byte("RESOLVED ORIGINAL")) // only one of the two resolves + srv := httptest.NewServer(h.Mux()) + defer srv.Close() + + body := openAIBody(map[string]any{"role": "user", "content": "go"}) + resp, err := http.Post(srv.URL+"/openai/v1/chat/completions", "application/json", strings.NewReader(string(body))) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + + if calls != 2 { + t.Fatalf("expected a continuation round, got %d upstream calls", calls) + } + // One tool message per tool_call_id (both call_1 and call_2), or the provider errors. + var toolCalls int + gjson.GetBytes(secondBody, "messages").ForEach(func(_, m gjson.Result) bool { + if m.Get("role").String() == "tool" { + toolCalls++ + } + return true + }) + if toolCalls != 2 { + t.Fatalf("continuation must have a tool result per call id, got %d: %s", toolCalls, secondBody) + } + if !strings.Contains(string(secondBody), "RESOLVED ORIGINAL") || !strings.Contains(string(secondBody), "no longer available") { + t.Fatalf("continuation should carry the resolved original and a placeholder for the expired id: %s", secondBody) + } +} + +func TestExpandRoundTrip(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.Write([]byte(`{}`)) })) + defer upstream.Close() + h, _ := buildHandler(t, "pipeline: [collapse]\ncomponents:\n collapse: {max_tokens: 20, head_lines: 2, tail_lines: 2}\n", upstream.URL) + srv := httptest.NewServer(h.Mux()) + defer srv.Close() + + var b strings.Builder + for i := 0; i < 40; i++ { + b.WriteString("log content line with enough words to matter here\n") + } + body := openAIBody(map[string]any{"role": "tool", "content": b.String()}) + http.Post(srv.URL+"/openai/v1/chat/completions", "application/json", strings.NewReader(string(body))) + + // The collapsed message carries an expand marker; the id must resolve via /expand. + stats, _ := http.Get(srv.URL + "/stats") + var snap metrics.Snapshot + json.NewDecoder(stats.Body).Decode(&snap) + stats.Body.Close() + if snap.Requests != 1 || snap.SavedTokens <= 0 { + t.Fatalf("stats not recorded: %+v", snap) + } +} diff --git a/schema/schema.go b/schema/schema.go new file mode 100644 index 0000000..2b9abd3 --- /dev/null +++ b/schema/schema.go @@ -0,0 +1,116 @@ +// Package schema wraps bifrost's provider-agnostic chat schema with the helpers +// context-guru components need: token accounting, deep-clone for fail-open +// snapshots, tool-result iteration, and byte-preservation for lossless +// round-trips of provider-specific fields. +// +// Components operate directly on *schemas.BifrostChatRequest (see package +// components); this package is the small toolbox around that type, not a +// competing model. Wire<->schema conversion lives in the host adapters — the +// bifrost proxy gets it for free from bifrost's transport, the AuthBridge +// plugin uses FromOpenAIBytes/FromAnthropicBytes (added in P3). +package schema + +import ( + "encoding/json" + + "github.com/kagenti/context-guru/internal/tokens" + "github.com/maximhq/bifrost/core/schemas" +) + +// Provider identifies the wire dialect a request arrived in. It drives +// provider-specific behaviour (e.g. cache_control is Anthropic-family) and how +// the adapter renders bytes back out. +type Provider = schemas.ModelProvider + +// MessagesTokens estimates the token cost of a request's messages by counting +// the message CONTENT text — what the model actually reads — not the JSON +// envelope. This is the signal the never-worse gate needs: control metadata +// like cache_control adds envelope bytes but no model-visible tokens, so a +// cache-injection Reformat must not look "worse" for adding it. +func MessagesTokens(req *schemas.BifrostChatRequest) int { + if req == nil { + return 0 + } + n := 0 + for _, m := range req.Input { + n += tokens.Count(MessageText(m)) + } + return n +} + +// TextTokens counts tokens in a raw string via the shared tokenizer. +func TextTokens(s string) int { return tokens.Count(s) } + +// CloneMessages deep-copies a message slice via JSON round-trip. Used to +// snapshot a request before a component runs so the pipeline can restore it on +// error/panic or when a component fails the never-worse gate. JSON round-trip +// is safe because bifrost's content types implement custom (Un)MarshalJSON that +// preserve cache_control/citations/cachePoint. +func CloneMessages(in []schemas.ChatMessage) []schemas.ChatMessage { + if in == nil { + return nil + } + b, err := json.Marshal(in) + if err != nil { + return in + } + var out []schemas.ChatMessage + if err := json.Unmarshal(b, &out); err != nil { + return in + } + return out +} + +// BlockText returns the text payload of a content block, or "" if the block +// carries no text (image/audio/file/refusal). +func BlockText(b schemas.ChatContentBlock) string { + if b.Text != nil { + return *b.Text + } + return "" +} + +// MessageText returns the concatenated text of a message, handling both the +// string-content and block-content representations. +func MessageText(m schemas.ChatMessage) string { + if m.Content == nil { + return "" + } + if m.Content.ContentStr != nil { + return *m.Content.ContentStr + } + var s string + for _, blk := range m.Content.ContentBlocks { + s += BlockText(blk) + } + return s +} + +// SetMessageText replaces a message's content with a single text string, +// collapsing any block structure. Components that rewrite a whole message's +// text (e.g. cmdfilter, offload markers) use this. +func SetMessageText(m *schemas.ChatMessage, text string) { + m.Content = &schemas.ChatMessageContent{ContentStr: &text} +} + +// Rewritable reports whether m's content can be safely replaced with a plain +// text string (via SetMessageText) without losing data. It is false when the +// message carries any non-text content block — image/audio/file/refusal, or a +// block type bifrost does not model (e.g. Anthropic tool_result, whose payload +// lives in fields MessageText never reads). Those bytes would vanish on a +// text-only rewrite and were never stashed, so components must skip such +// messages. String content and all-text block content are rewritable. +func Rewritable(m schemas.ChatMessage) bool { + if m.Content == nil || m.Content.ContentStr != nil { + return true + } + for _, b := range m.Content.ContentBlocks { + switch b.Type { + case schemas.ChatContentBlockTypeText, "": + // text (or an untyped block we treat as text) is safe + default: + return false + } + } + return true +} diff --git a/schema/schema_test.go b/schema/schema_test.go new file mode 100644 index 0000000..ba60488 --- /dev/null +++ b/schema/schema_test.go @@ -0,0 +1,86 @@ +package schema + +import ( + "testing" + + bschemas "github.com/maximhq/bifrost/core/schemas" +) + +func strp(s string) *string { return &s } + +func TestRewritable(t *testing.T) { + text := "hello" + cases := []struct { + name string + msg bschemas.ChatMessage + want bool + }{ + {"nil content", bschemas.ChatMessage{Role: bschemas.ChatMessageRoleTool}, true}, + {"string content", bschemas.ChatMessage{Content: &bschemas.ChatMessageContent{ContentStr: strp("x")}}, true}, + {"all text blocks", bschemas.ChatMessage{Content: &bschemas.ChatMessageContent{ContentBlocks: []bschemas.ChatContentBlock{ + {Type: bschemas.ChatContentBlockTypeText, Text: &text}, + }}}, true}, + {"has image block", bschemas.ChatMessage{Content: &bschemas.ChatMessageContent{ContentBlocks: []bschemas.ChatContentBlock{ + {Type: bschemas.ChatContentBlockTypeText, Text: &text}, + {Type: bschemas.ChatContentBlockTypeImage}, + }}}, false}, + {"unmodeled tool_result block", bschemas.ChatMessage{Content: &bschemas.ChatMessageContent{ContentBlocks: []bschemas.ChatContentBlock{ + {Type: bschemas.ChatContentBlockType("tool_result")}, + }}}, false}, + } + for _, c := range cases { + if got := Rewritable(c.msg); got != c.want { + t.Errorf("%s: Rewritable=%v want %v", c.name, got, c.want) + } + } +} + +func TestMessagesTokens(t *testing.T) { + if MessagesTokens(nil) != 0 { + t.Fatal("nil request => 0 tokens") + } + req := &bschemas.BifrostChatRequest{Input: []bschemas.ChatMessage{ + {Content: &bschemas.ChatMessageContent{ContentStr: strp("hello world this is some text")}}, + }} + if MessagesTokens(req) <= 0 { + t.Fatal("expected a positive token count for non-empty content") + } + if TextTokens("") != 0 { + t.Fatal("empty string => 0 tokens") + } +} + +func TestCloneMessagesIndependent(t *testing.T) { + orig := []bschemas.ChatMessage{{ + Role: bschemas.ChatMessageRoleTool, + Content: &bschemas.ChatMessageContent{ContentStr: strp("x")}, + }} + clone := CloneMessages(orig) + SetMessageText(&clone[0], "mutated") + if MessageText(orig[0]) != "x" { + t.Fatal("clone must be independent of the original") + } + if CloneMessages(nil) != nil { + t.Fatal("nil in => nil out") + } +} + +func TestBlockTextNonText(t *testing.T) { + if BlockText(bschemas.ChatContentBlock{Type: bschemas.ChatContentBlockTypeImage}) != "" { + t.Fatal("a non-text (image) block has no text") + } + if MessageText(bschemas.ChatMessage{}) != "" { + t.Fatal("nil content => empty text") + } +} + +func TestMessageTextBlocks(t *testing.T) { + a, b := "foo", "bar" + m := bschemas.ChatMessage{Content: &bschemas.ChatMessageContent{ContentBlocks: []bschemas.ChatContentBlock{ + {Type: bschemas.ChatContentBlockTypeText, Text: &a}, + {Type: bschemas.ChatContentBlockTypeText, Text: &b}, + }}} + if got := MessageText(m); got != "foobar" { + t.Fatalf("MessageText=%q want foobar", got) + } +} diff --git a/session/session.go b/session/session.go new file mode 100644 index 0000000..805f754 --- /dev/null +++ b/session/session.go @@ -0,0 +1,25 @@ +// Package session resolves the conversation key that state is scoped to. +// +// Per the design (D4): each host supplies an explicit id when it has one +// (bifrost proxy: x-context-guru-session header or Anthropic metadata.user_id; +// AuthBridge: pctx.Session A2A id; eval-containers: gateway-stamped). When none +// is available we fall back to a deterministic content hash of the system +// prompt + first user message, which needs no host cooperation. +package session + +import ( + "crypto/sha256" + "encoding/hex" + "strings" +) + +// Resolve returns the session key. If explicit is non-empty it wins; otherwise +// a stable hash of (system, firstUser) is used. The fallback matches winnow/lab +// behaviour so two turns of the same conversation land on the same key. +func Resolve(explicit, system, firstUser string) string { + if s := strings.TrimSpace(explicit); s != "" { + return s + } + h := sha256.Sum256([]byte(system + "\x00" + firstUser)) + return hex.EncodeToString(h[:])[:16] +} diff --git a/session/session_test.go b/session/session_test.go new file mode 100644 index 0000000..ec9cb2e --- /dev/null +++ b/session/session_test.go @@ -0,0 +1,24 @@ +package session + +import "testing" + +func TestResolveExplicitWins(t *testing.T) { + if got := Resolve(" explicit-id ", "sys", "user"); got != "explicit-id" { + t.Fatalf("a non-empty explicit id should win (trimmed), got %q", got) + } +} + +func TestResolveHashStableAndScoped(t *testing.T) { + a := Resolve("", "sys", "u1") + b := Resolve("", "sys", "u1") + c := Resolve("", "sys", "u2") + if a != b { + t.Fatal("identical (system, firstUser) must hash to the same session") + } + if a == c { + t.Fatal("a different first user must produce a different session") + } + if len(a) != 16 { + t.Fatalf("session hash should be 16 hex chars, got %q", a) + } +} diff --git a/store/store.go b/store/store.go new file mode 100644 index 0000000..19e93de --- /dev/null +++ b/store/store.go @@ -0,0 +1,156 @@ +// Package store holds context-guru's cross-call state behind one interface so +// both hosts (bifrost proxy, AuthBridge plugin) share it. v1 ships an in-memory +// TTL+LRU backend; SQLite/Redis slot in behind the same interface when a +// durable or multi-replica deployment is real (see the design doc, D5). +// +// It carries three things keyed by session: +// - Rewind: cache_key -> original bytes, so Offload components are reversible +// (the expand(id) tool loop resolves originals from here). +// - Sticky: the set of content ids already reduced on prior turns, so a +// component can keep its output byte-stable across turns (cache stability). +// - per-session token/metric rollups (added with metrics in P0/P5). +package store + +import ( + "container/list" + "sync" + "time" +) + +// Store is the interface components and adapters depend on. Implementations +// must be safe for concurrent use — one instance serves all requests. +type Store interface { + // Put stashes an original payload under key with the store's default TTL. + Put(key string, payload []byte) + // Get returns a stashed payload; ok=false if absent or expired. + Get(key string) (payload []byte, ok bool) + // Sticky returns the per-session set of already-reduced content ids. + Sticky(session string) map[string]struct{} + // MarkSticky records that id was reduced in this session. + MarkSticky(session, id string) +} + +type entry struct { + key string + payload []byte + expires time.Time +} + +// Memory is an in-memory Store: a TTL+LRU cache for rewind payloads plus a +// bounded per-session sticky-id set. Defaults mirror headroom's CCR store +// (1800s TTL, 1000 entries). +type Memory struct { + mu sync.Mutex + ttl time.Duration + max int + ll *list.List // LRU, front = most recent + items map[string]*list.Element // key -> element(*entry) + sticky map[string]map[string]struct{} + maxStick int + now func() time.Time // injectable for tests +} + +// Options configures a Memory store; the zero value yields sane defaults. +// yaml tags let it drop straight into the config file's store: block. +type Options struct { + TTLSeconds int `yaml:"ttl_seconds"` + MaxEntries int `yaml:"max_entries"` + MaxSessions int `yaml:"max_sessions"` +} + +// NewMemory builds an in-memory store. Zero/negative option fields fall back to +// defaults (1800s TTL, 1000 entries, 100 sessions of sticky sets). +func NewMemory(o Options) *Memory { + ttl := time.Duration(o.TTLSeconds) * time.Second + if o.TTLSeconds <= 0 { + ttl = 1800 * time.Second + } + max := o.MaxEntries + if max <= 0 { + max = 1000 + } + stick := o.MaxSessions + if stick <= 0 { + stick = 100 + } + return &Memory{ + ttl: ttl, max: max, maxStick: stick, + ll: list.New(), items: map[string]*list.Element{}, + sticky: map[string]map[string]struct{}{}, + now: time.Now, + } +} + +func (m *Memory) Put(key string, payload []byte) { + m.mu.Lock() + defer m.mu.Unlock() + if el, ok := m.items[key]; ok { + e := el.Value.(*entry) + e.payload = payload + e.expires = m.now().Add(m.ttl) + m.ll.MoveToFront(el) + return + } + e := &entry{key: key, payload: payload, expires: m.now().Add(m.ttl)} + m.items[key] = m.ll.PushFront(e) + for m.ll.Len() > m.max { + m.evictOldest() + } +} + +func (m *Memory) Get(key string) ([]byte, bool) { + m.mu.Lock() + defer m.mu.Unlock() + el, ok := m.items[key] + if !ok { + return nil, false + } + e := el.Value.(*entry) + if m.now().After(e.expires) { + m.remove(el) + return nil, false + } + m.ll.MoveToFront(el) + return e.payload, true +} + +func (m *Memory) Sticky(session string) map[string]struct{} { + m.mu.Lock() + defer m.mu.Unlock() + src := m.sticky[session] + out := make(map[string]struct{}, len(src)) + for k := range src { + out[k] = struct{}{} + } + return out +} + +func (m *Memory) MarkSticky(session, id string) { + m.mu.Lock() + defer m.mu.Unlock() + s := m.sticky[session] + if s == nil { + if len(m.sticky) >= m.maxStick { + // drop an arbitrary session to stay bounded; ponytail: good enough + // until a real eviction policy is warranted. + for k := range m.sticky { + delete(m.sticky, k) + break + } + } + s = map[string]struct{}{} + m.sticky[session] = s + } + s[id] = struct{}{} +} + +func (m *Memory) evictOldest() { + if el := m.ll.Back(); el != nil { + m.remove(el) + } +} + +func (m *Memory) remove(el *list.Element) { + m.ll.Remove(el) + delete(m.items, el.Value.(*entry).key) +} diff --git a/store/store_test.go b/store/store_test.go new file mode 100644 index 0000000..b655cf4 --- /dev/null +++ b/store/store_test.go @@ -0,0 +1,73 @@ +package store + +import ( + "testing" + "time" +) + +func TestMemoryPutGetMiss(t *testing.T) { + m := NewMemory(Options{}) + m.Put("k", []byte("v")) + if got, ok := m.Get("k"); !ok || string(got) != "v" { + t.Fatalf("Get=%q ok=%v", got, ok) + } + if _, ok := m.Get("absent"); ok { + t.Fatal("absent key must miss") + } +} + +func TestMemoryTTLExpiry(t *testing.T) { + now := time.Unix(0, 0) + m := NewMemory(Options{TTLSeconds: 10}) + m.now = func() time.Time { return now } + m.Put("k", []byte("v")) + now = now.Add(11 * time.Second) + if _, ok := m.Get("k"); ok { + t.Fatal("entry past its TTL must be gone") + } +} + +func TestMemoryPutUpdatesInPlace(t *testing.T) { + m := NewMemory(Options{}) + m.Put("k", []byte("old")) + m.Put("k", []byte("new")) + if got, _ := m.Get("k"); string(got) != "new" { + t.Fatalf("Put should overwrite, got %q", got) + } +} + +func TestMemoryLRUEviction(t *testing.T) { + m := NewMemory(Options{MaxEntries: 2}) + m.Put("a", []byte("1")) + m.Put("b", []byte("2")) + m.Get("a") // touch a -> b becomes the oldest + m.Put("c", []byte("3")) // over capacity -> evicts b + if _, ok := m.Get("b"); ok { + t.Fatal("LRU should have evicted the least-recently-used entry (b)") + } + if _, ok := m.Get("a"); !ok { + t.Fatal("recently touched entry (a) must survive") + } + if _, ok := m.Get("c"); !ok { + t.Fatal("newest entry (c) must be present") + } +} + +func TestStickyBoundedAndCopied(t *testing.T) { + m := NewMemory(Options{MaxSessions: 2}) + m.MarkSticky("s1", "a") + m.MarkSticky("s2", "b") + m.MarkSticky("s3", "c") // exceeds maxStick=2 -> an arbitrary session is dropped + if len(m.sticky) > 2 { + t.Fatalf("sticky sessions must stay bounded, got %d", len(m.sticky)) + } + if _, ok := m.Sticky("s3")["c"]; !ok { + t.Fatal("the newest sticky session must be present") + } + // Sticky returns a copy: mutating it must not affect the store. + got := m.Sticky("s3") + got["x"] = struct{}{} + if _, leaked := m.Sticky("s3")["x"]; leaked { + t.Fatal("Sticky must return a defensive copy") + } +} diff --git a/surfaces/openai.go b/surfaces/openai.go deleted file mode 100644 index e370424..0000000 --- a/surfaces/openai.go +++ /dev/null @@ -1,183 +0,0 @@ -package surfaces - -import ( - "encoding/json" - "strings" - - "github.com/kagenti/lab-context-engineering/canon" -) - -// OpenAI maps OpenAI Chat Completions onto the canonical (Anthropic-shaped) model. -// Tool-result reads are the main reduction target, so ToInternal normalizes the -// whole request but Render writes back only the reduced tool_result content into the -// original OpenAI message list — assistant tool_calls and plain text are left -// structurally intact. Ported from the reference prototype's openai_adapter. -type OpenAI struct{} - -func (OpenAI) Name() string { return "openai" } - -// openaiToken remembers what Render needs: the original request bytes (everything -// not rewritten survives verbatim) and tool_call_id → original message index. -type openaiToken struct { - original []byte - toolmap map[string]int -} - -func (OpenAI) ToInternal(body []byte) (canon.Request, RenderToken, error) { - var req map[string]any - if err := json.Unmarshal(body, &req); err != nil { - return canon.Request{}, nil, err - } - internal, toolmap := openaiToInternal(req) - return canon.Request{Root: internal}, openaiToken{original: body, toolmap: toolmap}, nil -} - -func (OpenAI) Render(req canon.Request, token RenderToken) ([]byte, error) { - tok, ok := token.(openaiToken) - if !ok { - // No token (shouldn't happen via the engine) — fall back to re-encoding. - return req.Encode() - } - var out map[string]any - if err := json.Unmarshal(tok.original, &out); err != nil { - return nil, err - } - openaiApplyBack(out, req.Root, tok.toolmap) - return json.Marshal(out) -} - -// contentString flattens OpenAI message content (string or array of text parts). -func contentString(content any) string { - switch c := content.(type) { - case string: - return c - case []any: - var parts []string - for _, p := range c { - if pm, ok := p.(map[string]any); ok { - if pm["type"] == "text" { - if t, ok := pm["text"].(string); ok { - parts = append(parts, t) - } - } - } - } - return strings.Join(parts, "\n") - default: - return "" - } -} - -// openaiToInternal converts an OpenAI request to the Anthropic-shaped model and a -// {tool_call_id: openai_message_index} map. -func openaiToInternal(req map[string]any) (map[string]any, map[string]int) { - var systemParts []string - var aMsgs []any - toolmap := map[string]int{} - - msgs, _ := req["messages"].([]any) - for j, raw := range msgs { - m, ok := raw.(map[string]any) - if !ok { - continue - } - switch m["role"] { - case "system": - systemParts = append(systemParts, contentString(m["content"])) - case "tool": - tcid, _ := m["tool_call_id"].(string) - aMsgs = append(aMsgs, map[string]any{ - "role": "user", - "content": []any{map[string]any{ - "type": "tool_result", - "tool_use_id": tcid, - "content": contentString(m["content"]), - }}, - }) - if tcid != "" { - toolmap[tcid] = j - } - case "assistant": - var blocks []any - if txt := contentString(m["content"]); txt != "" { - blocks = append(blocks, map[string]any{"type": "text", "text": txt}) - } - if calls, ok := m["tool_calls"].([]any); ok { - for _, tcRaw := range calls { - tc, ok := tcRaw.(map[string]any) - if !ok { - continue - } - fn, _ := tc["function"].(map[string]any) - var input any = map[string]any{} - if fn != nil { - if argStr, ok := fn["arguments"].(string); ok && argStr != "" { - var parsed any - if err := json.Unmarshal([]byte(argStr), &parsed); err == nil { - input = parsed - } - } - } - blk := map[string]any{"type": "tool_use", "id": tc["id"], "input": input} - if fn != nil { - blk["name"] = fn["name"] - } - blocks = append(blocks, blk) - } - } - if len(blocks) == 0 { - blocks = []any{map[string]any{"type": "text", "text": ""}} - } - aMsgs = append(aMsgs, map[string]any{"role": "assistant", "content": blocks}) - default: // user (or unknown) -> user text - aMsgs = append(aMsgs, map[string]any{ - "role": "user", - "content": []any{map[string]any{"type": "text", "text": contentString(m["content"])}}, - }) - } - } - - var nonEmpty []string - for _, p := range systemParts { - if p != "" { - nonEmpty = append(nonEmpty, p) - } - } - internal := map[string]any{ - "system": strings.Join(nonEmpty, "\n"), - "messages": aMsgs, - "model": req["model"], - } - return internal, toolmap -} - -// openaiApplyBack copies reduced tool_result content from the canonical request back -// into the original OpenAI message list. -func openaiApplyBack(out map[string]any, reduced map[string]any, toolmap map[string]int) { - outMsgs, _ := out["messages"].([]any) - redMsgs, _ := reduced["messages"].([]any) - for _, mRaw := range redMsgs { - m, ok := mRaw.(map[string]any) - if !ok { - continue - } - content, ok := m["content"].([]any) - if !ok { - continue - } - for _, bRaw := range content { - blk, ok := bRaw.(map[string]any) - if !ok || blk["type"] != "tool_result" { - continue - } - tcid, _ := blk["tool_use_id"].(string) - j, ok := toolmap[tcid] - if !ok || j < 0 || j >= len(outMsgs) { - continue - } - if om, ok := outMsgs[j].(map[string]any); ok { - om["content"] = blk["content"] - } - } - } -} diff --git a/surfaces/surfaces.go b/surfaces/surfaces.go deleted file mode 100644 index 29a32af..0000000 --- a/surfaces/surfaces.go +++ /dev/null @@ -1,63 +0,0 @@ -// Package surfaces maps a provider's wire format onto the canonical request model -// (canon.Request) and back. Adding support for an agent/provider API is a new -// Surface, not a new copy of the engine — the same stage pipeline runs for every -// surface. -package surfaces - -import ( - "errors" - - "github.com/kagenti/lab-context-engineering/canon" -) - -// ErrUnsupported is returned by a surface that cannot map its wire format to the -// canonical model yet. Callers MUST treat it as "forward the original request -// untouched" (fail-open), never as a hard failure. -var ErrUnsupported = errors.New("surfaces: wire format not supported for reduction") - -// RenderToken is opaque per-request state a surface needs to reconstruct the wire -// request after the canonical one has been reduced. It is nil for the identity -// (Anthropic) surface. -type RenderToken any - -// Surface is the wire ⇄ canonical seam. -type Surface interface { - // Name identifies the wire format ("anthropic", "openai", "gemini"). - Name() string - // ToInternal parses a wire request body into the canonical model plus a render - // token. It returns ErrUnsupported when the format cannot be reduced. - ToInternal(body []byte) (canon.Request, RenderToken, error) - // Render serializes a (possibly reduced) canonical request back to the wire - // format using the token from ToInternal. - Render(req canon.Request, token RenderToken) ([]byte, error) -} - -// Anthropic is the identity surface: the canonical model IS the Anthropic wire -// shape, so both directions are decode/encode of the same object. -type Anthropic struct{} - -func (Anthropic) Name() string { return "anthropic" } - -func (Anthropic) ToInternal(body []byte) (canon.Request, RenderToken, error) { - req, err := canon.Decode(body) - return req, nil, err -} - -func (Anthropic) Render(req canon.Request, _ RenderToken) ([]byte, error) { - return req.Encode() -} - -// Gemini (generateContent) is not yet mapped to the canonical model. It returns -// ErrUnsupported so the proxy forwards Gemini traffic untransformed — correct and -// safe — until a faithful mapping lands. -type Gemini struct{} - -func (Gemini) Name() string { return "gemini" } - -func (Gemini) ToInternal(body []byte) (canon.Request, RenderToken, error) { - return canon.Request{}, nil, ErrUnsupported -} - -func (Gemini) Render(req canon.Request, _ RenderToken) ([]byte, error) { - return req.Encode() -} diff --git a/surfaces/surfaces_test.go b/surfaces/surfaces_test.go deleted file mode 100644 index 803b9a8..0000000 --- a/surfaces/surfaces_test.go +++ /dev/null @@ -1,145 +0,0 @@ -package surfaces - -import ( - "encoding/json" - "reflect" - "testing" - - "github.com/kagenti/lab-context-engineering/canon" -) - -// jsonEqual compares two JSON byte slices for semantic (not byte) equality. -func jsonEqual(t *testing.T, a, b []byte) bool { - t.Helper() - var av, bv any - if err := json.Unmarshal(a, &av); err != nil { - t.Fatalf("unmarshal a: %v", err) - } - if err := json.Unmarshal(b, &bv); err != nil { - t.Fatalf("unmarshal b: %v", err) - } - return reflect.DeepEqual(av, bv) -} - -func TestAnthropicRoundTrip(t *testing.T) { - body := []byte(`{"model":"claude-x","max_tokens":1024,"system":"be terse","messages":[{"role":"user","content":[{"type":"text","text":"hi"}]}]}`) - s := Anthropic{} - req, token, err := s.ToInternal(body) - if err != nil { - t.Fatalf("ToInternal: %v", err) - } - if token != nil { - t.Fatalf("anthropic token should be nil, got %v", token) - } - out, err := s.Render(req, token) - if err != nil { - t.Fatalf("Render: %v", err) - } - if !jsonEqual(t, body, out) { - t.Fatalf("round-trip changed the request:\n in: %s\nout: %s", body, out) - } -} - -func TestAnthropicPreservesUnknownFields(t *testing.T) { - body := []byte(`{"model":"m","temperature":0.2,"stream":true,"metadata":{"user_id":"u1"},"messages":[]}`) - req, _, err := Anthropic{}.ToInternal(body) - if err != nil { - t.Fatalf("ToInternal: %v", err) - } - out, _ := req.Encode() - if !jsonEqual(t, body, out) { - t.Fatalf("unknown fields lost:\n in: %s\nout: %s", body, out) - } -} - -func TestOpenAIToInternalShape(t *testing.T) { - body := []byte(`{ - "model":"gpt-x", - "messages":[ - {"role":"system","content":"sys"}, - {"role":"user","content":"hello"}, - {"role":"assistant","content":"", "tool_calls":[{"id":"call_1","function":{"name":"read","arguments":"{\"path\":\"a.go\"}"}}]}, - {"role":"tool","tool_call_id":"call_1","content":"FILE CONTENTS"} - ] - }`) - req, token, err := OpenAI{}.ToInternal(body) - if err != nil { - t.Fatalf("ToInternal: %v", err) - } - if req.Model() != "gpt-x" { - t.Fatalf("model = %q", req.Model()) - } - if s, _ := req.Root["system"].(string); s != "sys" { - t.Fatalf("system = %q", s) - } - msgs := req.Messages() - if len(msgs) != 3 { // user, assistant(tool_use), user(tool_result) - t.Fatalf("got %d canonical messages, want 3", len(msgs)) - } - // assistant message should carry a tool_use block with parsed input. - asst := msgs[1] - blocks := canon.Blocks(asst) - var foundToolUse bool - for _, b := range blocks { - if canon.BlockType(b) == "tool_use" { - foundToolUse = true - input, _ := b["input"].(map[string]any) - if input["path"] != "a.go" { - t.Fatalf("tool_use input = %v", b["input"]) - } - } - } - if !foundToolUse { - t.Fatalf("no tool_use block in assistant message: %v", blocks) - } - if token == nil { - t.Fatalf("openai token must not be nil") - } -} - -func TestOpenAIRenderWritesBackToolResult(t *testing.T) { - body := []byte(`{ - "model":"gpt-x", - "messages":[ - {"role":"assistant","content":"","tool_calls":[{"id":"call_1","function":{"name":"read","arguments":"{}"}}]}, - {"role":"tool","tool_call_id":"call_1","content":"ORIGINAL LONG OUTPUT"} - ] - }`) - req, token, err := OpenAI{}.ToInternal(body) - if err != nil { - t.Fatalf("ToInternal: %v", err) - } - // Simulate a stage reducing the tool_result content. - for _, m := range req.Messages() { - for _, b := range canon.Blocks(m) { - if canon.BlockType(b) == "tool_result" { - b["content"] = "REDUCED" - } - } - } - out, err := OpenAI{}.Render(req, token) - if err != nil { - t.Fatalf("Render: %v", err) - } - var rendered map[string]any - if err := json.Unmarshal(out, &rendered); err != nil { - t.Fatalf("unmarshal rendered: %v", err) - } - msgs := rendered["messages"].([]any) - toolMsg := msgs[1].(map[string]any) - if toolMsg["content"] != "REDUCED" { - t.Fatalf("tool result not written back: %v", toolMsg["content"]) - } - // The assistant message (not a reduction target) must be untouched. - asst := msgs[0].(map[string]any) - if _, ok := asst["tool_calls"]; !ok { - t.Fatalf("assistant tool_calls were dropped") - } -} - -func TestGeminiUnsupportedIsFailOpen(t *testing.T) { - _, _, err := Gemini{}.ToInternal([]byte(`{"contents":[]}`)) - if err != ErrUnsupported { - t.Fatalf("want ErrUnsupported, got %v", err) - } -} diff --git a/testdata/fixtures/README.md b/testdata/fixtures/README.md deleted file mode 100644 index a041820..0000000 --- a/testdata/fixtures/README.md +++ /dev/null @@ -1,83 +0,0 @@ -# Measurement fixtures - -Real-world tool-output fixtures used by `cmd/labcx-bench` to measure, on REAL data, -how much each deterministic reduction component reduces tokens/bytes. Every fixture is -sourced from a real project's command output or test corpus; provenance is listed below. - -The harness wraps each fixture as a canonical Anthropic-shaped request (a `tool_result` -block, paired with the matching `tool_use` so command-aware passes can see the command), -runs the engine with one component enabled at a time, and reports token/byte reduction -plus reversibility. See `docs/RESULTS-offline.md` for the measured numbers. - -## `cmd_outputs/` — exercises the command-output filter (`internal/reduce/cmdfilter.go`) - -These are real CLI command outputs. The cmdfilter keeps the signal (failures, errors, -test-result summary) and drops routine noise (compile chatter, passing-line spam), -storing the original so the reduction is reversible. - -| file | source | what it exercises | -| --- | --- | --- | -| `pytest_failures.txt` | rtk `src/cmds/python/pytest_cmd.rs` (inline `-q`-mode test fixture, `test_filter_pytest_quiet_mode_failures`) — https://github.com/rtk-ai/rtk | `pytest` rule: keep FAILURES + summary, verbatim | -| `cargo_test_failure.txt` | rtk `src/cmds/rust/cargo_cmd.rs` (inline `filter_cargo_test` fixture) — https://github.com/rtk-ai/rtk | `cargo test` rule: drop passing `... ok` lines, keep failures + `test result:` | -| `cargo_build.txt` | reconstructed from rtk's real `Cargo.lock` (one `Compiling v` line per locked dependency, 203 crates, + a `Finished` line) — https://github.com/rtk-ai/rtk | `cargo build` rule: drop `Compiling` noise, keep `Finished` | - -Note on `cargo_build.txt`: this is the genuine output shape `cargo build --release` emits -for rtk — every crate name and version is taken verbatim from rtk's committed -`Cargo.lock` (`awk '/^name = /{n=$3} /^version = /{print n,$3}' Cargo.lock`). It is a -faithful reconstruction of a real build's stdout, used because rtk's inline unit-test -fixture (3 crates) sits below the 8-line cmdfilter floor. The other two cmd fixtures are -verbatim from rtk's Rust unit tests. - -## `structured_json/` — exercises the format re-encoder (`toon`, `tsv`/`csv`, `json_compact`) - -Large structured tool/MCP outputs. The format reducer re-encodes them in the smallest -faithful representation (Token-Oriented Object Notation, delimited tables, or compact -JSON), keeping the data identical (the original is stored for exact recovery). - -| file | source | what it exercises | -| --- | --- | --- | -| `flights_search.json` | winnow `benchmarks/data/tool_outputs.jsonl` record id-7 `context` (flight-search tool output) — `../winnow` | flat array of uniform scalar rows → `tsv`/`csv`/`toon` | -| `users_directory.json` | winnow `benchmarks/data/tool_outputs.jsonl` (user-directory lookup `context`) — `../winnow` | flat array of uniform scalar rows → `tsv`/`csv`/`toon` | -| `products_inventory.json` | winnow `benchmarks/data/tool_outputs.jsonl` (product-inventory `context`) — `../winnow` | flat array of uniform scalar rows → `tsv`/`csv`/`toon` | -| `oc_pods_slice.json` | rtk `tests/fixtures/oc_pods.json` (`oc get pods -o json`, representative 6-item slice of `items[]`) — https://github.com/rtk-ai/rtk | nested k8s object → `toon`/`json_compact` | - -The winnow JSON values were stored in the JSONL as already-parsed JSON arrays; they are -written here as canonical pretty-printed JSON (no content changed). - -## `search_results/` — exercises the format re-encoder on nested record arrays - -Real list-style command results (arrays of objects with nested fields). - -| file | source | what it exercises | -| --- | --- | --- | -| `glab_issue_list.json` | rtk `tests/fixtures/glab_issue_list_raw.json` (`glab issue list -F json`) — https://github.com/rtk-ai/rtk | nested array of records → `toon`/`json_compact` | -| `glab_mr_list.json` | rtk `tests/fixtures/glab_mr_list_raw.json` (`glab mr list -F json`) — https://github.com/rtk-ai/rtk | nested array of records → `toon`/`json_compact` | - -## `file_reads/` — exercises the code skeletonizer (`internal/reduce/actions.go`) - -A real source file read into context. The skeleton reducer keeps function/method -signatures and drops their bodies (tree-sitter, language-agnostic), reversibly. - -| file | source | what it exercises | -| --- | --- | --- | -| `runner.py` | rtk `scripts/benchmark-sessions/lib/runner.py` (verbatim) — https://github.com/rtk-ai/rtk | Python function bodies dropped to `{ ... }` | - -## A note on "headroom" fixtures - -The task referenced a possible "headroom" fixture source (see winnow's -`benchmarks/bench_headroom.py` / `compare_real_headroom.py`). Inspecting those, headroom -is the *real `headroom-ai` CLI* run over the same trace requests for a head-to-head -comparison — it is not a separate fixture corpus; the inputs it compresses are the same -structured tool outputs winnow targets. Those structured tool outputs are exactly what -`structured_json/` already represents (from winnow's `tool_outputs.jsonl`). So the -"headroom-style" workload (large structured outputs scored by token reduction with -lossless recovery) is represented here via the winnow data, and no separate headroom -package was vendored. This is the honest provenance. - -## Reproducing the fixture collection - -- rtk: `git clone --depth 1 https://github.com/rtk-ai/rtk /tmp/rtk` then copy the paths - listed above. The two cmd fixtures verbatim-from-source live inside rtk's Rust unit - tests (`r#"..."#` literals); they were lifted unchanged. -- winnow: `../winnow/benchmarks/data/tool_outputs.jsonl` — the `context` field of the - named records, written out as canonical JSON. diff --git a/testdata/fixtures/cmd_outputs/cargo_build.txt b/testdata/fixtures/cmd_outputs/cargo_build.txt deleted file mode 100644 index 1c070f3..0000000 --- a/testdata/fixtures/cmd_outputs/cargo_build.txt +++ /dev/null @@ -1,204 +0,0 @@ - Compiling adler2 v2.0.1 - Compiling ahash v0.8.12 - Compiling aho-corasick v1.1.4 - Compiling android_system_properties v0.1.5 - Compiling anstream v0.6.21 - Compiling anstyle v1.0.13 - Compiling anstyle-parse v0.2.7 - Compiling anstyle-query v1.1.5 - Compiling anstyle-wincon v3.0.11 - Compiling anyhow v1.0.102 - Compiling autocfg v1.5.0 - Compiling automod v1.0.16 - Compiling base64 v0.22.1 - Compiling bitflags v2.11.0 - Compiling block-buffer v0.10.4 - Compiling bstr v1.12.1 - Compiling bumpalo v3.20.2 - Compiling cc v1.2.56 - Compiling cfg-if v1.0.4 - Compiling chrono v0.4.44 - Compiling clap v4.5.60 - Compiling clap_builder v4.5.60 - Compiling clap_derive v4.5.55 - Compiling clap_lex v1.0.0 - Compiling colorchoice v1.0.4 - Compiling colored v2.2.0 - Compiling core-foundation-sys v0.8.7 - Compiling cpufeatures v0.2.17 - Compiling crc32fast v1.5.0 - Compiling crossbeam-deque v0.8.6 - Compiling crossbeam-epoch v0.9.18 - Compiling crossbeam-utils v0.8.21 - Compiling crypto-common v0.1.7 - Compiling digest v0.10.7 - Compiling dirs v5.0.1 - Compiling dirs-sys v0.4.1 - Compiling displaydoc v0.2.5 - Compiling env_home v0.1.0 - Compiling equivalent v1.0.2 - Compiling errno v0.3.14 - Compiling fallible-iterator v0.3.0 - Compiling fallible-streaming-iterator v0.1.9 - Compiling fastrand v2.3.0 - Compiling find-msvc-tools v0.1.9 - Compiling flate2 v1.1.9 - Compiling foldhash v0.1.5 - Compiling form_urlencoded v1.2.2 - Compiling generic-array v0.14.7 - Compiling getrandom v0.2.17 - Compiling getrandom v0.4.2 - Compiling globset v0.4.18 - Compiling hashbrown v0.14.5 - Compiling hashbrown v0.15.5 - Compiling hashbrown v0.16.1 - Compiling hashlink v0.9.1 - Compiling heck v0.5.0 - Compiling iana-time-zone v0.1.65 - Compiling iana-time-zone-haiku v0.1.2 - Compiling icu_collections v2.1.1 - Compiling icu_locale_core v2.1.1 - Compiling icu_normalizer v2.1.1 - Compiling icu_normalizer_data v2.1.1 - Compiling icu_properties v2.1.2 - Compiling icu_properties_data v2.1.2 - Compiling icu_provider v2.1.1 - Compiling id-arena v2.3.0 - Compiling idna v1.1.0 - Compiling idna_adapter v1.2.1 - Compiling ignore v0.4.25 - Compiling indexmap v2.13.0 - Compiling is_terminal_polyfill v1.70.2 - Compiling itoa v1.0.17 - Compiling js-sys v0.3.91 - Compiling lazy_static v1.5.0 - Compiling leb128fmt v0.1.0 - Compiling libc v0.2.182 - Compiling libredox v0.1.14 - Compiling libsqlite3-sys v0.28.0 - Compiling linux-raw-sys v0.12.1 - Compiling litemap v0.8.1 - Compiling log v0.4.29 - Compiling memchr v2.8.0 - Compiling miniz_oxide v0.8.9 - Compiling num-traits v0.2.19 - Compiling once_cell v1.21.3 - Compiling once_cell_polyfill v1.70.2 - Compiling option-ext v0.2.0 - Compiling percent-encoding v2.3.2 - Compiling pkg-config v0.3.32 - Compiling potential_utf v0.1.4 - Compiling prettyplease v0.2.37 - Compiling proc-macro2 v1.0.106 - Compiling quick-xml v0.37.5 - Compiling quote v1.0.45 - Compiling r-efi v6.0.0 - Compiling redox_users v0.4.6 - Compiling regex v1.12.3 - Compiling regex-automata v0.4.14 - Compiling regex-syntax v0.8.10 - Compiling ring v0.17.14 - Compiling rtk v0.42.4 - Compiling rusqlite v0.31.0 - Compiling rustix v1.1.4 - Compiling rustls v0.23.37 - Compiling rustls-pki-types v1.14.0 - Compiling rustls-webpki v0.103.13 - Compiling rustversion v1.0.22 - Compiling same-file v1.0.6 - Compiling semver v1.0.27 - Compiling serde v1.0.228 - Compiling serde_core v1.0.228 - Compiling serde_derive v1.0.228 - Compiling serde_json v1.0.149 - Compiling serde_spanned v0.6.9 - Compiling sha2 v0.10.9 - Compiling shlex v1.3.0 - Compiling simd-adler32 v0.3.8 - Compiling smallvec v1.15.1 - Compiling stable_deref_trait v1.2.1 - Compiling strsim v0.11.1 - Compiling subtle v2.6.1 - Compiling syn v2.0.117 - Compiling synstructure v0.13.2 - Compiling tempfile v3.26.0 - Compiling thiserror v1.0.69 - Compiling thiserror-impl v1.0.69 - Compiling tinystr v0.8.2 - Compiling toml v0.8.23 - Compiling toml_datetime v0.6.11 - Compiling toml_edit v0.22.27 - Compiling toml_write v0.1.2 - Compiling typenum v1.19.0 - Compiling unicode-ident v1.0.24 - Compiling unicode-xid v0.2.6 - Compiling untrusted v0.9.0 - Compiling ureq v2.12.1 - Compiling url v2.5.8 - Compiling utf8_iter v1.0.4 - Compiling utf8parse v0.2.2 - Compiling vcpkg v0.2.15 - Compiling version_check v0.9.5 - Compiling walkdir v2.5.0 - Compiling wasi v0.11.1+wasi-snapshot-preview1 - Compiling wasip2 v1.0.2+wasi-0.2.9 - Compiling wasip3 v0.4.0+wasi-0.3.0-rc-2026-01-06 - Compiling wasm-bindgen v0.2.114 - Compiling wasm-bindgen-macro v0.2.114 - Compiling wasm-bindgen-macro-support v0.2.114 - Compiling wasm-bindgen-shared v0.2.114 - Compiling wasm-encoder v0.244.0 - Compiling wasm-metadata v0.244.0 - Compiling wasmparser v0.244.0 - Compiling webpki-roots v0.26.11 - Compiling webpki-roots v1.0.6 - Compiling which v8.0.1 - Compiling winapi-util v0.1.11 - Compiling windows-core v0.62.2 - Compiling windows-implement v0.60.2 - Compiling windows-interface v0.59.3 - Compiling windows-link v0.2.1 - Compiling windows-result v0.4.1 - Compiling windows-strings v0.5.1 - Compiling windows-sys v0.48.0 - Compiling windows-sys v0.52.0 - Compiling windows-sys v0.59.0 - Compiling windows-sys v0.61.2 - Compiling windows-targets v0.48.5 - Compiling windows-targets v0.52.6 - Compiling windows_aarch64_gnullvm v0.48.5 - Compiling windows_aarch64_gnullvm v0.52.6 - Compiling windows_aarch64_msvc v0.48.5 - Compiling windows_aarch64_msvc v0.52.6 - Compiling windows_i686_gnu v0.48.5 - Compiling windows_i686_gnu v0.52.6 - Compiling windows_i686_gnullvm v0.52.6 - Compiling windows_i686_msvc v0.48.5 - Compiling windows_i686_msvc v0.52.6 - Compiling windows_x86_64_gnu v0.48.5 - Compiling windows_x86_64_gnu v0.52.6 - Compiling windows_x86_64_gnullvm v0.48.5 - Compiling windows_x86_64_gnullvm v0.52.6 - Compiling windows_x86_64_msvc v0.48.5 - Compiling windows_x86_64_msvc v0.52.6 - Compiling winnow v0.7.15 - Compiling winsafe v0.0.19 - Compiling wit-bindgen v0.51.0 - Compiling wit-bindgen-core v0.51.0 - Compiling wit-bindgen-rust v0.51.0 - Compiling wit-bindgen-rust-macro v0.51.0 - Compiling wit-component v0.244.0 - Compiling wit-parser v0.244.0 - Compiling writeable v0.6.2 - Compiling yoke v0.8.1 - Compiling yoke-derive v0.8.1 - Compiling zerocopy v0.8.40 - Compiling zerocopy-derive v0.8.40 - Compiling zerofrom v0.1.6 - Compiling zerofrom-derive v0.1.6 - Compiling zeroize v1.8.2 - Compiling zerotrie v0.2.3 - Compiling zerovec v0.11.5 - Compiling zerovec-derive v0.11.2 - Compiling zmij v1.0.21 - Finished `release` profile [optimized] target(s) in 1m 12s diff --git a/testdata/fixtures/cmd_outputs/cargo_test_failure.txt b/testdata/fixtures/cmd_outputs/cargo_test_failure.txt deleted file mode 100644 index 6df82b4..0000000 --- a/testdata/fixtures/cmd_outputs/cargo_test_failure.txt +++ /dev/null @@ -1,14 +0,0 @@ -running 5 tests -test foo::test_a ... ok -test foo::test_b ... FAILED -test foo::test_c ... ok - -failures: - ----- foo::test_b stdout ---- -thread 'foo::test_b' panicked at 'assert_eq!(1, 2)' - -failures: - foo::test_b - -test result: FAILED. 4 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out diff --git a/testdata/fixtures/cmd_outputs/pytest_failures.txt b/testdata/fixtures/cmd_outputs/pytest_failures.txt deleted file mode 100644 index e35ad9d..0000000 --- a/testdata/fixtures/cmd_outputs/pytest_failures.txt +++ /dev/null @@ -1,14 +0,0 @@ -=== test session starts === -platform linux -- Python 3.12.11, pytest-8.1.0 -collected 1705 items - -.......F....... - -=== FAILURES === -___ test_something ___ - -E AssertionError: expected True - -=== short test summary info === -FAILED tests/test_foo.py::test_something - AssertionError -5 failed, 1698 passed, 2 skipped in 108.89s diff --git a/testdata/fixtures/file_reads/runner.py b/testdata/fixtures/file_reads/runner.py deleted file mode 100644 index bd02dc1..0000000 --- a/testdata/fixtures/file_reads/runner.py +++ /dev/null @@ -1,163 +0,0 @@ -from __future__ import annotations - -import asyncio -import os -import subprocess -import tempfile -from pathlib import Path - -from .config import TaskConfig -from .manifest import ( - RunManifest, - SessionEntry, - TbEntry, - TbTaskEntry, - write_manifest, -) -from .session import run_all_sessions, setup_codebase, setup_rtk -from .terminal_bench import run_terminal_bench -from .vm import create_vm_pool, destroy_vm_pool - -ROOT_DIR = Path(__file__).resolve().parent.parent - - -def _create_tarball(source_dir: Path) -> str: - fd, tarball = tempfile.mkstemp(suffix=".tar.gz") - os.close(fd) - try: - subprocess.run( - ["tar", "czf", tarball, "-C", str(source_dir), "."], - check=True, - ) - except Exception: - Path(tarball).unlink(missing_ok=True) - raise - return tarball - - -def _print_step(step: int, total: int, msg: str): - print(f"\n[{step}/{total}] {msg}") - - -def _session_to_entry(r) -> SessionEntry: - return SessionEntry( - vm_name=r.vm_name, - group=r.group, - stdout_json=f"{r.vm_name}-stdout.json", - otel_log=f"{r.vm_name}-otel.log", - rtk_db=f"{r.vm_name}-tracking.db" if r.rtk_db_path else None, - exit_code=r.exit_code, - error=r.error or None, - ) - - -def _tb_to_entry(r) -> TbEntry: - return TbEntry( - vm_name=r.vm_name, - group=r.group, - total=r.total, - passed=r.passed, - failed=r.failed, - tasks=[TbTaskEntry(name=t.name, passed=t.passed, duration_s=t.duration_s) for t in r.tasks], - error=r.error, - ) - - -async def run_benchmark( - task: TaskConfig, - vms: int, - api_key: str, - output_dir: Path, - cloud_init: Path | None = None, - terminal_bench: bool = False, - keep_vms: bool = False, -) -> RunManifest: - if cloud_init is None: - cloud_init = ROOT_DIR / "cloud-init-base.yaml" - - output_dir.mkdir(parents=True, exist_ok=True) - - total_steps = 5 if terminal_bench else 4 - vm_names: list[str] = [] - local_tarball: str | None = None - - manifest = RunManifest( - task_name=task.name, - model=task.model, - vm_count=vms, - ) - - try: - _print_step(1, total_steps, f"Creating {vms * 2} VMs ({vms} RTK ON + {vms} RTK OFF)") - vm_names = await create_vm_pool(vms, cloud_init) - print(f" VMs ready: {', '.join(vm_names)}") - - _print_step(2, total_steps, "Setting up codebases") - if not task.codebase.is_github: - local_tarball = _create_tarball(task.codebase.local_path()) - - await asyncio.gather(*( - setup_codebase(name, task.codebase, local_tarball) - for name in vm_names - )) - print(" Codebases deployed") - - _print_step(3, total_steps, "Configuring RTK on ON VMs") - setup_script = ROOT_DIR / "setup-rtk.sh" - on_vms = [n for n in vm_names if "-on-" in n] - off_vms = [n for n in vm_names if "-off-" in n] - await asyncio.gather(*(setup_rtk(vm, setup_script) for vm in on_vms)) - print(f" RTK configured on {len(on_vms)} VMs") - - _print_step(4, total_steps, f"Running Claude sessions (timeout: {task.timeout_minutes}min)") - results = await run_all_sessions(vm_names, task, api_key, output_dir) - - on_ok = [r for r in results if r.group == "on" and not r.error] - off_ok = [r for r in results if r.group == "off" and not r.error] - errors = [r for r in results if r.error] - print(f" Completed: {len(on_ok)} ON, {len(off_ok)} OFF, {len(errors)} errors") - for r in errors: - print(f" {r.vm_name}: {r.error}") - - manifest.sessions = [_session_to_entry(r) for r in results] - - if terminal_bench: - _print_step(5, total_steps, "Running terminal-bench precision tests") - tb_on = await asyncio.gather(*( - run_terminal_bench(vm, "on", task.model, api_key) - for vm in on_vms - )) - tb_off = await asyncio.gather(*( - run_terminal_bench(vm, "off", task.model, api_key) - for vm in off_vms - )) - - manifest.terminal_bench = [_tb_to_entry(r) for r in list(tb_on) + list(tb_off)] - - ok_on = [r for r in tb_on if not r.error] - ok_off = [r for r in tb_off if not r.error] - if ok_on and ok_off: - on_total = sum(r.total for r in ok_on) - on_passed = sum(r.passed for r in ok_on) - off_total = sum(r.total for r in ok_off) - off_passed = sum(r.passed for r in ok_off) - on_rate = on_passed / on_total if on_total else 0 - off_rate = off_passed / off_total if off_total else 0 - print(f" terminal-bench: ON pass rate={on_rate:.0%}, OFF pass rate={off_rate:.0%}, delta={on_rate - off_rate:+.0%}") - - tb_errors = [r for r in list(tb_on) + list(tb_off) if r.error] - for r in tb_errors: - print(f" {r.vm_name}: {r.error}") - - write_manifest(manifest, output_dir) - print(f"\n Manifest written to {output_dir / 'manifest.json'}") - - finally: - if local_tarball: - Path(local_tarball).unlink(missing_ok=True) - if not keep_vms and vm_names: - print("\nCleaning up VMs...") - await destroy_vm_pool(vm_names) - print(" VMs destroyed") - - return manifest diff --git a/testdata/fixtures/search_results/glab_issue_list.json b/testdata/fixtures/search_results/glab_issue_list.json deleted file mode 100644 index 9ca28a1..0000000 --- a/testdata/fixtures/search_results/glab_issue_list.json +++ /dev/null @@ -1,122 +0,0 @@ -[ - { - "iid": 156, - "title": "Support glab CI pipeline filtering", - "state": "opened", - "author": {"username": "alice_dev", "name": "Alice Developer", "id": 42}, - "web_url": "https://gitlab.example.com/acme/toolkit/-/issues/156", - "created_at": "2026-03-01T10:00:00Z", - "updated_at": "2026-03-05T14:30:00Z", - "labels": ["enhancement", "glab"], - "assignees": [{"username": "alice_dev"}], - "description": "## Request\n\nAdd support for `glab ci` pipeline filtering.\n\n\n\n### Acceptance Criteria\n- [ ] `rtk glab ci list` shows compact pipeline summary\n- [ ] `rtk glab ci status` shows current pipeline status\n- [ ] Token savings >= 80%\n\n---\n\n[![status](https://img.shields.io/badge/status-in_progress-yellow)](https://example.com)\n" - }, - { - "iid": 150, - "title": "rtk cargo test shows full output when no failures", - "state": "opened", - "author": {"username": "bob_report", "name": "Bob Reporter", "id": 100}, - "web_url": "https://gitlab.example.com/acme/toolkit/-/issues/150", - "created_at": "2026-02-28T08:00:00Z", - "updated_at": "2026-03-02T16:00:00Z", - "labels": ["bug", "cargo"], - "assignees": [{"username": "dave_fix"}], - "description": "When all tests pass, `rtk cargo test` still shows verbose compilation output instead of just the summary line.\n\n### Steps to Reproduce\n1. Run `rtk cargo test` in a project with all passing tests\n2. Observe that compiler output is included\n\n### Expected\nOnly show test summary when all tests pass.\n\n### Actual\nFull compiler warnings and test output shown." - }, - { - "iid": 145, - "title": "Add Helm CLI support", - "state": "opened", - "author": {"username": "carol_infra", "name": "Carol Infra", "id": 200}, - "web_url": "https://gitlab.example.com/acme/toolkit/-/issues/145", - "created_at": "2026-02-25T12:00:00Z", - "updated_at": "2026-03-04T09:00:00Z", - "labels": ["enhancement", "infra"], - "assignees": [], - "description": "Helm CLI outputs are verbose. Would be great to have RTK support for:\n- `helm list` (compact table)\n- `helm status` (summary only)\n- `helm install/upgrade` (ok confirmation)\n\nSimilar to how `rtk kubectl` works." - }, - { - "iid": 140, - "title": "Binary size increased 30% after Python/Go modules", - "state": "opened", - "author": {"username": "eve_perf", "name": "Eve Performance", "id": 300}, - "web_url": "https://gitlab.example.com/acme/toolkit/-/issues/140", - "created_at": "2026-02-20T15:00:00Z", - "updated_at": "2026-02-22T10:00:00Z", - "labels": ["performance", "build"], - "assignees": [{"username": "frank_contrib"}], - "description": "After merging Python and Go support, stripped release binary went from 3.2MB to 4.1MB.\n\nInvestigate if we can:\n- Use feature flags to make modules optional\n- Reduce regex count (share patterns across modules)\n- Review serde usage (maybe avoid full JSON parsing for simple cases)" - }, - { - "iid": 135, - "title": "rtk gain --history shows wrong dates on macOS", - "state": "closed", - "author": {"username": "george_mac", "name": "George Mac", "id": 400}, - "web_url": "https://gitlab.example.com/acme/toolkit/-/issues/135", - "created_at": "2026-02-15T09:00:00Z", - "updated_at": "2026-02-18T11:00:00Z", - "labels": ["bug", "macos"], - "assignees": [{"username": "alice_dev"}], - "description": "On macOS, `rtk gain --history` shows dates in UTC instead of local timezone.\n\nFixed in v0.23.1." - }, - { - "iid": 130, - "title": "Support TOML-based filter DSL", - "state": "opened", - "author": {"username": "heidi_arch", "name": "Heidi Architect", "id": 500}, - "web_url": "https://gitlab.example.com/acme/toolkit/-/issues/130", - "created_at": "2026-02-10T08:00:00Z", - "updated_at": "2026-02-12T16:00:00Z", - "labels": ["enhancement", "architecture"], - "assignees": [], - "description": "Instead of writing Rust code for each new filter, allow users to define filters in TOML.\n\n```toml\n[[filter]]\ncommand = \"terraform plan\"\npattern = \"^(Plan|Apply|Error):\"\nformat = \"compact\"\n```\n\nThis would make RTK extensible without recompilation." - }, - { - "iid": 125, - "title": "Improve error messages for missing commands", - "state": "closed", - "author": {"username": "ivan_docs", "name": "Ivan Writer", "id": 600}, - "web_url": "https://gitlab.example.com/acme/toolkit/-/issues/125", - "created_at": "2026-02-05T14:00:00Z", - "updated_at": "2026-02-06T09:00:00Z", - "labels": ["enhancement", "ux"], - "assignees": [{"username": "ivan_docs"}], - "description": "When the underlying command is not installed (e.g., `rtk glab mr list` without glab), the error message is confusing:\n\n```\nError: Failed to run glab mr list\n```\n\nShould say something like:\n```\nError: glab not found. Install it: https://gitlab.com/gitlab-org/cli\n```" - }, - { - "iid": 120, - "title": "Add rtk completion command for shell completions", - "state": "opened", - "author": {"username": "judy_shell", "name": "Judy Shell", "id": 700}, - "web_url": "https://gitlab.example.com/acme/toolkit/-/issues/120", - "created_at": "2026-02-01T11:00:00Z", - "updated_at": "2026-02-03T15:00:00Z", - "labels": ["enhancement", "shell"], - "assignees": [], - "description": "Clap supports generating shell completions via `clap_complete`. Add a `rtk completion bash/zsh/fish` command.\n\nThis would help discoverability of available commands." - }, - { - "iid": 115, - "title": "rtk read crashes on binary files", - "state": "closed", - "author": {"username": "karl_refactor", "name": "Karl Refactorer", "id": 800}, - "web_url": "https://gitlab.example.com/acme/toolkit/-/issues/115", - "created_at": "2026-01-28T10:00:00Z", - "updated_at": "2026-01-30T12:00:00Z", - "labels": ["bug", "crash"], - "assignees": [{"username": "dave_fix"}], - "description": "Running `rtk read /path/to/binary.exe` panics with:\n```\nthread 'main' panicked at 'called `Result::unwrap()` on an `Err` value: Utf8Error'\n```\n\nShould detect binary files and skip filtering." - }, - { - "iid": 110, - "title": "Track savings per project directory", - "state": "opened", - "author": {"username": "lisa_feat", "name": "Lisa Feature", "id": 900}, - "web_url": "https://gitlab.example.com/acme/toolkit/-/issues/110", - "created_at": "2026-01-25T09:00:00Z", - "updated_at": "2026-01-27T14:00:00Z", - "labels": ["enhancement", "analytics"], - "assignees": [], - "description": "Currently `rtk gain` shows global stats. It would be useful to see savings broken down by project directory.\n\nProposal: store `cwd` in the tracking database and add `rtk gain --by-project` flag." - } -] diff --git a/testdata/fixtures/search_results/glab_mr_list.json b/testdata/fixtures/search_results/glab_mr_list.json deleted file mode 100644 index c502b62..0000000 --- a/testdata/fixtures/search_results/glab_mr_list.json +++ /dev/null @@ -1,182 +0,0 @@ -[ - { - "iid": 314, - "title": "feat(glab): add GitLab CLI (glab) command support", - "state": "opened", - "author": {"username": "alice_dev", "name": "Alice Developer", "id": 42}, - "web_url": "https://gitlab.example.com/acme/toolkit/-/merge_requests/314", - "created_at": "2026-03-01T10:00:00Z", - "updated_at": "2026-03-05T14:30:00Z", - "source_branch": "feat/glab-support", - "target_branch": "master", - "merge_status": "can_be_merged", - "draft": false, - "labels": ["enhancement", "cli"], - "assignees": [{"username": "alice_dev", "name": "Alice Developer"}], - "reviewers": [{"username": "bob_review"}, {"username": "carol_review"}], - "description": "## Summary\n\nAdd GitLab CLI support.\n\n\n\n## Changes\n- New module\n- MR/issue/CI filtering\n- Token savings 80-87%\n\n---\n\n[![CI](https://img.shields.io/badge/CI-passing-green)](https://ci.example.com)\n", - "head_pipeline": {"id": 98765, "status": "success", "ref": "feat/glab-support"} - }, - { - "iid": 310, - "title": "fix(git): handle merge commits in compact diff", - "state": "merged", - "author": {"username": "dave_fix", "name": "Dave Fixer", "id": 100}, - "web_url": "https://gitlab.example.com/acme/toolkit/-/merge_requests/310", - "created_at": "2026-02-28T08:00:00Z", - "updated_at": "2026-03-02T16:00:00Z", - "source_branch": "fix/merge-commits", - "target_branch": "master", - "merge_status": "can_be_merged", - "draft": false, - "labels": ["bug", "git"], - "assignees": [{"username": "dave_fix"}], - "reviewers": [{"username": "eve_review"}], - "description": "Fix handling of merge commits in `compact_diff`. Previously, merge commits were being skipped entirely which lost context.\n\n### Test Plan\n- [x] Unit tests added\n- [x] Manual verification with merge-heavy repos\n", - "head_pipeline": {"id": 98700, "status": "success", "ref": "fix/merge-commits"} - }, - { - "iid": 305, - "title": "feat(aws): add AWS CLI module with token-optimized output", - "state": "opened", - "author": {"username": "frank_contrib", "name": "Frank Contributor", "id": 200}, - "web_url": "https://gitlab.example.com/acme/toolkit/-/merge_requests/305", - "created_at": "2026-02-25T12:00:00Z", - "updated_at": "2026-03-04T09:00:00Z", - "source_branch": "feat/aws-cli", - "target_branch": "master", - "merge_status": "cannot_be_merged", - "draft": true, - "labels": ["enhancement", "infra"], - "assignees": [], - "reviewers": [{"username": "grace_review"}, {"username": "heidi_review"}], - "description": "Add AWS CLI support.\n\n![architecture](https://example.com/arch.png)\n\n## Commands\n- `rtk aws s3 ls`\n- `rtk aws ec2 describe-instances`\n- `rtk aws ecs list-services`\n\n## Token Savings\n| Command | Savings |\n|---------|--------|\n| s3 ls | 75% |\n| ec2 describe | 85% |\n| ecs list | 80% |\n", - "head_pipeline": {"id": 98650, "status": "failed", "ref": "feat/aws-cli"} - }, - { - "iid": 302, - "title": "chore(master): release 0.24.0", - "state": "merged", - "author": {"username": "release-bot", "name": "Release Bot", "id": 1}, - "web_url": "https://gitlab.example.com/acme/toolkit/-/merge_requests/302", - "created_at": "2026-02-20T00:00:00Z", - "updated_at": "2026-02-20T01:00:00Z", - "source_branch": "release-please--branches--master", - "target_branch": "master", - "merge_status": "can_be_merged", - "draft": false, - "labels": ["release"], - "assignees": [], - "reviewers": [], - "description": "## [0.24.0](https://example.com/compare/v0.23.0...v0.24.0)\n\n### Features\n* feat(aws): add AWS CLI module\n* feat(psql): add PostgreSQL module\n\n### Bug Fixes\n* fix(playwright): fix JSON parser\n", - "head_pipeline": {"id": 98600, "status": "success", "ref": "release-please--branches--master"} - }, - { - "iid": 298, - "title": "docs: update README with Python and Go command examples", - "state": "merged", - "author": {"username": "ivan_docs", "name": "Ivan Writer", "id": 300}, - "web_url": "https://gitlab.example.com/acme/toolkit/-/merge_requests/298", - "created_at": "2026-02-18T15:00:00Z", - "updated_at": "2026-02-19T10:00:00Z", - "source_branch": "docs/python-go-examples", - "target_branch": "master", - "merge_status": "can_be_merged", - "draft": false, - "labels": ["documentation"], - "assignees": [{"username": "ivan_docs"}], - "reviewers": [{"username": "judy_review"}], - "description": "Update README.md with comprehensive examples for:\n- Python commands (ruff, pytest, pip)\n- Go commands (go test, go build, golangci-lint)\n\nAll examples tested manually.", - "head_pipeline": null - }, - { - "iid": 295, - "title": "refactor: extract parser module from runner.rs", - "state": "closed", - "author": {"username": "karl_refactor", "name": "Karl Refactorer", "id": 400}, - "web_url": "https://gitlab.example.com/acme/toolkit/-/merge_requests/295", - "created_at": "2026-02-15T09:00:00Z", - "updated_at": "2026-02-16T11:00:00Z", - "source_branch": "refactor/parser-module", - "target_branch": "master", - "merge_status": "can_be_merged", - "draft": false, - "labels": ["refactor"], - "assignees": [{"username": "karl_refactor"}], - "reviewers": [], - "description": "Extract parser logic from runner.rs into dedicated parser/ module.\n\n---\n\nThis was superseded by #300 which took a different approach.\n\n***\n", - "head_pipeline": {"id": 98500, "status": "canceled", "ref": "refactor/parser-module"} - }, - { - "iid": 290, - "title": "feat(tee): save raw output on failure for LLM re-read", - "state": "merged", - "author": {"username": "lisa_feat", "name": "Lisa Feature", "id": 500}, - "web_url": "https://gitlab.example.com/acme/toolkit/-/merge_requests/290", - "created_at": "2026-02-10T08:00:00Z", - "updated_at": "2026-02-12T16:00:00Z", - "source_branch": "feat/tee-output", - "target_branch": "master", - "merge_status": "can_be_merged", - "draft": false, - "labels": ["enhancement"], - "assignees": [{"username": "lisa_feat"}], - "reviewers": [{"username": "mike_review"}], - "description": "## Tee Output Recovery\n\nSave raw unfiltered output on command failure.\nPrint one-line hint so LLMs can re-read instead of re-run.\n\n### Configuration\n```toml\n[tee]\nenabled = true\ndir = \"~/.local/share/rtk/tee\"\nmax_files = 20\nmax_size = 1048576\n```\n", - "head_pipeline": {"id": 98400, "status": "success", "ref": "feat/tee-output"} - }, - { - "iid": 285, - "title": "ci: add ARM64 Linux build to release workflow", - "state": "merged", - "author": {"username": "nancy_ci", "name": "Nancy CI", "id": 600}, - "web_url": "https://gitlab.example.com/acme/toolkit/-/merge_requests/285", - "created_at": "2026-02-05T14:00:00Z", - "updated_at": "2026-02-06T09:00:00Z", - "source_branch": "ci/arm64-build", - "target_branch": "master", - "merge_status": "can_be_merged", - "draft": false, - "labels": ["ci"], - "assignees": [{"username": "nancy_ci"}], - "reviewers": [{"username": "oscar_review"}], - "description": "Add ARM64 Linux target to the release workflow.\n\n- Uses `cross` for cross-compilation\n- Generates `.deb` and `.rpm` packages\n- Tested on Raspberry Pi 4 and AWS Graviton", - "head_pipeline": {"id": 98300, "status": "success", "ref": "ci/arm64-build"} - }, - { - "iid": 280, - "title": "fix(vitest): handle watch mode output gracefully", - "state": "opened", - "author": {"username": "peter_bugfix", "name": "Peter Bugfix", "id": 700}, - "web_url": "https://gitlab.example.com/acme/toolkit/-/merge_requests/280", - "created_at": "2026-02-01T11:00:00Z", - "updated_at": "2026-02-03T15:00:00Z", - "source_branch": "fix/vitest-watch", - "target_branch": "master", - "merge_status": "unchecked", - "draft": false, - "labels": ["bug", "vitest"], - "assignees": [{"username": "peter_bugfix"}], - "reviewers": [], - "description": "When vitest runs in watch mode, output is continuous and doesn't have a clear end marker. This fix detects watch mode and falls back to passthrough.\n\n\n", - "head_pipeline": {"id": 98200, "status": "running", "ref": "fix/vitest-watch"} - }, - { - "iid": 275, - "title": "feat(discover): add rtk discover command for missed savings analysis", - "state": "merged", - "author": {"username": "quinn_dev", "name": "Quinn Developer", "id": 800}, - "web_url": "https://gitlab.example.com/acme/toolkit/-/merge_requests/275", - "created_at": "2026-01-28T10:00:00Z", - "updated_at": "2026-01-30T12:00:00Z", - "source_branch": "feat/discover", - "target_branch": "master", - "merge_status": "can_be_merged", - "draft": false, - "labels": ["enhancement", "analytics"], - "assignees": [{"username": "quinn_dev"}], - "reviewers": [{"username": "rachel_review"}, {"username": "sam_review"}], - "description": "Add `rtk discover` command that scans Claude Code JSONL sessions and reports missed savings opportunities.\n\n## Features\n- Classifies commands as Supported/Unsupported/Ignored\n- Groups by category with estimated token savings\n- Reports top missed commands\n\n## Example\n```\n$ rtk discover\nAnalyzed 1,234 commands across 45 sessions\n\nMissed savings by category:\n Git: 234 commands, ~16,800 tokens\n Cargo: 89 commands, ~7,120 tokens\n```\n", - "head_pipeline": {"id": 98100, "status": "success", "ref": "feat/discover"} - } -] diff --git a/testdata/fixtures/structured_json/flights_search.json b/testdata/fixtures/structured_json/flights_search.json deleted file mode 100644 index 193a92f..0000000 --- a/testdata/fixtures/structured_json/flights_search.json +++ /dev/null @@ -1,90 +0,0 @@ -[ - { - "id": "FL001", - "from": "SFO", - "to": "JFK", - "date": "2026-07-15", - "stops": 1, - "price": 320 - }, - { - "id": "FL002", - "from": "LAX", - "to": "JFK", - "date": "2026-07-16", - "stops": 0, - "price": 410 - }, - { - "id": "FL003", - "from": "SFO", - "to": "JFK", - "date": "2026-07-15", - "stops": 1, - "price": 290 - }, - { - "id": "FL004", - "from": "SFO", - "to": "JFK", - "date": "2026-07-16", - "stops": 0, - "price": 380 - }, - { - "id": "FL005", - "from": "LAX", - "to": "JFK", - "date": "2026-07-15", - "stops": 1, - "price": 260 - }, - { - "id": "FL006", - "from": "SFO", - "to": "JFK", - "date": "2026-07-15", - "stops": 1, - "price": 305 - }, - { - "id": "FL007", - "from": "SFO", - "to": "JFK", - "date": "2026-07-16", - "stops": 1, - "price": 275 - }, - { - "id": "FL008", - "from": "LAX", - "to": "JFK", - "date": "2026-07-15", - "stops": 0, - "price": 430 - }, - { - "id": "FL009", - "from": "SFO", - "to": "JFK", - "date": "2026-07-15", - "stops": 1, - "price": 333 - }, - { - "id": "FL010", - "from": "SFO", - "to": "JFK", - "date": "2026-07-16", - "stops": 0, - "price": 360 - }, - { - "id": "FL999", - "from": "SFO", - "to": "JFK", - "date": "2026-07-15", - "stops": 0, - "price": 149 - } -] diff --git a/testdata/fixtures/structured_json/oc_pods_slice.json b/testdata/fixtures/structured_json/oc_pods_slice.json deleted file mode 100644 index f8ba424..0000000 --- a/testdata/fixtures/structured_json/oc_pods_slice.json +++ /dev/null @@ -1,147 +0,0 @@ -{ - "apiVersion": "v1", - "kind": "List", - "metadata": { - "resourceVersion": "" - }, - "items": [ - { - "metadata": { - "name": "alertmanager-main-0", - "namespace": "openshift-monitoring" - }, - "status": { - "phase": "Running", - "containerStatuses": [ - { - "name": "alertmanager", - "restartCount": 0 - }, - { - "name": "config-reloader", - "restartCount": 0 - }, - { - "name": "kube-rbac-proxy", - "restartCount": 0 - }, - { - "name": "kube-rbac-proxy-metric", - "restartCount": 0 - }, - { - "name": "kube-rbac-proxy-web", - "restartCount": 0 - }, - { - "name": "prom-label-proxy", - "restartCount": 0 - } - ] - } - }, - { - "metadata": { - "name": "alertmanager-main-1", - "namespace": "openshift-monitoring" - }, - "status": { - "phase": "Running", - "containerStatuses": [ - { - "name": "alertmanager", - "restartCount": 0 - }, - { - "name": "config-reloader", - "restartCount": 0 - }, - { - "name": "kube-rbac-proxy", - "restartCount": 0 - }, - { - "name": "kube-rbac-proxy-metric", - "restartCount": 0 - }, - { - "name": "kube-rbac-proxy-web", - "restartCount": 0 - }, - { - "name": "prom-label-proxy", - "restartCount": 0 - } - ] - } - }, - { - "metadata": { - "name": "cluster-monitoring-operator-7fb9fd4c5d-wm2k6", - "namespace": "openshift-monitoring" - }, - "status": { - "phase": "Running", - "containerStatuses": [ - { - "name": "cluster-monitoring-operator", - "restartCount": 0 - } - ] - } - }, - { - "metadata": { - "name": "kube-state-metrics-75586fdddb-5zw6b", - "namespace": "openshift-monitoring" - }, - "status": { - "phase": "Running", - "containerStatuses": [ - { - "name": "kube-rbac-proxy-main", - "restartCount": 0 - }, - { - "name": "kube-rbac-proxy-self", - "restartCount": 0 - }, - { - "name": "kube-state-metrics", - "restartCount": 0 - } - ] - } - }, - { - "metadata": { - "name": "metrics-server-fdfb77995-jmqnm", - "namespace": "openshift-monitoring" - }, - "status": { - "phase": "Running", - "containerStatuses": [ - { - "name": "metrics-server", - "restartCount": 0 - } - ] - } - }, - { - "metadata": { - "name": "metrics-server-fdfb77995-w4r8p", - "namespace": "openshift-monitoring" - }, - "status": { - "phase": "Running", - "containerStatuses": [ - { - "name": "metrics-server", - "restartCount": 0 - } - ] - } - } - ] -} \ No newline at end of file diff --git a/testdata/fixtures/structured_json/products_inventory.json b/testdata/fixtures/structured_json/products_inventory.json deleted file mode 100644 index 876c5f3..0000000 --- a/testdata/fixtures/structured_json/products_inventory.json +++ /dev/null @@ -1,44 +0,0 @@ -[ - { - "sku": "SKU0000", - "name": "item 0", - "price": 0, - "in_stock": true - }, - { - "sku": "SKU0001", - "name": "item 1", - "price": 13, - "in_stock": false - }, - { - "sku": "SKU0002", - "name": "item 2", - "price": 26, - "in_stock": false - }, - { - "sku": "SKU0003", - "name": "item 3", - "price": 39, - "in_stock": true - }, - { - "sku": "SKU0004", - "name": "item 4", - "price": 52, - "in_stock": false - }, - { - "sku": "SKU0006", - "name": "item 6", - "price": 78, - "in_stock": true - }, - { - "sku": "SKU0009", - "name": "item 9", - "price": 117, - "in_stock": true - } -] diff --git a/testdata/fixtures/structured_json/users_directory.json b/testdata/fixtures/structured_json/users_directory.json deleted file mode 100644 index 4e7b2b7..0000000 --- a/testdata/fixtures/structured_json/users_directory.json +++ /dev/null @@ -1,32 +0,0 @@ -[ - { - "uid": "user_0001", - "email": "person1@corp.com", - "role": "member" - }, - { - "uid": "user_0015", - "email": "person15@corp.com", - "role": "member" - }, - { - "uid": "user_0022", - "email": "person22@corp.com", - "role": "member" - }, - { - "uid": "user_0037", - "email": "alice.target@corp.com", - "role": "admin" - }, - { - "uid": "user_0041", - "email": "person41@corp.com", - "role": "member" - }, - { - "uid": "user_0058", - "email": "person58@corp.com", - "role": "member" - } -]