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:
Sakurasan committed 2026-10-26 00:54:46 +08:00
commit d5dac5a6fc
140 files changed
+26497

No files matched your search

+178
View File
@@ -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 }