mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-05 16:57:32 +00:00
## 1. 工作式子的工具禁令收成单一事实来源 禁令此前写了两遍:定义侧(把工具从 childTools 滤掉)与执行侧 (模型硬调时拒绝)。两处名单必须逐字一致,任何一边落后就是真实缺口: 定义侧漏滤 ⇒ 模型看得见但不该能用(白耗 token,还会反复试) 执行侧漏拒 ⇒ 模型绕过定义直接调用**就能生效** 现在只有 workerForbidden 一张表,每条禁令把"匹配方式 + 类别 + 解释" 绑在一个结构体里;定义侧 workerToolAllowed、执行侧 workerToolReject 都从它派生。 上一版我用 reason 字符串 + 下标猜分类 + hint 查表做了三处间接, 属于新造的技术债,已重写为单表直查。 判据(均经变异验证): TestWorkerToolDenyListIsSingleSource 长出第二份名单 ⇒ 红(实测确认) TestWorkerToolAllowedMatchesReject 两侧判据漂移 ⇒ 红 TestWorkerToolRejectExplainsWhy 拒绝文案不可执行 ⇒ 红 ## 2. 删除零使用的抽象(不留技术债) R1–R3 引入了 ChildKind / Host / Deliverer / DeviceResolver / ToolSpec / ToolResult / DeclaredKinds / KindWorker / State / SetState / BumpRound / ChannelDisposition,实现过程中发现它们是假抽象: · 各类型要用载体 Agent 的大半能力,要满足接口就得在实现里类型断言回 *Agent · 接口比被抽象的东西还大 ⇒ 只是把复杂度换个地方放 · 实测:26 个导出符号里 20 个零使用、零实现 已删除 host.go 与 kind.go,并把"四种类型清单"和"转投驻留子为何不是类型" 这两个有价值的结论移进 kinds.go 的文档注释(知识保留,死代码删除)。 child 包现在只剩实际被用的 9 个符号:Kind / Child / Entry / Registry / StopDeps / ErrChildExists / ErrChildNotFound(+ 2 个 Kind 常量)。 ## 3. 修掉误导性签名 Entry.Dispose(policy ResidualPolicy) 里 policy **两个实现都不读** —— 残余处置实际走 ApplyResidual / FinishResidentWith,在 Dispose 之前就 已经完成。看签名会以为这里处理残余,是误导。已去掉该参数, 并删掉 child 包里重复定义的 ResidualPolicy(core 包已有一份,且多一个 stash)。 ## 验证 go build ./... PASS go vet ./... PASS gofmt -l 干净 go test ./internal/agent/core/ -count=1 ok 15.300s go test ./internal/agent/core/ -count=1 -race ok 18.040s 干净检出(git worktree)单独 build + vet + test PASS
498 lines
20 KiB
Go
498 lines
20 KiB
Go
package core
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"log"
|
||
"strings"
|
||
|
||
agentAPI "github.com/JianFeeeee/HologramAgent/internal/agent/api"
|
||
)
|
||
|
||
// childTaskState 是一个子任务的生命周期状态。
|
||
//
|
||
// delivered 代替了早期的“读到即删”:完成通知会写进持久上下文
|
||
// (formatMergedTimeline 每轮重新注入),模型之后还会再查。读一次就删的
|
||
// 话,第二次查询返回“不存在或已过期”——那是一个**永远不会成功的可操作
|
||
// 信号**,模型只能一遍遍地重试/汇报,循环永不结束。
|
||
type childTaskState struct {
|
||
running bool
|
||
result string
|
||
delivered bool // 结果是否已交付过(用于幂等应答)
|
||
seq int64 // 完成顺序,用于有界淘汰
|
||
}
|
||
|
||
// maxRetainedChildTasks 是保留的已完成子任务上限(防结果无限占用内存)。
|
||
const maxRetainedChildTasks = 20
|
||
|
||
// evictChildTasksLocked 淘汰最旧的已完成子任务。调用方必须持有 childMu。
|
||
func (a *Agent) evictChildTasksLocked() {
|
||
for len(a.childTasks) > maxRetainedChildTasks {
|
||
oldestID := ""
|
||
var oldestSeq int64
|
||
for id, st := range a.childTasks {
|
||
if st.running {
|
||
continue
|
||
}
|
||
if oldestID == "" || st.seq < oldestSeq {
|
||
oldestID, oldestSeq = id, st.seq
|
||
}
|
||
}
|
||
if oldestID == "" {
|
||
return // 剩下全是运行中的,不淘汰
|
||
}
|
||
delete(a.childTasks, oldestID)
|
||
}
|
||
}
|
||
|
||
// ── 工作式子的工具禁令(**单一事实来源**) ──
|
||
//
|
||
// 此前这条禁令写了两遍:一遍在定义侧(把工具从表里滤掉),
|
||
// 一遍在执行侧(模型硬调时拒绝)。两处的工具名单必须逐字一致 ——
|
||
// 任何一边落后就是一个真实缺口:
|
||
//
|
||
// 定义侧漏滤 ⇒ 模型看得见但不该能用(白耗 token,还会反复试)
|
||
// 执行侧漏拒 ⇒ 模型绕过定义直接调用**就能生效**(模型确实会这么干)
|
||
//
|
||
// 现在只有下面这张表。定义侧走 workerToolAllowed,
|
||
// 执行侧走 workerToolReject,两者都从同一份 runnerForbidden 派生。
|
||
// 判据 TestWorkerToolDenyListIsSingleSource 钉住"不许再长出第二份"。
|
||
|
||
// workerDenyRule 是一条工具禁令:匹配方式 + 给模型看的解释。
|
||
//
|
||
// ★ 用结构体把"匹配"和"解释"绑在一起,避免上一版那种三处间接
|
||
// (先查 reason 字符串、再按下标猜分类、再查 hint)。读代码时
|
||
// 一条禁令的全部信息在一行里可见。
|
||
type workerDenyRule struct {
|
||
class string // 给模型看的类别("输出工具" / "系统工具")
|
||
tool string // 精确匹配(prefix=false 时)
|
||
prefix string // 前缀匹配(prefix=true 时)
|
||
isPref bool
|
||
hint string // 为什么不行、该怎么做
|
||
}
|
||
|
||
// workerForbidden 是工作式子**唯一**的工具禁令表。
|
||
//
|
||
// 为什么禁这些:
|
||
//
|
||
// 输出工具 结果回父(child_result),子直接对用户说话会与父的回复串台
|
||
// spawn_child 子不能再 spawn 子(无限递归)
|
||
// plgreload 插件重载是内核级动作,子无权触发
|
||
var workerForbidden = []workerDenyRule{
|
||
{
|
||
class: "输出工具", prefix: "output_send__", isPref: true,
|
||
hint: "结果请直接作为最终答复返回,父会转达用户",
|
||
},
|
||
{
|
||
class: "输出工具", tool: "output_list_channels",
|
||
hint: "结果请直接作为最终答复返回,父会转达用户",
|
||
},
|
||
{
|
||
class: "系统工具", tool: "spawn_child",
|
||
hint: "子不能再 spawn 子(会无限递归);需要多步请在本次任务内完成",
|
||
},
|
||
{
|
||
class: "系统工具", tool: "plgreload",
|
||
hint: "插件重载是内核级动作,只有父能触发",
|
||
},
|
||
}
|
||
|
||
// workerDenyRuleFor 返回命中的禁令(nil = 允许)。
|
||
func workerDenyRuleFor(name string) *workerDenyRule {
|
||
for i := range workerForbidden {
|
||
r := &workerForbidden[i]
|
||
if r.isPref {
|
||
if strings.HasPrefix(name, r.prefix) {
|
||
return r
|
||
}
|
||
continue
|
||
}
|
||
if name == r.tool {
|
||
return r
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// workerToolAllowed 报告某个工具是否**允许**给工作式子用(定义侧过滤)。
|
||
func workerToolAllowed(name string) bool { return workerDenyRuleFor(name) == nil }
|
||
|
||
// workerToolReject 在执行侧拒绝被禁工具,返回给模型的**可执行**说明。
|
||
//
|
||
// 返回空串表示放行。文案必须说明"为什么不行、该怎么做",否则模型会原样
|
||
// 重试同一条被拒的调用(本仓有过"4 次 files_write 失败"的先例)。
|
||
func workerToolReject(ct agentAPI.ToolCall) string {
|
||
r := workerDenyRuleFor(ct.Name)
|
||
if r == nil {
|
||
return ""
|
||
}
|
||
return fmt.Sprintf("子 Agent 不允许调用 %s: %s(%s)", r.class, ct.Name, r.hint)
|
||
}
|
||
|
||
func (a *Agent) executeSpawnChild(tc agentAPI.ToolCall, parentChannel string) string {
|
||
task, _ := tc.Arguments["task"].(string)
|
||
if task == "" {
|
||
if b, _ := json.Marshal(tc.Arguments); len(b) > 2 {
|
||
log.Printf("[spawn] task empty but arguments present: %s", truncateStr(string(b), 300))
|
||
}
|
||
return "请提供 task 参数"
|
||
}
|
||
maxTurns := 0
|
||
if v, ok := tc.Arguments["max_turns"].(float64); ok {
|
||
maxTurns = int(v)
|
||
}
|
||
if maxTurns < 1 {
|
||
maxTurns = defaultChildMaxTurns
|
||
}
|
||
if maxTurns > 30 {
|
||
maxTurns = 30
|
||
}
|
||
|
||
a.childMu.Lock()
|
||
a.childNextID++
|
||
taskID := fmt.Sprintf("child_%d", a.childNextID)
|
||
a.childMu.Unlock()
|
||
|
||
// parentChannel 由调用方(任务帧)传入:子任务完成通知要回到**发起这次
|
||
// spawn 的那个任务**的通道,而不是"内核当前通道"(那个概念已删除)。
|
||
if parentChannel == "" || parentChannel == channelConsolidation {
|
||
parentChannel = "cli"
|
||
}
|
||
|
||
a.childMu.Lock()
|
||
a.childTasks[taskID] = &childTaskState{running: true}
|
||
a.childMu.Unlock()
|
||
go a.runChildTask(taskID, task, parentChannel, maxTurns)
|
||
|
||
return fmt.Sprintf("子任务已启动(ID: %s,最多 %d 轮)。完成后会自动通知你,届时用 child_result 查看输出即可(**只需查询一次**)", taskID, maxTurns)
|
||
}
|
||
|
||
// defaultChildMaxTurns 子 Agent 默认工具轮数(可被 spawn_child 的 max_turns 参数覆盖)。
|
||
const defaultChildMaxTurns = 5
|
||
|
||
// childSystemPromptTemplate 是非驻留子 Agent 的系统提示词(%s = 任务描述)。
|
||
//
|
||
// ★ 为什么必须显式写「执行纪律」与「工具执行顺序」两节:
|
||
//
|
||
// 此前这两节缺失,子只能拿到「你是子任务助手,请完成任务」这一句。实测(2026-10-08
|
||
// 生产日志)子把「宣告计划」当成了任务完成 —— 交回父的结果大量是将来时:
|
||
//
|
||
// child_1 done: I'll start by searching for each school's admissions catalog information.
|
||
// child_3 done: 我将逐校用官方渠道核实。先并行检索四校的招生专业目录信息。
|
||
// child_5 done: I'll start by reading the existing artifacts.
|
||
//
|
||
// 根因是退出判据只看「模型有没有返回 tool_calls」(spawn.go 的 `len(resp.ToolCalls) == 0`):
|
||
// 模型一输出「I'll start by...」就再无 tool_calls,循环立即判定完成。**该判据未改动**
|
||
// (它是对的:模型确实不再要求工具了),所以要靠提示词让模型**不要**在拿到结果前
|
||
// 停止调用工具。
|
||
//
|
||
// 父的提示词里早有同类先例(buildSystemPrompt 的【记忆清理指令】:「必须实际调用
|
||
// memory_ 工具执行操作,不能只回复文本」「不要只描述计划而不执行」),子这里缺的正是它。
|
||
//
|
||
// 第二缺失是【工具执行顺序】:同轮多个 tool_call **默认并行**,而子不知道
|
||
// 「依赖前一步结果的调用必须分轮」。多步复杂任务(搜索 → 按结果抓页 → 解析)
|
||
// 因此被塞进同一轮,并行跑出无意义的组合。
|
||
//
|
||
// 不把父的【输出规则】/【中断消息】/【记忆清理】/【可用技能】/人格那几节一并搬过来:
|
||
// 子被显式禁用了 output_send__*(结果回父而非直接回用户),也无需保留记忆或响应中断;
|
||
// 搬来只会白占提示词预算。
|
||
const childSystemPromptTemplate = `你是 HologramAgent 的子任务助手。
|
||
请完成以下任务。完成即可,无需保留记忆或查询历史。
|
||
|
||
【执行纪律】这是硬性要求,不是建议:
|
||
- **必须实际调用工具去执行**,不要只回复「我将要做什么」。宣告计划不等于完成任务;
|
||
只要你还没把结果拿到手,就必须继续调用工具。
|
||
- 只有当你**已经取得实际结果**(或确认无法取得)时,才用纯文本汇报结论;
|
||
汇报内容必须是**已发生的事实**,而不是打算做的事。
|
||
- 任务确实无法完成时,如实说明卡在哪一步、已取得了什么,不要谎报完成。
|
||
|
||
【工具执行顺序】同一条回复里的多个工具调用**默认并行执行**,不是依次执行:
|
||
- 若某个调用的参数**需要另一个调用的结果**,必须**分两轮** —— 先发前一个,
|
||
看到结果后再发下一个。写在同一轮就等于假设了不存在的先后。
|
||
- 互相独立的调用可以放在同一轮并行发出(这是被鼓励的,更快)。
|
||
|
||
任务: %s`
|
||
|
||
func (a *Agent) runChildTask(taskID, task string, parentChannel string, maxTurns int) {
|
||
if a.provider == nil {
|
||
log.Printf("[child] %s failed: no LLM provider configured", taskID)
|
||
return
|
||
}
|
||
log.Printf("[child] %s started: %s", taskID, truncateStr(task, 80))
|
||
|
||
sysPrompt := fmt.Sprintf(childSystemPromptTemplate, task)
|
||
|
||
msgs := []agentAPI.Message{
|
||
{Role: "system", Content: sysPrompt},
|
||
{Role: "user", Content: task},
|
||
}
|
||
|
||
allTools := a.buildToolDefs()
|
||
childTools := make([]interface{}, 0, len(allTools))
|
||
for _, t := range allTools {
|
||
toolMap, ok := t.(map[string]interface{})
|
||
if !ok {
|
||
continue
|
||
}
|
||
fn, ok := toolMap["function"].(map[string]interface{})
|
||
if !ok {
|
||
continue
|
||
}
|
||
name, _ := fn["name"].(string)
|
||
if !workerToolAllowed(name) {
|
||
continue
|
||
}
|
||
childTools = append(childTools, t)
|
||
}
|
||
|
||
// 循环因何结束,供收尾文案如实描述(不要把「跑满预算」说成别的数字)。
|
||
//
|
||
// 为什么需要它:此前收尾文案硬编码「超过 5 轮」,而 max_turns 是**模型可传**
|
||
// 的参数(1-30),于是 max_turns=10 的子 Agent 明明跑满 10 轮,报告却说
|
||
// 「超过 5 轮」——日志在说谎,排查时会被这个假数字带偏。
|
||
//
|
||
// completed 与会空内容分开记:模型返回了空文本也是一次「回答过了」,
|
||
// 不能与「循环中途 break」归为同一类(那会把可解释的空回答报成故障)。
|
||
var finalResult string
|
||
completed := false
|
||
reachedLimit := false
|
||
for turn := 0; turn < maxTurns; turn++ {
|
||
req := &agentAPI.CompletionRequest{
|
||
Messages: msgs,
|
||
MaxTokens: 4096,
|
||
Tools: childTools,
|
||
ToolChoice: "auto",
|
||
DisableThinking: !a.thinkingEnabled,
|
||
}
|
||
|
||
// ★ 走与主 agent 同一套 provider 选择(而不是直接 a.provider.Chat)。
|
||
//
|
||
// 两件事因此成立:
|
||
// ① `core.agent.llm.child_provider` 对它生效(四类 agent 各自可配源)
|
||
// ② 多源回退对它生效 —— 此前它是唯一绕过回退链的地方:
|
||
// 默认源抖动一次,整条子任务就"执行失败",而主 agent 同刻能自动换源
|
||
resp, err := a.chatWithProviderChain(req)
|
||
if err != nil {
|
||
finalResult = fmt.Sprintf("子 Agent 执行失败: %v", err)
|
||
break
|
||
}
|
||
|
||
if len(resp.ToolCalls) == 0 {
|
||
finalResult = resp.Content
|
||
completed = true
|
||
break
|
||
}
|
||
|
||
if turn == maxTurns-1 {
|
||
// 本轮是最后一轮:它仍返回了工具调用,说明预算在此耗尽。
|
||
reachedLimit = true
|
||
}
|
||
|
||
// ★★ 走共享的 runToolBatch(2026-11-04 重构):
|
||
//
|
||
// 原实现逐条直调 `executeToolCall`(= executeToolCallOutcome 的文本壳),
|
||
// 内无任何 runStage ⇒ 普通子的工具调用同样**跳过 before/after_toolcall**,
|
||
// 与 rt 同型缺陷(见 toolbatch.go 头注释的对照表)。
|
||
//
|
||
// 保留两处**子专属**的差异,通过 batchOpts 注入而不是各写一遍执行逻辑:
|
||
// ① 禁用输出工具与系统工具(子把结果回父,不直接回用户)
|
||
// ② 强制串行(NoBatch)—— 子历史上是串行的,改并发会改变它已上线
|
||
// 行为的时序假设,不在本次重构范围内(判据 TestWorkerToolsStaySerial 钉住)
|
||
results := a.runToolBatch(a.ctx, resp.ToolCalls, batchOpts{
|
||
Channel: parentChannel,
|
||
NoBatch: true,
|
||
ToolFilter: workerToolReject,
|
||
})
|
||
// 子 Agent 的工具调用必须自记一行。此前子侧全程静默:父内核日志里
|
||
// 只有「started / done」两行,子任务失败时无法判断倒在哪一步(是搜不到、
|
||
// 解析失败,还是被轮上限截断)——只能看到一句「超时」。
|
||
for _, r := range results {
|
||
log.Printf("[child] %s turn %d/%d: %s -> %d bytes",
|
||
taskID, turn+1, maxTurns, r.Name, len(r.Text))
|
||
msgs = append(msgs, agentAPI.Message{Role: "assistant", Content: resp.Content,
|
||
ToolCalls: []agentAPI.ToolCall{{ID: r.CallID, Name: r.Name}}})
|
||
msgs = append(msgs, agentAPI.Message{Role: "tool", ToolCallID: r.CallID, Content: r.Text})
|
||
}
|
||
}
|
||
|
||
if finalResult == "" {
|
||
switch {
|
||
case completed:
|
||
// 模型确实回答完了,但内容是空的。如实说,不当作故障。
|
||
finalResult = "子 Agent 已结束(模型未给出文本内容)"
|
||
case reachedLimit:
|
||
// 跑满预算 ≠ 白干。子 Agent 在前几轮已经拿到了一批工具结果
|
||
// (实测 child_5 已跑完一串页面解析),直接丢弃它们会让父 Agent
|
||
// 只能重 spawn(日志里 child_5→6→7 就是同一任务连试三次,每次都
|
||
// 重烧一整个上下文)。所以先做一次「不带工具的收尾调用」,把已有
|
||
// 成果落成文本。仅在失败路径上多花一次 LLM 调用。
|
||
log.Printf("[child] %s exhausted budget maxTurns=%d, attempting wrap-up distillation of results so far", taskID, maxTurns)
|
||
if summary := a.salvageChildSummary(msgs, maxTurns); summary != "" {
|
||
finalResult = summary
|
||
} else {
|
||
finalResult = fmt.Sprintf("子 Agent 已达工具轮上限(%d 轮)仍未产出最终答复,任务未完成", maxTurns)
|
||
}
|
||
default:
|
||
// 既没拿到答复、也不是跑满预算:只可能是 Chat 出错后 break。
|
||
finalResult = "子 Agent 未产出结果(执行中断)"
|
||
}
|
||
}
|
||
|
||
a.childMu.Lock()
|
||
if st := a.childTasks[taskID]; st != nil {
|
||
st.running = false
|
||
st.result = finalResult
|
||
a.childSeq++
|
||
st.seq = a.childSeq
|
||
}
|
||
a.evictChildTasksLocked()
|
||
a.childMu.Unlock()
|
||
|
||
log.Printf("[child] %s done: %s", taskID, truncateStr(finalResult, 100))
|
||
|
||
notification := fmt.Sprintf("子任务 %s 已完成。请用 child_result 工具查看输出(只需查询一次;重复查询不会返回失败)。", taskID)
|
||
a.injectSelfChannel(selfInputMsg{
|
||
text: notification,
|
||
channel: parentChannel, // 回到父对话通道,正常处理(写入上下文 + emit 响应)
|
||
})
|
||
}
|
||
|
||
// executeChildResultTool 取回子任务结果。
|
||
//
|
||
// **幂等**:结果不会被“读到即删”,重复查询返回同一结果或一条明确提示。
|
||
// 这一点至关重要——完成通知会长期留在持久上下文里(formatMergedTimeline
|
||
// 每轮重新注入),如果重复查询返回“不存在”这种失败信号,模型会认定任务
|
||
// 未完成而无限重试(实测单轮 35 次工具调用、持续 514 秒)。
|
||
func (a *Agent) executeChildResultTool(tc agentAPI.ToolCall) string {
|
||
taskID, _ := tc.Arguments["task_id"].(string)
|
||
if taskID == "" {
|
||
return "请提供 task_id 参数"
|
||
}
|
||
|
||
a.childMu.Lock()
|
||
st, ok := a.childTasks[taskID]
|
||
if !ok {
|
||
a.childMu.Unlock()
|
||
return fmt.Sprintf("子任务 %s 不存在:从未创建该 ID(请核对 spawn_child 返回的 ID 拼写)", taskID)
|
||
}
|
||
if st.running {
|
||
a.childMu.Unlock()
|
||
return fmt.Sprintf("子任务 %s 仍在运行中,尚未完成。请等待完成通知后再查询。", taskID)
|
||
}
|
||
first := !st.delivered
|
||
st.delivered = true
|
||
result := st.result
|
||
a.childMu.Unlock()
|
||
|
||
if first {
|
||
return fmt.Sprintf("【子任务 %s 结果】\n%s", taskID, result)
|
||
}
|
||
// 重复查询不是失败:明确告诉模型“任务已完成、结果已给过”,让它停止重试。
|
||
return fmt.Sprintf("【子任务 %s 已完成】结果已在上文提供(见先前的 child_result 工具结果),无需重复查询;请直接基于上文结果继续。", taskID)
|
||
}
|
||
|
||
// childSalvagePrompt 是跑满预算后的收尾指令。
|
||
//
|
||
// 强调「不得再请求调用工具」而不是仅靠不带 Tools 参数:provider 形态各异,
|
||
// 有的对“无工具但正文写了工具调用”仍会编造出 tool_calls,而此处已经不再执行
|
||
// 任何工具,会直接落成空结果。
|
||
const childSalvagePrompt = `你已用完工具调用轮次预算,不能再调用任何工具。
|
||
请只根据上面已经取得的工具结果,直接给出**目前已知的结论与已收集到的数据**;
|
||
如有未完成的部分,明确列出“未查到/未完成”的项。不要请求调用工具,不要说“我将要去查”。`
|
||
|
||
// salvageChildSummary 在子 Agent 跑满轮预算后,用一次**不带工具**的调用把
|
||
// 已取得的中间成果落成文本。
|
||
//
|
||
// 为什么值得多花一次调用:此前的行为是直接丢弃全部中间结果、只回一句“超时”,
|
||
// 父 Agent 拿到的是一个不可操作的信号,只能重 spawn——那会重烧一整个上下文,
|
||
// 比这多出来的**一次**调用贵得多。
|
||
//
|
||
// 失败不抛错(外层只在失败路径上调用它):返回空串即回退到诚实的事实陈述。
|
||
func (a *Agent) salvageChildSummary(msgs []agentAPI.Message, maxTurns int) string {
|
||
if a.provider == nil {
|
||
return ""
|
||
}
|
||
salvageMsgs := make([]agentAPI.Message, 0, len(msgs)+1)
|
||
salvageMsgs = append(salvageMsgs, msgs...)
|
||
salvageMsgs = append(salvageMsgs, agentAPI.Message{Role: "user", Content: childSalvagePrompt})
|
||
|
||
resp, err := a.provider.Chat(a.ctx, &agentAPI.CompletionRequest{
|
||
Messages: salvageMsgs,
|
||
MaxTokens: 4096,
|
||
ToolChoice: "none", // 不给工具,强制走文本产出
|
||
DisableThinking: !a.thinkingEnabled,
|
||
})
|
||
if err != nil {
|
||
log.Printf("[child] wrap-up distillation failed (falling back to factual summary): %v", err)
|
||
return ""
|
||
}
|
||
content := strings.TrimSpace(resp.Content)
|
||
if content == "" {
|
||
return ""
|
||
}
|
||
return fmt.Sprintf("(注:已达 %d 轮工具上限,以下为已取得成果的收尾提炼,可能不完整)\n%s", maxTurns, content)
|
||
}
|
||
|
||
func (a *Agent) executeLLMTool(tc agentAPI.ToolCall) string {
|
||
if a.providerManager == nil {
|
||
return "LLM 源管理器不可用"
|
||
}
|
||
switch tc.Name {
|
||
case "llm_list_sources":
|
||
sources := a.providerManager.List()
|
||
if len(sources) == 0 {
|
||
return "没有可用的 LLM 源"
|
||
}
|
||
parts := []string{"可用 LLM 源:"}
|
||
for _, name := range sources {
|
||
mark := " "
|
||
if p := a.providerManager.Get(""); p != nil && p.Name() == name {
|
||
mark = "→"
|
||
}
|
||
parts = append(parts, fmt.Sprintf(" %s %s", mark, name))
|
||
}
|
||
return strings.Join(parts, "\n")
|
||
|
||
case "llm_set_source":
|
||
name, _ := tc.Arguments["name"].(string)
|
||
if name == "" {
|
||
return "请提供源名称"
|
||
}
|
||
if err := a.providerManager.SetDefault(name); err != nil {
|
||
return fmt.Sprintf("切换失败: %v", err)
|
||
}
|
||
a.provider = a.providerManager.Get(name)
|
||
return fmt.Sprintf("已切换到 LLM 源: %s", name)
|
||
|
||
default:
|
||
return fmt.Sprintf("未知的 LLM 工具: %s", tc.Name)
|
||
}
|
||
}
|
||
|
||
// chatWithProviderChain 用本 agent 的 provider 链跑一次**非流式**请求。
|
||
//
|
||
// 优先级:自身 preferredProvider 提前 → 其余源按原顺序回退。
|
||
// 与主 agent 的 `callLLMWithFallback` 的区别是这里不重试、不切 active provider:
|
||
// 子任务是短生命周期,重试只会把一个已经抖动的时间窗拉长。
|
||
func (a *Agent) chatWithProviderChain(req *agentAPI.CompletionRequest) (*agentAPI.CompletionResponse, error) {
|
||
providers := a.resolveProviders(req)
|
||
var lastErr error
|
||
for _, p := range providers {
|
||
resp, err := p.Chat(a.ctx, req)
|
||
if err == nil {
|
||
return resp, nil
|
||
}
|
||
lastErr = err
|
||
// 取消/超时不再试下一个:那是调用方主动结束,不是"这个源坏了"。
|
||
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||
return nil, err
|
||
}
|
||
}
|
||
if lastErr == nil {
|
||
lastErr = fmt.Errorf("没有可用的 LLM 源")
|
||
}
|
||
return nil, lastErr
|
||
}
|