Skip to content

Commit dc39cc8

Browse files
committed
Add project context and tool hints to system prompt, improve error messages
Two features to reduce wasted fan-out tool rounds: 1. System prompt now includes auto-detected project type (from go.mod, package.json, etc.) and top-level directory listing. Providers get immediate context without needing list_directory as their first call. 2. Tool usage hints tell providers exactly what tools they have (file_read, list_directory, grep_search), how to use them effectively, and explicitly warn against trying shell_exec. Also improves error handling: - Empty/garbage paths (""", ":") fall back to project root listing - File-not-found shows nearby files + suggests list_directory/grep_search - Invalid grep regex auto-escapes to literal string search
1 parent 733dbb8 commit dc39cc8

5 files changed

Lines changed: 158 additions & 12 deletions

File tree

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
66

77
## [Unreleased]
88

9+
## [1.17.0] - 2026-03-26
10+
11+
### Added
12+
- **Automatic project context in system prompt**: Providers now receive the project type (detected from go.mod, package.json, etc.) and top-level directory listing in the system prompt, eliminating the wasted first tool round where every provider does `list_directory .`.
13+
- **Tool usage hints in system prompt**: Providers see a clear list of available tools (file_read, list_directory, grep_search) with tips for efficient codebase exploration and an explicit warning not to attempt shell_exec.
14+
15+
### Fixed
16+
- **Graceful handling of bad tool arguments**: `file_read` and `list_directory` now normalize empty/garbage paths (e.g., `":"`, `""`) to the project root instead of erroring. File-not-found errors now show nearby files as hints and suggest alternative tools. `grep_search` auto-escapes invalid regex to literal string search instead of failing.
17+
918
## [1.16.0] - 2026-03-25
1019

1120
### Added

cmd/polycode/app.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -172,14 +172,19 @@ func startTUI(cfg *config.Config) error {
172172
memDir := filepath.Join(config.ConfigDir(), "memory")
173173
memStore := memory.NewMemoryStore(memDir)
174174

175-
// Build system prompt from instruction hierarchy + repo memory
175+
// Build system prompt from instruction hierarchy + repo memory + project context
176176
instructions := memory.LoadInstructions(workDir)
177177
memPrompt := memStore.FormatForPrompt()
178178
systemContent := instructions
179179
if memPrompt != "" {
180180
systemContent += "\n\n" + memPrompt
181181
}
182182

183+
// Inject project context and tool hints so providers don't waste rounds exploring.
184+
projectCtx := action.BuildProjectContext(workDir)
185+
toolHints := action.ToolUsageHints()
186+
systemContent += "\n\n" + projectCtx + "\n" + toolHints
187+
183188
// Connect to MCP servers and discover tools
184189
var mcpClient *mcp.MCPClient
185190
if len(cfg.MCP.Servers) > 0 {

internal/action/executor.go

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package action
33
import (
44
"encoding/json"
55
"fmt"
6+
"strings"
67
"time"
78

89
"github.com/izzoa/polycode/internal/provider"
@@ -87,11 +88,10 @@ func (e *Executor) executeFileRead(call provider.ToolCall) ToolResult {
8788
Error: fmt.Errorf("invalid arguments for file_read: %w", err),
8889
}
8990
}
90-
if args.Path == "" {
91-
return ToolResult{
92-
ToolCallID: call.ID,
93-
Error: fmt.Errorf("file_read: path is required"),
94-
}
91+
// Normalize empty or garbage paths to the project root.
92+
args.Path = strings.TrimSpace(args.Path)
93+
if args.Path == "" || args.Path == ":" || args.Path == "." {
94+
args.Path = "."
9595
}
9696
result := e.readFile(args.Path)
9797
result.ToolCallID = call.ID

internal/action/file_ops.go

Lines changed: 43 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -50,8 +50,31 @@ func (e *Executor) readFile(path string) ToolResult {
5050

5151
info, err := os.Stat(cleanPath)
5252
if err != nil {
53+
if os.IsNotExist(err) {
54+
// Suggest alternatives: list the parent directory if it exists.
55+
dir := filepath.Dir(cleanPath)
56+
hint := ""
57+
if entries, dirErr := os.ReadDir(dir); dirErr == nil {
58+
var names []string
59+
for _, e := range entries {
60+
n := e.Name()
61+
if e.IsDir() {
62+
n += "/"
63+
}
64+
names = append(names, n)
65+
if len(names) >= 10 {
66+
names = append(names, "...")
67+
break
68+
}
69+
}
70+
hint = fmt.Sprintf("\nFiles in %s: %s", filepath.Base(dir), strings.Join(names, ", "))
71+
}
72+
return ToolResult{
73+
Error: fmt.Errorf("file_read: %q not found.%s\nHint: use list_directory to explore, or grep_search to find files by content.", path, hint),
74+
}
75+
}
5376
return ToolResult{
54-
Error: fmt.Errorf("failed to read file %s: %w", path, err),
77+
Error: fmt.Errorf("file_read: cannot read %q: %w", path, err),
5578
}
5679
}
5780

@@ -221,7 +244,9 @@ func (e *Executor) executeListDirectory(call provider.ToolCall) ToolResult {
221244
Error: fmt.Errorf("invalid arguments for list_directory: %w", err),
222245
}
223246
}
224-
if args.Path == "" {
247+
// Normalize empty or garbage paths to project root.
248+
args.Path = strings.TrimSpace(args.Path)
249+
if args.Path == "" || args.Path == ":" {
225250
args.Path = "."
226251
}
227252

@@ -232,9 +257,15 @@ func (e *Executor) executeListDirectory(call provider.ToolCall) ToolResult {
232257

233258
info, err := os.Stat(cleanPath)
234259
if err != nil {
260+
if os.IsNotExist(err) {
261+
return ToolResult{
262+
ToolCallID: call.ID,
263+
Error: fmt.Errorf("list_directory: %q not found. Use '.' for the project root.", args.Path),
264+
}
265+
}
235266
return ToolResult{
236267
ToolCallID: call.ID,
237-
Error: fmt.Errorf("list_directory: %w", err),
268+
Error: fmt.Errorf("list_directory: cannot access %q: %w", args.Path, err),
238269
}
239270
}
240271
if !info.IsDir() {
@@ -346,10 +377,16 @@ func (e *Executor) executeGrepSearch(call provider.ToolCall) ToolResult {
346377

347378
re, err := regexp.Compile(args.Pattern)
348379
if err != nil {
349-
return ToolResult{
350-
ToolCallID: call.ID,
351-
Error: fmt.Errorf("grep_search: invalid regex: %w", err),
380+
// Try as literal string if regex fails.
381+
escaped := regexp.QuoteMeta(args.Pattern)
382+
re2, err2 := regexp.Compile(escaped)
383+
if err2 != nil {
384+
return ToolResult{
385+
ToolCallID: call.ID,
386+
Error: fmt.Errorf("grep_search: invalid pattern %q. Hint: use a plain text string or valid Go regex.", args.Pattern),
387+
}
352388
}
389+
re = re2
353390
}
354391

355392
var results strings.Builder

internal/action/project_context.go

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
package action
2+
3+
import (
4+
"fmt"
5+
"os"
6+
"path/filepath"
7+
"strings"
8+
)
9+
10+
// projectFile defines a well-known project file and its significance.
11+
type projectFile struct {
12+
name string
13+
desc string
14+
}
15+
16+
var keyProjectFiles = []projectFile{
17+
{"go.mod", "Go module"},
18+
{"package.json", "Node.js project"},
19+
{"Cargo.toml", "Rust project"},
20+
{"pyproject.toml", "Python project"},
21+
{"requirements.txt", "Python dependencies"},
22+
{"Makefile", "Makefile"},
23+
{"Dockerfile", "Docker"},
24+
{"docker-compose.yml", "Docker Compose"},
25+
{"README.md", "README"},
26+
{"CLAUDE.md", "AI instructions"},
27+
{".gitignore", "Git ignore rules"},
28+
}
29+
30+
// BuildProjectContext generates a snapshot of the project structure and key
31+
// files for inclusion in the system prompt. This gives providers immediate
32+
// context without needing a tool round to explore.
33+
func BuildProjectContext(workDir string) string {
34+
var b strings.Builder
35+
36+
b.WriteString("## Project Context\n\n")
37+
38+
// Detect project type from key files.
39+
var detected []string
40+
for _, kf := range keyProjectFiles {
41+
if _, err := os.Stat(filepath.Join(workDir, kf.name)); err == nil {
42+
detected = append(detected, fmt.Sprintf("%s (%s)", kf.name, kf.desc))
43+
}
44+
}
45+
if len(detected) > 0 {
46+
b.WriteString("**Project type:** ")
47+
b.WriteString(strings.Join(detected, ", "))
48+
b.WriteString("\n\n")
49+
}
50+
51+
// Top-level directory listing.
52+
entries, err := os.ReadDir(workDir)
53+
if err != nil {
54+
return b.String()
55+
}
56+
57+
b.WriteString("**Project root (`./`):**\n```\n")
58+
for _, entry := range entries {
59+
name := entry.Name()
60+
// Skip hidden files except key ones.
61+
if strings.HasPrefix(name, ".") {
62+
switch name {
63+
case ".gitignore", ".github", ".env.example":
64+
// keep
65+
default:
66+
continue
67+
}
68+
}
69+
if entry.IsDir() {
70+
name += "/"
71+
}
72+
b.WriteString(name + "\n")
73+
}
74+
b.WriteString("```\n")
75+
76+
return b.String()
77+
}
78+
79+
// ToolUsageHints returns guidance text for providers on how to use the
80+
// available read-only tools effectively during fan-out.
81+
func ToolUsageHints() string {
82+
return `## Available Tools
83+
84+
You have the following read-only tools for exploring this codebase:
85+
86+
- **file_read** — Read a file's contents. Pass a directory path to get its listing. Use "." for the project root.
87+
- **list_directory** — List directory contents. Set recursive=true to see up to 3 levels deep. Use "." for the project root.
88+
- **grep_search** — Search for text/regex patterns across files. Supports file type filtering with the "include" parameter (e.g., "*.go").
89+
90+
**Tips for efficient exploration:**
91+
1. Start by reading key files like README.md, go.mod, or the main entry point.
92+
2. Use grep_search to find specific functions, types, or patterns.
93+
3. Use list_directory with recursive=true on specific subdirectories, not the whole project.
94+
4. Do NOT attempt to use shell_exec, file_write, or any tools not listed above — they are not available to you.`
95+
}

0 commit comments

Comments
 (0)