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
23 changes: 23 additions & 0 deletions internal/agent/guardrail_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (

"github.com/enowdev/antares/internal/config"
"github.com/enowdev/antares/internal/store"
"github.com/enowdev/antares/internal/tools"
)

// The tool-call guardrail auto-continue hinges on incompleteTodos: it decides
Expand Down Expand Up @@ -114,3 +115,25 @@ func TestAbsoluteCeilingDisabledWhenZero(t *testing.T) {
t.Fatal("AbsoluteMaxToolCalls should be 0 when disabled")
}
}

func TestSystemPromptRequiresSuccessfulWriteBeforeClaimingCreation(t *testing.T) {
cfg := config.Default()
cfg.Memory.Enabled = false
a := agentWithConfig(cfg)
sess := &store.Session{ID: "s", Workspace: "/workspace", Meta: store.Meta{}}
write, ok := tools.Default().Get("write_file")
if !ok {
t.Fatal("write_file is not registered")
}
prompt := a.buildSystemPrompt(context.Background(), Request{}, sess, []tools.Tool{write})
for _, want := range []string{
"only after write_file returns a successful result",
"user's statement that a file was created is not evidence",
"check it with read_file first",
"Do not retry the same failed write",
} {
if !strings.Contains(prompt, want) {
t.Fatalf("system prompt missing %q", want)
}
}
}
3 changes: 3 additions & 0 deletions internal/agent/prompt.go
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,9 @@ help them now — do not block them.
b.WriteString("- read_file returns lines as `NUMBER|CONTENT`. The `|` is metadata only. When calling edit_file, copy **only** the content after `|` into old_string/new_string — never the line number. Preserve tabs and spaces exactly (do not expand tabs to spaces). Line endings are matched automatically.\n")
b.WriteString("- Before every edit_file call, re-read the region you are editing (read_file with offset/limit on large files). edit_file requires an exact, unique old_string from that fresh read. After any successful edit or write, re-read before making another edit; do not reuse an older block or invent identifiers. If it reports multiple occurrences, include unique neighbouring lines or use replace_all only when every occurrence should change.\n")
}
if hasTool(active, "write_file") {
b.WriteString("- File creation is complete only after write_file returns a successful result. A filename in the request, planned arguments, a diff preview, or the user's statement that a file was created is not evidence that it exists. Never turn any of those into your own confirmation. If asked whether a file exists, where it was written, or what it contains without a successful write_file result in this run, check it with read_file first and report the real result. Do not retry the same failed write; explain its actionable error or use a genuinely different valid path/content.\n")
}
if hasTool(active, "vps_upload") || hasTool(active, "vps_download") || hasTool(active, "vps_run") {
// Without this, models fall back to terminal rsync/scp and never use
// the saved-host SFTP tools (credentials and TOFU stay unused).
Expand Down
39 changes: 26 additions & 13 deletions internal/tools/file.go
Original file line number Diff line number Diff line change
Expand Up @@ -236,48 +236,57 @@ type writeFileTool struct{}

func (writeFileTool) Name() string { return "write_file" }
func (writeFileTool) Description() string {
return "Create or overwrite a file with the given content. Parent directories are created automatically."
return "Create or overwrite a file with the given content. Always provide content; use an empty string only to intentionally create a zero-byte file. Parent directories are created automatically."
}
func (writeFileTool) RequiresApproval() bool { return true }
func (writeFileTool) Schema() map[string]any {
return schema(map[string]any{
"path": prop("string", "Destination file path."),
"content": prop("string", "Full file content to write."),
"content": prop("string", "Complete file content. This field must be present; use an empty string only for an intentional zero-byte file."),
"append": propDefault("boolean", "Append instead of overwriting.", false),
}, "path", "content")
}

func (writeFileTool) Execute(_ context.Context, in Input) Result {
var args struct {
Path string `json:"path"`
Content string `json:"content"`
Append bool `json:"append"`
Path string `json:"path"`
Content *string `json:"content"`
Append bool `json:"append"`
}
if err := in.Bind(&args); err != nil {
return Errorf("%v", err)
}
if args.Content == nil {
return Errorf("content is required; provide the complete file content (use an empty string for an intentional empty file)")
}
content := *args.Content
path, err := resolveWrite(in, args.Path)
if err != nil {
return Errorf("%v", err)
}
existed := false
if info, statErr := os.Stat(path); statErr == nil {
if info.IsDir() {
return Errorf("cannot write %s: target is a directory; provide a file path", args.Path)
}
existed = true
} else if !os.IsNotExist(statErr) {
return Errorf("cannot inspect %s: %v", args.Path, statErr)
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return Errorf("cannot create parent directory: %v", err)
}

existed := false
if _, err := os.Stat(path); err == nil {
existed = true
}
if args.Append {
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
if err != nil {
return Errorf("cannot open %s: %v", args.Path, err)
}
defer f.Close()
if _, err := f.WriteString(args.Content); err != nil {
if _, err := f.WriteString(content); err != nil {
return Errorf("cannot append to %s: %v", args.Path, err)
}
} else if err := writeWithCheckpoint(in, path, []byte(args.Content), "write_file"); err != nil {
} else if err := writeWithCheckpoint(in, path, []byte(content), "write_file"); err != nil {
return Errorf("cannot write %s: %v", args.Path, err)
}

Expand All @@ -289,9 +298,13 @@ func (writeFileTool) Execute(_ context.Context, in Input) Result {
verb = "Appended to"
}
rel := relTo(in.Workspace, path)
lines := 0
if content != "" {
lines = strings.Count(content, "\n") + 1
}
return Result{
Content: fmt.Sprintf("%s %s (%d bytes, %d lines)", verb, rel, len(args.Content), strings.Count(args.Content, "\n")+1),
Meta: map[string]any{"path": rel, "bytes": len(args.Content)},
Content: fmt.Sprintf("%s %s (%d bytes, %d lines)", verb, rel, len(content), lines),
Meta: map[string]any{"path": rel, "bytes": len(content)},
}
}

Expand Down
64 changes: 64 additions & 0 deletions internal/tools/file_project_test.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
package tools

import (
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)

Expand Down Expand Up @@ -60,3 +64,63 @@ func TestOrdinarySessionStaysConfined(t *testing.T) {
t.Fatalf("ordinary read outside workspace must be refused")
}
}

func TestWriteFileCreatesEmptyFileAndParents(t *testing.T) {
workspace := t.TempDir()
args, err := json.Marshal(map[string]any{"path": "nested/empty.txt", "content": ""})
if err != nil {
t.Fatal(err)
}
result := (writeFileTool{}).Execute(context.Background(), Input{Workspace: workspace, Args: args})
if result.IsError {
t.Fatalf("write_file: %s", result.Content)
}
if !strings.Contains(result.Content, "0 bytes, 0 lines") {
t.Fatalf("empty-file result = %q, want zero bytes and zero lines", result.Content)
}
path := filepath.Join(workspace, "nested", "empty.txt")
info, err := os.Stat(path)
if err != nil {
t.Fatalf("created empty file missing: %v", err)
}
if info.IsDir() || info.Size() != 0 {
t.Fatalf("created empty target = %+v, want a zero-byte file", info)
}
}

func TestWriteFileRejectsDirectoryTargetWithoutMutation(t *testing.T) {
workspace := t.TempDir()
path := filepath.Join(workspace, "existing")
if err := os.Mkdir(path, 0o755); err != nil {
t.Fatal(err)
}
args, err := json.Marshal(map[string]any{"path": "existing", "content": "should not write"})
if err != nil {
t.Fatal(err)
}
result := (writeFileTool{}).Execute(context.Background(), Input{Workspace: workspace, Args: args})
if !result.IsError || !strings.Contains(result.Content, "target is a directory") {
t.Fatalf("directory target result = %+v, want actionable error", result)
}
entries, err := os.ReadDir(path)
if err != nil {
t.Fatal(err)
}
if len(entries) != 0 {
t.Fatalf("directory was mutated: %v", entries)
}
}

func TestWriteFileRejectsMissingContentBeforeFilesystemMutation(t *testing.T) {
workspace := t.TempDir()
result := (writeFileTool{}).Execute(context.Background(), Input{
Workspace: workspace,
Args: []byte(`{"path":"nested/missing.txt"}`),
})
if !result.IsError || !strings.Contains(result.Content, "content is required") {
t.Fatalf("missing-content result = %+v, want actionable error", result)
}
if _, err := os.Stat(filepath.Join(workspace, "nested")); !os.IsNotExist(err) {
t.Fatalf("missing content mutated filesystem: %v", err)
}
}
35 changes: 27 additions & 8 deletions web/src/components/chat/ToolCallCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
import { cn } from '@/lib/utils'
import type { ToolCallView } from '@/pages/ChatPage'
import { useI18n } from '@/lib/i18n'
import { isIncompleteToolCall } from '@/lib/toolCallState'

type IconType = React.ComponentType<{ className?: string; weight?: 'regular' | 'fill' }>
type Meta = { label: string; Icon: IconType }
Expand Down Expand Up @@ -48,8 +49,9 @@ type Diff = { rows: DiffRow[]; added: number; removed: number }

// Line-based diff (LCS) between old and new source, for the expanded edit view.
function lineDiff(oldText: string, newText: string): Diff {
const a = (oldText || '').replace(/\n$/, '').split('\n')
const b = (newText || '').replace(/\n$/, '').split('\n')
if (!oldText && !newText) return { rows: [], added: 0, removed: 0 }
const a = oldText.replace(/\n$/, '').split('\n')
const b = newText.replace(/\n$/, '').split('\n')
if (!oldText) {
return { rows: b.map((text) => ({ type: 'add', text })), added: b.length, removed: 0 }
}
Expand Down Expand Up @@ -150,6 +152,7 @@ export const ToolCallCard = memo(function ToolCallCard({ call }: { call: ToolCal
const isDiff = isEdit || isCreate
const isRead = call.name === 'read_file'
const output = call.result ?? call.progress ?? ''
const incomplete = isIncompleteToolCall(call)

// Only the file tools render as filename-over-directory. Other tools may also
// carry a `path` arg (e.g. view_image with a URL), but for them that is just
Expand All @@ -169,17 +172,32 @@ export const ToolCallCard = memo(function ToolCallCard({ call }: { call: ToolCal
// Lines read, for the collapsed read summary.
const readLines = isRead && output.trim() ? output.trim().split('\n').length : 0

const canExpand = isDiff ? diff.rows.length > 0 : (call.args && call.args !== '{}') || !!output
const canExpand = call.isError
? !!output
: incomplete
? true
: isDiff
? diff.rows.length > 0
: (call.args && call.args !== '{}') || !!output

// Right-side status: a diff tally for edits, "N lines" for reads, a spinner
// while running, else a short result echo.
const right = call.isError ? (
<span className="text-[10px] font-semibold text-destructive">{t('chat.toolError')}</span>
<span className="max-w-48 truncate text-[10px] font-semibold text-destructive" title={output}>
{output.trim().split('\n')[0] || t('chat.toolError')}
</span>
) : incomplete ? (
<span className="text-[10px] font-semibold text-[var(--warning)]" title={t('chat.toolIncomplete')}>
{t('chat.toolIncomplete')}
</span>
) : isDiff ? (
<span className="flex items-center gap-1.5 text-[10px] font-semibold tabular-nums">
{call.running ? <CircleNotch className="size-3 animate-spin text-muted-foreground" /> : null}
{diff.added > 0 ? <span className="text-emerald-500">+{diff.added}</span> : null}
{diff.removed > 0 ? <span className="text-destructive">-{diff.removed}</span> : null}
{!call.running && diff.added === 0 && diff.removed === 0 ? (
<CheckCircle className="size-3.5 text-[var(--success)]" weight="fill" />
) : null}
</span>
) : call.running ? (
<CircleNotch className="size-3.5 animate-spin text-muted-foreground" />
Expand All @@ -199,7 +217,7 @@ export const ToolCallCard = memo(function ToolCallCard({ call }: { call: ToolCal
<div
className={cn(
'overflow-hidden rounded-[var(--radius-sm)] border bg-card',
call.isError ? 'border-destructive/40' : 'border-border',
call.isError ? 'border-destructive/40' : incomplete ? 'border-[var(--warning)]/40' : 'border-border',
)}
>
<button
Expand Down Expand Up @@ -258,7 +276,7 @@ export const ToolCallCard = memo(function ToolCallCard({ call }: { call: ToolCal
</button>

{open && canExpand ? (
isDiff ? (
isDiff && !call.isError ? (
<div className="border-t border-border bg-muted/30">
<div className="max-h-64 overflow-auto py-1 font-mono text-[11px] leading-[1.5]">
{diff.rows.map((r, ri) => (
Expand Down Expand Up @@ -302,7 +320,7 @@ export const ToolCallCard = memo(function ToolCallCard({ call }: { call: ToolCal
</pre>
</div>
) : null}
{output ? (
{output || incomplete ? (
<div>
<p className="mb-1 text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
{t('chat.toolResult')}
Expand All @@ -311,9 +329,10 @@ export const ToolCallCard = memo(function ToolCallCard({ call }: { call: ToolCal
className={cn(
'max-h-80 overflow-auto whitespace-pre-wrap break-words rounded bg-muted/50 p-2 font-mono text-[11px]',
call.isError && 'text-destructive',
incomplete && 'text-[var(--warning)]',
)}
>
{output}
{output || t('chat.toolIncompleteDetail')}
</pre>
</div>
) : null}
Expand Down
2 changes: 2 additions & 0 deletions web/src/lib/i18n.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -468,6 +468,8 @@ const en = {
'chat.toolArgs': 'Arguments',
'chat.toolResult': 'Result',
'chat.toolError': 'error',
'chat.toolIncomplete': 'incomplete',
'chat.toolIncompleteDetail': 'This tool call did not finish; no result was recorded.',
'chat.nLines': '{n} lines',
'chat.tasks': 'Tasks',
'chat.copyCode': 'Copy code',
Expand Down
54 changes: 54 additions & 0 deletions web/src/lib/toolCallState.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { describe, expect, test } from 'bun:test'
import {
changedFilesFromMessages,
isIncompleteToolCall,
isSuccessfulToolCall,
} from './toolCallState.ts'

describe('tool call completion', () => {
test('requires a non-error result before treating a call as successful', () => {
expect(isSuccessfulToolCall({ result: 'Created file' })).toBe(true)
expect(isSuccessfulToolCall({ result: 'cannot write', isError: true })).toBe(false)
expect(isSuccessfulToolCall({ running: false })).toBe(false)
})

test('identifies a persisted call without a result as incomplete', () => {
expect(isIncompleteToolCall({ running: false })).toBe(true)
expect(isIncompleteToolCall({ running: true })).toBe(false)
expect(isIncompleteToolCall({ result: '' })).toBe(false)
expect(isIncompleteToolCall({ result: 'cannot write', isError: true })).toBe(false)
})
})

describe('changed files', () => {
test('excludes failed and incomplete writes', () => {
const files = changedFilesFromMessages([
{
toolCalls: [
{ name: 'write_file', args: '{"path":"failed.txt"}', result: 'cannot write', isError: true },
{ name: 'write_file', args: '{"path":"interrupted.txt"}' },
{ name: 'write_file', args: '{"path":"empty.txt"}', result: '' },
],
},
])

expect(files).toEqual([{ path: 'empty.txt', tool: 'write_file' }])
})

test('keeps only the newest successful write for each path', () => {
const files = changedFilesFromMessages([
{ toolCalls: [{ name: 'write_file', args: '{"path":"same.txt"}', result: 'Created same.txt' }] },
{
toolCalls: [
{ name: 'edit_file', args: '{"path":"same.txt"}', result: 'Updated same.txt' },
{ name: 'edit_file', args: '{"path":"other.txt"}', result: 'Updated other.txt' },
],
},
])

expect(files).toEqual([
{ path: 'other.txt', tool: 'edit_file' },
{ path: 'same.txt', tool: 'edit_file' },
])
})
})
Loading
Loading