Skip to content

Commit ea188ee

Browse files
committed
Guard task completion after failed tools
1 parent 442f82b commit ea188ee

11 files changed

Lines changed: 327 additions & 9 deletions

File tree

internal/agent/runtime/apply_patch_test.go

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package runtime
22

33
import (
4+
"bytes"
45
"context"
56
"encoding/json"
67
"io"
@@ -15,13 +16,18 @@ import (
1516
)
1617

1718
func applyPatchSess(t *testing.T, root string) *Session {
19+
t.Helper()
20+
return applyPatchSessWithOut(t, root, io.Discard)
21+
}
22+
23+
func applyPatchSessWithOut(t *testing.T, root string, out io.Writer) *Session {
1824
t.Helper()
1925
st, err := store.Open(context.Background(), filepath.Join(t.TempDir(), "state.db"))
2026
if err != nil {
2127
t.Fatal(err)
2228
}
2329
t.Cleanup(func() { st.Close() })
24-
return newSess(st, captureProviderNil{}, root, "allow-all", permissions.ModeAllowAll, io.Discard)
30+
return newSess(st, captureProviderNil{}, root, "allow-all", permissions.ModeAllowAll, out)
2531
}
2632

2733
// A multi-file patch where every edit is valid applies them ALL.
@@ -89,3 +95,45 @@ func TestApplyPatchRollbackDeletesCreated(t *testing.T) {
8995
t.Fatal("a file created by a rolled-back patch must be deleted")
9096
}
9197
}
98+
99+
func TestEditFileWriteRendersCreatedFilePreview(t *testing.T) {
100+
root := t.TempDir()
101+
var out bytes.Buffer
102+
s := applyPatchSessWithOut(t, root, &out)
103+
104+
in, _ := json.Marshal(tools.EditFileInput{
105+
Path: "new.go",
106+
OldString: "",
107+
NewString: "package p\n\nfunc Answer() int {\n\treturn 42\n}\n",
108+
})
109+
if r := s.editFile(context.Background(), in); r.isError {
110+
t.Fatalf("write should apply: %q", r.text())
111+
}
112+
got := ansiSeq.ReplaceAllString(out.String(), "")
113+
for _, want := range []string{"Write(", "new.go", "Created file (5 lines)", "func Answer() int", "return 42"} {
114+
if !strings.Contains(got, want) {
115+
t.Errorf("write preview missing %q in:\n%s", want, got)
116+
}
117+
}
118+
}
119+
120+
func TestApplyPatchWriteRendersCreatedFilePreview(t *testing.T) {
121+
root := t.TempDir()
122+
var out bytes.Buffer
123+
s := applyPatchSessWithOut(t, root, &out)
124+
125+
in, _ := json.Marshal(tools.ApplyPatchInput{Edits: []tools.EditFileInput{{
126+
Path: "new.go",
127+
OldString: "",
128+
NewString: "package p\n\nfunc Answer() int {\n\treturn 42\n}\n",
129+
}}})
130+
if r := s.applyPatch(context.Background(), in); r.isError {
131+
t.Fatalf("patch should apply: %q", r.text())
132+
}
133+
got := ansiSeq.ReplaceAllString(out.String(), "")
134+
for _, want := range []string{"Write(", "new.go", "Created file (5 lines)", "func Answer() int", "return 42"} {
135+
if !strings.Contains(got, want) {
136+
t.Errorf("apply_patch write preview missing %q in:\n%s", want, got)
137+
}
138+
}
139+
}

internal/agent/runtime/diagnostics.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,7 @@ func (s *Session) lspDiagResult(lang, path string, diags []lsp.Diagnostic) toolR
158158
out := strings.TrimRight(b.String(), "\n")
159159
s.toolLine(true, "Diagnostics", lang+" (lsp)", strconv.Itoa(len(diags))+" issue(s)", true)
160160
s.toolResult(linesPreview(out, maxDiagPreviewLines)) // show WHAT the issues are, not just a red count
161-
return textResult(s.redactor.Redact(truncate(out, maxToolOutput)))
161+
return errResult(s.redactor.Redact(truncate(out, maxToolOutput)))
162162
}
163163

