Skip to content

Commit d3ae231

Browse files
authored
Merge pull request #154 from itmisx/fix/tool-executor-timeout
🐛 fix: tool executor timeout
2 parents e6714bd + 17480eb commit d3ae231

2 files changed

Lines changed: 55 additions & 1 deletion

File tree

agent/llm.go

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1349,7 +1349,55 @@ func executeTool(tc ToolCall, mode AgentMode, lastFile *string) tools.ToolResult
13491349
}
13501350
// 唯一收口:所有真正执行的工具(Read/Bash/Grep/WebFetch/MCP 等,主 agent / 子 agent / Explore 都走这里)
13511351
// 的返回值在进会话历史前统一限幅,防止单条超大结果撑爆上下文(issue #135)。
1352-
res := t.Executor(args)
1352+
res := runExecutorGuarded(t, args)
13531353
res.Output = clampToolOutput(t.Name, res.Output)
13541354
return res
13551355
}
1356+
1357+
// fsToolTimeout 列出"纯本地、无自带超时"的工具:它们理应秒级返回,一旦底层
1358+
// stat/read 卡在病态挂载点(典型是 WSL /mnt/c 的 9p 在杀软扫描/文件被 Windows
1359+
// 进程占用时 stall),或目标是非普通文件(FIFO/设备,os.ReadFile 永等写端),
1360+
// 会无限阻塞、拖死整个工具循环(实测 Read 卡 50 分钟)。给它们套看门狗超时兜底。
1361+
//
1362+
// 刻意排除(各有自己的时长管控,套小超时反而会误杀合法长跑):
1363+
// - Bash:自带 timeout 参数(可设几百秒)
1364+
// - Fetch / Search:网络请求自带超时
1365+
// - Workflow / Explore:派子 agent,合法长跑数分钟
1366+
// - MCP 工具(mcp__ 前缀):各自服务端管控
1367+
var fsToolTimeout = map[string]time.Duration{
1368+
"Read": 60 * time.Second,
1369+
"Write": 60 * time.Second,
1370+
"Update": 60 * time.Second,
1371+
"Glob": 60 * time.Second,
1372+
"Grep": 60 * time.Second,
1373+
"List": 60 * time.Second,
1374+
"Tree": 60 * time.Second,
1375+
"CodeGraph": 120 * time.Second,
1376+
"OCR": 120 * time.Second,
1377+
}
1378+
1379+
// runExecutorGuarded 执行 executor;对本地文件类工具套看门狗超时,防止病态挂载点
1380+
// 把整个工具循环无限卡死。超时后给 LLM 返回失败让它自纠,而不是干等。
1381+
//
1382+
// executor 签名固定为 map[string]any→ToolResult、拿不到 context,无法真正取消那个
1383+
// 已陷在内核 read() 里的 goroutine;但 done 为带缓冲 channel,系统调用一旦最终返回
1384+
// goroutine 就能写入并退出,不会泄漏(只有真正永不返回的病态 fd 会留一个孤儿 goroutine,
1385+
// 这是无 context 取消能力下可接受的代价——关键是工具循环已经解开了)。
1386+
func runExecutorGuarded(t *tools.Tool, args map[string]any) tools.ToolResult {
1387+
d, guarded := fsToolTimeout[t.Name]
1388+
if !guarded {
1389+
return t.Executor(args)
1390+
}
1391+
done := make(chan tools.ToolResult, 1)
1392+
go func() { done <- t.Executor(args) }()
1393+
select {
1394+
case res := <-done:
1395+
return res
1396+
case <-time.After(d):
1397+
return tools.ToolResult{
1398+
Output: fmt.Sprintf("工具 %s 执行超时(%s)。目标可能位于卡死的挂载点"+
1399+
"(如 WSL /mnt/c 的 9p),或是非普通文件(管道/设备)。请换个路径或确认挂载可用。", t.Name, d),
1400+
Success: false,
1401+
}
1402+
}
1403+
}

tools/read_file.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,12 @@ func ReadFile(args map[string]any) ToolResult {
3030
if info.IsDir() {
3131
return ToolResult{Output: "目标是目录,请使用 List 工具", Success: false}
3232
}
33+
// 非普通文件(FIFO/设备/socket)上 os.ReadFile 会永等写端 —— 而文件读取的
34+
// 系统调用没有 context 版本、看门狗超时只能丢弃 goroutine 不能真正取消,
35+
// 这一类会留永久孤儿 goroutine。源头直接拒掉,把"被丢弃 goroutine"封成有界。
36+
if !info.Mode().IsRegular() {
37+
return ToolResult{Output: "目标不是普通文件(管道/设备/符号链接环等),拒绝读取", Success: false}
38+
}
3339
if info.Size() > 10*1024*1024 {
3440
return ToolResult{Output: "文件过大(>10MB)", Success: false}
3541
}

0 commit comments

Comments
 (0)