Skip to content

Commit d72f6df

Browse files
morganlintonclaude
andcommitted
feat: keep the working toolset available; write files instead of printing code
Root cause of "it prints the whole HTML instead of writing files": the auto tool-selector gated edit tools behind keyword guesses, so "build a bio site" got shell but no file_write/file_edit — the model couldn't save anything. - Replace the keyword heuristic: auto now sends the full configured pool for any real request, and no tools only for plain greetings (small talk). - Add file_write to the core defaults so new files can be created from scratch. - System prompt now tells the model to write code to files with the tools and reply with a short summary — never paste file contents into the chat. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 4c6297a commit d72f6df

4 files changed

Lines changed: 115 additions & 174 deletions

File tree

.env.example

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,8 @@ APPROVAL_POLICY=always
2828
# of prompt eval per turn — meaningful on small local models.
2929
# Available: apply_patch, file_read, file_write, file_edit, glob, grep, list_dir,
3030
# shell, repo_search, run_tests, batch_edit, ship_status, web_fetch, update_plan, task
31-
# Default (core): file_read, grep, list_dir, file_edit, shell, update_plan, task
32-
# AGENT_TOOLS=file_read,grep,list_dir,file_edit,shell,update_plan,task
31+
# Default (core): file_read, grep, list_dir, file_edit, file_write, shell, update_plan, task
32+
# AGENT_TOOLS=file_read,grep,list_dir,file_edit,file_write,shell,update_plan,task
3333

3434
# Tool schema selection. auto sends only prompt-relevant enabled tools;
3535
# fixed sends every enabled tool schema every turn.

README.md

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -241,11 +241,11 @@ All model-tuning lives under `/doctor`:
241241
| Workflow | `run_tests`, `ship_status`, `web_fetch`, `update_plan`, `task` |
242242
| MCP | anything an MCP server exposes, surfaced as `mcp__<server>__<tool>` |
243243

244-
Each tool definition costs prompt-eval time on small local models. The
245-
default `toolSelection: "auto"` sends only the tools that look relevant to
246-
each prompt; switch to `fixed` to always send the full set. Toggle the
247-
active pool with `/tools file_read,grep,list_dir`, or persistently in
248-
`agent.config.json`.
244+
The default `toolSelection: "auto"` keeps the full working pool available for
245+
any real request (so "build me a site" writes files instead of dumping code
246+
into the chat) and sends no tools only for plain greetings. `fixed` always
247+
sends the pool. Set the pool with `/tools file_read,grep,list_dir`, or
248+
persistently in `agent.config.json`.
249249

250250
### Approval policies
251251

@@ -440,7 +440,7 @@ OPENROUTER_API_KEY=sk-or-... # required for openroute
440440
OPENAI_BASE_URL=https://api.openai.com/v1 # point at a compatible proxy if needed
441441

442442
APPROVAL_POLICY=always # always | dangerous-only | never
443-
AGENT_TOOLS=file_read,grep,list_dir,file_edit,shell,update_plan,task
443+
AGENT_TOOLS=file_read,grep,list_dir,file_edit,file_write,shell,update_plan,task
444444
AGENT_TOOL_SELECTION=auto # auto | fixed
445445

446446
WARMUP=true # pre-warm prompt cache at startup
@@ -460,7 +460,7 @@ root. Common shape:
460460
"backend": "ollama",
461461
"modelOverride": "qwen2.5-coder:14b",
462462
"approvalPolicy": "dangerous-only",
463-
"tools": ["file_read", "grep", "list_dir", "file_edit", "shell", "update_plan", "task"],
463+
"tools": ["file_read", "grep", "list_dir", "file_edit", "file_write", "shell", "update_plan", "task"],
464464
"toolSelection": "auto",
465465
"maxSteps": 20,
466466
"workspaceRoot": "/path/to/project",

