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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -310,7 +310,13 @@ built by one function apiece:

`issueContext` (also in `prompt.go`) renders the shared issue-body/comment block all the task
prompts embed, truncating at `maxBodyChars`/`maxCommentChars`/`maxCommentsInclu` so a very long
issue thread can't blow the prompt out.
issue thread can't blow the prompt out. It also drops the harness's own comments (the plan, the
PR announcement, failure notes — anything carrying the `<!-- coding-agent-loop:...` marker) and
bare `implement` approvals before that window is applied: none of it is information the model
needs re-sent to itself on every turn, and on a retry the failure notes are pure noise. Only
genuine human discussion survives the filter. `prCommentTaskPrompt` caps at `maxPRCommentsInclu`,
`maxDiffHunkChars`, and `maxReviewsInclu` for the same reason on the PR-comment path. See issue
#18.

To change what the agent is told, edit the relevant function in `prompt.go` — `prompt_test.go`
pins the exact wording of the harness rules (git/GitHub ownership, scope, autonomy), so a
Expand All @@ -325,6 +331,8 @@ result is parsed from a streamed JSONL transcript:

```sh
claude --print --output-format stream-json --verbose --no-session-persistence \
--strict-mcp-config --disable-slash-commands \
--exclude-dynamic-system-prompt-sections --autocompact 200000 \
--model <head of the role's models.json ladder> \
--fallback-model <the rest of that ladder, comma-separated> \
--effort <models.json "effort" for this model+role, if set> \
Expand All @@ -344,6 +352,18 @@ one for the role, otherwise the CLI's own default applies. The terminal `type: "
(tokens used, cost, which model actually served the run, stop reason) is what gets recorded against
the run in SQLite.

