Skip to content

Commit 5c5d0bf

Browse files
committed
Merge main into feat/runtime-performance
2 parents 94ff28f + 2749447 commit 5c5d0bf

22 files changed

Lines changed: 1677 additions & 310 deletions

‎internal/agent/prompt_budget_test.go‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ func TestEagerToolSchemaTokenBudget(t *testing.T) {
6868
}
6969
}
7070

71-
func TestAgentAdvertisesPatchInsteadOfAmbiguousStringReplacement(t *testing.T) {
71+
func TestAgentAdvertisesBothEditTools(t *testing.T) {
7272
registry := tools.NewRegistry()
7373
for _, tool := range tools.CoreToolsScoped(t.TempDir(), nil) {
7474
registry.Register(tool)
@@ -79,9 +79,9 @@ func TestAgentAdvertisesPatchInsteadOfAmbiguousStringReplacement(t *testing.T) {
7979
names[definition.Name] = true
8080
}
8181
if !names["apply_patch"] {
82-
t.Fatal("agent must retain apply_patch for existing-file changes")
82+
t.Fatal("agent must retain apply_patch for multi-hunk changes")
8383
}
84-
if names["edit_file"] {
85-
t.Fatal("agent must not receive the ambiguous string-replacement tool")
84+
if !names["edit_file"] {
85+
t.Fatal("agent must receive edit_file for targeted exact replacements")
8686
}
8787
}

‎internal/agent/system_prompt.md‎

Lines changed: 31 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -30,15 +30,10 @@ work.
3030
read-before-edit discipline: inspect the target file and nearby callers,
3131
tests, or config before you modify behavior. Never edit a file you have not
3232
read.
33-
2. **Plan.** Use update_plan when work spans multiple components or independent
34-
workstreams, has sequencing uncertainty, or needs sustained tracking across
35-
many tool calls. Skip update_plan for bounded changes in one component even
36-
when the natural workflow is inspect, implement, and verify. When a plan is
37-
useful, keep it current only when the overall phase materially changes; do
38-
not spend separate turns updating it after every file or command. Mark
39-
completed work and the next in-progress step together when practical, and
40-
keep at most one item in_progress. Never create a plan after the work is
41-
already complete merely to describe what you did.
33+
2. **Plan.** Use update_plan for multi-component, sequencing-uncertain, or long
34+
work. Skip it for bounded changes in one component. Update only when the
35+
overall phase changes, keep at most one item in_progress, and never create a
36+
plan after the work is complete.
4237
3. **Implement.** Make focused changes that match the surrounding code's style,
4338
naming, and conventions. Prefer the smallest change that fully solves the
4439
problem. Avoid broad refactors, unrelated rewrites, dependency churn, and
@@ -54,48 +49,48 @@ work.
5449

5550
- Choose the narrowest tool that safely accomplishes the step. Prefer native
5651
file tools - read_file, read_minified_file, list_directory, glob, grep,
57-
write_file, apply_patch - over shelling out to
52+
edit_file, apply_patch, write_file - over shelling out to
5853
cat/sed/awk/python for file operations.
5954
They are safer, reviewable, and produce clean diffs.
60-
- Prefer read_minified_file only while exploring large or unfamiliar source when
61-
no exact edit is yet likely. For a small file or a likely edit target, use
62-
read_file directly. Do not read the same file first with read_minified_file and
63-
then again with read_file unless the initial inspection reveals a new need for
64-
exact text, comments, or line numbers.
55+
- Use read_minified_file only to explore large or unfamiliar source. Use
56+
read_file for small or likely edit targets and exact text, comments, or line
57+
numbers. Do not read both unless a new need for exact content appears.
6558
- Keep edits focused and reviewable. A single patch may update several related
6659
files when they form one coherent change; do not hide unrelated edits in a
6760
bulk shell or script rewrite.
68-
- For edits to existing files, prefer apply_patch with minimal, targeted hunks
69-
and enough unchanged context to identify the intended location. Match the
70-
existing indentation, imports, and idioms. Match the file's comment density:
71-
do not add explanatory comments unless the user asks or the code is already
61+
- For edits to existing files, use edit_file for a targeted change (old_string
62+
must match the file exactly and be unique, so include a few surrounding
63+
lines) and apply_patch when one coherent change spans several hunks or
64+
files. Use write_file only to create a file or when most of it changes; do
65+
not rewrite a whole file to change a few lines. Match the existing
66+
indentation, imports, and idioms. Match the file's comment density: do not
67+
add explanatory comments unless the user asks or the code is already
7268
comment-dense.
69+
- A successful edit result already confirms the change; do not re-read a file
70+
just to verify an edit that succeeded. If an edit fails, read the error, fix
71+
the old_string or hunk, and retry the same tool rather than switching to a
72+
full rewrite.
7373
- Solve the problem as posed, not a more general version of it. Add no
7474
speculative abstraction, configurability, or handling for cases that cannot
7575
occur, and nothing the user did not ask for. A small diff can still be
7676
over-built; if a 200-line solution could be 50, rewrite it. Once the required
77-
behavior passes validation, stop rather than adding optional cleanup,
78-
assertions, or refactors.
77+
behavior passes validation, stop instead of adding optional cleanup.
7978
- Preserve behavior you were not asked to change. Do not delete or rewrite code
8079
you did not author unless the task requires it; if you must, say so.
8180

8281
## Testing gate (mandatory)
8382

84-
- After code changes, verify after edits with the project's documented
85-
validators: tests, type-checks, linters, and/or build as appropriate. Scope
86-
iteration checks; reserve full-suite runs for milestones. Combine compatible
87-
validators into one command when diagnostics stay useful. Run the final set
88-
once after the last edit; rerun only after another change or a failure needing
89-
proof.
90-
- If unsure which build, validation, or release entry points apply, search the
91-
repo (Makefile, package manifests, CI config) and use them instead of inventing
92-
a parallel flow.
83+
- After code changes, run the project's documented appropriate validators.
84+
Scope iteration checks, combine compatible commands when diagnostics stay
85+
useful, and run the final set once after the last edit. Rerun only after a
86+
change or failure.
87+
- If unsure which entry points apply, inspect the Makefile, manifests, or CI;
88+
do not invent a parallel flow.
9389
- Never claim a task is done, and never commit, while validators are failing. If
9490
they fail, fix the cause and rerun; do not paper over it. If you could not run
9591
a validator, say so explicitly rather than implying success.
96-
- Treat a successful native patch result as confirmation of the applied edit. Do
97-
not reread files solely to confirm it; reread only when the next change needs
98-
exact content or the patch/result was ambiguous.
92+
- A successful native patch confirms the edit. Reread only when the next change
93+
needs exact content or the result was ambiguous.
9994

10095
## Tool use
10196

@@ -117,7 +112,7 @@ work.
117112
you need to clean up a running foreground command yourself, use write_stdin.
118113
- write_stdin's session_id is only ever an id returned by a still-running
119114
exec_command; never guess or probe ids. If you have no such session, start one
120-
with exec_command, or use write_file/apply_patch for file changes.
115+
with exec_command, or use edit_file/apply_patch/write_file for file changes.
121116
- write_stdin with empty input polls an existing exec_command session, and
122117
`\u0003` interrupts it. Sending other stdin bytes may require approval because
123118
it can drive the running process beyond the original command. Non-tty sessions
@@ -153,8 +148,7 @@ work.
153148
- Default to concise, skimmable output. Lead with the answer or the result.
154149
- Use GitHub-flavored Markdown: headings to structure longer replies, fenced
155150
code blocks for code, and `inline code` for file paths, commands, symbols, and
156-
short snippets. Reference code with file paths; include line numbers when
157-
already known or materially useful. Do not make extra tool calls solely to
158-
discover line numbers for the final summary.
151+
short snippets. Reference code with file paths and include line numbers when
152+
already known; do not look them up solely for the final summary.
159153
- Report outcomes faithfully: if tests failed, show it; if a step was skipped,
160154
say so; when something is done and verified, state it plainly without hedging.

‎internal/agent/system_prompt_models.go‎

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -60,16 +60,17 @@ const openAIPromptAddendum = `<model_guidance>
6060
longer answers, fenced code blocks for code, and ` + "`inline code`" + ` for paths,
6161
commands, and symbols.
6262
- Strongly prefer the native file tools (read_file, list_directory, grep, glob,
63-
write_file, apply_patch) over shelling out to cat/sed/awk/python for
64-
file work. When native apply_patch is available, call it directly; never run
65-
apply_patch through exec_command. One coherent native patch may update related
66-
files; do not batch file writes into a shell script.
63+
edit_file, apply_patch, write_file) over shelling out to cat/sed/awk/python for
64+
file work. Use them directly; never run apply_patch through exec_command.
65+
read_file, edit_file and write_file take one file per call, while one coherent
66+
apply_patch may span several files. Keep independent native calls in the same
67+
turn, and do not batch file writes into a script.
6768
- Persist until the task is fully handled this turn: gather context, implement,
6869
run the validators, and report — do not stop at a partial result.
6970
</model_guidance>`
7071

7172
const geminiPromptAddendum = `<model_guidance>
72-
- Prefer the dedicated tools (read_file, grep, glob, apply_patch) over
73+
- Prefer the dedicated tools (read_file, grep, glob, edit_file, apply_patch) over
7374
equivalent shell commands; they are safer and produce cleaner diffs.
7475
- Be concise and concrete. When you run a shell command with side effects, state
7576
in one short clause why it is needed.

‎internal/agent/system_prompt_test.go‎

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -39,21 +39,21 @@ func TestCoreSystemPromptIncludesCodingQualityRules(t *testing.T) {
3939
"inspect the target file",
4040
"plan then act",
4141
"choose the narrowest tool",
42-
"for edits to existing files, prefer apply_patch",
42+
"for edits to existing files, use edit_file",
4343
"verify after edits",
4444
"honor the active permission mode",
4545
"avoid broad refactors",
4646
"search the web before answering",
4747
"do not recognize",
4848
"scaled to the work",
4949
"comment density",
50-
"skip update_plan for bounded changes in one component",
51-
"do not read the same file first with read_minified_file and then again with read_file",
52-
"combine compatible validators into one command",
53-
"instead of inventing a parallel flow",
54-
"do not reread files solely to confirm",
50+
"skip it for bounded changes in one component",
51+
"do not read both unless a new need for exact content appears",
52+
"combine compatible commands when diagnostics stay useful",
53+
"do not invent a parallel flow",
54+
"a successful native patch confirms the edit",
5555
"once the required behavior passes validation, stop",
56-
"do not make extra tool calls solely to discover line numbers",
56+
"do not look them up solely for the final summary",
5757
} {
5858
if !strings.Contains(prompt, want) {
5959
t.Fatalf("expected core system prompt to include %q, got:\n%s", want, buildSystemPrompt(Options{}))
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
package sandbox
2+
3+
import (
4+
"path/filepath"
5+
"strings"
6+
"testing"
7+
)
8+
9+
func TestApplyPatchPathBlockOnlyRejectsRelativeTraversal(t *testing.T) {
10+
root := t.TempDir()
11+
inside := filepath.Join(root, "main.js")
12+
structured := func(path string) string {
13+
return strings.Join([]string{"*** Begin Patch", "*** Update File: " + path, "@@", "-a", "+b", "*** End Patch"}, "\n")
14+
}
15+
for name, patch := range map[string]string{
16+
"absolute inside workspace": structured(inside),
17+
"relative": structured("main.js"),
18+
"decorated markers": "*** Begin Patch ***\n*** Update File: main.js\n@@\n-a\n+b\n*** End Patch ***",
19+
"no-space marker": "***Begin Patch\n*** Update File: main.js\n@@\n-a\n+b\n***End Patch",
20+
} {
21+
request := Request{ToolName: "apply_patch", WorkspaceRoot: root, SideEffect: SideEffectWrite, Args: map[string]any{"patch": patch}}
22+
if block := applyPatchPathBlock(request); block != nil {
23+
t.Fatalf("%s: unexpected block %+v", name, block)
24+
}
25+
}
26+
for _, path := range []string{"../escape.js", ".."} {
27+
for name, patch := range map[string]string{"canonical": structured(path), "no-space": "***Begin Patch\n*** Update File: " + path + "\n@@\n-a\n+b\n***End Patch"} {
28+
request := Request{ToolName: "apply_patch", WorkspaceRoot: root, SideEffect: SideEffectWrite, Args: map[string]any{"patch": patch}}
29+
block := applyPatchPathBlock(request)
30+
if block == nil || block.Code != BlockOutsideWorkspace {
31+
t.Fatalf("%s %q must be blocked as traversal, got %+v", name, path, block)
32+
}
33+
}
34+
}
35+
}
36+
37+
// Every marker spelling the tool applies must be classified as structured at
38+
// the sandbox boundary too; otherwise the boundary scans the patch as a unified
39+
// diff, extracts no targets, and validates nothing (fail-open).
40+
func TestStructuredPatchClassifierMatchesToolSpellings(t *testing.T) {
41+
for _, header := range []string{"*** Begin Patch", "*** Begin Patch ***", "***Begin Patch", " *** Begin Patch ", "\ufeff*** Begin Patch"} {
42+
patch := header + "\n*** Update File: main.js\n@@\n-a\n+b\n*** End Patch"
43+
if !IsStructuredPatch(patch) {
44+
t.Fatalf("%q must classify as a structured patch", header)
45+
}
46+
if paths := applyPatchPaths(patch); len(paths) != 1 || paths[0] != "main.js" {
47+
t.Fatalf("%q: sandbox must extract the structured target, got %v", header, paths)
48+
}
49+
}
50+
for _, header := range []string{"--- a/x", "Begin Patch", "*** Begin Patchwork", "*** Update File: x"} {
51+
if IsStructuredPatch(header + "\n-a\n+b") {
52+
t.Fatalf("%q must not classify as a structured patch", header)
53+
}
54+
}
55+
if StructuredPatchMarker("*** End Patch ***") != "end" || StructuredPatchMarker("***End Patch") != "end" {
56+
t.Fatal("decorated end markers must classify as end")
57+
}
58+
}
59+
60+
func TestApplyPatchRequestPathsCarryAbsolutePathsToScopeValidation(t *testing.T) {
61+
root := t.TempDir()
62+
// NewScope also grants the system temp dir, so a sibling t.TempDir() is
63+
// legitimately in scope; pick a path under the filesystem root instead.
64+
outside, err := filepath.Abs(filepath.Join(string(filepath.Separator), "zero-outside-workspace-test", "escape.js"))
65+
if err != nil {
66+
t.Fatal(err)
67+
}
68+
scope, err := NewScope(root, nil)
69+
if err != nil {
70+
t.Fatal(err)
71+
}
72+
inside := filepath.Join(root, "main.js")
73+
structured := func(header, footer, path string) string {
74+
return strings.Join([]string{header, "*** Update File: " + path, "@@", "-a", "+b", footer}, "\n")
75+
}
76+
for name, spelling := range map[string][2]string{"canonical": {"*** Begin Patch", "*** End Patch"}, "no-space": {"***Begin Patch", "***End Patch"}} {
77+
// Failure path: the exact path the boundary parsed must be denied by the
78+
// scope. structuredPatchHeaderPaths normalises separators to "/", so the
79+
// parsed form is compared in slash form and then validated as-is.
80+
paths := applyPatchRequestPaths(map[string]any{"patch": structured(spelling[0], spelling[1], outside)})
81+
if len(paths) != 1 || paths[0] != filepath.ToSlash(outside) {
82+
t.Fatalf("%s: absolute patch path must reach scope validation unchanged, got %v", name, paths)
83+
}
84+
if block := scope.validate(paths[0]); block == nil || block.Code != BlockOutsideWorkspace {
85+
t.Fatalf("%s: scope must deny the parsed outside path %q, got %+v", name, paths[0], block)
86+
}
87+
// Success path: the parsed inside path must be accepted by the scope.
88+
paths = applyPatchRequestPaths(map[string]any{"patch": structured(spelling[0], spelling[1], inside)})
89+
if len(paths) != 1 || paths[0] != filepath.ToSlash(inside) {
90+
t.Fatalf("%s: inside patch path must reach scope validation unchanged, got %v", name, paths)
91+
}
92+
if block := scope.validate(paths[0]); block != nil {
93+
t.Fatalf("%s: scope must accept the parsed inside path %q, got %+v", name, paths[0], block)
94+
}
95+
}
96+
}

‎internal/sandbox/risk.go‎

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -281,11 +281,15 @@ func applyPatchPathBlock(request Request) *pathBlock {
281281
if patch == "" {
282282
return nil
283283
}
284+
// Only relative traversal is rejected up front. Absolute paths flow through
285+
// the regular workspace-scope validation below (requestPaths), which accepts
286+
// one inside the workspace and denies one outside — a model that echoes the
287+
// absolute path read_file showed it must not be blocked for that alone.
284288
for _, path := range applyPatchPaths(patch) {
285289
if path == "" || path == "/dev/null" {
286290
continue
287291
}
288-
if filepath.IsAbs(path) || path == ".." || strings.HasPrefix(path, "../") {
292+
if path == ".." || strings.HasPrefix(path, "../") {
289293
return &pathBlock{
290294
Code: BlockOutsideWorkspace,
291295
Path: path,
@@ -296,8 +300,35 @@ func applyPatchPathBlock(request Request) *pathBlock {
296300
return nil
297301
}
298302

303+
// structuredPatchMarkerPattern is the single classifier for structured-patch
304+
// markers, shared by the sandbox boundary and the apply_patch tool (which
305+
// imports this package). It accepts the canonical "*** Begin Patch" /
306+
// "*** End Patch" and the decorated spellings models emit ("*** Begin Patch ***",
307+
// "***Begin Patch", trailing whitespace). Both sides must agree: a spelling the
308+
// tool would apply but the sandbox did not recognise would make the sandbox
309+
// scan the patch as a unified diff, extract no targets, and validate nothing.
310+
var structuredPatchMarkerPattern = regexp.MustCompile(`^\*{3}\s*(Begin|End) Patch\s*\**\s*$`)
311+
312+
// StructuredPatchMarker classifies a line as the "begin" or "end" marker of a
313+
// structured patch, or "" when it is neither.
314+
func StructuredPatchMarker(line string) string {
315+
match := structuredPatchMarkerPattern.FindStringSubmatch(strings.TrimSpace(line))
316+
if match == nil {
317+
return ""
318+
}
319+
return strings.ToLower(match[1])
320+
}
321+
322+
// IsStructuredPatch reports whether patch opens with a structured begin marker.
323+
// The tool applies exactly the patches this returns true for, so the sandbox
324+
// extracts structured header paths for exactly the same set.
325+
func IsStructuredPatch(patch string) bool {
326+
first, _, _ := strings.Cut(strings.TrimSpace(strings.TrimPrefix(patch, "\ufeff")), "\n")
327+
return StructuredPatchMarker(first) == "begin"
328+
}
329+
299330
func applyPatchPaths(patch string) []string {
300-
if strings.HasPrefix(strings.TrimSpace(strings.TrimPrefix(patch, "\ufeff")), "*** Begin Patch") {
331+
if IsStructuredPatch(patch) {
301332
return structuredPatchHeaderPaths(patch)
302333
}
303334
return patchHeaderPaths(patch)

0 commit comments

Comments
 (0)