Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion cmd/meat/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,11 @@ func run(ctx context.Context, o runOpts) error {
cacheStore(o.cacheDir, key, res)

o.render(res)
fmt.Fprintf(o.stderr, "\nmeat: tokens in=%d out=%d in %s\n", res.InputTokens, res.OutputTokens, elapsed)
cached := ""
if res.CacheReadTokens > 0 || res.CacheWriteTokens > 0 {
cached = fmt.Sprintf(" (cache read=%d write=%d)", res.CacheReadTokens, res.CacheWriteTokens)
}
fmt.Fprintf(o.stderr, "\nmeat: tokens in=%d%s out=%d in %s\n", res.InputTokens, cached, res.OutputTokens, elapsed)
return nil
}

Expand Down
67 changes: 53 additions & 14 deletions meat/anthropic.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ func NewAnthropicFromEnv(ctx context.Context, model string) (*AnthropicModel, er
type antReq struct {
Model string `json:"model"`
MaxTokens int `json:"max_tokens"`
System string `json:"system"`
System []antBlock `json:"system"`
Messages []antMessage `json:"messages"`
Tools []antTool `json:"tools,omitempty"`
}
Expand All @@ -82,16 +82,26 @@ type antMessage struct {
}

type antBlock struct {
Type string `json:"type"`
Text string `json:"text,omitempty"`
ID string `json:"id,omitempty"`
Name string `json:"name,omitempty"`
Input json.RawMessage `json:"input,omitempty"`
ToolUseID string `json:"tool_use_id,omitempty"`
Content string `json:"content,omitempty"`
IsError bool `json:"is_error,omitempty"`
Type string `json:"type"`
Text string `json:"text,omitempty"`
ID string `json:"id,omitempty"`
Name string `json:"name,omitempty"`
Input json.RawMessage `json:"input,omitempty"`
ToolUseID string `json:"tool_use_id,omitempty"`
Content string `json:"content,omitempty"`
IsError bool `json:"is_error,omitempty"`
CacheControl *antCacheControl `json:"cache_control,omitempty"`
}

// antCacheControl marks a cache breakpoint: the request prefix up to and
// including the marked block is cached.
type antCacheControl struct {
Type string `json:"type"`
}

// The 5-minute lifetime outlasts one Abridge run's 4-minute budget.
var ephemeralCache = &antCacheControl{Type: "ephemeral"}

type antTool struct {
Name string `json:"name"`
Description string `json:"description"`
Expand All @@ -102,8 +112,10 @@ type antResp struct {
Content []antBlock `json:"content"`
StopReason string `json:"stop_reason"`
Usage struct {
InputTokens int `json:"input_tokens"`
OutputTokens int `json:"output_tokens"`
InputTokens int `json:"input_tokens"`
OutputTokens int `json:"output_tokens"`
CacheCreationInputTokens int `json:"cache_creation_input_tokens"`
CacheReadInputTokens int `json:"cache_read_input_tokens"`
} `json:"usage"`
Error *struct {
Type string `json:"type"`
Expand All @@ -117,13 +129,17 @@ func (m *AnthropicModel) Generate(ctx context.Context, system string, messages [
return nil, fmt.Errorf("meat: AnthropicModel.APIKey is empty")
}

// The loop re-sends the whole conversation every turn, so most input is a
// replay of an identical prefix. Cache the system prompt (stable across
// runs) and the numbered diff (stable across turns).
reqBody := antReq{
Model: cmpOr(m.Model, DefaultAnthropicModel),
MaxTokens: maxOutputTokens,
System: system,
System: []antBlock{{Type: "text", Text: system, CacheControl: ephemeralCache}},
Messages: toAntMessages(messages),
Tools: toAntTools(tools),
}
markCacheBreakpoints(reqBody.Messages)
body, err := json.Marshal(reqBody)
if err != nil {
return nil, err
Expand Down Expand Up @@ -153,9 +169,14 @@ func (m *AnthropicModel) Generate(ctx context.Context, system string, messages [
return nil, fmt.Errorf("anthropic response truncated at max_tokens (%d); the diff may be too large to abridge in one pass", maxOutputTokens)
}

// InputTokens stays the total processed, so it stays comparable to
// pre-caching runs; the breakdown splits it by billing rate.
out := &Response{
InputTokens: resp.Usage.InputTokens,
OutputTokens: resp.Usage.OutputTokens,
InputTokens: resp.Usage.InputTokens +
resp.Usage.CacheCreationInputTokens + resp.Usage.CacheReadInputTokens,
OutputTokens: resp.Usage.OutputTokens,
CacheWriteTokens: resp.Usage.CacheCreationInputTokens,
CacheReadTokens: resp.Usage.CacheReadInputTokens,
}
for _, b := range resp.Content {
switch b.Type {
Expand Down Expand Up @@ -252,6 +273,24 @@ func toAntTools(tools []Tool) []antTool {
return out
}

// markCacheBreakpoints marks the first message (the numbered diff) and the
// last, which rolls forward each turn to absorb accumulated tool results.
// Anthropic allows four breakpoints per request; the system prompt uses one.
func markCacheBreakpoints(msgs []antMessage) {
mark := func(m *antMessage) {
if len(m.Content) > 0 {
m.Content[len(m.Content)-1].CacheControl = ephemeralCache
}
}
if len(msgs) == 0 {
return
}
mark(&msgs[0])
if len(msgs) > 1 {
mark(&msgs[len(msgs)-1])
}
}

func toAntMessages(messages []Message) []antMessage {
out := make([]antMessage, 0, len(messages))
for _, m := range messages {
Expand Down
8 changes: 7 additions & 1 deletion meat/anthropic_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,15 @@ func TestAbridge_AnthropicEditPlanEndToEnd(t *testing.T) {
w.WriteHeader(http.StatusBadRequest)
return
}
if !strings.Contains(req.System, "edit plan") {
if len(req.System) != 1 || !strings.Contains(req.System[0].Text, "edit plan") {
t.Errorf("system prompt does not describe edit plans")
}
if req.System[0].CacheControl == nil {
t.Errorf("system prompt is not a cache breakpoint")
}
if len(req.Messages) > 0 && req.Messages[0].Content[len(req.Messages[0].Content)-1].CacheControl == nil {
t.Errorf("numbered diff is not a cache breakpoint")
}
if len(req.Tools) != 2 || req.Tools[0].Name != "preview_plan" || req.Tools[1].Name != "submit" || strings.Contains(string(req.Tools[1].InputSchema), "smart_diff") || !strings.Contains(string(req.Tools[1].InputSchema), `"fold"`) {
t.Errorf("unexpected plan tool schema: %+v", req.Tools)
}
Expand Down
2 changes: 2 additions & 0 deletions meat/chunk.go
Original file line number Diff line number Diff line change
Expand Up @@ -966,6 +966,8 @@ func abridgeChunked(ctx context.Context, model Model, req Request) (*Result, err
}
merged.InputTokens += res.InputTokens
merged.OutputTokens += res.OutputTokens
merged.CacheWriteTokens += res.CacheWriteTokens
merged.CacheReadTokens += res.CacheReadTokens
if strings.TrimSpace(res.SmartDiff) != "" {
piece := res.SmartDiff
if chunk.sectionID >= 0 {
Expand Down
36 changes: 34 additions & 2 deletions meat/diff.go
Original file line number Diff line number Diff line change
Expand Up @@ -128,10 +128,42 @@ func numberedDiff(diff string) string {
if len(lines) == 0 {
return ""
}
return renderNumbered(lines, nil)
}

// numberedDiffEliding collapses every hidden line, keeping original 1-based
// numbers so the model's coordinates still address the immutable input. Each
// hidden run becomes one marker row, so the model can see something sits
// between two visible lines and will not fold across it.
func numberedDiffEliding(diff string, hidden []bool) string {
lines := splitSourceLines(diff)
if len(lines) == 0 {
return ""
}
return renderNumbered(lines, hidden)
}

// Marker for a collapsed run of automatically removed import rows.
const elidedImportRun = "(imports, removed automatically)"

func renderNumbered(lines []sourceLine, hidden []bool) string {
width := len(strconv.Itoa(len(lines)))
var b strings.Builder
for i, line := range lines {
fmt.Fprintf(&b, "%*d|%s\n", width, i+1, line.text)
for i := 0; i < len(lines); i++ {
if hidden != nil && hidden[i] {
run := i
for run+1 < len(lines) && hidden[run+1] {
run++
}
if run == i {
fmt.Fprintf(&b, "%*d|%s\n", width, i+1, elidedImportRun)
} else {
fmt.Fprintf(&b, "%*d-%d|%s\n", width, i+1, run+1, elidedImportRun)
}
i = run
continue
}
fmt.Fprintf(&b, "%*d|%s\n", width, i+1, lines[i].text)
}
return b.String()
}
Expand Down
12 changes: 12 additions & 0 deletions meat/imports.go
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,18 @@ func hiddenLineRanges(hidden []bool) []lineRange {
return ranges
}

// mandatoryHiddenMask reports which lines the compiler removes regardless of
// the model's plan. Those rows are elided from the prompt: their fate is
// already decided, so sending them is wasted.
func mandatoryHiddenMask(diff string) []bool {
lines := splitSourceLines(diff)
if len(lines) == 0 {
return nil
}
layout := analyzeDiff(lines)
return mandatoryRemovalMask(len(lines), mandatoryImportRemovalPlan(lines, layout))
}

func mandatoryRemovalMask(lines int, ranges []lineRange) []bool {
hidden := make([]bool, lines)
for _, r := range ranges {
Expand Down
65 changes: 53 additions & 12 deletions meat/meat.go
Original file line number Diff line number Diff line change
Expand Up @@ -100,8 +100,13 @@ type Result struct {
// Summary is a one-line, high-level description of the change.
Summary string `json:"summary"`
// InputTokens and OutputTokens are the cumulative token usage across the run.
// InputTokens counts all input processed, including cached.
InputTokens int `json:"input_tokens"`
OutputTokens int `json:"output_tokens"`
// CacheWriteTokens and CacheReadTokens split InputTokens by billing rate.
// Zero on providers without an explicit prompt cache.
CacheWriteTokens int `json:"cache_write_tokens,omitempty"`
CacheReadTokens int `json:"cache_read_tokens,omitempty"`
}

// noToolCallNudge is sent when the model produced text but no tool call. Like
Expand Down Expand Up @@ -134,7 +139,7 @@ func Abridge(ctx context.Context, model Model, req Request) (*Result, error) {
// abridgeOne runs the agent loop on one single-run-sized diff: the whole
// input when it fits, or one chunk of a split diff.
func abridgeOne(ctx context.Context, model Model, req Request, opts runOptions) (*Result, error) {
numbered := numberedDiff(req.UnifiedDiff)
numbered := numberedDiffEliding(req.UnifiedDiff, mandatoryHiddenMask(req.UnifiedDiff))

maxTurns := req.MaxTurns
if maxTurns <= 0 {
Expand All @@ -145,6 +150,8 @@ func abridgeOne(ctx context.Context, model Model, req Request, opts runOptions)
ctx, cancel := context.WithTimeout(ctx, abridgeBudget)
defer cancel()

system := buildSystemPrompt(req.UnifiedDiff)

tb := &toolbox{root: req.RepoRoot, rawDiff: req.UnifiedDiff, noMoves: opts.chunkRun, moves: opts.chunkMoves}
tools := tb.tools()

Expand All @@ -158,23 +165,25 @@ func abridgeOne(ctx context.Context, model Model, req Request, opts runOptions)
progress = func(string) {}
}

var inTok, outTok int
var inTok, outTok, cacheWrite, cacheRead int
var previewIDs []string
var retentionNudged bool
var fallback *Result

for turn := 0; turn < maxTurns; turn++ {
progress(fmt.Sprintf("thinking (turn %d)", turn+1))
resp, err := model.Generate(ctx, systemPrompt, messages, tools)
resp, err := model.Generate(ctx, system, messages, tools)
if err != nil {
if fallback != nil && callerCtx.Err() == nil {
fallback.InputTokens = inTok
fallback.OutputTokens = outTok
setUsage(fallback, inTok, outTok, cacheWrite, cacheRead)
return fallback, nil
}
return nil, fmt.Errorf("meat: model generate: %w", err)
}
inTok += resp.InputTokens
outTok += resp.OutputTokens
cacheWrite += resp.CacheWriteTokens
cacheRead += resp.CacheReadTokens

messages = append(messages, Message{Role: RoleAssistant, Content: resp.Content})

Expand All @@ -185,6 +194,9 @@ func abridgeOne(ctx context.Context, model Model, req Request, opts runOptions)
}
progress(describeToolCall(b))
out, isErr := tb.run(ctx, b.ToolName, b.ToolInput)
if b.ToolName == "preview_plan" {
previewIDs = append(previewIDs, b.ID)
}
results = append(results, Block{
Type: "tool_result",
ToolUseID: b.ID,
Expand All @@ -195,18 +207,18 @@ func abridgeOne(ctx context.Context, model Model, req Request, opts runOptions)

if tb.submitSeen {
candidate := &Result{
SmartDiff: tb.smartDiff,
Summary: tb.submitted.Summary,
InputTokens: inTok,
OutputTokens: outTok,
SmartDiff: tb.smartDiff,
Summary: tb.submitted.Summary,
}
setUsage(candidate, inTok, outTok, cacheWrite, cacheRead)
canRefine := !retentionNudged && turn+1 < maxTurns &&
tb.submittedPlan != nil && retentionPressure(tb.submittedPlan.stats)
if canRefine {
fallback = candidate
retentionNudged = true
tb.clearSubmission()
messages = append(messages, Message{Role: RoleUser, Content: results})
elideSupersededPreviews(messages, previewIDs)
continue
}
return candidate, nil
Expand All @@ -222,25 +234,54 @@ func abridgeOne(ctx context.Context, model Model, req Request, opts runOptions)
continue
}
messages = append(messages, Message{Role: RoleUser, Content: results})
elideSupersededPreviews(messages, previewIDs)
}

if fallback != nil {
if err := callerCtx.Err(); err != nil {
return nil, fmt.Errorf("meat: caller context: %w", err)
}
fallback.InputTokens = inTok
fallback.OutputTokens = outTok
setUsage(fallback, inTok, outTok, cacheWrite, cacheRead)
return fallback, nil
}
return nil, fmt.Errorf("meat: agent did not submit within %d turns", maxTurns)
}

// Replaces a preview_plan result that a later preview superseded.
const supersededPreviewStub = "(superseded preview elided; plans are complete restatements against the ORIGINAL diff)"

// elideSupersededPreviews stubs every preview_plan result but the newest. Each
// embeds a full rendered diff, and since plans are complete restatements rather
// than incremental edits, an older preview cannot inform a later turn.
func elideSupersededPreviews(messages []Message, previewIDs []string) {
if len(previewIDs) < 2 {
return
}
stale := make(map[string]bool, len(previewIDs)-1)
for _, id := range previewIDs[:len(previewIDs)-1] {
stale[id] = true
}
for _, m := range messages {
for i, b := range m.Content {
if b.Type == "tool_result" && stale[b.ToolUseID] && m.Content[i].ToolResult != supersededPreviewStub {
m.Content[i].ToolResult = supersededPreviewStub
}
}
}
}

// setUsage stamps cumulative token counters onto a result.
func setUsage(r *Result, in, out, cacheWrite, cacheRead int) {
r.InputTokens, r.OutputTokens = in, out
r.CacheWriteTokens, r.CacheReadTokens = cacheWrite, cacheRead
}

// The static model-visible user-prompt fragments. Every string the model can
// see that is not derived from the input diff lives in a named const so
// promptSurface can hash the complete frozen surface.
const (
userPromptIntro = "Abridge the following unified diff into a reading diff by submitting a complete remove/replace/fold plan against the numbered original lines. Meat applies your plan to the original diff; you do not write the resulting diff yourself. Coordinates are 1-based and always refer to the original numbering. The `N|` gutter is display-only and is not part of a line's source text. Use preview_plan to inspect sizeable drafts before submit.\n"
userPromptImports = "Imports/includes/requires/use declarations are removed automatically, including multiline blocks and recognized imports inside embedded source strings. They may appear in the numbered input but never in a preview or result. Do not spend edit coordinates on them, never fold across them into behavioral rows, and do not mention them in the summary.\n"
userPromptImports = "Rows shown as (imports, removed automatically) stand for import/include/require/use declarations already elided from the input and absent from every preview and result. Do not spend edit coordinates on them, never fold across one into behavioral rows, and do not mention them in the summary.\n"
userPromptMoves = "Meat detected exact source-evidenced moves across hunks/files: %s. Give both sides of each pair identical keep/remove/fold/replace treatment, including matching fold boundaries and equivalent local elisions; automatically removed rows need none. Asymmetric plans are rejected.\n"
userPromptTools = "Use read_file/grep on the surrounding source only when it changes your judgment about what is load-bearing (or whether a file is generated), then preview or submit.\n"
userPromptNoTools = "Judge from the diff text alone, then preview or submit.\n"
Expand Down
5 changes: 3 additions & 2 deletions meat/meat_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -658,7 +658,8 @@ func TestPromptSurfaceStaysFrozen(t *testing.T) {

moveDiff := exactMoveDiff
surfaces := map[string]string{
"systemPrompt": systemPrompt,
"systemPrompt": buildSystemPrompt(moveDiff),
"systemPrompt (py)": buildSystemPrompt("--- a/x.py\n+++ b/x.py\n"),
"userPrompt": buildUserPrompt(Request{UnifiedDiff: moveDiff, RepoRoot: "/repo"}, runOptions{}, numberedDiff(moveDiff)),
"userPrompt (no root)": buildUserPrompt(Request{UnifiedDiff: moveDiff}, runOptions{}, numberedDiff(moveDiff)),
"userPrompt (no move)": buildUserPrompt(Request{UnifiedDiff: surfaceFixtureNoMoveDiff, RepoRoot: "/repo"}, runOptions{}, numberedDiff(surfaceFixtureNoMoveDiff)),
Expand Down Expand Up @@ -761,7 +762,7 @@ func TestSurfaceFixturesCoverBothMoveBranches(t *testing.T) {
}

func TestRubricHashPinned(t *testing.T) {
const pinned = "441f5e6e28ad3add"
const pinned = "c42069520d2b338a"
if h := RubricHash(); h != pinned {
t.Errorf("RubricHash() = %q, pinned %q; the model-visible prompt surface changed — review it against the freeze policy on systemPrompt, then update the pin", h, pinned)
}
Expand Down
Loading