The four flags on the second line are unconditional, baked-in defaults with no config knob (see
issue #18): the daemon shells out with the operator's own environment, so without them every run
would inherit that operator's ambient MCP servers and skills into the system prompt on every
single turn. `--strict-mcp-config` loads zero MCP servers (the harness only needs the built-in
file/bash tools); `--disable-slash-commands` drops the skills listing, which the harness never
invokes anyway; `--exclude-dynamic-system-prompt-sections` moves per-machine sections (cwd, env,
git status) out of the cached system prompt and into the first user message, improving
prompt-cache reuse; `--autocompact 200000` bounds how large the conversation grows before
compaction, which is what caps the per-turn cache-read cost on a long run. An operator who needs
MCP in agent runs can still add `--mcp-config <file>` to `claude.extra_args`, which
`--strict-mcp-config` honours.

## Responding to PR comments

Once a draft PR is open, a reviewer can hand feedback back to the agent without re-labelling
Expand Down
34 changes: 34 additions & 0 deletions internal/claude/runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,32 @@ func (r *Result) TokensOut() int64 {
return r.Usage.OutputTokens
}

// FreshTokensIn is the input tokens actually processed fresh, excluding all
// cache traffic — the number that predicts rate-limit pressure.
func (r *Result) FreshTokensIn() int64 {
if r == nil {
return 0
}
return r.Usage.InputTokens
}

// CacheWriteTokens is the input tokens written into the prompt cache.
func (r *Result) CacheWriteTokens() int64 {
if r == nil {
return 0
}
return r.Usage.CacheCreationInputTokens
}

// CacheReadTokens is the input tokens served from the prompt cache, billed at
// a fraction of fresh input and not what drives a rate limit.
func (r *Result) CacheReadTokens() int64 {
if r == nil {
return 0
}
return r.Usage.CacheReadInputTokens
}

// Options configures one headless invocation.
type Options struct {
// Binary is the claude executable.
Expand Down Expand Up @@ -200,6 +226,14 @@ func (r *Runner) Run(ctx context.Context, opts Options) (*Result, error) {
"--output-format", "stream-json",
"--verbose", // required alongside stream-json in print mode
"--no-session-persistence",
// Keep the per-turn system prompt to what the harness actually needs:
// no ambient MCP servers, no skills listing, dynamic sections moved out
// of the cached prefix, and a hard cap on how large the conversation
// grows before compaction. See issue #18.
"--strict-mcp-config",
"--disable-slash-commands",
"--exclude-dynamic-system-prompt-sections",
"--autocompact", "200000",
}
if opts.Model != "" {
args = append(args, "--model", opts.Model)
Expand Down
36 changes: 36 additions & 0 deletions internal/claude/runner_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,42 @@ printf '{"type":"result","subtype":"success","is_error":false,"result":"%s"}\n'
}
}

// The harness must never load the operator's ambient MCP servers, skills, or
// an unbounded conversation into every turn's system prompt — see issue #18.
func TestRunPassesTokenReductionFlags(t *testing.T) {
bin := stubCLI(t, `cat > /dev/null
for a in "$@"; do printf '%s\n' "$a" >> "$ARGS_FILE"; done
echo '`+successResult+`'
`)
argsFile := filepath.Join(t.TempDir(), "args.txt")
res, err := (&Runner{}).Run(context.Background(), Options{
Binary: bin, LogPath: filepath.Join(t.TempDir(), "run.jsonl"),
Env: []string{"ARGS_FILE=" + argsFile},
})
if err != nil {
t.Fatalf("run: %v", err)
}
if res == nil {
t.Fatal("expected a result")
}
got, err := os.ReadFile(argsFile)
if err != nil {
t.Fatalf("stub did not record args: %v", err)
}
args := string(got)
for _, want := range []string{
"--strict-mcp-config",
"--disable-slash-commands",
"--exclude-dynamic-system-prompt-sections",
"--autocompact",
"200000",
} {
if !strings.Contains(args, want) {
t.Errorf("invocation should include %q, got: %s", want, args)
}
}
}

func TestLogPathRequired(t *testing.T) {
if _, err := (&Runner{}).Run(context.Background(), Options{Binary: "true"}); err == nil {
t.Fatal("LogPath must be required so every run leaves a transcript")
Expand Down
9 changes: 4 additions & 5 deletions internal/discord/notifier.go
Original file line number Diff line number Diff line change
Expand Up @@ -115,9 +115,7 @@ func (n *Notifier) post(e embed) {
return
}

n.wg.Add(1)
go func() {
defer n.wg.Done()
n.wg.Go(func() {
ctx, cancel := context.WithTimeout(context.Background(), postTimeout)
defer cancel()

Expand All @@ -137,7 +135,7 @@ func (n *Notifier) post(e embed) {
if resp.StatusCode >= 300 {
n.log("discord: webhook returned %d", resp.StatusCode)
}
}()
})
}

// postRun sends e to the webhook after making sure it carries a clickable
Expand Down Expand Up @@ -266,7 +264,8 @@ func (n *Notifier) ClaudeFinished(r RunRef, res *claude.Result, elapsed time.Dur
embedField{Name: "Model", Value: orNone(res.PrimaryModel()), Inline: true},
embedField{Name: "Turns", Value: fmt.Sprintf("%d", res.NumTurns), Inline: true},
embedField{Name: "Cost", Value: money(res.TotalCostUSD), Inline: true},
embedField{Name: "Tokens", Value: fmt.Sprintf("%d in / %d out", res.TokensIn(), res.TokensOut()), Inline: true},
embedField{Name: "Tokens", Value: fmt.Sprintf("%d in (%d new · %d written · %d cached) / %d out",
res.TokensIn(), res.FreshTokensIn(), res.CacheWriteTokens(), res.CacheReadTokens(), res.TokensOut()), Inline: true},
embedField{Name: "Duration", Value: humanDuration(elapsed), Inline: true},
embedField{Name: "Session", Value: orNone(res.SessionID), Inline: true},
),
Expand Down
2 changes: 1 addition & 1 deletion internal/gate/gate.go
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@ type Snapshot struct {
Percent float64 `json:"percent,omitempty"`
Enabled bool `json:"enabled"`
Note string `json:"note,omitempty"`
CooldownEnd time.Time `json:"cooldown_end,omitempty"`
CooldownEnd time.Time `json:"cooldown_end"`
}

// Gate evaluates and records gating state.
Expand Down
8 changes: 4 additions & 4 deletions internal/install/install.go
Original file line number Diff line number Diff line change
Expand Up @@ -470,12 +470,12 @@ func runSystemctl(log func(string, ...any), args ...string) error {
}

func argsString(args []string) string {
s := ""
var s strings.Builder
for i, a := range args {
if i > 0 {
s += " "
s.WriteString(" ")
}
s += a
s.WriteString(a)
}
return s
return s.String()
}
23 changes: 15 additions & 8 deletions internal/orchestrator/loop.go
Original file line number Diff line number Diff line change
Expand Up @@ -238,12 +238,10 @@ func (o *Orchestrator) tick(ctx context.Context) {
capacity--

cand := candidate{repo: repo, number: r.Number, title: r.Title, url: r.URL}
o.wg.Add(1)
go func() {
defer o.wg.Done()
o.wg.Go(func() {
defer o.releaseRepo(cand.repo)
o.work(ctx, cand)
}()
})
}
}

Expand Down Expand Up @@ -657,7 +655,7 @@ func (o *Orchestrator) execute(ctx context.Context, log *slog.Logger, cand candi
if err != nil {
return fmt.Errorf("select model: %w", err)
}
if err := o.opts.Store.RecordUsage(ctx, runID, head.ID, "", 0, 0, 0, 0); err != nil {
if err := o.opts.Store.RecordUsage(ctx, runID, store.RunUsage{ModelID: head.ID}); err != nil {
log.Warn("could not pre-record model", "error", err)
}

Expand Down Expand Up @@ -737,8 +735,16 @@ func (o *Orchestrator) execute(ctx context.Context, log *slog.Logger, cand candi
if m := result.PrimaryModel(); m != "" {
usedModel = m
}
if err := o.opts.Store.RecordUsage(bookkeeping, runID, usedModel, result.SessionID,
result.TotalCostUSD, result.TokensIn(), result.TokensOut(), result.NumTurns); err != nil {
if err := o.opts.Store.RecordUsage(bookkeeping, runID, store.RunUsage{
ModelID: usedModel,
SessionID: result.SessionID,
CostUSD: result.TotalCostUSD,
TokensIn: result.TokensIn(),
TokensOut: result.TokensOut(),
CacheRead: result.CacheReadTokens(),
CacheWrite: result.CacheWriteTokens(),
Turns: result.NumTurns,
}); err != nil {
log.Warn("usage record failed", "error", err)
}
// Re-record now that the model that actually served the run is known.
Expand Down Expand Up @@ -791,7 +797,8 @@ func (o *Orchestrator) execute(ctx context.Context, log *slog.Logger, cand candi
log.Warn("could not clear usage gate", "error", err)
}
o.opts.Discord.GateCleared()
o.event(ctx, runID, "claude_done", fmt.Sprintf("turns=%d cost=$%.4f", result.NumTurns, result.TotalCostUSD))
o.event(ctx, runID, "claude_done", fmt.Sprintf("turns=%d cost=$%.4f fresh_in=%d cached_in=%d out=%d",
result.NumTurns, result.TotalCostUSD, result.FreshTokensIn(), result.CacheReadTokens(), result.TokensOut()))

if phase == phasePlan {
if strings.TrimSpace(result.Result) == "" {
Expand Down
2 changes: 1 addition & 1 deletion internal/orchestrator/loop_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -245,7 +245,7 @@ func TestTaskPromptHandlesEmptyBody(t *testing.T) {

func TestTaskPromptTrimsLongDiscussions(t *testing.T) {
issue := gh.Issue{Number: 1, Title: "t", Body: "b"}
for i := 0; i < 40; i++ {
for range 40 {
issue.Comments = append(issue.Comments, gh.Comment{Author: gh.User{Login: "u"}, Body: "comment"})
}
p := implementTaskPrompt("acme/widgets", issue, "")
Expand Down
6 changes: 3 additions & 3 deletions internal/orchestrator/phase.go
Original file line number Diff line number Diff line change
Expand Up @@ -135,11 +135,11 @@ func extractPlan(body string) string {
if !isPlanComment(body) {
return ""
}
i := strings.Index(body, planHeader)
if i < 0 {
_, after, ok := strings.Cut(body, planHeader)
if !ok {
return ""
}
plan := body[i+len(planHeader):]
plan := after

// The footer is matched from the end: a plan may well contain its own
// horizontal rule, and only the last one is the comment's own.
Expand Down
25 changes: 16 additions & 9 deletions internal/orchestrator/prcomments.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ func mentionsAgent(body, handle string) bool {
return false
}
inFence := false
for _, line := range strings.Split(body, "\n") {
for line := range strings.SplitSeq(body, "\n") {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "```") {
inFence = !inFence
Expand Down Expand Up @@ -238,12 +238,10 @@ func (o *Orchestrator) tickPRComments(ctx context.Context, capacity int) int {
capacity--

cand := candidate{repo: repo, number: r.Number, title: pr.Title, url: pr.URL}
o.wg.Add(1)
go func() {
defer o.wg.Done()
o.wg.Go(func() {
defer o.releaseRepo(cand.repo)
o.workPRComments(ctx, cand, pr, pending)
}()
})
}
return capacity
}
Expand Down Expand Up @@ -382,7 +380,7 @@ func (o *Orchestrator) executePRComments(ctx context.Context, log *slog.Logger,
if err != nil {
return fmt.Errorf("select model: %w", err)
}
if err := o.opts.Store.RecordUsage(ctx, runID, head.ID, "", 0, 0, 0, 0); err != nil {
if err := o.opts.Store.RecordUsage(ctx, runID, store.RunUsage{ModelID: head.ID}); err != nil {
log.Warn("could not pre-record model", "error", err)
}
if err := o.opts.Store.SetRunStatus(ctx, runID, store.StatusWorking); err != nil {
Expand Down Expand Up @@ -432,8 +430,16 @@ func (o *Orchestrator) executePRComments(ctx context.Context, log *slog.Logger,
if m := result.PrimaryModel(); m != "" {
usedModel = m
}
if err := o.opts.Store.RecordUsage(ctx, runID, usedModel, result.SessionID,
result.TotalCostUSD, result.TokensIn(), result.TokensOut(), result.NumTurns); err != nil {
if err := o.opts.Store.RecordUsage(ctx, runID, store.RunUsage{
ModelID: usedModel,
SessionID: result.SessionID,
CostUSD: result.TotalCostUSD,
TokensIn: result.TokensIn(),
TokensOut: result.TokensOut(),
CacheRead: result.CacheReadTokens(),
CacheWrite: result.CacheWriteTokens(),
Turns: result.NumTurns,
}); err != nil {
log.Warn("usage record failed", "error", err)
}
o.recordSession(ctx, log, cand, runID, result.SessionID, usedModel)
Expand Down Expand Up @@ -476,7 +482,8 @@ func (o *Orchestrator) executePRComments(ctx context.Context, log *slog.Logger,
log.Warn("could not clear usage gate", "error", err)
}
o.opts.Discord.GateCleared()
o.event(ctx, runID, "claude_done", fmt.Sprintf("turns=%d cost=$%.4f", result.NumTurns, result.TotalCostUSD))
o.event(ctx, runID, "claude_done", fmt.Sprintf("turns=%d cost=$%.4f fresh_in=%d cached_in=%d out=%d",
result.NumTurns, result.TotalCostUSD, result.FreshTokensIn(), result.CacheReadTokens(), result.TokensOut()))

hasWork, err := o.opts.Git.HasWork(ctx, worktree, pr.HeadRefName)
if err != nil {
Expand Down
2 changes: 1 addition & 1 deletion internal/orchestrator/progress_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ func TestProgressHeartbeatIsRateLimited(t *testing.T) {
if first != 1 {
t.Fatalf("the first event past the interval should report, got %d lines:\n%s", first, buf.String())
}
for i := 0; i < 50; i++ {
for range 50 {
p.observe("assistant", assistantEvent(t, "Bash"))
}
if got := strings.Count(buf.String(), "claude still working"); got != 1 {
Expand Down
Loading
Loading