Commit Graph

3 Commits

Author SHA1 Message Date
8fa6f36982 fix(agent): 插件崩溃熔断从不生效 —— 冷却锚点用了从未赋值的字段
## 缺陷(写上一个提交的判据时撞出来的)

recordCrash 标记 Unhealthy=true,但 isHealthy 的冷却判据是:

    time.Since(r.LastReload) > reloadCooldown

而 LastReload **只在 markReloaded 里被赋值**(即"重载成功"之后)。
插件刚崩、从未重载时它是**零值**:

    time.Since(零值) ≈ 2000 年 >> 30s
    ⇒ 判"冷却期早已过去" ⇒ 立刻把 Unhealthy 置回 false

实测(连打 3 次 recordCrash):

    crash #1 → needReload=false healthy=true
    crash #2 → needReload=false healthy=true
    crash #3 → needReload=true  healthy=true   ← 刚标完就自愈

## 影响

三处「插件不健康则跳过执行」全是**死分支**:

    task.go:1065        stepToolBegin 中的跳过
    task.go:1135        (上一提交刚补的那个分支的另一处同构)
    toolbatch.go:155    并发批次中的跳过

即:一个正在崩溃循环的插件会被**持续调用**,每次调用再崩一次。
recordCrash 返回的 needReload=true 也没意义 —— 下一句 isHealthy 就说它健康了。

pendingReloads 用同一个零值字段 ⇒ autoReloadPlugins 每次都会尝试重载
**刚崩的**插件,很可能立刻再崩(崩溃循环)。

## 修法:冷却锚点改为 LastCrash

语义上「熔断」是"刚崩完先别用它,等 30s 再看" —— 观察窗口应锚在
**最近一次崩溃**,而不是"最近一次重载成功"。

    isHealthy      锚点 LastReload → LastCrash
    pendingReloads 锚点 LastReload → LastCrash

## 顺带删掉冗余

    unhealthyPlugins()      零生产调用方(全仓 grep 只有定义本身)
    record.FirstCrash       只写不读(写它的唯一目的是被下面这个判断用,
                            而那个判断早就改成只看 LastCrash 了)

## 判据(5 条,3 次变异全部实测变红)

  TestCircuitBreakerActuallyOpens                   ★ 主判据
      变异:锚点改回 LastReload ⇒ "★ 连续 3 次崩溃后插件仍被判健康"

  TestCircuitBreakerWaitsForCooldownBeforeReload
      变异:pendingReloads 锚点改回 LastReload ⇒
      "★ 刚崩完就进了重载队列(应等冷却期):[p]"

  TestCircuitBreakerRecoversAfterCooldown
      变异:删掉恢复分支 ⇒ "★ 冷却期过后仍未恢复健康 ——
      插件会被永久停用(比不熔断更糟)"

  TestCircuitBreakerUnknownPluginIsHealthy    从未崩过的插件不记账
  TestCircuitBreakerWindowResets              窗口外的旧崩溃不该累加

## 判据本身的两个坑(已避开)

① 只断言 recordCrash 的返回值 —— 它本来就返回 true,测了等于没测。
   必须断言 **isHealthy 的结论**,那才是被熔断逻辑消费的东西。

② 不测"冷却之后会恢复" —— 那样把代码改成恒 return false 也能过,
   而那就成了永久禁用插件(比不熔断更糟)。

## 连带:deniedpair_test 的前置条件改回真实状态机

上一提交里,第二条判据只能**手工置 Unhealthy**(因为 recordCrash 标完
立刻被自愈撤销)。现在熔断真生效了,改回用 recordCrash 打满阈值 ——
判据依赖公开状态机而不是私有字段。

## 验证

    go build ./...            PASS
    go vet ./...              PASS
    gofmt -l                  干净
    go test ./... -count=1    62 包全过,0 FAIL
    干净检出(worktree)       build + TestCircuitBreaker/TestDenied 全绿
2026-11-04 16:34:46 +08:00
9b7a6d2c82 fix(llm): 参数无法解析时给出真因,不再静默丢弃整条调用
★ 上次修复误判了成因。真实根因(本次运行日志 34/34 同形):
    {"command": "…完好的长命令…", "timeout": 20s}
  command 一字节没错,只是 timeout 值少了引号 —— cmd_run 的 schema 把 timeout
  声明成 string、示例写着 "10s, 1m, 30s",模型照抄格式却忘了引号。
  finish_reason=length 出现 0 次 ⇒ 上次那条"截断"分支从不生效。

旧行为把**整个参数**丢掉,模型只看到 "command is required",看不出坏在 timeout,
只能原样重试。实测本次运行 cmd_run 失败率 35%(34 败 / 71 成),
12 分钟的任务里更是 48% 时间耗在这上面 —— 每次失败都付一次完整 LLM 往返。

三处改动:
1. repairToolArgsJSON:解析失败时先试窄修复 —— 只给"值位置上未加引号的带单位
   数字"补引号,且修完必须真能解析成功才接受。不碰合法 JSON、不动正文里的 20s、
   不会把真截断"修好"。
2. 修复仍失败时不再静默降级成空 map,改为带 __arg_error 交给模型,并按成因
   分流文案:截断→拆小参数;JSON 写坏→提醒带单位的值要加引号。
3. 统一键名 __arg_error(原 __truncated_error 只覆盖截断,语义过窄)。

同一缺陷面不止 cmd:agentcli/healthcheck/timer 都有 string 类型却以
"5m, 1h" 作示例的参数,此修复一并覆盖。

回归测试:真实日志样本修复、保守性(不碰合法/正文/截断)、
端到端(修复后 timeout 仍能被 time.ParseDuration 接受)。
2026-09-19 16:49:16 +08:00
9bee5295b8 feat: output channel redesign - per-channel output gates, LLM chain events, SDKConfig
- Output channels generate per-channel tools: output_send__{name} (type=output) + output_send__{name}_help
- content is JSON string transparently passed to plugin handler for routing
- EventAgentLLMChain: full LLM response forwarded after each turn for webui/logs
- sdk.New refactored to SDKConfig struct (no more 13 positional args)
- RegisterOutputChannel adds desc param for JSON format documentation
- channelDevice simplified (no Tools method), desc field added
- Child agent permission updated for output_send__ prefix
- System prompt: output gates, multi-call, long messages split
- WebUI: subscribes to EventAgentLLMChain in SSE, no output channel
- Tests updated for new naming convention
2026-07-16 12:11:16 +08:00