Files
HomeAgent/internal/agent/core/argvalidate.go
JianFeeeee 34c0df2705 feat(toolcall): 批次并发调度与同通道保序(阶段 2d,闭合 2c 判据缺口)
规则(三条全满足才并发):
  1. 批内 >1 个工具
  2. **全部**工具声明 ParallelSafe —— 一个不声明就整批降级,不做部分并发
  3. 不含需保序的同通道输出发送

SDK:
· ToolDef 加 ParallelSafe bool。⚠️ 零值 false 是刻意的:存量插件不改一行
  就得到**保守**行为(整批串行),不会因升级被意外并发。声明它是责任
  而非特权。纯新增字段,无签名变更。
· io.ToolDef 同步加该字段(设备/通道工具走 io 路径,只查 StageHost 会漏)。

core:
· 新增 StepToolBatch —— runTaskSteps 是单线程驱动状态机的,
  「每步一个工具」的游标模型无法表达「一批同时跑」,故需独立 step。
· stepToolBatch:fan-out(每工具一 goroutine,各写自己的 toolCtxs[i])
  → join → **按索引顺序**串行收尾(after_toolcall / 落消息 / 事件)。
  收尾必须串行且按索引:f.Msgs 是共享切片,且按索引落才能让模型读到的
  上下文顺序与它自己发出的顺序一致。
· runOneTool 抽出「before_toolcall + 执行」的单工具逻辑,串行/并发两条路共用。
· toolParallelSafe / batchRunnable 判据函数。

★ 修掉一个我自己引入的竞争:resolveTurnScenes 会把结果记进**共享**的
f.sceneDone / f.turnScene(memorypass.go:289)。最初在每个 goroutine 里
各调一次 —— 既是数据竞争,又会各自触发一次 EnterSceneWithHint,
重复计入场景强度(正是 sceneDone 注释警告过的问题)。改为在 fan-out
**之前**解析一次,goroutine 内只读。

判据(parallelsched_test.go,4 条):
· 全批 ParallelSafe ⇒ 并发峰值 >= 2(用阻塞设备观察真实并发)
· 一个非 ParallelSafe ⇒ 整批串行,但**仍全部执行**
· 同 output_send__<通道> 连发 3 条 ⇒ 严格按声明顺序到达
· ★ 并发下每个工具的 ctx 只带自己的 ToolCalls、after 读到自己结果

★ 并关闭了 2c 的判据缺口:此前两条 2c 判据在**串行**下无法区分
per-tool 与单槽(变体验证后仍全绿)。新增的并发版判据在退回单槽时
触发 **6 处 DATA RACE 报告 + 串味断言失败**(dup:k_a 与 k_a 撞名)。
至此 2c 可记为已验证。

过程中三次自伤:
· resolveTurnScenes 竞争(上述);
· 我的 harness 用 StageHost 注册 handler 遮蔽了设备工具,
  slowDevice 根本没被调用("实际 0")——改为在 io.ToolDef 上声明;
· 批内并发峰值判据最初用 StageHost 声明 ParallelSafe,掩盖了
  「设备工具也需要该字段」这一真实缺口。

回归:internal/agent/... internal/sdk/... internal/plugin/...
      internal/plugins/... 全绿(17 包);core 包 -race 全绿。
2026-09-27 11:27:24 +08:00

