feat: AutoZero 通用 Agent 框架(从 AutoGo 五阶段拆分完成)
llm(三协议 Provider)/ session(JSONL 持久化)/ tools(Registry、围栏、 builtin、computer)/ cdp(browser)/ skills / mcp / agent(循环、两级压缩、 编排、审批门控)+ 底座 jsonl/textx/execx/diag/scratch/spill/imagecache。 产品装配代码(config 桥接、schedule 工具、memory/plan/retro/prompt) 留在 AutoGo,宿主接入方式见 MIGRATION.md。
This commit is contained in:
commit
d5dac5a6fc
140 files changed
+26497
No files matched your search
@@ -0,0 +1,178 @@
|
||||
// 输出上限的自动续写(对齐 ZCode 的 turn-output-token-continuation)。
|
||||
//
|
||||
// 症状:模型这一轮还在写,但 max_tokens 用完了,于是话说一半停住。agent 的收尾
|
||||
// 判定是"这条没有 tool_calls ⇒ 说完了",所以这种半截答案被当成**成功收尾**——
|
||||
// 用户看到的是"它讲一半就停",只能自己打「继续」。这是"要反复按继续"的另一个
|
||||
// 高频原因(区别于空回复与坏流:那两种是网关抽风,这一种是合法但没写完)。
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.gopher.ink/mirrors2/AutoZero/llm"
|
||||
)
|
||||
|
||||
// outputContinueMax 是一次切断的续写次数上限。3 次沿用 ZCode 实测口径:再多就说明
|
||||
// 模型在原地循环而不是偶发的长度问题。
|
||||
//
|
||||
// 成 var 而不是 const:用例要把预算压到 1,否则测"耗尽"要白跑三次请求。
|
||||
var outputContinueMax = 3
|
||||
|
||||
// outputResumeInstruction 续写轮临时追加到 system 末尾的指令。
|
||||
//
|
||||
// 挂 system 而不往会话里塞一条 user 消息,理由与 wrapUpInstruction 同:后者会作为
|
||||
// 真实用户输入落盘,污染历史(用户从没说过这句话),还会把 /rewind 的回合号数错。
|
||||
const outputResumeInstruction = `
|
||||
|
||||
## 你的输出被长度上限切断
|
||||
|
||||
上一条回复在写完之前撞到了输出 token 上限。现在**接着写下去**:
|
||||
- 从断掉的那个字继续,不要道歉、不要复述、不要重来已经写过的内容;
|
||||
- 如果断在句子/代码块中间,直接续上,保持与前文同样的格式与缩进;
|
||||
- 剩下的内容如果还很长,分成更小段写完;
|
||||
- 确实需要用户决定下一步时,把问题问出来(用 ask_user),而不是停下来沉默。`
|
||||
|
||||
// outputLimitNote 是续写预算用尽时前置在正文前的一行标记。写进正文(而不是只发
|
||||
// 一条告警)的理由同 wrapUpNote:用户一眼看到为什么停,模型下一轮也知道该接哪。
|
||||
const outputLimitNote = "⏸ 回复被输出长度上限切断,已自动续写 %d 次仍未写完——回复「继续」我就接着写。"
|
||||
|
||||
// outputLimitNoteFirst 是**没有**尝试续写时的标记(例如思考签名在场,见下面)。
|
||||
const outputLimitNoteFirst = "⏸ 回复被输出长度上限切断——回复「继续」我就接着写。"
|
||||
|
||||
// continueOutput 在回复被输出上限切断时自动续写,并把几段输出**合并成一条**
|
||||
// assistant 消息。
|
||||
//
|
||||
// 请求形状是 assistant prefill:把已说出的部分作为历史末尾的 assistant 消息再发一次
|
||||
// (三家都支持,Anthropic 把它作为正式特性叫 prefill)。这样模型看见的就是自己的半截话,
|
||||
// 续出来的内容天然衔接。
|
||||
//
|
||||
// 合并而不是各存一条,是为了会话里不出现两条相邻 assistant——那会让"哪条是本回合的
|
||||
// 最终回复"、回合号与配对判定都多一套特例。
|
||||
//
|
||||
// 两个刻意的保守点:
|
||||
// - 续写请求本身失败时**不弄坏这一轮**:半截答案比一个错误有用(今天的行为就是
|
||||
// 直接停在这里),所以只记一行 stderr;
|
||||
// - 上一条回复带 thinking 签名时不续写:Anthropic 不接受"思考中"的 prefill,而把
|
||||
// 两段的 thinking 块与签名拼在一起会让下一个请求被拒(签名必须与块一一对应)。
|
||||
func (a *Agent) continueOutput(ctx context.Context, final llm.Message, msgs []llm.Message) llm.Message {
|
||||
if final.ReasoningSignature != "" || final.RedactedThinking != "" {
|
||||
return a.markOutputLimit(final)
|
||||
}
|
||||
for round := 1; round <= outputContinueMax; round++ {
|
||||
if ctx.Err() != nil {
|
||||
break // 用户已经喊停:手里这半截就是这一轮的结果
|
||||
}
|
||||
a.outputContinues++
|
||||
fmt.Fprintf(a.Opt.Stderr, "[output-limit] 回复被输出上限切断,自动续写 %d/%d\n", round, outputContinueMax)
|
||||
// 复制一份历史再追加:msgs 属于调用方(contextView 的结果),不能就地改。
|
||||
req := append(append([]llm.Message{}, msgs...), final)
|
||||
started := time.Now()
|
||||
cont, err := a.Provider.Stream(ctx, a.System, req, a.toolDefs(),
|
||||
llm.StreamOptions{Thinking: a.Thinking, SystemDynamic: a.systemSuffix() + outputResumeInstruction},
|
||||
func(d llm.StreamDelta) {
|
||||
if d.Text != "" {
|
||||
fmt.Fprint(a.Opt.Stdout, d.Text)
|
||||
}
|
||||
if d.Reasoning != "" && a.Opt.OnReasoning != nil {
|
||||
a.Opt.OnReasoning(d.Reasoning)
|
||||
}
|
||||
})
|
||||
if err != nil {
|
||||
if ctx.Err() == nil {
|
||||
fmt.Fprintf(a.Opt.Stderr, "[output-limit] 续写失败,保留已有内容:%v\n", err)
|
||||
}
|
||||
break
|
||||
}
|
||||
final.DurationMs += time.Since(started).Milliseconds()
|
||||
final.Content += cont.Content
|
||||
final.Reasoning += cont.Reasoning
|
||||
final.OutputLimited = cont.OutputLimited
|
||||
if len(cont.ToolCalls) > 0 {
|
||||
final.ToolCalls = cont.ToolCalls
|
||||
}
|
||||
if cont.Usage != nil {
|
||||
// 每次续写都是一次真实请求,input 也按次累加(与"每步一条 assistant
|
||||
// 各自带 usage"的统计口径一致),只加 output 会把成本算少。
|
||||
final.Usage = mergeUsage(final.Usage, cont.Usage)
|
||||
a.usedIn += cont.Usage.InputTokens
|
||||
a.usedOut += cont.Usage.OutputTokens
|
||||
}
|
||||
if !final.OutputLimited {
|
||||
return final // 写完了
|
||||
}
|
||||
}
|
||||
return a.markOutputLimit(final)
|
||||
}
|
||||
|
||||
// abandonStream 在重跑同一步之前通知宿主:刚才流出去的那些字作废。
|
||||
// 只在真流出了东西时才通知(没流出的话,宿主白擦一次屏幕,什么都没换回来)。
|
||||
// 两个累积器本身由 call() 在每次尝试开始时清零,这里不动它们。
|
||||
func (a *Agent) abandonStream(reason string, text, reasoning *strings.Builder) {
|
||||
if a.Opt.OnAbandon == nil || (text.Len() == 0 && reasoning.Len() == 0) {
|
||||
return
|
||||
}
|
||||
a.Opt.OnAbandon(reason, text.Len())
|
||||
}
|
||||
|
||||
// persistPartial 把中断时已经流出来的那半截回复落盘。
|
||||
//
|
||||
// 为什么值得单列一个函数:provider 在出错时返回的是零值 Message(半截内容不在返回值
|
||||
// 里),所以内容只能由 step 的 delta 回调自己攒着,在这里统一落。
|
||||
// 什么都不到盘上时(连接一开就断)静默跳过——不要留一条空 assistant 消息,
|
||||
// 它会被"没有 tool_calls ⇒ 说完了"读成一次正常的空回答。
|
||||
func (a *Agent) persistPartial(text, reasoning string, started time.Time) {
|
||||
if a.Session == nil || (strings.TrimSpace(text) == "" && strings.TrimSpace(reasoning) == "") {
|
||||
return
|
||||
}
|
||||
m := llm.Message{
|
||||
Role: llm.RoleAssistant,
|
||||
Content: text,
|
||||
Reasoning: reasoning,
|
||||
Interrupted: true,
|
||||
StartedAt: started.UnixMilli(),
|
||||
DurationMs: time.Since(started).Milliseconds(),
|
||||
Step: a.curStep,
|
||||
}
|
||||
a.stampIdentity(&m)
|
||||
if err := a.Session.Append(m); err != nil {
|
||||
fmt.Fprintf(a.Opt.Stderr, "[interrupt] 半截回复没能落盘:%v\n", err)
|
||||
return
|
||||
}
|
||||
fmt.Fprintln(a.Opt.Stderr, "[interrupt] 已保留中断前流出的内容")
|
||||
}
|
||||
|
||||
// markOutputLimit 给"还是没写完"的回复加一行说明。没被切断时原样返回。
|
||||
func (a *Agent) markOutputLimit(m llm.Message) llm.Message {
|
||||
if !m.OutputLimited {
|
||||
return m
|
||||
}
|
||||
note := outputLimitNoteFirst
|
||||
if a.outputContinues > 0 {
|
||||
note = fmt.Sprintf(outputLimitNote, a.outputContinues)
|
||||
}
|
||||
m.Content = note + "\n\n" + m.Content
|
||||
return m
|
||||
}
|
||||
|
||||
// mergeUsage 累加两次响应的 usage(各字段分别求和;nil 侧直接返回另一侧)。
|
||||
func mergeUsage(a, b *llm.Usage) *llm.Usage {
|
||||
switch {
|
||||
case a == nil:
|
||||
return b
|
||||
case b == nil:
|
||||
return a
|
||||
}
|
||||
return &llm.Usage{
|
||||
InputTokens: a.InputTokens + b.InputTokens,
|
||||
OutputTokens: a.OutputTokens + b.OutputTokens,
|
||||
CacheReadTokens: a.CacheReadTokens + b.CacheReadTokens,
|
||||
CacheCreationTokens: a.CacheCreationTokens + b.CacheCreationTokens,
|
||||
ReasoningTokens: a.ReasoningTokens + b.ReasoningTokens,
|
||||
}
|
||||
}
|
||||
|
||||
// outputContinuations 返回本回合续写了几次(复盘记账用)。
|
||||
func (a *Agent) outputContinuations() int { return a.outputContinues }
|
||||
Reference in new issue
Block a user