fix(backthink): 后台思考静默广播——不再把内部推理泄到聊天思维链 + XML 动作/旁白解析
【静默广播修复(bug:思维链露出 backthink 推理)】 - 根因:LoopThinker.Think 复用同一 loop.Loop 且 loop 全局带 OnStream,backthink 后台思考时 把 [background autonomous reflection] 的 thinking/流式事件广播到前端,被当聊天思维链显示。 - loop/mute_ctx.go:WithMutedBroadcast(ctx) + isMutedBroadcast(ctx)——context 上静默标志。 - loop 四处广播抑制(stream.go 流式 / step.go 静默走非流式 agentStep / request.go capacity 事件 / tools.go tool_execution),静默时跳过 UI 广播,推理照常返回结论。 - thinker.go LoopThinker.Think 用 loop.WithMutedBroadcast(ctx),backthink 不再泄 thinking。 【XML 动作/旁白结构化解析】 - cyrene.yaml systemPrompt 加「表达方式:动作与旁白」——动作 <action>…</action>、 旁白 <narration>…</narration>;克制≤1-2个/段、贴合唤醒态、重要信息不加、朗读正文忽略标签。 - 前端 parseContent.ts:ContentPart[](text/action/narration),非贪婪+反向引用、跨行、非法/ 未闭合兜底纯文本不崩;summarizeContent 供思维导图/状态。 - 前端 renderContent.tsx:action/narration → 灰斜体(mu-text-lo italic)句中原位渲染。 - 应用到 chat.tsx UserBubble/AssistantBubble + messages 各 mode body。 go build/vet/test(loop/backthink)绿 + 前端 tsc/vite 绿。
This commit is contained in:
1 parent
6dbb4a2e18
commit
33eeadfc38
11 files changed
+146
-19
No files matched your search
@@ -0,0 +1,25 @@
|
||||
package loop
|
||||
|
||||
import "context"
|
||||
|
||||
// muteKey is the context key for the "suppress UI broadcast" flag. When set,
|
||||
// a Run performs its work as usual but does NOT push stream/thinking/capacity
|
||||
// events to the UI channel (OnStream / OnCapacity). This is used by internal
|
||||
// autonomous passes (e.g. background reflection) whose intermediate reasoning
|
||||
// must not leak into the user-facing chat stream. The loop itself is unchanged;
|
||||
// only the outward event emission is muted.
|
||||
type muteKey struct{}
|
||||
|
||||
// WithMutedBroadcast returns a context whose loop Run suppresses UI stream and
|
||||
// capacity broadcast, while still returning the final result to the caller.
|
||||
func WithMutedBroadcast(ctx context.Context) context.Context {
|
||||
return context.WithValue(ctx, muteKey{}, true)
|
||||
}
|
||||
|
||||
// isMutedBroadcast reports whether the Run driving ctx should suppress UI
|
||||
// broadcast. An unset flag (the common case) returns false so normal chat
|
||||
// streaming is untouched.
|
||||
func isMutedBroadcast(ctx context.Context) bool {
|
||||
v, _ := ctx.Value(muteKey{}).(bool)
|
||||
return v
|
||||
}
|
||||
@@ -80,7 +80,7 @@ func (l *Loop) toolSchemas(ctx context.Context, messages []model.Message) []mode
|
||||
Recall: l.capacityRecall(ctx, messages),
|
||||
})
|
||||
acc.state = dec.State
|
||||
if l.onCapacity != nil {
|
||||
if l.onCapacity != nil && !isMutedBroadcast(ctx) {
|
||||
for _, ev := range dec.Events {
|
||||
l.onCapacity(ev)
|
||||
}
|
||||
|
||||
@@ -31,7 +31,9 @@ func (l *Loop) step(ctx context.Context, sessionID string, messages []model.Mess
|
||||
out *stepOutcome
|
||||
err error
|
||||
)
|
||||
if l.onStream != nil {
|
||||
// Muted (internal autonomous pass) bypasses streaming: it neither streams to
|
||||
// the UI nor pays the streaming path's extra overhead. Normal chat streams.
|
||||
if l.onStream != nil && !isMutedBroadcast(ctx) {
|
||||
out, err = l.streamStep(ctx, messages)
|
||||
} else {
|
||||
out, err = l.agentStep(ctx, sessionID, messages)
|
||||
|
||||
@@ -32,7 +32,9 @@ func (l *Loop) streamStep(ctx context.Context, messages []model.Message) (*stepO
|
||||
}
|
||||
|
||||
resp, err := adapter.ChatStream(ctx, req, func(ev model.StreamEvent) {
|
||||
if l.onStream != nil {
|
||||
// Suppress UI broadcast for internal autonomous passes (backthink),
|
||||
// whose intermediate reasoning must not leak into the chat stream.
|
||||
if l.onStream != nil && !isMutedBroadcast(ctx) {
|
||||
l.onStream(ev)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -275,14 +275,14 @@ func (l *Loop) invokeOne(ctx context.Context, d *tool.Dispatcher, tc agent.ToolC
|
||||
if d == nil {
|
||||
return ToolOutcome{Err: fmt.Errorf("no tool dispatcher configured for %q", tc.Name), ErrKind: tool.KindExecution}
|
||||
}
|
||||
l.emitToolExecution(tc.Name, "start")
|
||||
l.emitToolExecution(ctx, tc.Name, "start")
|
||||
_, toolSpan := l.span(ctx, "tool."+tc.Name, trace.SpanKindTool)
|
||||
result, err := d.Invoke(ctx, tc.Name, tc.Args)
|
||||
phase := "end"
|
||||
if err != nil {
|
||||
phase = "end:error"
|
||||
}
|
||||
l.emitToolExecution(tc.Name, phase)
|
||||
l.emitToolExecution(ctx, tc.Name, phase)
|
||||
out := ToolOutcome{Result: result, Err: err, ErrKind: errorKind(err)}
|
||||
if err != nil {
|
||||
l.spanProp(toolSpan, "error", err.Error())
|
||||
@@ -297,9 +297,10 @@ func (l *Loop) invokeOne(ctx context.Context, d *tool.Dispatcher, tc agent.ToolC
|
||||
|
||||
// emitToolExecution forwards a tool_execution_update progress event to the
|
||||
// frontend when the loop is on the streaming path (OnStream configured); it is a
|
||||
// no-op otherwise. phase is a short label like "start" or "end:error".
|
||||
func (l *Loop) emitToolExecution(name, phase string) {
|
||||
if l.onStream == nil {
|
||||
// no-op otherwise (muted internal passes skip it too). phase is a short label
|
||||
// like "start" or "end:error".
|
||||
func (l *Loop) emitToolExecution(ctx context.Context, name, phase string) {
|
||||
if l.onStream == nil || isMutedBroadcast(ctx) {
|
||||
return
|
||||
}
|
||||
l.onStream(model.StreamEvent{Kind: model.StreamKindToolExecution, Delta: phase + ":" + name})
|
||||
|
||||
Reference in new issue
Block a user