mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-21 09:28:14 +00:00
fix: LLM 工具循环 400、中断消息注入、ConPTY 终端支持
- agent: 工具轮请求尾部补 user 占位(zen 网关强制),tool 消息正确配对 - agent: 工具提醒/中断以 system 角色注入并带 [中断消息] 前缀,不进用户履历;系统提示词说明中断消息格式 - agentcli: 基于 ConPTY 的交互式终端(ptywin fork),terminal_create/read/write/resize/close/watch - webui: server 输出通道适配器(保留 reasoning_content/disable_thinking) - GUI: 沉浸式标题栏、icon 圆角重制、mascot 等打磨
This commit is contained in:
@ -100,6 +100,9 @@ type Agent struct {
|
||||
// 当前轮次的非文本媒体数据(图片/音频),供 describe_image 等工具访问
|
||||
pendingMedia map[string]interface{}
|
||||
|
||||
// 当前输入是否为工具提醒/中断(以 system 角色注入,避免被当成用户消息)
|
||||
interruptInput bool
|
||||
|
||||
// 非文本输入处理配置
|
||||
inputCfg types.InputProcessingConfig
|
||||
|
||||
|
||||
@ -86,6 +86,83 @@ func TestDocToTriplesEmptyContent(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 2: 归档上下文文档不得产出模板垃圾(context_archived 来源/主题模板三元组)
|
||||
func TestDocToTriplesArchivedContext(t *testing.T) {
|
||||
doc := &document.Doc{
|
||||
Summary: "来自 2 个来源的 5 条对话 (qq, webui) 涉及: 天气, 测试",
|
||||
Content: "[15:04] qq: 今天天气怎么样\n[15:05] agent: 今天天气很好",
|
||||
Source: "context_archived",
|
||||
Meta: map[string]string{"is_archived_context": "true"},
|
||||
}
|
||||
triples := docToTriples(doc, nil)
|
||||
|
||||
for _, tr := range triples {
|
||||
if tr.Subject == "文档" && tr.Relation == "来源" && tr.Object == "context_archived" {
|
||||
t.Errorf("archived context must not write 来源 triple: %+v", tr)
|
||||
}
|
||||
if tr.Subject == "文档" && tr.Relation == "主题" {
|
||||
t.Errorf("archived context must not write 主题 template triple: %+v", tr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 2: 模板化摘要(summarizeEntries 生成)不得作为主题写入
|
||||
func TestDocToTriplesTemplateSummary(t *testing.T) {
|
||||
doc := &document.Doc{
|
||||
Summary: "来自 3 个来源的 10 条对话 (a, b, c) 涉及: 关键词1, 关键词2, 关键词3",
|
||||
Content: "[10:00] a: 你好",
|
||||
Source: "manual",
|
||||
}
|
||||
triples := docToTriples(doc, nil)
|
||||
|
||||
for _, tr := range triples {
|
||||
if tr.Subject == "文档" && tr.Relation == "主题" {
|
||||
t.Errorf("template summary must not be written as 主题 triple: %+v", tr)
|
||||
}
|
||||
}
|
||||
// 但非归档来源仍保留 来源 三元组
|
||||
foundSource := false
|
||||
for _, tr := range triples {
|
||||
if tr.Subject == "文档" && tr.Relation == "来源" && tr.Object == "manual" {
|
||||
foundSource = true
|
||||
}
|
||||
}
|
||||
if !foundSource {
|
||||
t.Errorf("non-archived source should still produce 来源 triple")
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 2: 过长摘要不得写入主题
|
||||
func TestDocToTriplesLongSummary(t *testing.T) {
|
||||
long := ""
|
||||
for i := 0; i < 100; i++ {
|
||||
long += "很长的摘要内容片段重复拼接"
|
||||
}
|
||||
doc := &document.Doc{
|
||||
Summary: long,
|
||||
Content: "[10:00] a: 你好",
|
||||
Source: "test",
|
||||
}
|
||||
triples := docToTriples(doc, nil)
|
||||
for _, tr := range triples {
|
||||
if tr.Subject == "文档" && tr.Relation == "主题" {
|
||||
t.Errorf("overlong summary must not be written as 主题 triple")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsTemplateSummary(t *testing.T) {
|
||||
if !isTemplateSummary("来自 2 个来源的 5 条对话 (qq, webui) 涉及: 天气") {
|
||||
t.Errorf("template summary not recognized")
|
||||
}
|
||||
if isTemplateSummary("今天天气很好") {
|
||||
t.Errorf("plain summary wrongly recognized as template")
|
||||
}
|
||||
if !isTemplateSummary("") {
|
||||
t.Errorf("empty summary should be treated as template")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncateStr(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
|
||||
@ -4,6 +4,7 @@ import (
|
||||
"fmt"
|
||||
"log"
|
||||
"runtime/debug"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||||
@ -397,15 +398,19 @@ func docToTriples(doc *document.Doc, embedder nlp.Vectorizer) []memory.Triple {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 文档元数据
|
||||
triples = append(triples, memory.Triple{
|
||||
Subject: "文档",
|
||||
SubjectType: "Concept",
|
||||
Relation: "主题",
|
||||
Object: doc.Summary,
|
||||
ObjectType: "Topic",
|
||||
Confidence: 1.0,
|
||||
})
|
||||
isArchivedContext := doc.Meta != nil && doc.Meta["is_archived_context"] == "true"
|
||||
|
||||
// 文档元数据:仅当 summary 合理(非空、非模板化、长度适中)时才写「主题」
|
||||
if !isArchivedContext && doc.Summary != "" && len([]rune(doc.Summary)) < 80 && !isTemplateSummary(doc.Summary) {
|
||||
triples = append(triples, memory.Triple{
|
||||
Subject: "文档",
|
||||
SubjectType: "Concept",
|
||||
Relation: "主题",
|
||||
Object: doc.Summary,
|
||||
ObjectType: "Topic",
|
||||
Confidence: 1.0,
|
||||
})
|
||||
}
|
||||
|
||||
// NLP 通用提取
|
||||
e := nlp.NewExtractor(nil)
|
||||
@ -422,7 +427,8 @@ func docToTriples(doc *document.Doc, embedder nlp.Vectorizer) []memory.Triple {
|
||||
}
|
||||
}
|
||||
|
||||
if doc.Source != "" {
|
||||
// 仅当来源非归档上下文且非空时写「来源」——归档文档写死模板三元组属于垃圾
|
||||
if doc.Source != "" && doc.Source != "context_archived" {
|
||||
triples = append(triples, memory.Triple{
|
||||
Subject: "文档",
|
||||
SubjectType: "Concept",
|
||||
@ -436,6 +442,16 @@ func docToTriples(doc *document.Doc, embedder nlp.Vectorizer) []memory.Triple {
|
||||
return triples
|
||||
}
|
||||
|
||||
// isTemplateSummary 识别 summarizeEntries 生成的模板化摘要
|
||||
// (形如「来自 N 个来源的 M 条对话 (src1, src2) 涉及: kw1, kw2」),
|
||||
// 这类摘要无独立信息量,不应作为「主题」实体写入图库。
|
||||
func isTemplateSummary(s string) bool {
|
||||
if s == "" {
|
||||
return true
|
||||
}
|
||||
return strings.HasPrefix(s, "来自 ") && strings.Contains(s, "条对话")
|
||||
}
|
||||
|
||||
func (a *Agent) emitMemoryCandidate(source, input, response string, toolResults []ToolResultItem, toolsUsed []string) {
|
||||
a.io.EmitOutput("memory", "memory_candidate", map[string]interface{}{
|
||||
"source": source,
|
||||
|
||||
@ -287,6 +287,16 @@ func (a *Agent) processTextInput(evt *agentIO.InputEvent, input string) {
|
||||
}
|
||||
}
|
||||
|
||||
// 工具提醒/中断(terminal_watch、timer 等)不是用户发言:
|
||||
// 以 system 角色注入 LLM,且不写入用户对话履历。
|
||||
isInterrupt, _ := evt.Payload["interrupt"].(bool)
|
||||
a.mu.Lock()
|
||||
a.interruptInput = isInterrupt
|
||||
a.mu.Unlock()
|
||||
if isInterrupt {
|
||||
noMemory = true
|
||||
}
|
||||
|
||||
stageCtx := a.stageCtxFromInput(input, evt.Source, "")
|
||||
stageCtx.Extra["input_source"] = evt.Source
|
||||
stageCtx.Extra["output_channel"] = evt.OutputChannel
|
||||
@ -320,11 +330,13 @@ func (a *Agent) processTextInput(evt *agentIO.InputEvent, input string) {
|
||||
log.Printf("[agent] pruned %d low-relevance events to document memory", archived)
|
||||
}
|
||||
|
||||
a.context.Append(ContextEvent{
|
||||
Timestamp: start,
|
||||
Source: evt.Source,
|
||||
Input: input,
|
||||
})
|
||||
if !isInterrupt {
|
||||
a.context.Append(ContextEvent{
|
||||
Timestamp: start,
|
||||
Source: evt.Source,
|
||||
Input: input,
|
||||
})
|
||||
}
|
||||
|
||||
response, toolsUsed, toolResults, err := a.process(input, stageCtx)
|
||||
if err != nil {
|
||||
@ -380,7 +392,6 @@ func (a *Agent) emitResponse(evt *agentIO.InputEvent, response string) {
|
||||
if stageCtx.TokenUsage != nil {
|
||||
payload["usage"] = stageCtx.TokenUsage
|
||||
}
|
||||
|
||||
if evt.ResponseCh != nil {
|
||||
evt.ResponseCh <- &agentIO.OutputEvent{
|
||||
RequestID: evt.RequestID,
|
||||
@ -392,11 +403,15 @@ func (a *Agent) emitResponse(evt *agentIO.InputEvent, response string) {
|
||||
}
|
||||
}
|
||||
|
||||
a.publishEvent(events.EventAgentOutput, map[string]interface{}{
|
||||
out := map[string]interface{}{
|
||||
"content": response,
|
||||
"channel": ch,
|
||||
"source": evt.Source,
|
||||
})
|
||||
}
|
||||
if stageCtx.ReasoningContent != "" {
|
||||
out["reasoning_content"] = stageCtx.ReasoningContent
|
||||
}
|
||||
a.publishEvent(events.EventAgentOutput, out)
|
||||
stageCtx.Phase = sdk.StageAfterOutput
|
||||
a.runStage(sdk.StageAfterOutput, stageCtx)
|
||||
}
|
||||
|
||||
@ -27,6 +27,14 @@ func (a *Agent) process(input string, stageCtx *sdk.StageContext) (response stri
|
||||
tools := a.buildToolDefs()
|
||||
|
||||
msgs := a.buildMessages(sysPrompt, input, budget.ContextTokens)
|
||||
// 工具提醒(interrupt):以 system 角色注入,不让模型误认为用户发言
|
||||
if a.interruptInput {
|
||||
last := msgs[len(msgs)-1]
|
||||
last.Role = "system"
|
||||
last.Content = "[中断消息] " + last.Content
|
||||
msgs[len(msgs)-1] = last
|
||||
a.interruptInput = false
|
||||
}
|
||||
if blocks, ok := stageCtx.Extra["media_blocks"].([]agentAPI.ContentBlock); ok && len(blocks) > 0 {
|
||||
if len(msgs) > 0 {
|
||||
msgs[len(msgs)-1].Blocks = blocks
|
||||
@ -56,7 +64,16 @@ func (a *Agent) process(input string, stageCtx *sdk.StageContext) (response stri
|
||||
for _, interrupt := range a.drainInterrupts() {
|
||||
msgs = append(msgs, agentAPI.Message{
|
||||
Role: "system",
|
||||
Content: interrupt,
|
||||
Content: "[中断消息] " + interrupt,
|
||||
})
|
||||
}
|
||||
|
||||
// zen 兼容网关要求请求的最后一条消息必须是 user(thinking 续写模式校验),
|
||||
// 工具轮产出的 tool/assistant 消息作结尾会被 400 拒绝,故补一条 user 占位。
|
||||
if last := msgs[len(msgs)-1]; last.Role != "user" {
|
||||
msgs = append(msgs, agentAPI.Message{
|
||||
Role: "user",
|
||||
Content: "请根据以上工具结果继续。",
|
||||
})
|
||||
}
|
||||
|
||||
@ -177,6 +194,13 @@ func (a *Agent) process(input string, stageCtx *sdk.StageContext) (response stri
|
||||
}
|
||||
a.publishEvent(events.EventAgentLLMChain, chainPayload)
|
||||
|
||||
if resp.ReasoningContent != "" {
|
||||
a.publishEvent(events.EventReasoning, map[string]interface{}{
|
||||
"content": resp.ReasoningContent,
|
||||
"channel": a.currentOutputChannel,
|
||||
})
|
||||
}
|
||||
|
||||
if len(resp.ToolCalls) == 0 {
|
||||
return resp.Content, toolsUsed, toolResults, nil
|
||||
}
|
||||
@ -185,15 +209,16 @@ func (a *Agent) process(input string, stageCtx *sdk.StageContext) (response stri
|
||||
for _, tc := range resp.ToolCalls {
|
||||
if len(a.interceptCh) > 0 {
|
||||
for _, interrupt := range a.drainInterrupts() {
|
||||
msgs = append(msgs, agentAPI.Message{Role: "system", Content: interrupt})
|
||||
msgs = append(msgs, agentAPI.Message{Role: "system", Content: "[中断消息] " + interrupt})
|
||||
}
|
||||
a.publishEvent(events.EventToolCall, map[string]interface{}{
|
||||
"tool": tc.Name,
|
||||
"plugin": a.resolveToolPlugin(tc.Name),
|
||||
"args": tc.Arguments,
|
||||
"status": "interrupted",
|
||||
"reason": "user interrupt before execution",
|
||||
})
|
||||
a.publishEvent(events.EventToolCall, map[string]interface{}{
|
||||
"tool": tc.Name,
|
||||
"plugin": a.resolveToolPlugin(tc.Name),
|
||||
"args": tc.Arguments,
|
||||
"status": "interrupted",
|
||||
"reason": "user interrupt before execution",
|
||||
"channel": a.currentOutputChannel,
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
@ -208,13 +233,14 @@ func (a *Agent) process(input string, stageCtx *sdk.StageContext) (response stri
|
||||
result := fmt.Sprintf("工具 %s 已被插件拒绝", tc.Name)
|
||||
msgs = append(msgs, agentAPI.Message{Role: "assistant", ToolCalls: []agentAPI.ToolCall{tc}})
|
||||
msgs = append(msgs, agentAPI.Message{Role: "tool", ToolCallID: tc.ID, Content: result})
|
||||
a.publishEvent(events.EventToolCall, map[string]interface{}{
|
||||
"tool": tc.Name,
|
||||
"plugin": pluginName,
|
||||
"args": tc.Arguments,
|
||||
"result": result,
|
||||
"status": "denied",
|
||||
})
|
||||
a.publishEvent(events.EventToolCall, map[string]interface{}{
|
||||
"tool": tc.Name,
|
||||
"plugin": pluginName,
|
||||
"args": tc.Arguments,
|
||||
"result": result,
|
||||
"status": "denied",
|
||||
"channel": a.currentOutputChannel,
|
||||
})
|
||||
continue
|
||||
}
|
||||
tc.Arguments = stageCtx.ToolCalls[0].Arguments
|
||||
@ -248,16 +274,17 @@ func (a *Agent) process(input string, stageCtx *sdk.StageContext) (response stri
|
||||
msgs = append(msgs, agentAPI.Message{Role: "tool", ToolCallID: tc.ID, Content: result})
|
||||
|
||||
a.publishEvent(events.EventToolCall, map[string]interface{}{
|
||||
"tool": tc.Name,
|
||||
"plugin": pluginName,
|
||||
"args": tc.Arguments,
|
||||
"result": result,
|
||||
"status": "ok",
|
||||
"tool": tc.Name,
|
||||
"plugin": pluginName,
|
||||
"args": tc.Arguments,
|
||||
"result": result,
|
||||
"status": "ok",
|
||||
"channel": a.currentOutputChannel,
|
||||
})
|
||||
|
||||
if len(a.interceptCh) > 0 {
|
||||
for _, interrupt := range a.drainInterrupts() {
|
||||
msgs = append(msgs, agentAPI.Message{Role: "system", Content: interrupt})
|
||||
msgs = append(msgs, agentAPI.Message{Role: "system", Content: "[中断消息] " + interrupt})
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
@ -12,6 +12,14 @@ import (
|
||||
)
|
||||
|
||||
func (a *Agent) runStage(stage sdk.Stage, ctx *sdk.StageContext) bool {
|
||||
payload := map[string]interface{}{
|
||||
"phase": string(stage),
|
||||
"channel": a.currentOutputChannel,
|
||||
}
|
||||
if ctx != nil && len(ctx.ToolCalls) > 0 {
|
||||
payload["tool"] = ctx.ToolCalls[0].Name
|
||||
}
|
||||
a.publishEvent(events.EventStage, payload)
|
||||
if a.stageHost == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
@ -49,12 +49,13 @@ func (a *Agent) buildSystemPrompt(memContext string, userInput string) string {
|
||||
}
|
||||
}
|
||||
|
||||
prompt += "\n\n【输出规则】你有多组输出门工具(type=output),每个对应一个输出通道。回复用户时必须调用对应的 output_send__{通道名} 工具。\n"
|
||||
prompt += "- payload 参数是消息载荷(文本直接填文字),type 指定载荷类型(text/voice/image/file),meta 是 JSON 发送元数据(群号/用户号等)。\n"
|
||||
prompt += "\n\n【中断消息】长任务执行期间,工具/插件/定时器等会通过中断机制向你发送提醒(如 QQ 新消息、终端输出到达、定时器到点等)。中断消息以 system 角色注入,内容带 [中断消息] 前缀,**不是用户发言,但也必须认真处理**:优先停下当前长任务,针对中断内容作出响应或决定继续执行。不要忽略带 [中断消息] 前缀的 system 消息。"
|
||||
|
||||
prompt += "\n\n【输出规则】回复会自动发送到用户的输入来源通道,直接返回纯文本即可送达,无需调用任何工具。\n"
|
||||
prompt += "- 输出门工具 output_send__{通道名} 用于主动向指定通道推送消息(如群发、主动通知、向其他通道发言),不是回复的必要步骤。除非用户要求在别的通道发送,否则不要使用。\n"
|
||||
prompt += "- 用 output_send__{通道名}_help 查看该通道的 meta 格式和 type 枚举。\n"
|
||||
prompt += "- 同一轮对话中可多次调用输出门工具。长消息应当分多次发出,而不是一口气发完。\n"
|
||||
prompt += "- 直接返回纯文本不会到达任何用户端。\n"
|
||||
prompt += "- 需要多步执行的长任务:**必须先**用 output_send__ 发一条确认消息告诉用户已收到(如「好的我去看看~」),**然后再**执行具体排查工具。确认消息不代表任务完成,发出后仍需继续执行实际工具并最终汇报结果。"
|
||||
prompt += "- 需要多步执行的长任务:**必须先**用输出门工具向当前输入通道发一条确认消息告诉用户已收到(如「好的我去看看~」,也可以直接返回文本),**然后再**执行具体排查工具。确认消息不代表任务完成,发出后仍需继续执行实际工具并最终汇报结果。"
|
||||
|
||||
if a.indexer != nil {
|
||||
prompt += "\n\n" + a.indexer.BuildToolPrompt()
|
||||
|
||||
Reference in New Issue
Block a user