fix: LLM 工具循环 400、中断消息注入、ConPTY 终端支持

- agent: 工具轮请求尾部补 user 占位(zen 网关强制),tool 消息正确配对
- agent: 工具提醒/中断以 system 角色注入并带 [中断消息] 前缀,不进用户履历;系统提示词说明中断消息格式
- agentcli: 基于 ConPTY 的交互式终端(ptywin fork),terminal_create/read/write/resize/close/watch
- webui: server 输出通道适配器(保留 reasoning_content/disable_thinking)
- GUI: 沉浸式标题栏、icon 圆角重制、mascot 等打磨
This commit is contained in:
JianFeeeee
2026-08-14 00:48:40 +08:00
parent 816597caac
commit 147d0baaf9
43 changed files with 4670 additions and 1478 deletions

View File

@ -46,8 +46,17 @@ func terminalRunning(t *TerminalSession) bool {
return t.cmd != nil && (t.cmd.ProcessState == nil || !t.cmd.ProcessState.Exited())
}
// terminalWatch 终端提醒规则(由 terminal_watch 工具设置)。
type terminalWatch struct {
interval time.Duration // 固定时间反馈间隔0 禁用
onExit bool // 命令执行结束提醒(默认 true
bufferBytes int // 该终端专用缓冲阈值字节0 使用全局 notify_bytes
quiet bool // 静默模式:不随输出流通知,仅定时反馈/结束提醒/空闲汇总
}
type TerminalSession struct {
id string
command string
cmd *exec.Cmd
session ptyTerm
mu sync.Mutex
@ -59,8 +68,15 @@ type TerminalSession struct {
done chan struct{}
// 通知节流字段
unreadBytes int // 最近一次通知后积累的未读字节数
lastNotify time.Time // 最近一次通知时间
unreadBytes int // 最近一次通知后积累的未读字节数
lastNotify time.Time // 最近一次通知时间
lastData time.Time // 最近一次读到的数据时间(用于判定输出停止)
lastFeedback time.Time // 最近一次定时反馈时间
backoff time.Duration // 输出风暴退避:持续高速输出时通知间隔翻倍
watch terminalWatch // 该终端的提醒规则
// 实时画面推流terminal_output 事件)
stream bytes.Buffer // 待推送的增量输出,由 readLoop 每 200ms flush 一次
}
func (t *TerminalSession) Write(input string) (int, error) {
@ -85,12 +101,13 @@ func (t *TerminalSession) Close() {
t.mu.Unlock()
close(t.stopCh)
// 先终止进程各平台实现Linux 信号 / Windows TerminateProcess幂等再释放资源。
// 不能依赖 cmd.Process.Kill()Windows 后端 cmd.Process 为占位(仅 Pid
if t.session != nil {
_ = t.session.Kill()
}
t.session.Close()
<-t.done
if t.cmd != nil && t.cmd.Process != nil {
t.cmd.Process.Kill()
}
}
func (t *TerminalSession) ReadOutput() string {
@ -119,6 +136,17 @@ func (t *TerminalSession) appendOutput(data []byte) {
}
}
t.buf.Write(data)
// 同步追加到实时画面推流缓冲(最大 64KB超出丢弃最旧部分
const maxStream = 64 * 1024
if t.stream.Len()+len(data) > maxStream {
excess := t.stream.Len() + len(data) - maxStream
if t.stream.Len() > excess {
t.stream.Next(excess)
} else {
t.stream.Reset()
}
}
t.stream.Write(data)
}
func (t *TerminalSession) IsExpired() bool {
@ -194,8 +222,10 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
}
s.RegisterTool("terminal_create", sdk.ToolDef{
Name: "terminal_create",
Description: "创建一个新的交互式终端会话。返回终端 ID后续通过此 ID 进行读写操作。适用于运行交互式程序如 vim、ssh、top、nano 等。终端默认 5 分钟后自动关闭,可通过 timeout 参数调整。",
Name: "terminal_create",
Description: "创建一个新的交互式终端会话。返回终端 ID后续通过此 ID 进行读写操作。适用于运行交互式程序如 vim、ssh、top、nano 等。" +
"通知模式通过 notify 参数选择(默认 exitexit=仅命令执行结束后提醒一次interval=定时反馈(如 interval=30s 每 30 秒反馈一次状态摘要);" +
"buffer=未读输出积累到指定字节数后提醒(如 buffer=8192多个模式用逗号组合如 interval=30s,buffer=8192。终端默认 5 分钟后自动关闭,可通过 timeout 参数调整。",
NoMemory: true,
Parameters: map[string]interface{}{
"type": "object",
@ -204,6 +234,10 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
"type": "string",
"description": "要执行的命令(默认 bash。如需运行特定程序直接传入即可例如vim /tmp/test.txt",
},
"notify": map[string]interface{}{
"type": "string",
"description": "通知模式可选exit默认命令结束后提醒interval=时长(定时反馈,如 30s/1mbuffer=字节数(缓冲阈值提醒);可逗号组合",
},
"timeout": map[string]interface{}{
"type": "string",
"description": "终端自动关闭时间,例如 5m, 10m, 30m, 1h默认 5m",
@ -249,8 +283,8 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
})
s.RegisterTool("terminal_read", sdk.ToolDef{
Name: "terminal_read",
Description: "读取指定终端的当前屏幕内容。返回自上次读取以来的新输出。如需持续监控请多次调用。",
Name: "terminal_read",
Description: "读取指定终端的输出。mode=new默认返回自上次读取以来的新输出并清空缓冲mode=now 返回终端当前显示的全部屏幕内容(不清空缓冲)。如需持续监控请多次调用。",
NoMemory: true,
Parameters: map[string]interface{}{
"type": "object",
@ -259,9 +293,13 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
"type": "string",
"description": "终端 ID",
},
"mode": map[string]interface{}{
"type": "string",
"description": "读取模式new默认新输出并清空缓冲或 now当前屏幕全部内容不清理",
},
"clear": map[string]interface{}{
"type": "boolean",
"description": "读取后是否清除缓冲区(默认 true",
"description": "读取后是否清除缓冲区(默认与 mode 一致new 清除now 不清除",
},
},
"required": []string{"id"},
@ -326,6 +364,48 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
return p.handleList()
})
s.RegisterTool("terminal_watch", sdk.ToolDef{
Name: "terminal_watch",
Description: "为指定终端设置提醒规则,避免长时间运行任务(编译/下载/构建等)的输出造成通知风暴。" +
"可选规则interval=固定时间反馈(每隔该时长向 agent 反馈一次终端状态摘要);" +
"on_exit=命令执行结束提醒buffer_bytes=未读输出积累到该字节数时提醒一次;" +
"quiet=静默模式(抑制随输出流的通知,仅保留定时反馈与结束提醒,推荐长任务使用)。" +
"未提供的字段保持原值clear=true 清除全部规则。默认 on_exit=true。",
NoMemory: true,
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"id": map[string]interface{}{
"type": "string",
"description": "终端 ID来自 terminal_create 的返回值",
},
"interval": map[string]interface{}{
"type": "string",
"description": "固定时间反馈间隔,如 30s, 1m, 5m可选0 禁用)",
},
"on_exit": map[string]interface{}{
"type": "boolean",
"description": "命令执行结束时是否提醒(默认 true",
},
"buffer_bytes": map[string]interface{}{
"type": "integer",
"description": "未读输出积累阈值(字节),达到后提醒一次(可选,默认全局 2048",
},
"quiet": map[string]interface{}{
"type": "boolean",
"description": "静默模式:不随输出流通知,仅保留定时反馈与结束提醒(推荐编译/下载等长任务)",
},
"clear": map[string]interface{}{
"type": "boolean",
"description": "清除该终端全部提醒规则(恢复默认行为)",
},
},
"required": []string{"id"},
},
}, func(args map[string]interface{}) (interface{}, error) {
return p.handleWatch(args)
})
p.wg.Add(1)
go p.cleanupLoop(s)
@ -378,18 +458,28 @@ func (p *Plugin) handleCreate(s *sdk.PluginSDK, args map[string]interface{}) (in
cols = uint16(c)
}
// 通知模式:默认 exit命令执行结束后提醒一次
// 支持 interval=30s / buffer=8192 / quiet可逗号组合。
watch := terminalWatch{onExit: true, quiet: true}
if notifyStr, ok := args["notify"].(string); ok && notifyStr != "" {
watch = parseNotifyMode(notifyStr, watch)
}
term, cmd, err := newCommandPty(command, rows, cols)
if err != nil {
return map[string]interface{}{"error": fmt.Sprintf("创建终端失败: %v", err)}, nil
}
session := &TerminalSession{
id: "",
command: command,
cmd: cmd,
session: term,
createdAt: time.Now(),
timeout: timeout,
stopCh: make(chan struct{}),
done: make(chan struct{}),
watch: watch,
}
p.mu.Lock()
@ -404,15 +494,34 @@ func (p *Plugin) handleCreate(s *sdk.PluginSDK, args map[string]interface{}) (in
log.Printf("[agentcli] created terminal %s: command=%q timeout=%v rows=%d cols=%d", id, command, timeout, rows, cols)
return map[string]interface{}{
"id": id,
"status": "created",
"command": command,
"timeout": timeout.String(),
"rows": rows,
"cols": cols,
"id": id,
"status": "created",
"command": command,
"timeout": timeout.String(),
"rows": rows,
"cols": cols,
"notify_mode": notifyModeString(watch),
}, nil
}
// notifyModeString 输出可读的通知模式描述。
func notifyModeString(w terminalWatch) string {
var parts []string
if w.onExit {
parts = append(parts, "exit")
}
if w.interval > 0 {
parts = append(parts, "interval="+w.interval.String())
}
if w.bufferBytes > 0 {
parts = append(parts, fmt.Sprintf("buffer=%d", w.bufferBytes))
}
if len(parts) == 0 {
return "quiet"
}
return strings.Join(parts, ",")
}
func (p *Plugin) handleWrite(s *sdk.PluginSDK, args map[string]interface{}) (interface{}, error) {
id, _ := args["id"].(string)
if id == "" {
@ -455,13 +564,49 @@ func (p *Plugin) handleWrite(s *sdk.PluginSDK, args map[string]interface{}) (int
}, nil
}
// parseNotifyMode 解析 notify 参数并合并进 watch。
// 支持exit / quiet / interval=时长 / buffer=字节数,逗号分隔组合。
func parseNotifyMode(s string, base terminalWatch) terminalWatch {
w := base
for _, part := range strings.Split(s, ",") {
part = strings.TrimSpace(part)
if part == "" {
continue
}
kv := strings.SplitN(part, "=", 2)
key := strings.TrimSpace(kv[0])
val := ""
if len(kv) == 2 {
val = strings.TrimSpace(kv[1])
}
switch key {
case "exit":
w.onExit = true
w.quiet = false
case "quiet", "silent":
w.quiet = true
case "interval":
if d, err := time.ParseDuration(val); err == nil && d > 0 {
w.interval = d
}
case "buffer":
var n int
if _, err := fmt.Sscanf(val, "%d", &n); err == nil && n > 0 {
w.bufferBytes = n
}
}
}
return w
}
func (p *Plugin) handleRead(args map[string]interface{}) (interface{}, error) {
id, _ := args["id"].(string)
if id == "" {
return map[string]interface{}{"error": "id is required"}, nil
}
clear := true
mode, _ := args["mode"].(string)
clear := mode != "now"
if v, ok := args["clear"].(bool); ok {
clear = v
}
@ -474,19 +619,29 @@ func (p *Plugin) handleRead(args map[string]interface{}) (interface{}, error) {
}
var output string
session.mu.Lock()
if clear {
output = session.ReadAndClearOutput()
output = session.buf.String()
session.buf.Reset()
// 实时画面推流缓冲同步清空,避免 terminal_output 事件与读取结果重复
session.stream.Reset()
} else {
output = session.ReadOutput()
output = session.buf.String()
}
session.mu.Unlock()
if output == "" {
output = "[终端无新输出]"
if mode == "now" {
output = "[终端当前无屏幕内容]"
} else {
output = "[终端无新输出]"
}
}
return map[string]interface{}{
"status": "ok",
"terminal": id,
"mode": mode,
"output": output,
"running": terminalRunning(session),
"uptime": time.Since(session.createdAt).String(),
@ -550,6 +705,55 @@ func (p *Plugin) handleClose(args map[string]interface{}) (interface{}, error) {
}, nil
}
func (p *Plugin) handleWatch(args map[string]interface{}) (interface{}, error) {
id, _ := args["id"].(string)
if id == "" {
return map[string]interface{}{"error": "id is required"}, nil
}
p.mu.Lock()
session, ok := p.sessions[id]
p.mu.Unlock()
if !ok {
return map[string]interface{}{"error": fmt.Sprintf("终端 %s 不存在或已关闭", id)}, nil
}
session.mu.Lock()
if v, ok := args["clear"].(bool); ok && v {
session.watch = terminalWatch{onExit: true}
} else {
if v, ok := args["interval"].(string); ok && v != "" {
if d, err := time.ParseDuration(v); err == nil && d >= 0 {
session.watch.interval = d
}
}
if v, ok := args["on_exit"].(bool); ok {
session.watch.onExit = v
}
if v, ok := args["buffer_bytes"].(float64); ok && v >= 0 {
session.watch.bufferBytes = int(v)
}
if v, ok := args["quiet"].(bool); ok {
session.watch.quiet = v
}
if session.watch.interval == 0 && session.watch.bufferBytes == 0 && !session.watch.quiet {
session.watch.onExit = true
}
}
w := session.watch
session.mu.Unlock()
log.Printf("[agentcli] watch updated for %s: %+v", id, w)
return map[string]interface{}{
"status": "ok",
"terminal": id,
"interval": w.interval.String(),
"on_exit": w.onExit,
"buffer_bytes": w.bufferBytes,
"quiet": w.quiet,
}, nil
}
func (p *Plugin) handleList() (interface{}, error) {
p.mu.Lock()
defer p.mu.Unlock()
@ -571,6 +775,7 @@ func (p *Plugin) handleList() (interface{}, error) {
}
terms = append(terms, termInfo{
ID: t.id,
Command: t.command,
Uptime: time.Since(t.createdAt).Round(time.Second).String(),
ExpiresIn: remaining.Round(time.Second).String(),
Running: running,
@ -598,9 +803,24 @@ func (p *Plugin) readLoop(t *TerminalSession, s *sdk.PluginSDK) {
readCh := make(chan readResult, 4)
go p.reader(t, buf, readCh)
// 实时画面推流 ticker每 200ms 批量发布一次 terminal_output 事件
flushTicker := time.NewTicker(200 * time.Millisecond)
defer flushTicker.Stop()
// 立即发送首次"终端已启动"通知,让 agent 感知存在
s.InjectText("agentcli", "agentcli", fmt.Sprintf("[终端 %s 已启动]", t.id))
t.lastNotify = time.Now()
now := time.Now()
t.mu.Lock()
t.lastNotify = now
t.lastData = now
t.lastFeedback = now
t.mu.Unlock()
// 硬上限:未读输出积累达到该值也通知一次(防大输出静默丢失),频率极低
hardNotifyBytes := 64 * 1024
hardNotifyInterval := 10 * time.Second
// 输出停止判定:超过该时长无新数据则视为输出停止
quietLatency := 2 * time.Second
for {
if t.IsExpired() {
@ -613,16 +833,52 @@ func (p *Plugin) readLoop(t *TerminalSession, s *sdk.PluginSDK) {
}
if !terminalRunning(t) {
s.InjectText("agentcli", "agentcli", fmt.Sprintf("[终端 %s 中的进程已退出]", t.id))
if t.watch.onExit {
s.InjectText("agentcli", "agentcli", fmt.Sprintf("[终端 %s 中的命令已执行结束]", t.id))
} else {
s.InjectText("agentcli", "agentcli", fmt.Sprintf("[终端 %s 中的进程已退出]", t.id))
}
p.mu.Lock()
delete(p.sessions, t.id)
p.mu.Unlock()
return
}
// 固定时间反馈watch.interval > 0 时每隔该时长主动反馈一次状态摘要
t.mu.Lock()
if t.watch.interval > 0 && time.Since(t.lastFeedback) >= t.watch.interval {
t.lastFeedback = time.Now()
t.lastNotify = t.lastFeedback
unread := t.unreadBytes
t.unreadBytes = 0
preview := previewTail(t.buf.String(), 120)
t.mu.Unlock()
s.InjectText("agentcli", "agentcli",
fmt.Sprintf("[终端 %s 定时反馈: 运行中, 期间新输出约 %d 字节]\n%s", t.id, unread, preview))
continue
}
t.mu.Unlock()
select {
case <-t.stopCh:
return
case <-flushTicker.C:
// 批量推送终端实时画面增量(独立 ticker避免被高密度数据饿死
var streamData string
t.mu.Lock()
if t.stream.Len() > 0 {
streamData = t.stream.String()
t.stream.Reset()
}
t.mu.Unlock()
if streamData != "" {
s.Publish(&sdk.Event{
Type: sdk.EventTerminalOutput,
Source: "agentcli",
Payload: map[string]interface{}{"terminal_id": t.id, "output": streamData, "running": terminalRunning(t)},
Timestamp: time.Now().UnixMilli(),
})
}
case r := <-readCh:
if r.err != nil {
// 读取错误/EOF → 立即通知(进程可能已结束)
@ -634,32 +890,66 @@ func (p *Plugin) readLoop(t *TerminalSession, s *sdk.PluginSDK) {
copy(data, buf[:r.n])
t.appendOutput(data)
// 语义通知:累积未读字节数
// 缓冲阈值通知(仅当 agent 显式选择 buffer 模式,或未读积累达到硬上限)。
// 默认模式(仅 exit 提醒)下不随输出流通知,杜绝通知风暴。
t.mu.Lock()
t.lastData = time.Now()
t.unreadBytes += r.n
needNotify := t.unreadBytes >= p.notifyBytes ||
time.Since(t.lastNotify) >= p.notifyInterval
t.mu.Unlock()
if needNotify {
t.mu.Lock()
preview := t.buf.String()
if len(preview) > 200 {
preview = preview[len(preview)-200:] // 取最新 200 字符
bufThr := t.watch.bufferBytes
if bufThr <= 0 {
bufThr = p.notifyBytes
}
minInterval := p.notifyInterval
if t.watch.interval > 0 {
minInterval = t.watch.interval
}
// 风暴退避:距上次通知不足 1s 说明输出极速,通知间隔翻倍(上限 30s
if time.Since(t.lastNotify) < time.Second && t.unreadBytes >= bufThr {
if t.backoff == 0 {
t.backoff = minInterval
} else if t.backoff < 30*time.Second {
t.backoff *= 2
if t.backoff > 30*time.Second {
t.backoff = 30 * time.Second
}
}
preview = sanitizePreview(preview)
t.unreadBytes = 0
}
interval := t.backoff + minInterval
isHard := t.watch.bufferBytes <= 0 && t.unreadBytes >= hardNotifyBytes
if isHard && hardNotifyInterval > interval {
interval = hardNotifyInterval
}
need := t.unreadBytes >= bufThr && time.Since(t.lastNotify) >= interval
if need {
t.lastNotify = time.Now()
t.unreadBytes = 0
preview := previewTail(t.buf.String(), 200)
t.mu.Unlock()
s.InjectText("agentcli", "agentcli",
fmt.Sprintf("[终端 %s 有新输出]\n%s", t.id, preview))
} else {
t.mu.Unlock()
s.InjectText("agentcli", "agentcli", fmt.Sprintf("[终端 %s 有新输出]\n%s", t.id, preview))
}
}
case <-time.After(pollInterval):
// 空闲轮询:输出已停止时复位退避
t.mu.Lock()
if t.backoff > 0 && time.Since(t.lastData) >= quietLatency {
t.backoff = 0
}
t.mu.Unlock()
}
}
}
// previewTail 返回 s 末尾最多 n 字符,并转义控制字符保证可读。
func previewTail(s string, n int) string {
if len(s) > n {
s = s[len(s)-n:]
}
return sanitizePreview(s)
}
type readResult struct {
n int
err error