mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-06 01:07:35 +00:00
refactor(terminal): 终端逻辑回归 agentcli,内核不再编码终端语义
按用户指正调整架构:终端是 agentcli 的职责,内核不该有终端逻辑;插件与
agentcli 的联动应走事件总线,而不是内核内部组件。
## 删掉内核里的终端语义
`internal/agent/core/terminal_registry.go`(280 行)整体删除。它做了两件不该
内核做的事:定义「什么是终端会话」、从 EventToolCall 的 Go map 文本里解析
terminal id / command 并归并会话与命令历史。连带移除:
- Agent 的 terminalReg 字段、构造、subscribeTerminalRegistry 订阅
- StatusAPI 上的 ListTerminals / CmdHistory
- sdk.TerminalAPI + PluginSDK.Terminal() + Registry.SetTerminalAPI 及接线
- 对应测试(迁到插件侧视图包)
保留 `sdk.TerminalStatus` / `CmdExecStatus`:它们只是线上 JSON 格式,WebUI/CLI
返回给前端时口径一致。
## 插件侧订阅事件总线自攒视图
新增 `internal/pluginview`(TerminalView):把原先内核那份归并逻辑原样搬过来,
由插件自己持有。数据源仍是两条总线事件:
EventTerminalOutput(agentcli 发)→ 会话输出 + 生命周期
EventToolCall (内核发) → cmd_run 历史、terminal_create/close 归并
WebUI 在 NewHandler 订阅、Close 退订;CLI 在 Start 订阅、Stop 退订。
两边共用同一实现,避免历史上「两份推导各踩一次 Go map 文本」的坑。
视图不是权威:真值在 agentcli,插件晚于会话启动会缺早期会话(注释写明)。
## agentcli 保持内置
它现在仍需要 `Publish`(进程内事件总线写入)——外部插件没有上行事件通道
(`EventPublish` 在 withheldCapabilities 里,proc 只有 events.subscribe)。
按判定标准留内置;将来要外置,须先开一条受控的插件→内核上行事件通道,
本次刻意不开(用户决定:先保持内置)。
## 文档
中英 ARCHITECTURE 新增「终端归属」小节:说明职责边界、两条事件、视图非权威、
以及 agentcli 为何仍内置;内置判定表里 cli/webui 去掉 TerminalAPI 一项。
## 验收(本机实例实测)
- 内核编译通过,删除后 `go build ./... && go vet ./...` 干净,`go test ./internal/...` 全绿
- 实例重启后:让 agent 开终端跑 `echo term-view-test` →
WebUI `GET /api/v1/terminals` 返回 term_1(output 正确、running=false)
CLI `/terminals`(unix socket)返回同一份;
两处都是从事件总线自攒,内核侧无终端代码参与
- cmd 历史分支由迁过来的单测覆盖(Go map 文本解析、生命周期、上限裁剪)
This commit is contained in:
@ -881,7 +881,7 @@ func flushTermStream(s *sdk.PluginSDK, t *TerminalSession) bool {
|
||||
// emitTermState 把终端存活状态作为 EventTerminalOutput 事件上报。
|
||||
//
|
||||
// 生命周期事件(创建/关闭/退出/超时)必须显式发:终端无输出时 ticker
|
||||
// 不会发事件,内核的终端权威视图与所有插件(WebUI/CLI)都依赖这些事件
|
||||
// 不会发事件,而所有插件(WebUI/CLI)的终端视图都依赖这些事件
|
||||
// 才能知道终端的存在与终止。payload 带 command 供无 ToolCall 事件的
|
||||
// 直调路径(CLI /terminal create 经 ToolAPI.ExecuteTool)回填命令名。
|
||||
// 幂等:重复发同一 running 值不会产生状态跳变。
|
||||
|
||||
@ -32,7 +32,7 @@ package plugins
|
||||
// 插件只用公开 SDK 能力 ⇒ **应当外置**(.hmap 安装,独立演进)
|
||||
//
|
||||
// 正例(留内置,各有非它不可的理由):
|
||||
// cli / webui TerminalAPI/TrackerAPI/StatusAPI/SupervisorAPI/AdapterAPI
|
||||
// cli / webui TrackerAPI/StatusAPI/SupervisorAPI/AdapterAPI
|
||||
// healthcheck SelftestAPI(能构造虚拟内核实例)
|
||||
// pluginmgr failback 恢复链必需(webui/pluginmgr/recoverydiag)
|
||||
// seq ToolAPI(工具表直接操作)
|
||||
|
||||
@ -17,6 +17,7 @@ import (
|
||||
|
||||
"github.com/JianFeeeee/HomeAgent/internal/config"
|
||||
"github.com/JianFeeeee/HomeAgent/internal/plugin"
|
||||
"github.com/JianFeeeee/HomeAgent/internal/pluginview"
|
||||
sdk "github.com/JianFeeeee/HomeAgent/internal/sdk"
|
||||
)
|
||||
|
||||
@ -44,11 +45,13 @@ func init() {
|
||||
}
|
||||
|
||||
type Plugin struct {
|
||||
name string
|
||||
socket string
|
||||
ln net.Listener
|
||||
mu sync.Mutex
|
||||
wg sync.WaitGroup
|
||||
name string
|
||||
socket string
|
||||
ln net.Listener
|
||||
mu sync.Mutex
|
||||
wg sync.WaitGroup
|
||||
termView *pluginview.TerminalView
|
||||
termUnsubs []func()
|
||||
}
|
||||
|
||||
func New(name, socketPath string) *Plugin {
|
||||
@ -62,9 +65,14 @@ func (p *Plugin) Name() string { return p.name }
|
||||
|
||||
func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
s.SetAutoRestart(true)
|
||||
// 终端与命令历史的权威视图在内核(「内核开,两个插件接」),
|
||||
// /terminals 与 /cmd/history 直接从 s.Status().GetKernelStatus() 读,
|
||||
// 无需在此订阅事件自攒。
|
||||
// 终端状态归 agentcli,内核不再维护终端语义(2026-10-20 调整)。
|
||||
// CLI 与 WebUI 用同一份 internal/pluginview 实现订阅事件总线,
|
||||
// /terminals 与 /cmd/history 读自己攒的视图。
|
||||
p.termView = pluginview.NewTerminalView()
|
||||
p.termUnsubs = append(p.termUnsubs,
|
||||
s.Subscribe(sdk.EventTerminalOutput, func(evt *sdk.Event) { p.termView.OnTerminalOutput(evt.Payload) }),
|
||||
s.Subscribe(sdk.EventToolCall, func(evt *sdk.Event) { p.termView.OnToolCall(evt.Payload) }),
|
||||
)
|
||||
|
||||
// inputch 先登记:本插件既用 "cli" 作输出目标,也用它注入输入(终端行)。
|
||||
// 输入侧必须显式登记,否则"把 inputch 划给驻留子"会找不到它。
|
||||
@ -1077,37 +1085,29 @@ func (p *Plugin) cmdAgents(conn net.Conn, s *sdk.PluginSDK) {
|
||||
|
||||
// ======== /terminals /cmd/history /terminal ========
|
||||
|
||||
// 终端与命令历史的权威视图在内核(「内核开,两个插件接」)。
|
||||
//
|
||||
// 内核订自己的事件总线归并 EventToolCall(terminal_create/close、cmd_run)
|
||||
// 与 EventTerminalOutput(agentcli 生命周期 + 输出),产出唯一真相挂在
|
||||
// TerminalAPI(见 internal/agent/core/terminal_registry.go)。
|
||||
// WebUI 与 CLI 都从 s.Status().GetKernelStatus() 读同一份,不再各自订阅推导——
|
||||
// 之前两份推导还各自踩过同一个坑:工具 result 是 Go map 文本,断言成
|
||||
// map[string]interface{} 永远失败 → /terminals 空空如也。
|
||||
// 终端视图:订阅事件总线攒的(与 WebUI 共用 internal/pluginview 实现)。
|
||||
// 状态真值在 agentcli;内核不参与终端语义。
|
||||
|
||||
// cmdTerminals 读内核权威快照,与 WebUI 的 GET /api/v1/terminals 同源。
|
||||
// cmdTerminals 返回终端会话快照,与 WebUI 的 GET /api/v1/terminals 同口径。
|
||||
func (p *Plugin) cmdTerminals(conn net.Conn, s *sdk.PluginSDK) {
|
||||
t := s.Terminal()
|
||||
if t == nil {
|
||||
if p.termView == nil {
|
||||
writeJSONContent(conn, map[string]interface{}{"terminals": []interface{}{}})
|
||||
return
|
||||
}
|
||||
terms := t.ListTerminals()
|
||||
terms, _ := p.termView.Snapshot()
|
||||
if terms == nil {
|
||||
terms = []sdk.TerminalStatus{}
|
||||
}
|
||||
writeJSONContent(conn, map[string]interface{}{"terminals": terms})
|
||||
}
|
||||
|
||||
// cmdCmdHistory 读内核权威快照,与 WebUI 的 GET /api/v1/cmd/history 同源。
|
||||
// cmdCmdHistory 返回命令历史快照,与 WebUI 的 GET /api/v1/cmd/history 同口径。
|
||||
func (p *Plugin) cmdCmdHistory(conn net.Conn, s *sdk.PluginSDK) {
|
||||
t := s.Terminal()
|
||||
if t == nil {
|
||||
if p.termView == nil {
|
||||
writeJSONContent(conn, map[string]interface{}{"history": []interface{}{}})
|
||||
return
|
||||
}
|
||||
cmds := t.CmdHistory()
|
||||
_, cmds := p.termView.Snapshot()
|
||||
if cmds == nil {
|
||||
cmds = []sdk.CmdExecStatus{}
|
||||
}
|
||||
@ -1191,6 +1191,13 @@ func writeLine(conn net.Conn, v interface{}) {
|
||||
}
|
||||
|
||||
func (p *Plugin) Stop() error {
|
||||
for _, unsub := range p.termUnsubs {
|
||||
if unsub != nil {
|
||||
unsub()
|
||||
}
|
||||
}
|
||||
p.termUnsubs = nil
|
||||
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if p.ln != nil {
|
||||
|
||||
@ -16,6 +16,7 @@ import (
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"github.com/JianFeeeee/HomeAgent/internal/pluginview"
|
||||
sdk "github.com/JianFeeeee/HomeAgent/internal/sdk"
|
||||
"net/http"
|
||||
"path"
|
||||
@ -122,9 +123,13 @@ type Handler struct {
|
||||
kernelCtl sdk.KernelControlAPI
|
||||
pluginMgr sdk.PluginManager
|
||||
status sdk.StatusAPI
|
||||
term sdk.TerminalAPI
|
||||
llm sdk.LLMAPI
|
||||
|
||||
// termView 是插件侧终端视图:订阅事件总线自己攒(内核不再维护终端语义)。
|
||||
// 见 internal/pluginview/terminal.go 的职责边界说明。
|
||||
termView *pluginview.TerminalView
|
||||
termUnsubs []func()
|
||||
|
||||
// hostPort 是 webui 实际监听的 ":port"(用于推导服务入口链接)。
|
||||
hostPort string
|
||||
|
||||
@ -182,9 +187,8 @@ func NewHandler(s *sdk.PluginSDK) *Handler {
|
||||
tr sdk.TrackerAPI
|
||||
se sdk.SettingsAPI
|
||||
pm sdk.PluginManager
|
||||
st sdk.StatusAPI
|
||||
term sdk.TerminalAPI
|
||||
llm sdk.LLMAPI
|
||||
st sdk.StatusAPI
|
||||
llm sdk.LLMAPI
|
||||
// kc 是内核重启/关机通道;nil 表示该部署没接(端点会明确报不可用)。
|
||||
kc sdk.KernelControlAPI
|
||||
)
|
||||
@ -194,7 +198,6 @@ func NewHandler(s *sdk.PluginSDK) *Handler {
|
||||
tm, ks, tr = s.TextMemory(), s.Knowledge(), s.Tracker()
|
||||
se, pm = s.Settings(), s.PluginMgr()
|
||||
st, llm = s.Status(), s.LLM()
|
||||
term = s.Terminal()
|
||||
kc = s.KernelControl()
|
||||
}
|
||||
h := &Handler{
|
||||
@ -212,8 +215,8 @@ func NewHandler(s *sdk.PluginSDK) *Handler {
|
||||
kernelCtl: kc,
|
||||
pluginMgr: pm,
|
||||
status: st,
|
||||
term: term,
|
||||
llm: llm,
|
||||
termView: pluginview.NewTerminalView(),
|
||||
sessions: make(map[string]time.Time),
|
||||
loginLimiter: newLoginLimiter(loginMaxFails, loginWindow),
|
||||
pendingIdx: -1,
|
||||
@ -227,6 +230,19 @@ func NewHandler(s *sdk.PluginSDK) *Handler {
|
||||
chatStop: make(chan struct{}),
|
||||
chatLoopDone: make(chan struct{}),
|
||||
}
|
||||
// 订阅事件总线攒终端视图(内核不再维护终端语义):
|
||||
// EventTerminalOutput —— agentcli 的输出与生命周期
|
||||
// EventToolCall —— cmd_run 历史、terminal_create/close 归并
|
||||
if s != nil {
|
||||
h.termUnsubs = append(h.termUnsubs,
|
||||
s.Subscribe(sdk.EventTerminalOutput, func(ev *sdk.Event) {
|
||||
h.termView.OnTerminalOutput(ev.Payload)
|
||||
}),
|
||||
s.Subscribe(sdk.EventToolCall, func(ev *sdk.Event) {
|
||||
h.termView.OnToolCall(ev.Payload)
|
||||
}),
|
||||
)
|
||||
}
|
||||
go h.chatPersistLoop()
|
||||
h.loadChatHistory()
|
||||
if s != nil {
|
||||
|
||||
@ -390,6 +390,15 @@ func (h *Handler) flushChat() {
|
||||
// 由插件 Stop 调用;不这样做会丢掉最后一轮对话。
|
||||
func (h *Handler) Close() {
|
||||
h.chatCloseOnce.Do(func() {
|
||||
// 先退订事件总线:插件被 disable/reload 时必须解绑,
|
||||
// 否则闭包继续持有 Handler(泄漏)并往已关闭的视图里写。
|
||||
for _, unsub := range h.termUnsubs {
|
||||
if unsub != nil {
|
||||
unsub()
|
||||
}
|
||||
}
|
||||
h.termUnsubs = nil
|
||||
|
||||
safeclose.Safe(&h.chatStopOnce2, h.chatStop)
|
||||
<-h.chatLoopDone
|
||||
})
|
||||
|
||||
@ -8,35 +8,34 @@ import (
|
||||
|
||||
// 终端面:终端会话列表与命令历史接口。
|
||||
//
|
||||
// 「内核开,两个插件接」后,终端会话与命令历史的权威视图在内核
|
||||
// (internal/agent/core/terminal_registry.go):内核订阅自己的事件总线
|
||||
// 归并 EventToolCall(terminal_create/close、cmd_run)与 EventTerminalOutput
|
||||
// (agentcli 生命周期 + 输出)。WebUI 不再自己订阅事件攒一份——直接读内核
|
||||
// 开放的 TerminalAPI(s.Terminal()),与 CLI 同源同口径。
|
||||
// 职责边界(2026-10-20 调整):终端状态归 agentcli,内核**不**再维护终端语义
|
||||
// (旧的 internal/agent/core/terminal_registry.go 已删除)。WebUI 在
|
||||
// NewHandler 里订阅事件总线(EventTerminalOutput + EventToolCall)攒自己的视图,
|
||||
// CLI 用同一份 internal/pluginview 实现,两个插件的解析口径一致。
|
||||
//
|
||||
// 为什么不塞进 KernelStatus:那是全量快照,前端每 3 秒轮询 /kernel,
|
||||
// 把每终端最多 64KB 的输出缓冲背进去会让轮询成本爆炸。
|
||||
|
||||
// handleTerminals 返回内核权威的终端会话快照。
|
||||
// handleTerminals 返回终端会话快照(本插件从事件总线攒的视图)。
|
||||
func (h *Handler) handleTerminals(w http.ResponseWriter, r *http.Request) {
|
||||
if h.term == nil {
|
||||
if h.termView == nil {
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"terminals": []interface{}{}})
|
||||
return
|
||||
}
|
||||
terms := h.term.ListTerminals()
|
||||
terms, _ := h.termView.Snapshot()
|
||||
if terms == nil {
|
||||
terms = []sdk.TerminalStatus{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"terminals": terms})
|
||||
}
|
||||
|
||||
// handleCmdHistory 返回内核权威的命令执行历史快照。
|
||||
// handleCmdHistory 返回命令执行历史快照(本插件从事件总线攒的视图)。
|
||||
func (h *Handler) handleCmdHistory(w http.ResponseWriter, r *http.Request) {
|
||||
if h.term == nil {
|
||||
if h.termView == nil {
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"history": []interface{}{}})
|
||||
return
|
||||
}
|
||||
cmds := h.term.CmdHistory()
|
||||
_, cmds := h.termView.Snapshot()
|
||||
if cmds == nil {
|
||||
cmds = []sdk.CmdExecStatus{}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user