299 lines
9.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package core
import (
"fmt"
"strconv"
"strings"
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
)
// 阶段 1c:按 ToolDef.Parameters 预校验。
//
// 存在的理由:`required` 在仓内被声明了 69 处,却**没有任何消费方**
// (内核从不读它)。校验散落在每个工具内部手写成中文字符串
// ("path is required"),要等工具**真被调用**才暴露——而模型看到这类
// 与真因无关的报错只会原样重试(实测 cmd_run 失败率 34%~48% 的成因)。
//
// ⚠️ 第一要务是**不误伤**。工具内部的 getter 是宽松解析的
// (见 utils.go:`getBool` 注释写明"实际调用里 bool/string/float 三种都出现过",
//
// `getFloat` 接受 int 与 float64)。若校验比工具本身更严,
//
// 就是内核自己制造新的失败——那比不校验更糟。
// 因此本校验器**只拦真正无法解析的形态**,对宽松等价形态一律放行。
func validateToolArgs(args map[string]interface{}, schema map[string]interface{}) *sdk.ToolError {
if schema == nil {
return nil
}
props, _ := schema["properties"].(map[string]interface{})
required := schemaRequired(schema)
// ① required 检查:**键必须存在**,且值不得是空字符串。
// ⚠️ 判据是「键的存在性」而非「值是否为 nil」——显式 null 是模型
// 有意传的零值,不能当缺失;而键真的没传才是缺失。
for _, name := range required {
if name == "" {
continue
}
v, present := args[name]
if !present || isBlankArg(v) {
return newToolError(ErrReasonRequired, name,
fmt.Sprintf("缺少必填参数 %s", name),
requiredHint(name, props[name]))
}
}
// ② 类型检查:只对**已提供**的 required 字段做,且只拦真正对不上的。
for _, name := range required {
if name == "" {
continue
}
v, present := args[name]
if !present {
continue // 已在 ① 报过
}
if ve := checkArgType(v, propType(props[name])); ve != nil {
ve.Field = name
return ve
}
}
return nil
}
// schemaRequired 取出 required 列表,两种声明形态都认。
func schemaRequired(schema map[string]interface{}) []string {
switch v := schema["required"].(type) {
case []string:
return v
case []interface{}:
out := make([]string, 0, len(v))
for _, x := range v {
if s, ok := x.(string); ok {
out = append(out, s)
}
}
return out
}
return nil
}
// propType 取某个属性的声明类型(没有 properties 时返回空串 = 不检查)。
func propType(prop interface{}) string {
m, ok := prop.(map[string]interface{})
if !ok {
return ""
}
t, _ := m["type"].(string)
return t
}
// isBlankArg 报告一个**已提供**的值是否为空(只有空字符串算)。
//
// nil 不在此判定:显式 null 是模型有意传的零值,工具侧按零值处理
// (getString→""、getBool→false、map 取键→nil),把它当缺失会误伤。
// 真正的「没传」由 required 检查里的**键存在性**判定,不靠值。
func isBlankArg(v interface{}) bool {
if s, ok := v.(string); ok {
return strings.TrimSpace(s) == ""
}
return false
}
// requiredHint 为缺失的必填参数生成**可执行**的改法。
// 带上属性描述——那是作者写给模型的说明,比"参数不能为空"有用得多。
func requiredHint(name string, prop interface{}) string {
desc := ""
if m, ok := prop.(map[string]interface{}); ok {
desc, _ = m["description"].(string)
}
if desc != "" {
return fmt.Sprintf("请补上 %s 参数(%s)。该参数为必填,"+
"不要重复本次调用——先补参数再调用。", name, desc)
}
return fmt.Sprintf("请补上 %s 参数(必填)。该参数为必填,"+
"不要重复本次调用——先补参数再调用。", name)
}
// checkArgType 校验单个值的类型,**只拦真正无法解析的形态**。
//
// 放行清单(依据 utils.go 的宽松解析约定与实测的模型输出形态):
//
// · boolean:true/false、"true"/"false"/"1"/"0"/"yes"/"no"、0/1
// · integer:int、int64、float64(整数值)、"20" 这类数字字符串
// (unitNumberRe 修的正是这种)、含单位字符串("20s")
// · string:string;以及**结构体**(见下)
// · array:[]interface{}、[]string
// · object:map[string]interface{}
//
// ⚠️ string 放行结构体:模型常把复杂值塞进声明为 string 的参数
// (cmd 的 command 就常被写成含 JSON 的长文本)。拦它等于制造新失败;
// 真要用错时工具内部会自己报"格式不对",那已足够。
func checkArgType(v interface{}, want string) *sdk.ToolError {
if want == "" {
return nil
}
// 显式 null 一律放行:模型有意传 null 时,工具侧按零值处理,
// 拦它等于制造新失败(这正是本函数最该避免的)。
if v == nil {
return nil
}
switch want {
case "string":
// 宽松:只要不是显式的 bool/数字/数组/对象,基本都算字符串意图。
// 只在**明显是容器/标量错配**时报错。
switch v.(type) {
case []interface{}, []string, map[string]interface{}:
return newToolError(ErrReasonType, "", "", "")
}
return nil
case "integer":
switch x := v.(type) {
case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64:
return nil
case float32, float64:
return nil // JSON 解码的常态
case string:
// 数字或带单位字符串都放行(getFloat 会解析)。
t := strings.TrimSpace(x)
if t == "" {
return nil
}
if _, err := strconv.ParseFloat(strings.TrimRight(t, "msdh"), 64); err == nil {
return nil
}
// 非数字字符串:可能是 "20s" 这类带单位的(unitNumberRe 的目标形态)
trimmed := strings.TrimRightFunc(t, func(r rune) bool {
return r == 's' || r == 'm' || r == 'h' || r == 'd'
})
if _, err := strconv.ParseFloat(trimmed, 64); err == nil {
return nil
}
return newToolError(ErrReasonType, "",
fmt.Sprintf("参数需要整数,收到 %q", x),
"请改传数字(如 20 或 20.0),或把该参数改用 string 并带单位(如 \"20s\")。")
case bool:
return newToolError(ErrReasonType, "",
"参数需要整数,收到布尔值", "请改传数字。")
default:
return newToolError(ErrReasonType, "",
fmt.Sprintf("参数需要整数,收到 %T", v), "请改传数字。")
}
case "number":
switch x := v.(type) {
case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64,
float32, float64:
return nil
case string:
if _, err := strconv.ParseFloat(strings.TrimSpace(x), 64); err == nil {
return nil
}
return newToolError(ErrReasonType, "",
fmt.Sprintf("参数需要数字,收到 %q", x),
"请改传数字,或把该参数改用 string。")
}
return nil
case "boolean":
// getBool 的宽松形态全部放行(true/false/"1"/"0"/"yes"/... 与 0/1)。
return nil
case "array":
switch v.(type) {
case []interface{}, []string:
return nil
}
return newToolError(ErrReasonType, "",
fmt.Sprintf("参数需要数组,收到 %T", v), "请改传数组,如 [\"a\", \"b\"]。")
case "object":
switch v.(type) {
case map[string]interface{}:
return nil
}
return newToolError(ErrReasonType, "",
fmt.Sprintf("参数需要对象,收到 %T", v), "请改传对象,如 {\"k\": \"v\"}。")
}
return nil
}
// validateArgsAgainstSchema 按工具声明的 schema 校验参数。
//
// 两条来源都要查:插件工具走 StageHost,设备/通道工具走 IOManager
// (cmd_run / files_write 都属后者——只查前者会让它们完全绕过校验)。
// **查不到 schema 就放行**:没有声明不等于参数非法。
func (a *Agent) validateArgsAgainstSchema(tc agentAPI.ToolCall) *sdk.ToolError {
if a == nil {
return nil
}
if a.stageHost != nil {
if def := a.stageHost.ToolDef(tc.Name); def != nil {
return validateToolArgs(tc.Arguments, def.Parameters)
}
}
if a.io != nil {
if def, ok := a.io.ToolDefOf(tc.Name); ok {
return validateToolArgs(tc.Arguments, def.Parameters)
}
}
return nil
}
// toolParallelSafe 报告工具是否可被**并发执行**。
//
// 两条来源都要查(与 validateArgsAgainstSchema 同理):插件工具走 StageHost,
// 设备/通道工具走 IOManager。查不到 ⇒ 保守返回 false(不可并发)。
//
// 为什么保守:新语义下并发会改变工具的行为前提,让存量插件意外并发
// 比慢一点危险得多——判不出就该按串行走。
func (a *Agent) toolParallelSafe(name string) bool {
if a == nil {
return false
}
if a.stageHost != nil {
if def := a.stageHost.ToolDef(name); def != nil {
return def.ParallelSafe
}
}
if a.io != nil {
if def, ok := a.io.ToolDefOf(name); ok {
return def.ParallelSafe
}
}
return false
}
// batchRunnable 并发执行本批工具。
//
// 何时并发(三条全满足):
// 1. 批内 >1 个工具
// 2. **全部**工具都声明 ParallelSafe —— 一个不声明就整批降级,
// 不做"部分并发":部分并发收益不抵其不可预测性
// 3. 不含需要保序的同通道输出发送(同 output_send__<通道> 多次发送)
//
// 保序为什么不用 ParallelSafe 表达:那属于**批内**约束而非工具属性,
// 且同一工具在不同批里的通道可能不同(output_send__qq 两次、一次 qq 一次 cli)。
func (f *TaskFrame) batchRunnable(a *Agent) bool {
if f == nil || len(f.PendingTools) <= 1 {
return false
}
chans := map[string]bool{}
for _, tc := range f.PendingTools {
if !a.toolParallelSafe(tc.Name) {
return false
}
// 同通道多次发送必须保序 —— 用户可见消息顺序敏感
if isOutputDeliveryTool(tc.Name) {
ch := strings.TrimPrefix(tc.Name, "output_send__")
if chans[ch] {
return false
}
chans[ch] = true
}
}
return true
}