src/config.rs

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -444,9 +444,17 @@ const SYSTEM_PROMPT: &str = concat!(
444444
"- Only call a tool when the user's request actually needs filesystem access.\n",
445445
"- When you do call a tool, emit a real tool call — not a JSON description in your text response.\n",
446446
"\n",
447-
"When working with code:\n",
447+
"When the user asks you to create or change code or files:\n",
448+
"- DO write the changes to disk with the file tools: file_write to create a\n",
449+
" new file, file_edit to modify an existing one. Then briefly say what you\n",
450+
" did.\n",
451+
"- DO NOT paste file contents or large code blocks into your reply. The user\n",
452+
" sees the files and diffs directly — repeating them wastes tokens and is\n",
453+
" not what they asked for. Only show code inline if they explicitly ask to\n",
454+
" see it, or for a tiny (1-3 line) illustration.\n",
455+
"- After the edits, reply with a short summary: what you created or changed\n",
456+
" and the key files — a sentence or two, not the code.\n",
448457
"- Make minimal targeted edits consistent with existing style.\n",
449-
"- Be concise. The user can read the diff.\n",
450458
"- For a task that takes 3 or more steps, call update_plan first with the\n",
451459
" full plan, then call it again to mark each step done as you finish it.\n",
452460
" Keep exactly one step in_progress. Skip the plan for trivial one-shot tasks.\n",
@@ -481,6 +489,7 @@ impl Default for AgentConfig {
481489
"grep".into(),
482490
"list_dir".into(),
483491
"file_edit".into(),
492+
"file_write".into(),
484493
"shell".into(),
485494
"update_plan".into(),
486495
"task".into(),

src/tools/mod.rs

Lines changed: 95 additions & 163 deletions
Original file line numberDiff line numberDiff line change
@@ -72,125 +72,69 @@ pub trait Tool: Send + Sync {
7272
}
7373
}
7474

75-
fn enabled(config: &AgentConfig, name: &str) -> bool {
76-
config.tools.iter().any(|t| t == name)
77-
}
78-
79-
fn push_if_enabled(out: &mut Vec<String>, config: &AgentConfig, name: &str) {
80-
if enabled(config, name) && !out.iter().any(|t| t == name) {
81-
out.push(name.to_string());
82-
}
83-
}
84-
75+
/// Choose the tools to expose for a given prompt.
76+
///
77+
/// `Fixed` always sends the whole configured pool. `Auto` (the default) keeps
78+
/// the full working set available for any real request and sends *no* tools
79+
/// only for obvious small talk (greetings, thanks) — the model still decides
80+
/// whether to call them. This mirrors how modern harnesses work: the agent
81+
/// always has read/edit/write/shell on hand, so "build me a site" results in
82+
/// files written to disk instead of code dumped into the chat. The old
83+
/// keyword-bucket heuristic guessed wrong on phrasings like "build a bio site"
84+
/// (no edit tools surfaced), which forced the model to print code it couldn't
85+
/// save.
8586
pub fn select_tool_names(config: &AgentConfig, prompt: &str) -> Vec<String> {
86-
if config.tool_selection == ToolSelection::Fixed {
87+
if config.tool_selection == ToolSelection::Fixed || !is_small_talk(prompt) {
8788
return config.tools.clone();
8889
}
90+
Vec::new()
91+
}
8992

90-
let lower = prompt.to_lowercase();
91-
let fileish = [
92-
"file",
93-
"files",
94-
"repo",
95-
"repository",
96-
"code",
97-
"src",
98-
"read",
99-
"open",
100-
"search",
101-
"grep",
102-
"find",
103-
"list",
104-
"directory",
105-
"folder",
106-
"where is",
107-
"inspect",
108-
"review",
109-
]
110-
.iter()
111-
.any(|needle| lower.contains(needle));
112-
let editish = [
113-
"edit",
114-
"change",
115-
"modify",
116-
"update",
117-
"fix",
118-
"implement",
119-
"add support",
120-
"refactor",
121-
"write",
122-
"create",
123-
"delete",
124-
"patch",
125-
"build it",
126-
]
127-
.iter()
128-
.any(|needle| lower.contains(needle));
129-
let shellish = [
130-
"run ", "execute", "command", "terminal", "shell", "test", "cargo ", "npm ", "git ",
131-
"build", "lint", "check",
132-
]
133-
.iter()
134-
.any(|needle| lower.contains(needle));
135-
let testish = ["failing", "verify", "unit test", "test suite"]
136-
.iter()
137-
.any(|needle| lower.contains(needle))
138-
|| (lower.contains("test") && !lower.contains("latest"));
139-
let shipish = [
140-
"ship",
141-
"ready to commit",
142-
"ready to ship",
143-
"shipcheck",
144-
"handoff",
145-
"release",
146-
]
147-
.iter()
148-
.any(|needle| lower.contains(needle));
149-
let batchish = [
150-
"multi-file",
151-
"multiple files",
152-
"across files",
153-
"batch edit",
154-
"coordinated",
155-
]
156-
.iter()
157-
.any(|needle| lower.contains(needle));
158-
159-
let mut out = Vec::new();
160-
if fileish || editish {
161-
if config.project_memory.enabled {
162-
push_if_enabled(&mut out, config, "repo_search");
163-
}
164-
push_if_enabled(&mut out, config, "file_read");
165-
push_if_enabled(&mut out, config, "grep");
166-
push_if_enabled(&mut out, config, "list_dir");
167-
push_if_enabled(&mut out, config, "glob");
168-
// Delegated read-only investigation keeps deep exploration out of the
169-
// parent context.
170-
push_if_enabled(&mut out, config, "task");
171-
}
172-
if editish {
173-
push_if_enabled(&mut out, config, "file_edit");
174-
push_if_enabled(&mut out, config, "apply_patch");
175-
push_if_enabled(&mut out, config, "file_write");
176-
}
177-
// Multi-step work (editing or running things) benefits from a visible plan.
178-
if editish || shellish {
179-
push_if_enabled(&mut out, config, "update_plan");
93+
/// Conservatively detect throwaway social messages (greetings, thanks) so we
94+
/// can skip sending tool schemas for them. Deliberately narrow: a false
95+
/// negative just sends tools the model won't use, but a false positive would
96+
/// starve a real request of its tools, so we only match very short, clearly
97+
/// social inputs.
98+
fn is_small_talk(prompt: &str) -> bool {
99+
let t = prompt.trim().to_lowercase();
100+
let t = t.trim_end_matches(['.', '!', '?', ' ']);
101+
if t.is_empty() {
102+
return true;
180103
}
181-
if shellish {
182-
push_if_enabled(&mut out, config, "shell");
183-
}
184-
if testish || shellish {
185-
push_if_enabled(&mut out, config, "run_tests");
186-
}
187-
if batchish && editish {
188-
push_if_enabled(&mut out, config, "batch_edit");
189-
}
190-
if shipish {
191-
push_if_enabled(&mut out, config, "ship_status");
192-
}
193-
out
104+
const EXACT: &[&str] = &[
105+
"hi",
106+
"hii",
107+
"hey",
108+
"hello",
109+
"yo",
110+
"sup",
111+
"thanks",
112+
"thank you",
113+
"thx",
114+
"ty",
115+
"ok",
116+
"okay",
117+
"k",
118+
"cool",
119+
"nice",
120+
"great",
121+
"awesome",
122+
"hi there",
123+
"hey there",
124+
"hello there",
125+
"good morning",
126+
"good afternoon",
127+
"good evening",
128+
"how are you",
129+
"how's it going",
130+
"who are you",
131+
"what are you",
132+
"what can you do",
133+
"gm",
134+
"bye",
135+
"goodbye",
136+
];
137+
EXACT.contains(&t)
194138
}
195139

196140
/// Returns true when a tool result indicates the workspace was mutated (for ship-loop hooks).
@@ -329,85 +273,73 @@ mod tests {
329273
use crate::config::ToolSelection;
330274

331275
#[test]
332-
fn auto_chat_uses_no_tools() {
276+
fn small_talk_sends_no_tools() {
333277
let config = AgentConfig {
334278
tool_selection: ToolSelection::Auto,
335279
..Default::default()
336280
};
337-
assert!(select_tool_names(&config, "hello there").is_empty());
281+
for greeting in [
282+
"hi",
283+
"hello there",
284+
"thanks",
285+
"ok",
286+
" hey! ",
287+
"Good morning",
288+
] {
289+
assert!(
290+
select_tool_names(&config, greeting).is_empty(),
291+
"{greeting:?} should be treated as small talk"
292+
);
293+
}
338294
}
339295

340296
#[test]
341-
fn auto_file_question_uses_read_tools() {
297+
fn real_request_sends_the_full_pool() {
342298
let config = AgentConfig {
343299
tool_selection: ToolSelection::Auto,
344300
..Default::default()
345301
};
346-
let names = select_tool_names(&config, "search the repo for config");
347-
assert!(names.contains(&"file_read".to_string()));
348-
assert!(names.contains(&"grep".to_string()));
349-
assert!(names.contains(&"list_dir".to_string()));
302+
assert_eq!(
303+
select_tool_names(&config, "what files are in src?"),
304+
config.tools
305+
);
350306
}
351307

352308
#[test]
353-
fn repo_search_is_opt_in_not_default() {
354-
// repo_search is no longer in the default pool; auto-selection can't
355-
// surface it unless the user enables it.
309+
fn build_request_includes_write_tools() {
310+
// Regression: "build a bio site" used to surface no edit tools, forcing
311+
// the model to dump code into the chat. It must now get file_write +
312+
// file_edit + shell so it can actually create files.
356313
let config = AgentConfig {
357314
tool_selection: ToolSelection::Auto,
358315
..Default::default()
359316
};
360-
assert!(!config.tools.contains(&"repo_search".to_string()));
361-
let names = select_tool_names(&config, "search the repo for config");
362-
assert!(!names.contains(&"repo_search".to_string()));
317+
let names = select_tool_names(&config, "build a bio site");
318+
assert!(names.contains(&"file_write".to_string()));
319+
assert!(names.contains(&"file_edit".to_string()));
320+
assert!(names.contains(&"shell".to_string()));
363321
}
364322

365323
#[test]
366-
fn auto_repo_search_requires_memory_enabled() {
367-
// When repo_search IS enabled, auto-selection still gates it on memory.
368-
let base_tools = vec![
369-
"repo_search".to_string(),
370-
"file_read".to_string(),
371-
"grep".to_string(),
372-
"list_dir".to_string(),
373-
];
374-
let enabled = AgentConfig {
375-
tools: base_tools.clone(),
376-
..Default::default()
377-
};
378-
assert!(select_tool_names(&enabled, "search the repo for config")
379-
.contains(&"repo_search".to_string()));
380-
381-
let disabled = AgentConfig {
382-
tools: base_tools,
383-
project_memory: crate::config::ProjectMemoryConfig {
384-
enabled: false,
385-
..Default::default()
386-
},
387-
..Default::default()
388-
};
389-
assert!(!select_tool_names(&disabled, "search the repo for config")
390-
.contains(&"repo_search".to_string()));
391-
}
392-
393-
#[test]
394-
fn auto_verify_prompt_includes_run_tests() {
324+
fn auto_only_sends_configured_tools() {
395325
let config = AgentConfig {
396-
tools: vec!["run_tests".into(), "file_read".into(), "file_edit".into()],
326+
tool_selection: ToolSelection::Auto,
327+
tools: vec!["file_read".into(), "shell".into()],
397328
..Default::default()
398329
};
399-
let names = select_tool_names(&config, "verify the failing unit test");
400-
assert!(names.contains(&"run_tests".to_string()));
330+
assert_eq!(
331+
select_tool_names(&config, "do some real work"),
332+
vec!["file_read".to_string(), "shell".to_string()]
333+
);
401334
}
402335

403336
#[test]
404-
fn auto_ship_prompt_includes_ship_status() {
337+
fn fixed_mode_sends_full_pool_even_for_greetings() {
405338
let config = AgentConfig {
406-
tools: vec!["ship_status".into(), "file_read".into()],
339+
tool_selection: ToolSelection::Fixed,
407340
..Default::default()
408341
};
409-
let names = select_tool_names(&config, "is this ready to ship?");
410-
assert!(names.contains(&"ship_status".to_string()));
342+
assert_eq!(select_tool_names(&config, "hi"), config.tools);
411343
}
412344

413345
#[test]

0 commit comments

Comments
 (0)