From 8372f5bd8f79f1d47dd0d904ab937b81500d3ae3 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sun, 13 Sep 2026 06:17:49 +0800 Subject: [PATCH] =?UTF-8?q?fix(scheduler)!:=20=E6=92=A4=E6=8E=89=E2=80=9C?= =?UTF-8?q?=E4=BC=98=E5=85=88=E7=BA=A7=3D=E5=8F=AF=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=E7=AD=96=E7=95=A5=E8=A1=A8=E2=80=9D=E7=9A=84=E9=94=99=E8=AF=AF?= =?UTF-8?q?=E8=AE=BE=E8=AE=A1=EF=BC=8C=E5=9B=9E=E5=BD=92=E5=86=85=E6=A0=B8?= =?UTF-8?q?=E5=86=85=E9=83=A8=E5=B1=9E=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户指正:**优先级是内核内部属性**,不是配置项,更不该由插件声明。 我此前把它建模成“策略表 + 字符串解析”,甚至准备接配置中心 (core.agent.priority.)——方向性错误,故整体撤销。 撤销: - 删除 ParseLevel(字符串解析只服务于“外部可配”这个错误前提) - 删除 AgentConfig.PriorityLookup / Agent.priorityLookup 及 taskLevel 中的查表分支; taskLevel 回归为纯内核内部规则(cli/webui/http→L3,system/_consolidation_→L1, 其余 L1),注释明确“不对外暴露、不做运维可调项” - 设计稿 §3.2 改写为“内核内部属性,不做成配置项”,并删除 §15 里 “ChannelDef.Priority / InjectOptions.Priority 进公开 SDK”这一方向(同属外化) - 未触碰配置中心(registry.go/main.go 的优先级配置一行未加) 同时落地 D6(与本撤销无关、此前遗漏的承诺): - AgentConfig.MaxToolTurns + runTaskSteps 在发起新一轮 LLM 前按 f.Turn 收尾; 0 = 不限;cmd/homed/main.go 从既有 core.agent.max_tool_turns 取值 - 新增 task_test.go 2 项:上限 3 时恰好跑 3 批工具/3 次 LLM 并收尾; 0 = 不限(跑完脚本) 验收:agent 全量 + -race;全仓 build/vet 通过 --- cmd/homed/main.go | 1 + docs/zh/input-scheduler-design.md | 27 +++++++----- internal/agent/core/agent.go | 12 +++--- internal/agent/core/scheduler.go | 23 ++++++---- internal/agent/core/task.go | 12 ++++++ internal/agent/core/task_test.go | 71 +++++++++++++++++++++++++++++++ 6 files changed, 121 insertions(+), 25 deletions(-) diff --git a/cmd/homed/main.go b/cmd/homed/main.go index 9db220d..d3eea1e 100644 --- a/cmd/homed/main.go +++ b/cmd/homed/main.go @@ -535,6 +535,7 @@ func main() { ArchiveInterval: cfgReg.GetDuration("core.agent.archive_interval", 60*time.Minute), ReviewInterval: cfgReg.GetDuration("core.agent.review_interval", 120*time.Minute), MergeInterval: cfgReg.GetDuration("core.agent.merge_interval", 120*time.Minute), + MaxToolTurns: cfgReg.GetInt("core.agent.max_tool_turns", 10), ContextSavePath: filepath.Join(cfg.Daemon.DataDir, "memory", "context.json"), EmbeddingModelPath: cfgReg.GetString("core.agent.embedding_model_path", ""), Embedder: embedder, diff --git a/docs/zh/input-scheduler-design.md b/docs/zh/input-scheduler-design.md index dd379f0..cc1edbd 100644 --- a/docs/zh/input-scheduler-design.md +++ b/docs/zh/input-scheduler-design.md @@ -95,15 +95,20 @@ Step(枚举,顺序执行,步与步之间是安全点): - **默认级 = `L1`**:未显式声明一律最低级("显式才是特权",避免新插件默认获得抢占权)。 - **取值域仅这四档**,不引入任意整数,避免"9 级比 4 级大但没人知道怎么排"。 -### 3.2 优先级从哪来(v1:内核策略表) +### 3.2 优先级从哪来(内核内部属性,**不做成配置项**) -v1 **不**在公开 SDK 上加字段,来源有三,按优先级取: +优先级是**内核内部属性**:内核预定义四级,并按**内核自己的规则**为任务与中断定级。 -1. 内核策略表:`core.agent.priority.`(新增 ConfigDef,值 `L1..L4`); -2. `ChannelDef` 的内核侧扩展(仅 `internal/sdk` 可用,内置插件可声明); -3. 未命中 → `L1`。 +- ❌ **不是运维可调项**。不引入 `core.agent.priority.` 这类配置键, + 也不把 `PriorityLookup` 做成可注入的策略表——那等于把内核的调度内部属性 + 外化成配置,与“谁能打断谁”的内部语义相反。 +- ❌ 也不暴露给插件声明(这个方向曾写入 v2 计划,已删除)。 +- ✅ v1 的内部缺省规则(仅为实现缺省值,语义上是内核自己的事): + `cli`/`webui`/`http` → `L3`;`system` / `_consolidation_` → `L1`;其余 → `L1`(默认级)。 +- 定级规则可随内核演进调整,但**始终不对外暴露**。 -> v2 再把 `ChannelDef.Priority` / `InjectOptions.Priority` 加入公开 SDK(纯追加),见 §13。 +> 具体“哪类工作算哪一级”的完整规则由内核定义;本稿只固定四级语义与定级位置 +> (`(*Agent).taskLevel`),不承诺配置面。 ### 3.3 抢占判据 @@ -475,7 +480,9 @@ go test -race -count=1 ./internal/agent/... ./internal/plugin/... ./internal/sdk ## 15. 开放问题(后续版本) 1. 异步 step + `tool.cancel`(真正让工具可抢占)。 -2. `ChannelDef.Priority` / `InjectOptions.Priority` 进入公开 SDK。 -3. 帧落盘(跨进程/崩溃恢复)。 -4. 多 agent 并行调度。 -5. 与 `plan.md` §13.7 的 `RuntimeManager + 分组 worker` 合并(本设计是其前置)。 +2. 帧落盘(跨进程/崩溃恢复)。 +3. 多 agent 并行调度。 +4. 与 `plan.md` §13.7 的 `RuntimeManager + 分组 worker` 合并(本设计是其前置)。 + +> 已删除:“`ChannelDef.Priority` / `InjectOptions.Priority` 进入公开 SDK”—— +> 优先级是内核内部属性(§3.2),不应由插件声明。 diff --git a/internal/agent/core/agent.go b/internal/agent/core/agent.go index 172ddc9..f2696ef 100644 --- a/internal/agent/core/agent.go +++ b/internal/agent/core/agent.go @@ -114,8 +114,8 @@ type Agent struct { // M2 起取代 eventLoop 的隐式 channel 排队。 sched *scheduler - // 优先级策略表(可空);见 AgentConfig.PriorityLookup。 - priorityLookup func(source, channel string) Level + // 工具轮次硬上限(0 = 不限);见 AgentConfig.MaxToolTurns。 + maxToolTurns int // 进行中的 LLM 请求取消函数,interceptLoop 可调用以在请求中打断 cancelLLM context.CancelFunc @@ -224,9 +224,9 @@ type AgentConfig struct { InputProcessing types.InputProcessingConfig // 非文本输入处理配置 - // PriorityLookup 是内核的优先级策略表(设计文档 §3.2)。 - // 返回 L1..L4;返回 0 或越界值表示“无策略”,由 Agent 的通道名兜底决定。 - PriorityLookup func(source, channel string) Level + // MaxToolTurns 是单个任务允许的工具轮次上限(0 = 不限)。 + // 设计文档 D6:主循环必须有硬上限,否则模型不停调用就永不完结。 + MaxToolTurns int } func New(cfg AgentConfig) *Agent { @@ -310,7 +310,7 @@ func New(cfg AgentConfig) *Agent { selfInputCh: make(chan selfInputMsg, 64), childTasks: make(map[string]*childTaskState), sched: newScheduler(256), - priorityLookup: cfg.PriorityLookup, + maxToolTurns: cfg.MaxToolTurns, pluginHealth: newPluginHealthTracker(), thinkingEnabled: cfg.ThinkingEnabled, inputCfg: cfg.InputProcessing, diff --git a/internal/agent/core/scheduler.go b/internal/agent/core/scheduler.go index 53a227b..0e81c0a 100644 --- a/internal/agent/core/scheduler.go +++ b/internal/agent/core/scheduler.go @@ -66,6 +66,13 @@ func (l Level) String() string { } } +// ParseLevel 已删除。 +// +// 为何不保留:优先级是**内核内部属性**,不是配置项——内核预定义四级 +// (L1 后台 / L2 消息 / L3 交互 / L4 紧急),由内核按内部规则为任务与中断定级。 +// 曾一度做成 `core.agent.priority.` 这种“策略表 + 字符串解析”, +// 那等于把内核的内部属性外化成运维配置,与设计意图相反。 + // TaskKind 区分任务来源。 type TaskKind int @@ -471,17 +478,15 @@ func (s *scheduler) currentLevel() Level { return DefaultLevel } -// taskLevel 是内核的优先级策略:四级的来源(设计文档 §3.2)。 +// taskLevel 是内核为任务定级的内部规则。 // -// 优先走注入的查找函数(配置表);未命中则用通道名兜底: -// cli/webui/http 为人机交互(L3),system/_consolidation_ 为后台(L1), -// 其余一律默认级(L1)。显式才是特权:没有策略就不给抢占权。 +// ❗优先级是**内核内部属性**,不做成配置项:内核预定义四级,并按内部规则 +// 为任务与中断定级。下方规则只是 v1 的内部缺省值——它决定“谁能让位于谁”, +// 属于内核自己的隐私,不对外暴露为运维可调项。 +// +// 缺省:cli/webui/http 为人机交互(L3),system/_consolidation_ 为后台(L1), +// 其余一律默认级(L1)。 func (a *Agent) taskLevel(source, channel string) Level { - if a.priorityLookup != nil { - if l := a.priorityLookup(source, channel); l >= LevelBackground && l <= LevelCritical { - return l - } - } switch channel { case "cli", "webui", "http": return LevelInteractive diff --git a/internal/agent/core/task.go b/internal/agent/core/task.go index 6bc2616..1621968 100644 --- a/internal/agent/core/task.go +++ b/internal/agent/core/task.go @@ -150,6 +150,18 @@ func (a *Agent) runTaskSteps(f *TaskFrame) stepOutcome { if !a.inCriticalSection() && a.sched.preemptGrantedFor() && a.sched.canSuspend() { return outcomeSuspended } + // 工具轮次硬上限(设计文档 D6):在发起下一轮 LLM 前收尾。 + // f.Turn 只在 stepTurnEnd 递增,所以它等于「已完成的工具批数」; + // 因此这里允许 maxToolTurns 批,而不会多跑第 maxToolTurns+1 轮。 + if f.Step == StepLLM && a.maxToolTurns > 0 && f.Turn >= a.maxToolTurns { + log.Printf("[agent] 已达最大工具轮次 %d(turn=%d),强制收尾", a.maxToolTurns, f.Turn) + if f.Resp != nil && strings.TrimSpace(f.Resp.Content) != "" { + f.Response = f.Resp.Content + } else { + f.Response = fmt.Sprintf("[系统] 已达到最大工具轮次 %d,任务中止。", a.maxToolTurns) + } + return outcomeDone + } switch a.step(f) { case outcomeDone: return outcomeDone diff --git a/internal/agent/core/task_test.go b/internal/agent/core/task_test.go index 6cdbc4e..7ca148b 100644 --- a/internal/agent/core/task_test.go +++ b/internal/agent/core/task_test.go @@ -165,3 +165,74 @@ func TestTaskFrame_UnknownStepFails(t *testing.T) { t.Fatal("未知 step 必须带错误信息") } } + +// D6:工具轮次硬上限——模型不停调用工具时,必须在有限步内收尾。 +// +// 这是审查里定位的 P0(core.agent.max_tool_turns 只定义、从没被读过), +// 也是调度器的前提:任务必须可终止。 +func TestMaxToolTurns_CapsRunawayLoop(t *testing.T) { + sh := NewStageHost() + sh.RegisterTool("t_loop", sdk.ToolDef{Name: "t_loop", Plugin: "t"}, func(args map[string]interface{}) (interface{}, error) { + return "again", nil + }) + + // 脚本远长于上限:provider 每轮都给下一批工具调用,模拟“永不停止”。 + script := make([]*agentAPI.CompletionResponse, 0, 20) + for i := 0; i < 20; i++ { + script = append(script, &agentAPI.CompletionResponse{ + Content: "继续", + ToolCalls: []agentAPI.ToolCall{tc("c1", "t_loop")}, + }) + } + sp := &scriptProvider{script: script} + a := New(AgentConfig{ + ID: "cap", + Provider: sp, + ProviderManager: agentAPI.NewProviderManager(), + IO: agentIO.NewIOManager(), + StageHost: sh, + MaxToolTurns: 3, + }) + + resp, toolsUsed, toolResults, err := a.process("循环", a.stageCtxFromInput("循环", "", "")) + if err != nil { + t.Fatalf("process 返回错误: %v", err) + } + if len(toolsUsed) != 3 || len(toolResults) != 3 { + t.Fatalf("工具批=%d/%d,期望恰好 3(到上限即止,不多跑第 4 轮)", len(toolsUsed), len(toolResults)) + } + if len(sp.reqs) != 3 { + t.Fatalf("LLM 调用=%d,期望 3(上限后不再发起新请求)", len(sp.reqs)) + } + if resp != "继续" { + t.Fatalf("响应=%q,期望返回最近一次 LLM 文本", resp) + } +} + +// 上限为 0 表示不限(显式退出机制)。 +func TestMaxToolTurns_ZeroMeansUnlimited(t *testing.T) { + sh := NewStageHost() + sh.RegisterTool("t_loop", sdk.ToolDef{Name: "t_loop", Plugin: "t"}, func(args map[string]interface{}) (interface{}, error) { + return "again", nil + }) + sp := &scriptProvider{script: []*agentAPI.CompletionResponse{ + {Content: "a", ToolCalls: []agentAPI.ToolCall{tc("c1", "t_loop")}}, + {Content: "b", ToolCalls: []agentAPI.ToolCall{tc("c2", "t_loop")}}, + {Content: "c"}, + }} + a := New(AgentConfig{ + ID: "nocap", + Provider: sp, + ProviderManager: agentAPI.NewProviderManager(), + IO: agentIO.NewIOManager(), + StageHost: sh, + MaxToolTurns: 0, + }) + resp, toolsUsed, _, err := a.process("x", a.stageCtxFromInput("x", "", "")) + if err != nil { + t.Fatalf("process 返回错误: %v", err) + } + if len(toolsUsed) != 2 || resp != "c" { + t.Fatalf("不限时应跑完脚本:tools=%d resp=%q", len(toolsUsed), resp) + } +}