164164
// diagnosticsLang picks the language from the path extension, else the repo markers.
@@ -238,5 +238,5 @@ func (s *Session) diagResult(lang, out string) toolResult {
238238
n := strings.Count(out, "\n") + 1
239239
s.toolLine(true, "Diagnostics", lang, strconv.Itoa(n)+" line(s)", true)
240240
s.toolResult(linesPreview(out, maxDiagPreviewLines))
241-
return textResult(s.redactor.Redact(truncate(out, maxToolOutput)))
241+
return errResult(s.redactor.Redact(truncate(out, maxToolOutput)))
242242
}

internal/agent/runtime/diagnostics_test.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,9 @@ func TestGoDiagnostics(t *testing.T) {
4545
// Introduce a type error → diagnostics surfaces it with the file name.
4646
write("bad.go", "package tmpdiag\n\nvar X int = \"not an int\"\n")
4747
r := s.diagnosticsTool(context.Background(), in)
48+
if !r.isError {
49+
t.Fatal("diagnostics with compiler output should be an error tool result")
50+
}
4851
if !strings.Contains(r.text(), "bad.go") {
4952
t.Fatalf("compile error not surfaced with file: %q", r.text())
5053
}

internal/agent/runtime/exec.go

Lines changed: 78 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -154,16 +154,18 @@ func isParallelSafe(name string) bool {
154154
func (s *Session) execute(ctx context.Context, u wire.Block) toolResult {
155155
s.mu.Lock()
156156
s.metrics.toolCalls++
157+
seq := s.metrics.toolCalls
157158
s.mu.Unlock()
158159
// Redact the tool input before it is persisted — the model may pass a secret
159160
// (e.g. a bearer token in a command).
160161
s.emit(ctx, events.KindToolCalled, map[string]any{"tool": u.Name, "input": s.redactor.Redact(string(u.Input))})
161162
tr := s.dispatch(ctx, u)
163+
s.mu.Lock()
162164
if tr.isError {
163-
s.mu.Lock()
164165
s.metrics.toolErrors++
165-
s.mu.Unlock()
166166
}
167+
s.noteCompletionBlockerLocked(seq, u, tr)
168+
s.mu.Unlock()
167169
// Central guarantee: no known secret value ever reaches the model — redact
168170
// the text of every block (images carry no text).
169171
for i := range tr.blocks {
@@ -174,6 +176,62 @@ func (s *Session) execute(ctx context.Context, u wire.Block) toolResult {
174176
return tr
175177
}
176178

179+
// noteCompletionBlockerLocked records failed tools that are themselves a
180+
// deliverable the user can inspect (PR creation, artifact publish, etc.). The
181+
// todo tracker refuses to mark work done while one is unresolved, so a model
182+
// cannot paper over "GitHub(create PR) · failed" with "Task(done)".
183+
func (s *Session) noteCompletionBlockerLocked(seq int, u wire.Block, tr toolResult) {
184+
label, ok := completionBlockerLabel(u)
185+
if !ok {
186+
return
187+
}
188+
if tr.isError {
189+
s.metrics.blockerSeq = seq
190+
s.metrics.blockerLabel = label
191+
s.metrics.blockerDetail = s.redactor.Redact(firstLine(tr.text()))
192+
return
193+
}
194+
if s.metrics.blockerLabel == label {
195+
s.metrics.blockerSeq = 0
196+
s.metrics.blockerLabel = ""
197+
s.metrics.blockerDetail = ""
198+
}
199+
}
200+
201+
func completionBlockerLabel(u wire.Block) (string, bool) {
202+
switch u.Name {
203+
case tools.RunTests:
204+
return "test run", true
205+
case tools.Diagnostics:
206+
return "diagnostics check", true
207+
case tools.GitHub:
208+
var in tools.GitHubInput
209+
if json.Unmarshal(u.Input, &in) != nil {
210+
return "", false
211+
}
212+
switch in.Action {
213+
case "pr_create":
214+
return "GitHub create PR", true
215+
case "comment":
216+
return "GitHub comment", true
217+
}
218+
case tools.Artifact:
219+
var in tools.ArtifactInput
220+
if json.Unmarshal(u.Input, &in) != nil {
221+
return "", false
222+
}
223+
switch in.Action {
224+
case "publish":
225+
return "artifact publish", true
226+
case "update":
227+
return "artifact update", true
228+
case "delete":
229+
return "artifact delete", true
230+
}
231+
}
232+
return "", false
233+
}
234+
177235
func (s *Session) dispatch(ctx context.Context, u wire.Block) toolResult {
178236
// A reader sub-agent has no mutating tools; reject them defensively even if one
179237
// is somehow proposed (they aren't advertised — see toolDefs). Bash is the
@@ -1375,7 +1433,7 @@ func (s *Session) editFile(ctx context.Context, input json.RawMessage) toolResul
13751433
verb = "Write"
13761434
}
13771435
safeDiff := s.redactor.Redact(res.Diff)
1378-
newContent := s.redactor.Redact(in.NewString)
1436+
newContent := s.newFilePreviewContent(res.Path, in.NewString)
13791437
if secrets.IsSecretPath(in.Path) {
13801438
// Editing a credential file: mask values in both the diff and new content.
13811439
safeDiff = secrets.RedactSecretFile(safeDiff)
@@ -1536,13 +1594,19 @@ func (s *Session) applyPatch(ctx context.Context, input json.RawMessage) toolRes
15361594

15371595
var b strings.Builder
15381596
fmt.Fprintf(&b, "OK, applied %d edits across %d file(s) atomically:\n", len(applied), len(dedupePaths(paths)))
1539-
for _, res := range applied {
1597+
for i, res := range applied {
15401598
verb := "Update"
15411599
if res.Created {
15421600
verb = "Write"
15431601
}
15441602
s.toolLine(true, verb, res.Path, "", false)
1545-
if d := s.redactor.Redact(res.Diff); d != "" {
1603+
if res.Created {
1604+
newContent := s.newFilePreviewContent(res.Path, in.Edits[i].NewString)
1605+
if secrets.IsSecretPath(res.Path) {
1606+
newContent = secrets.RedactSecretFile(newContent)
1607+
}
1608+
renderNewFile(s.out, newContent, res.Path, s.diffWidth())
1609+
} else if d := s.redactor.Redact(res.Diff); d != "" {
15461610
renderDiff(s.out, d, res.Path, s.diffWidth())
15471611
}
15481612
fmt.Fprintf(&b, " %s %s\n", verb, res.Path)
@@ -1555,6 +1619,15 @@ func (s *Session) applyPatch(ctx context.Context, input json.RawMessage) toolRes
15551619
return textResult(out)
15561620
}
15571621

1622+
func (s *Session) newFilePreviewContent(path, fallback string) string {
1623+
if abs, err := safeJoin(s.root, path); err == nil {
1624+
if b, err := os.ReadFile(abs); err == nil {
1625+
return s.redactor.Redact(string(b))
1626+
}
1627+
}
1628+
return s.redactor.Redact(fallback)
1629+
}
1630+
15581631
// dedupePaths returns the unique paths in first-seen order.
15591632
func dedupePaths(paths []string) []string {
15601633
seen := map[string]bool{}

internal/agent/runtime/github.go

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"os/exec"
88
"strconv"
99
"strings"
10+
"time"
1011

1112
"github.com/memcode-ai/memcode/internal/agent/permissions"
1213
"github.com/memcode-ai/memcode/internal/agent/tools"
@@ -51,6 +52,10 @@ func (s *Session) githubTool(ctx context.Context, input json.RawMessage) toolRes
5152
if strings.TrimSpace(in.Title) == "" || strings.TrimSpace(in.Body) == "" {
5253
return errResult("pr_create needs a `title` and `body`.")
5354
}
55+
if msg := s.prCreatePreflight(ctx, in.Base); msg != "" {
56+
s.toolLine(true, "GitHub", "create PR", "blocked: "+clip(msg, 160), true)
57+
return errResult(msg)
58+
}
5459
if ok, reason := s.gate(ctx, permissions.Medium, false, ApprovalRequest{
5560
Title: in.Title, Label: "Open a GitHub PR", Detail: "gh pr create", Risk: permissions.Medium.String(),
5661
}); !ok {
@@ -78,6 +83,56 @@ func (s *Session) githubTool(ctx context.Context, input json.RawMessage) toolRes
7883
}
7984
}
8085

86+
// prCreatePreflight keeps PR creation at the end of the work: a PR without
87+
// committed, pushed branch work is at best a noisy gh prompt and at worst a
88+
// false "done" marker. Return "" when ready, else a user/model-facing reason.
89+
func (s *Session) prCreatePreflight(ctx context.Context, base string) string {
90+
run := func(args ...string) (string, error) {
91+
cctx, cancel := context.WithTimeout(ctx, 10*time.Second)
92+
defer cancel()
93+
cmd := exec.CommandContext(cctx, "git", args...)
94+
cmd.Dir = s.root
95+
out, err := cmd.CombinedOutput()
96+
return strings.TrimSpace(string(out)), err
97+
}
98+
if out, err := run("status", "--porcelain"); err != nil {
99+
return "pr_create preflight failed: git status failed: " + firstLine(out)
100+
} else if strings.TrimSpace(out) != "" {
101+
return "pr_create is a final step: the worktree still has uncommitted changes. Commit the branch work and push it before creating the PR."
102+
}
103+
branch, err := run("branch", "--show-current")
104+
if err != nil || branch == "" {
105+
return "pr_create needs a named feature branch; commit on a branch before creating the PR."
106+
}
107+
if branch == "main" || branch == "master" {
108+
return "pr_create needs a feature branch, not " + branch + ". Create and push a branch before opening the PR."
109+
}
110+
if strings.TrimSpace(base) == "" {
111+
base = "origin/main"
112+
} else if !strings.Contains(base, "/") {
113+
base = "origin/" + strings.TrimSpace(base)
114+
}
115+
ahead, err := run("rev-list", "--count", base+"..HEAD")
116+
if err != nil {
117+
return "pr_create preflight failed: cannot compare against " + base + " — fetch the base branch or pass a valid base."
118+
}
119+
if ahead == "0" {
120+
return "pr_create has nothing to open: this branch has no commits ahead of " + base + ". Commit the completed work first."
121+
}
122+
upstream, err := run("rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}")
123+
if err != nil || upstream == "" {
124+
return "pr_create needs the current branch pushed upstream. Run git push -u origin " + branch + " after committing, then create the PR."
125+
}
126+
unpushed, err := run("rev-list", "--count", upstream+"..HEAD")
127+
if err != nil {
128+
return "pr_create preflight failed: cannot compare against upstream " + upstream + ". Push the branch again, then create the PR."
129+
}
130+
if unpushed != "0" {
131+
return "pr_create needs the latest commits pushed first (" + unpushed + " local commit(s) are not on " + upstream + ")."
132+
}
133+
return ""
134+
}
135+
81136
// ghRun invokes gh with the given args in the repo root, returns its output (redacted,
82137
// truncated), and surfaces a tool line. A non-zero exit returns the error text.
83138
func (s *Session) ghRun(ctx context.Context, label string, args ...string) toolResult {
@@ -91,7 +146,11 @@ func (s *Session) ghRun(ctx context.Context, label string, args ...string) toolR
91146
out, err := cmd.CombinedOutput()
92147
text := strings.TrimSpace(string(out))
93148
if err != nil {
94-
s.toolLine(true, "GitHub", label, "failed", true)
149+
reason := strings.TrimSpace(firstLine(text))
150+
if reason == "" {
151+
reason = err.Error()
152+
}
153+
s.toolLine(true, "GitHub", label, "failed: "+clip(s.redactor.Redact(reason), 160), true)
95154
return errResult(fmt.Sprintf("gh %s failed: %v\n%s", args[0], err, truncate(text, 2000)))
96155
}
97156
s.toolLine(true, "GitHub", label, "", false)

internal/agent/runtime/github_test.go

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import (
44
"context"
55
"encoding/json"
66
"io"
7+
"os"
8+
"os/exec"
79
"path/filepath"
810
"strings"
911
"testing"
@@ -54,3 +56,58 @@ func TestGitHubToolAdvertised(t *testing.T) {
5456
t.Fatal("github tool should be advertised in normal chat")
5557
}
5658
}
59+
60+
func TestGitHubPRCreatePreflightBlocksDirtyWorktree(t *testing.T) {
61+
root := t.TempDir()
62+
git(t, root, "init", "-b", "feat/pr")
63+
if err := os.WriteFile(filepath.Join(root, "draft.txt"), []byte("uncommitted\n"), 0o644); err != nil {
64+
t.Fatal(err)
65+
}
66+
67+
s := githubSess(t)
68+
s.root = root
69+
in, _ := json.Marshal(tools.GitHubInput{Action: "pr_create", Title: "Draft PR", Body: "Body"})
70+
r := s.githubTool(context.Background(), in)
71+
if !r.isError {
72+
t.Fatal("pr_create should fail before gh when the worktree is dirty")
73+
}
74+
if !strings.Contains(r.text(), "worktree still has uncommitted changes") {
75+
t.Fatalf("dirty-worktree reason missing: %q", r.text())
76+
}
77+
}
78+
79+
func TestGitHubPRCreatePreflightBlocksBranchWithNoCommitsAhead(t *testing.T) {
80+
root := t.TempDir()
81+
git(t, root, "init", "-b", "main")
82+
git(t, root, "config", "user.email", "test@example.com")
83+
git(t, root, "config", "user.name", "Test User")
84+
if err := os.WriteFile(filepath.Join(root, "README.md"), []byte("# test\n"), 0o644); err != nil {
85+
t.Fatal(err)
86+
}
87+
git(t, root, "add", "README.md")
88+
git(t, root, "commit", "-m", "initial")
89+
git(t, root, "update-ref", "refs/remotes/origin/main", "HEAD")
90+
git(t, root, "switch", "-c", "feat/noop")
91+
92+
s := githubSess(t)
93+
s.root = root
94+
in, _ := json.Marshal(tools.GitHubInput{Action: "pr_create", Title: "Draft PR", Body: "Body"})
95+
r := s.githubTool(context.Background(), in)
96+
if !r.isError {
97+
t.Fatal("pr_create should fail before gh when the branch has no commits")
98+
}
99+
if !strings.Contains(r.text(), "no commits ahead of origin/main") {
100+
t.Fatalf("no-ahead reason missing: %q", r.text())
101+
}
102+
}
103+
104+
func git(t *testing.T, root string, args ...string) string {
105+
t.Helper()
106+
cmd := exec.Command("git", args...)
107+
cmd.Dir = root
108+
out, err := cmd.CombinedOutput()
109+
if err != nil {
110+
t.Fatalf("git %s failed: %v\n%s", strings.Join(args, " "), err, out)
111+
}
112+
return strings.TrimSpace(string(out))
113+
}

internal/agent/runtime/metricsstate.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ type metricsState struct {
1212
didVerify bool // any build/test command run this session
1313
lastEditSeq int // tool-call seq of the most recent edit
1414
lastVerifyOKSeq int // tool-call seq of the most recent passing verification
15+
blockerSeq int // failed deliverable tool that must be resolved before a todo is done
16+
blockerLabel string // user-facing name of the blocked deliverable (e.g. GitHub create PR)
17+
blockerDetail string // first useful error line to feed back on refused todo completion
1518
readHashes map[string]string // path → content hash when last read/wrote (stale-edit guard; lazily inited)
1619
reportsSpilled int // sub-agent reports written to .memcode/sessions/<id>/reports/ (names the files)
1720
}

0 commit comments

Comments
 (0)