fix(channels): announce tools before execution

This commit is contained in:
Goon committed 2026-05-31 19:29:49 +07:00
1 parent 0e980ffc76
commit a8afed2a81
22 files changed
+454 -165

No files matched your search

+2 -1
View File
@@ -78,7 +78,8 @@ Normal channel messages pass through the shared inbound debouncer before agent e
- `quick_ack.mode = "llm_generated"` uses the existing main-turn `block.reply` event as the natural progress response for non-streaming channel runs. It does not make a separate LLM call.
- `quick_ack.templates` are fallback messages. In generated mode, the first template is sent only if no generated `block.reply` arrives before `quick_ack.min_delay_ms`.
- `quick_ack.mode = "fixed_template"` preserves the older fixed-template acknowledgement behavior.
- `quick_ack.mode = "off"` disables chat-behavior quick acknowledgement. When explicit `gateway.block_reply` is on, the first pre-tool `block.reply` is treated as the immediate acknowledgement and suppressed; later tool-iteration `block.reply` messages can still be delivered as intermediate replies.
- `quick_ack.mode = "off"` disables chat-behavior quick acknowledgement. When explicit `gateway.block_reply` is on, the first generic pre-tool `block.reply` is treated as the immediate acknowledgement and suppressed; deterministic tool announcements still deliver before tool execution because they are part of Intermediate Replies.
- If a model requests tools without assistant text, or with text that does not name the tools, the pipeline emits a safe `tool_announcement` fallback using only sanitized tool names. Tool arguments are never included in this message.
- `final_split` splits long final text replies into a bounded number of paragraph messages.
- Per-channel `chat_behavior` overrides inherit the gateway config unless a field is explicitly set.
+18
View File
@@ -6,6 +6,24 @@ Significant changes, features, and fixes in reverse chronological order.
## 2026-05-31
### Tool-call announcements
**Fixes**
- Intermediate Replies now guarantees a pre-tool announcement when the model
requests tools without assistant text, or with text that does not name the
tools. The fallback uses sanitized tool names only and is tagged as
`tool_announcement`.
- Quick acknowledgement off still suppresses generic first acknowledgements,
but no longer suppresses explicit tool announcements.
**Tests**
- Added pipeline coverage for empty-content tool calls and channel coverage for
`tool_announcement` delivery when Quick acknowledgement is off.
---
### Channel intermediate reply gating
**Fixes**
+18 -9
View File
@@ -51,6 +51,22 @@ func (l *Loop) buildPipelineDeps(req *RunRequest, bridgeRS *runState) pipeline.P
}
cb := l.pipelineCallbacks(req, bridgeRS)
emitBlockReply := func(content, source string) {
sanitized := SanitizeAssistantContent(content)
if sanitized == "" || IsSilentReply(sanitized) {
return
}
payload := map[string]string{"content": sanitized}
if source != "" {
payload["source"] = source
}
cb.emitRun(AgentEvent{
Type: protocol.AgentEventBlockReply,
AgentID: l.id,
RunID: req.RunID,
Payload: payload,
})
}
return pipeline.PipelineDeps{
TokenCounter: tokencount.NewTiktokenCounter(),
@@ -105,16 +121,9 @@ func (l *Loop) buildPipelineDeps(req *RunRequest, bridgeRS *runState) pipeline.P
CallLLM: cb.callLLM,
UniqueToolCallIDs: uniquifyToolCallIDs,
EmitBlockReply: func(content string) {
sanitized := SanitizeAssistantContent(content)
if sanitized != "" && !IsSilentReply(sanitized) {
cb.emitRun(AgentEvent{
Type: protocol.AgentEventBlockReply,
AgentID: l.id,
RunID: req.RunID,
Payload: map[string]string{"content": sanitized},
})
}
emitBlockReply(content, "")
},
EmitBlockReplyWithSource: emitBlockReply,
// Prune callbacks
PruneMessages: cb.pruneMessages,
@@ -272,3 +272,52 @@ func TestHandleAgentEvent_QuickAckDisabledSuppressesInitialExplicitBlockReply(t
})
}
}
func TestHandleAgentEvent_ToolAnnouncementBypassesInitialQuickAckSuppression(t *testing.T) {
for _, tc := range []struct {
name string
quick ResolvedQuickAckConfig
}{
{
name: "enabled_false",
quick: ResolvedQuickAckConfig{Enabled: false, Templates: []string{"Fallback."}},
},
{
name: "mode_off",
quick: ResolvedQuickAckConfig{Enabled: true, Mode: QuickAckModeOff, Templates: []string{"Fallback."}},
},
} {
t.Run(tc.name, func(t *testing.T) {
behavior := ResolvedChatBehavior{
Enabled: true,
QuickAck: tc.quick,
}
mb := bus.New()
mgr := NewManager(mb)
mgr.RegisterChannel("test", &chatBehaviorTestChannel{name: "test"})
mgr.RegisterRunWithBehavior("run-1", "test", "chat-1", "msg-1", nil, uuid.Nil, false, true, true, behavior)
mgr.HandleAgentEvent(protocol.AgentEventRunStarted, "run-1", nil)
mgr.HandleAgentEvent(protocol.AgentEventBlockReply, "run-1", map[string]string{
"content": "Tôi sẽ dùng `skill_search` để xử lý bước tiếp theo.",
"source": protocol.BlockReplySourceToolAnnouncement,
})
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
got, ok := mb.SubscribeOutbound(ctx)
if !ok {
t.Fatal("expected tool announcement outbound message")
}
if got.Content != "Tôi sẽ dùng `skill_search` để xử lý bước tiếp theo." {
t.Fatalf("tool announcement content = %q", got.Content)
}
delivered, last := mgr.InterimDeliverySnapshot("run-1")
if delivered != 1 || last != got.Content {
t.Fatalf("interim delivery snapshot = (%d, %q), want delivered announcement", delivered, last)
}
})
}
}
+3 -1
View File
@@ -278,6 +278,7 @@ func (m *Manager) HandleAgentEvent(eventType, runID string, payload any) {
if content == "" {
return
}
source := extractPayloadString(payload, "source")
rc.mu.Lock()
streaming := rc.Streaming
rc.blockReplySeen++
@@ -293,7 +294,8 @@ func (m *Manager) HandleAgentEvent(eventType, runID string, payload any) {
if !blockReplyEnabled && !generatedProgress {
return
}
if isInitialBlockReply && blockReplyEnabled && !generatedProgress && ShouldSuppressInitialBlockReply(chatBehavior, streaming) {
isToolAnnouncement := source == protocol.BlockReplySourceToolAnnouncement
if isInitialBlockReply && !isToolAnnouncement && blockReplyEnabled && !generatedProgress && ShouldSuppressInitialBlockReply(chatBehavior, streaming) {
return
}
+33 -31
View File
@@ -177,37 +177,39 @@ func init() {
MsgInvalidProviderOrModel: "invalid provider or model",
// Builtin tool descriptions
MsgToolReadFile: "Read the contents of a file from the agent's workspace by path",
MsgToolWriteFile: "Write content to a file in the workspace, creating directories as needed",
MsgToolListFiles: "List files and directories in a given path within the workspace",
MsgToolEdit: "Apply targeted search-and-replace edits to existing files without rewriting the entire file",
MsgToolExec: "Execute a shell command in the workspace and return stdout/stderr",
MsgToolWebSearch: "Search the web for information using a search engine (Brave or DuckDuckGo)",
MsgToolWebFetch: "Fetch a web page or API endpoint and extract its text content",
MsgToolMemorySearch: "Search through the agent's long-term memory using semantic similarity",
MsgToolMemoryGet: "Retrieve a specific memory document by its file path",
MsgToolKGSearch: "Search entities, relationships, and observations in the agent's knowledge graph",
MsgToolReadImage: "Analyze images using a vision-capable LLM provider",
MsgToolReadDocument: "Analyze documents (PDF, Word, Excel, PowerPoint, CSV, etc.) using a document-capable LLM provider",
MsgToolCreateImage: "Generate images from text prompts using an image generation provider",
MsgToolReadAudio: "Analyze audio files (speech, music, sounds) using an audio-capable LLM provider",
MsgToolReadVideo: "Analyze video files using a video-capable LLM provider",
MsgToolCreateVideo: "Generate videos from text descriptions using AI",
MsgToolCreateAudio: "Generate music or sound effects from text descriptions using AI",
MsgToolTTS: "Convert text to natural-sounding speech audio",
MsgToolBrowser: "Automate browser interactions: navigate pages, click elements, fill forms, take screenshots",
MsgToolSessionsList: "List active chat sessions across all channels",
MsgToolSessionStatus: "Get the current status and metadata of a specific chat session",
MsgToolSessionsHistory: "Retrieve the message history of a specific chat session",
MsgToolSessionsSend: "Send a message to an active chat session on behalf of the agent",
MsgToolMessage: "Send a proactive message to a user on a connected channel (Telegram, Discord, etc.)",
MsgToolCron: "Schedule or manage recurring tasks using cron expressions, at-times, or intervals",
MsgToolSpawn: "Spawn a subagent for background work or delegate a task to a linked agent",
MsgToolSkillSearch: "Search for available skills by keyword or description to find relevant capabilities",
MsgToolUseSkill: "Activate a skill to use its specialized capabilities (tracing marker)",
MsgToolSkillManage: "Create, patch, or delete skills from conversation experience",
MsgToolPublishSkill: "Register a skill directory in the system database, making it discoverable",
MsgToolTeamTasks: "View, create, update, and complete tasks on the team task board",
MsgToolReadFile: "Read the contents of a file from the agent's workspace by path",
MsgToolWriteFile: "Write content to a file in the workspace, creating directories as needed",
MsgToolListFiles: "List files and directories in a given path within the workspace",
MsgToolEdit: "Apply targeted search-and-replace edits to existing files without rewriting the entire file",
MsgToolExec: "Execute a shell command in the workspace and return stdout/stderr",
MsgToolWebSearch: "Search the web for information using a search engine (Brave or DuckDuckGo)",
MsgToolWebFetch: "Fetch a web page or API endpoint and extract its text content",
MsgToolMemorySearch: "Search through the agent's long-term memory using semantic similarity",
MsgToolMemoryGet: "Retrieve a specific memory document by its file path",
MsgToolKGSearch: "Search entities, relationships, and observations in the agent's knowledge graph",
MsgToolReadImage: "Analyze images using a vision-capable LLM provider",
MsgToolReadDocument: "Analyze documents (PDF, Word, Excel, PowerPoint, CSV, etc.) using a document-capable LLM provider",
MsgToolCreateImage: "Generate images from text prompts using an image generation provider",
MsgToolReadAudio: "Analyze audio files (speech, music, sounds) using an audio-capable LLM provider",
MsgToolReadVideo: "Analyze video files using a video-capable LLM provider",
MsgToolCreateVideo: "Generate videos from text descriptions using AI",
MsgToolCreateAudio: "Generate music or sound effects from text descriptions using AI",
MsgToolTTS: "Convert text to natural-sounding speech audio",
MsgToolBrowser: "Automate browser interactions: navigate pages, click elements, fill forms, take screenshots",
MsgToolSessionsList: "List active chat sessions across all channels",
MsgToolSessionStatus: "Get the current status and metadata of a specific chat session",
MsgToolSessionsHistory: "Retrieve the message history of a specific chat session",
MsgToolSessionsSend: "Send a message to an active chat session on behalf of the agent",
MsgToolMessage: "Send a proactive message to a user on a connected channel (Telegram, Discord, etc.)",
MsgToolCron: "Schedule or manage recurring tasks using cron expressions, at-times, or intervals",
MsgToolSpawn: "Spawn a subagent for background work or delegate a task to a linked agent",
MsgToolSkillSearch: "Search for available skills by keyword or description to find relevant capabilities",
MsgToolUseSkill: "Activate a skill to use its specialized capabilities (tracing marker)",
MsgToolSkillManage: "Create, patch, or delete skills from conversation experience",
MsgToolPublishSkill: "Register a skill directory in the system database, making it discoverable",
MsgToolTeamTasks: "View, create, update, and complete tasks on the team task board",
MsgToolAnnouncementSingle: "I'll use %s to handle the next step.",
MsgToolAnnouncementMulti: "I'll use %s to handle the next step.",
MsgSkillNudgePostscript: "This task involved several steps. Want me to save the process as a reusable skill? Reply **\"save as skill\"** or **\"skip\"**.",
MsgSkillNudge70Pct: "[System] You are at 70% of your iteration budget. Consider whether any patterns from this session would make a good skill.",
+33 -31
View File
@@ -177,37 +177,39 @@ func init() {
MsgInvalidProviderOrModel: "provider hoặc model không hợp lệ",
// Mô tả công cụ tích hợp
MsgToolReadFile: "Đọc nội dung tệp từ workspace của agent theo đường dẫn",
MsgToolWriteFile: "Ghi nội dung vào tệp trong workspace, tự động tạo thư mục nếu cần",
MsgToolListFiles: "Liệt kê tệp và thư mục trong đường dẫn chỉ định",
MsgToolEdit: "Chỉnh sửa tệp bằng cách tìm và thay thế đoạn văn bản cụ thể",
MsgToolExec: "Thực thi lệnh shell trong workspace và trả về kết quả",
MsgToolWebSearch: "Tìm kiếm thông tin trên web bằng công cụ tìm kiếm (Brave hoặc DuckDuckGo)",
MsgToolWebFetch: "Tải trang web hoặc API endpoint và trích xuất nội dung văn bản",
MsgToolMemorySearch: "Tìm kiếm trong bộ nhớ dài hạn của agent bằng độ tương đồng ngữ nghĩa",
MsgToolMemoryGet: "Lấy tài liệu bộ nhớ cụ thể theo đường dẫn tệp",
MsgToolKGSearch: "Tìm kiếm thực thể, quan hệ và ghi chú trong đồ thị tri thức của agent",
MsgToolReadImage: "Phân tích hình ảnh bằng nhà cung cấp LLM có khả năng nhìn",
MsgToolReadDocument: "Phân tích tài liệu (PDF, Word, Excel, PowerPoint, CSV, v.v.) bằng LLM",
MsgToolCreateImage: "Tạo hình ảnh từ mô tả văn bản bằng nhà cung cấp tạo ảnh AI",
MsgToolReadAudio: "Phân tích tệp âm thanh (giọng nói, nhạc, âm thanh) bằng LLM",
MsgToolReadVideo: "Phân tích tệp video bằng nhà cung cấp LLM có khả năng xử lý video",
MsgToolCreateVideo: "Tạo video từ mô tả văn bản bằng AI",
MsgToolCreateAudio: "Tạo nhạc hoặc hiệu ứng âm thanh từ mô tả văn bản bằng AI",
MsgToolTTS: "Chuyển văn bản thành giọng nói tự nhiên",
MsgToolBrowser: "Tự động hóa trình duyệt: điều hướng trang, click, điền form, chụp ảnh màn hình",
MsgToolSessionsList: "Liệt kê các phiên chat đang hoạt động trên tất cả kênh",
MsgToolSessionStatus: "Xem trạng thái và thông tin chi tiết của một phiên chat",
MsgToolSessionsHistory: "Xem lịch sử tin nhắn của một phiên chat cụ thể",
MsgToolSessionsSend: "Gửi tin nhắn vào một phiên chat đang hoạt động thay mặt agent",
MsgToolMessage: "Gửi tin nhắn chủ động đến người dùng trên kênh đã kết nối (Telegram, Discord, v.v.)",
MsgToolCron: "Lên lịch hoặc quản lý tác vụ định kỳ bằng biểu thức cron, giờ cố định, hoặc khoảng thời gian",
MsgToolSpawn: "Tạo subagent chạy nền hoặc giao việc cho agent đã liên kết",
MsgToolSkillSearch: "Tìm kiếm kỹ năng có sẵn theo từ khóa hoặc mô tả",
MsgToolUseSkill: "Kích hoạt kỹ năng để sử dụng khả năng chuyên biệt (đánh dấu tracing)",
MsgToolSkillManage: "Tạo, sửa hoặc xóa kỹ năng từ trải nghiệm hội thoại",
MsgToolPublishSkill: "Đăng ký thư mục kỹ năng vào hệ thống, cho phép tìm kiếm và cấp quyền",
MsgToolTeamTasks: "Xem, tạo, cập nhật và hoàn thành tác vụ trên bảng tác vụ nhóm",
MsgToolReadFile: "Đọc nội dung tệp từ workspace của agent theo đường dẫn",
MsgToolWriteFile: "Ghi nội dung vào tệp trong workspace, tự động tạo thư mục nếu cần",
MsgToolListFiles: "Liệt kê tệp và thư mục trong đường dẫn chỉ định",
MsgToolEdit: "Chỉnh sửa tệp bằng cách tìm và thay thế đoạn văn bản cụ thể",
MsgToolExec: "Thực thi lệnh shell trong workspace và trả về kết quả",
MsgToolWebSearch: "Tìm kiếm thông tin trên web bằng công cụ tìm kiếm (Brave hoặc DuckDuckGo)",
MsgToolWebFetch: "Tải trang web hoặc API endpoint và trích xuất nội dung văn bản",
MsgToolMemorySearch: "Tìm kiếm trong bộ nhớ dài hạn của agent bằng độ tương đồng ngữ nghĩa",
MsgToolMemoryGet: "Lấy tài liệu bộ nhớ cụ thể theo đường dẫn tệp",
MsgToolKGSearch: "Tìm kiếm thực thể, quan hệ và ghi chú trong đồ thị tri thức của agent",
MsgToolReadImage: "Phân tích hình ảnh bằng nhà cung cấp LLM có khả năng nhìn",
MsgToolReadDocument: "Phân tích tài liệu (PDF, Word, Excel, PowerPoint, CSV, v.v.) bằng LLM",
MsgToolCreateImage: "Tạo hình ảnh từ mô tả văn bản bằng nhà cung cấp tạo ảnh AI",
MsgToolReadAudio: "Phân tích tệp âm thanh (giọng nói, nhạc, âm thanh) bằng LLM",
MsgToolReadVideo: "Phân tích tệp video bằng nhà cung cấp LLM có khả năng xử lý video",
MsgToolCreateVideo: "Tạo video từ mô tả văn bản bằng AI",
MsgToolCreateAudio: "Tạo nhạc hoặc hiệu ứng âm thanh từ mô tả văn bản bằng AI",
MsgToolTTS: "Chuyển văn bản thành giọng nói tự nhiên",
MsgToolBrowser: "Tự động hóa trình duyệt: điều hướng trang, click, điền form, chụp ảnh màn hình",
MsgToolSessionsList: "Liệt kê các phiên chat đang hoạt động trên tất cả kênh",
MsgToolSessionStatus: "Xem trạng thái và thông tin chi tiết của một phiên chat",
MsgToolSessionsHistory: "Xem lịch sử tin nhắn của một phiên chat cụ thể",
MsgToolSessionsSend: "Gửi tin nhắn vào một phiên chat đang hoạt động thay mặt agent",
MsgToolMessage: "Gửi tin nhắn chủ động đến người dùng trên kênh đã kết nối (Telegram, Discord, v.v.)",
MsgToolCron: "Lên lịch hoặc quản lý tác vụ định kỳ bằng biểu thức cron, giờ cố định, hoặc khoảng thời gian",
MsgToolSpawn: "Tạo subagent chạy nền hoặc giao việc cho agent đã liên kết",
MsgToolSkillSearch: "Tìm kiếm kỹ năng có sẵn theo từ khóa hoặc mô tả",
MsgToolUseSkill: "Kích hoạt kỹ năng để sử dụng khả năng chuyên biệt (đánh dấu tracing)",
MsgToolSkillManage: "Tạo, sửa hoặc xóa kỹ năng từ trải nghiệm hội thoại",
MsgToolPublishSkill: "Đăng ký thư mục kỹ năng vào hệ thống, cho phép tìm kiếm và cấp quyền",
MsgToolTeamTasks: "Xem, tạo, cập nhật và hoàn thành tác vụ trên bảng tác vụ nhóm",
MsgToolAnnouncementSingle: "Tôi sẽ dùng %s để xử lý bước tiếp theo.",
MsgToolAnnouncementMulti: "Tôi sẽ dùng %s để xử lý bước tiếp theo.",
MsgSkillNudgePostscript: "Tác vụ này cần nhiều bước. Bạn muốn tôi lưu quy trình này thành kỹ năng tái sử dụng không? Trả lời **\"lưu kỹ năng\"** hoặc **\"bỏ qua\"**.",
MsgSkillNudge70Pct: "[System] Bạn đã dùng 70% ngân sách vòng lặp. Cân nhắc xem các mẫu trong phiên này có nên lưu thành kỹ năng không.",
+33 -31
View File
@@ -177,37 +177,39 @@ func init() {
MsgInvalidProviderOrModel: "provider 或 model 无效",
// 内置工具描述
MsgToolReadFile: "按路径读取代理工作区中的文件内容",
MsgToolWriteFile: "将内容写入工作区中的文件,自动创建所需目录",
MsgToolListFiles: "列出工作区指定路径中的文件和目录",
MsgToolEdit: "通过查找和替换对现有文件进行定向编辑,无需重写整个文件",
MsgToolExec: "在工作区中执行 shell 命令并返回标准输出/错误",
MsgToolWebSearch: "使用搜索引擎(Brave 或 DuckDuckGo)在网络上搜索信息",
MsgToolWebFetch: "获取网页或 API 端点并提取其文本内容",
MsgToolMemorySearch: "使用语义相似度搜索代理的长期记忆",
MsgToolMemoryGet: "按文件路径检索特定的记忆文档",
MsgToolKGSearch: "搜索代理知识图谱中的实体、关系和观察记录",
MsgToolReadImage: "使用具有视觉能力的 LLM 提供商分析图像",
MsgToolReadDocument: "使用 LLM 分析文档(PDF、Word、Excel、PowerPoint、CSV 等)",
MsgToolCreateImage: "使用 AI 图像生成提供商从文本提示生成图像",
MsgToolReadAudio: "使用具有音频能力的 LLM 分析音频文件(语音、音乐、声音)",
MsgToolReadVideo: "使用具有视频能力的 LLM 分析视频文件",
MsgToolCreateVideo: "使用 AI 从文本描述生成视频",
MsgToolCreateAudio: "使用 AI 从文本描述生成音乐或音效",
MsgToolTTS: "将文本转换为自然语音音频",
MsgToolBrowser: "自动化浏览器交互:导航页面、点击元素、填写表单、截图",
MsgToolSessionsList: "列出所有渠道中的活跃聊天会话",
MsgToolSessionStatus: "获取特定聊天会话的当前状态和元数据",
MsgToolSessionsHistory: "检索特定聊天会话的消息历史",
MsgToolSessionsSend: "代理代表向活跃聊天会话发送消息",
MsgToolMessage: "在已连接的渠道(Telegram、Discord 等)上向用户主动发送消息",
MsgToolCron: "使用 cron 表达式、定时或间隔来调度或管理定期任务",
MsgToolSpawn: "创建子代理执行后台工作或将任务委派给已链接的代理",
MsgToolSkillSearch: "按关键字或描述搜索可用技能以查找相关功能",
MsgToolUseSkill: "激活技能以使用其专门功能(追踪标记)",
MsgToolSkillManage: "从对话经验中创建、修补或删除技能",
MsgToolPublishSkill: "将技能目录注册到系统数据库中,使其可被发现和授权",
MsgToolTeamTasks: "查看、创建、更新和完成团队任务板上的任务",
MsgToolReadFile: "按路径读取代理工作区中的文件内容",
MsgToolWriteFile: "将内容写入工作区中的文件,自动创建所需目录",
MsgToolListFiles: "列出工作区指定路径中的文件和目录",
MsgToolEdit: "通过查找和替换对现有文件进行定向编辑,无需重写整个文件",
MsgToolExec: "在工作区中执行 shell 命令并返回标准输出/错误",
MsgToolWebSearch: "使用搜索引擎(Brave 或 DuckDuckGo)在网络上搜索信息",
MsgToolWebFetch: "获取网页或 API 端点并提取其文本内容",
MsgToolMemorySearch: "使用语义相似度搜索代理的长期记忆",
MsgToolMemoryGet: "按文件路径检索特定的记忆文档",
MsgToolKGSearch: "搜索代理知识图谱中的实体、关系和观察记录",
MsgToolReadImage: "使用具有视觉能力的 LLM 提供商分析图像",
MsgToolReadDocument: "使用 LLM 分析文档(PDF、Word、Excel、PowerPoint、CSV 等)",
MsgToolCreateImage: "使用 AI 图像生成提供商从文本提示生成图像",
MsgToolReadAudio: "使用具有音频能力的 LLM 分析音频文件(语音、音乐、声音)",
MsgToolReadVideo: "使用具有视频能力的 LLM 分析视频文件",
MsgToolCreateVideo: "使用 AI 从文本描述生成视频",
MsgToolCreateAudio: "使用 AI 从文本描述生成音乐或音效",
MsgToolTTS: "将文本转换为自然语音音频",
MsgToolBrowser: "自动化浏览器交互:导航页面、点击元素、填写表单、截图",
MsgToolSessionsList: "列出所有渠道中的活跃聊天会话",
MsgToolSessionStatus: "获取特定聊天会话的当前状态和元数据",
MsgToolSessionsHistory: "检索特定聊天会话的消息历史",
MsgToolSessionsSend: "代理代表向活跃聊天会话发送消息",
MsgToolMessage: "在已连接的渠道(Telegram、Discord 等)上向用户主动发送消息",
MsgToolCron: "使用 cron 表达式、定时或间隔来调度或管理定期任务",
MsgToolSpawn: "创建子代理执行后台工作或将任务委派给已链接的代理",
MsgToolSkillSearch: "按关键字或描述搜索可用技能以查找相关功能",
MsgToolUseSkill: "激活技能以使用其专门功能(追踪标记)",
MsgToolSkillManage: "从对话经验中创建、修补或删除技能",
MsgToolPublishSkill: "将技能目录注册到系统数据库中,使其可被发现和授权",
MsgToolTeamTasks: "查看、创建、更新和完成团队任务板上的任务",
MsgToolAnnouncementSingle: "我将使用 %s 处理下一步。",
MsgToolAnnouncementMulti: "我将使用 %s 处理下一步。",
MsgSkillNudgePostscript: "此任务涉及多个步骤。要我将此过程保存为可重用技能吗?回复 **\"保存技能\"** 或 **\"跳过\"**。",
MsgSkillNudge70Pct: "[System] 您已使用 70% 的迭代预算。请考虑本次会话中的模式是否值得保存为技能。",
+4
View File
@@ -252,6 +252,10 @@ const (
MsgSkillNudge70Pct = "skill.nudge_70_pct"
MsgSkillNudge90Pct = "skill.nudge_90_pct"
// Tool progress announcements (user-facing)
MsgToolAnnouncementSingle = "progress.tool_announcement.single" // "I'll use %s to handle the next step."
MsgToolAnnouncementMulti = "progress.tool_announcement.multi" // "I'll use %s to handle the next step."
// --- Tenants ---
MsgInvalidRole = "error.invalid_role" // "invalid role: allowed values are owner, admin, operator, member, viewer"
+5 -4
View File
@@ -61,10 +61,11 @@ type PipelineDeps struct {
InjectReminders func(ctx context.Context, input *RunInput, msgs []providers.Message) []providers.Message
// Think callbacks (ThinkStage)
BuildFilteredTools func(state *RunState) ([]providers.ToolDefinition, error)
CallLLM func(ctx context.Context, state *RunState, req providers.ChatRequest) (*providers.ChatResponse, error)
UniqueToolCallIDs func(calls []providers.ToolCall, runID string, iteration int) []providers.ToolCall
EmitBlockReply func(content string) // emit block.reply for intermediate assistant content
BuildFilteredTools func(state *RunState) ([]providers.ToolDefinition, error)
CallLLM func(ctx context.Context, state *RunState, req providers.ChatRequest) (*providers.ChatResponse, error)
UniqueToolCallIDs func(calls []providers.ToolCall, runID string, iteration int) []providers.ToolCall
EmitBlockReply func(content string) // emit block.reply for intermediate assistant content
EmitBlockReplyWithSource func(content, source string) // emit block.reply with payload source metadata
// Prune callbacks (PruneStage)
PruneMessages func(msgs []providers.Message, budget int) ([]providers.Message, PruneStats)
+106
View File
@@ -17,7 +17,9 @@ import (
"github.com/nextlevelbuilder/goclaw/internal/config"
"github.com/nextlevelbuilder/goclaw/internal/hooks"
"github.com/nextlevelbuilder/goclaw/internal/providers"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/internal/workspace"
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
)
// --- shared test helpers ---
@@ -123,6 +125,110 @@ func TestThinkStage_WithToolCalls_ReturnsContinue(t *testing.T) {
}
}
func TestThinkStage_EmptyToolCallContent_EmitsToolAnnouncement(t *testing.T) {
t.Parallel()
var gotContent, gotSource string
deps := &PipelineDeps{
Config: PipelineConfig{MaxIterations: 10, MaxTokens: 1000},
CallLLM: func(_ context.Context, _ *RunState, _ providers.ChatRequest) (*providers.ChatResponse, error) {
return &providers.ChatResponse{
FinishReason: "tool_calls",
ToolCalls: []providers.ToolCall{{
ID: "tc1",
Name: "search\n`secret`",
Arguments: map[string]any{"api_key": "must-not-leak"},
}},
}, nil
},
EmitBlockReplyWithSource: func(content, source string) {
gotContent = content
gotSource = source
},
}
stage := NewThinkStage(deps)
state := defaultState()
err := stage.Execute(context.Background(), state)
if err != nil {
t.Fatalf("Execute() error: %v", err)
}
if gotSource != protocol.BlockReplySourceToolAnnouncement {
t.Fatalf("block reply source = %q, want %q", gotSource, protocol.BlockReplySourceToolAnnouncement)
}
if !strings.Contains(gotContent, "search secret") {
t.Fatalf("announcement content = %q, want sanitized tool name", gotContent)
}
if strings.Contains(gotContent, "must-not-leak") || strings.Contains(gotContent, "api_key") {
t.Fatalf("announcement leaked tool arguments: %q", gotContent)
}
}
func TestThinkStage_GeneratedToolCallContent_AppendsMissingToolAnnouncement(t *testing.T) {
t.Parallel()
var gotContent, gotSource string
deps := &PipelineDeps{
Config: PipelineConfig{MaxIterations: 10, MaxTokens: 1000},
CallLLM: func(_ context.Context, _ *RunState, _ providers.ChatRequest) (*providers.ChatResponse, error) {
return &providers.ChatResponse{
Content: "Tôi sẽ tìm trong kho kỹ năng trước.",
FinishReason: "tool_calls",
ToolCalls: []providers.ToolCall{{ID: "tc1", Name: "skill_search"}},
}, nil
},
EmitBlockReplyWithSource: func(content, source string) {
gotContent = content
gotSource = source
},
}
stage := NewThinkStage(deps)
state := defaultState()
ctx := store.WithLocale(context.Background(), "vi")
err := stage.Execute(ctx, state)
if err != nil {
t.Fatalf("Execute() error: %v", err)
}
want := "Tôi sẽ tìm trong kho kỹ năng trước.\n\nTôi sẽ dùng `skill_search` để xử lý bước tiếp theo."
if gotContent != want {
t.Fatalf("block reply content = %q", gotContent)
}
if gotSource != protocol.BlockReplySourceToolAnnouncement {
t.Fatalf("block reply source = %q, want %q", gotSource, protocol.BlockReplySourceToolAnnouncement)
}
}
func TestThinkStage_GeneratedToolCallContent_DoesNotDuplicateNamedTool(t *testing.T) {
t.Parallel()
var gotContent, gotSource string
deps := &PipelineDeps{
Config: PipelineConfig{MaxIterations: 10, MaxTokens: 1000},
CallLLM: func(_ context.Context, _ *RunState, _ providers.ChatRequest) (*providers.ChatResponse, error) {
return &providers.ChatResponse{
Content: "Tôi sẽ dùng `skill_search` để tìm capability phù hợp trước.",
FinishReason: "tool_calls",
ToolCalls: []providers.ToolCall{{ID: "tc1", Name: "skill_search"}},
}, nil
},
EmitBlockReplyWithSource: func(content, source string) {
gotContent = content
gotSource = source
},
}
stage := NewThinkStage(deps)
state := defaultState()
err := stage.Execute(context.Background(), state)
if err != nil {
t.Fatalf("Execute() error: %v", err)
}
if gotContent != "Tôi sẽ dùng `skill_search` để tìm capability phù hợp trước." {
t.Fatalf("block reply content = %q", gotContent)
}
if gotSource != protocol.BlockReplySourceToolAnnouncement {
t.Fatalf("block reply source = %q, want %q", gotSource, protocol.BlockReplySourceToolAnnouncement)
}
}
func TestThinkStage_Truncation_FirstRetry_AppendsContinueMessage(t *testing.T) {
t.Parallel()
deps := &PipelineDeps{
+93 -6
View File
@@ -6,7 +6,10 @@ import (
"log/slog"
"strings"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
"github.com/nextlevelbuilder/goclaw/internal/providers"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
)
const maxTruncRetries = 3
@@ -23,7 +26,7 @@ func NewThinkStage(deps *PipelineDeps) *ThinkStage {
return &ThinkStage{deps: deps, result: Continue}
}
func (s *ThinkStage) Name() string { return "think" }
func (s *ThinkStage) Name() string { return "think" }
func (s *ThinkStage) Result() StageResult { return s.result }
// Execute builds tools, calls LLM, handles truncation, sets flow control.
@@ -145,15 +148,99 @@ func (s *ThinkStage) Execute(ctx context.Context, state *RunState) error {
}
state.Messages.AppendPending(assistantMsg)
// Emit block.reply for intermediate assistant content during tool iterations.
// Non-streaming channels (Zalo, Discord, WhatsApp) need this for delivery.
if resp.Content != "" && s.deps.EmitBlockReply != nil {
s.deps.EmitBlockReply(resp.Content)
}
s.emitToolIterationBlockReply(ctx, resp)
return nil
}
func (s *ThinkStage) emitToolIterationBlockReply(ctx context.Context, resp *providers.ChatResponse) {
content := resp.Content
source := protocol.BlockReplySourceLLMProgress
announcement := defaultToolAnnouncement(ctx, resp.ToolCalls)
if strings.TrimSpace(content) == "" {
content = announcement
source = protocol.BlockReplySourceToolAnnouncement
} else if announcement != "" {
if !contentMentionsAnnouncedTools(content, resp.ToolCalls) {
content = strings.TrimRight(content, "\r\n\t ") + "\n\n" + announcement
}
source = protocol.BlockReplySourceToolAnnouncement
}
if strings.TrimSpace(content) == "" {
return
}
if s.deps.EmitBlockReplyWithSource != nil {
s.deps.EmitBlockReplyWithSource(content, source)
return
}
if s.deps.EmitBlockReply != nil {
s.deps.EmitBlockReply(content)
}
}
func defaultToolAnnouncement(ctx context.Context, calls []providers.ToolCall) string {
names := announcedToolNames(calls)
if len(names) == 0 {
return ""
}
list := strings.Join(names, ", ")
locale := store.LocaleFromContext(ctx)
if len(names) == 1 {
return i18n.T(locale, i18n.MsgToolAnnouncementSingle, list)
}
return i18n.T(locale, i18n.MsgToolAnnouncementMulti, list)
}
func announcedToolNames(calls []providers.ToolCall) []string {
names := sanitizedAnnouncementToolNames(calls)
for i, name := range names {
names[i] = "`" + name + "`"
}
return names
}
func contentMentionsAnnouncedTools(content string, calls []providers.ToolCall) bool {
names := sanitizedAnnouncementToolNames(calls)
if len(names) == 0 {
return false
}
lowerContent := strings.ToLower(content)
for _, name := range names {
if !strings.Contains(lowerContent, strings.ToLower(name)) {
return false
}
}
return true
}
func sanitizedAnnouncementToolNames(calls []providers.ToolCall) []string {
names := make([]string, 0, len(calls))
seen := make(map[string]struct{}, len(calls))
for _, call := range calls {
name := sanitizeToolAnnouncementName(call.Name)
if name == "" {
continue
}
if _, ok := seen[name]; ok {
continue
}
seen[name] = struct{}{}
names = append(names, name)
}
return names
}
func sanitizeToolAnnouncementName(name string) string {
name = strings.TrimSpace(name)
name = strings.NewReplacer("`", "", "\n", " ", "\r", " ", "\t", " ").Replace(name)
name = strings.Join(strings.Fields(name), " ")
runes := []rune(name)
if len(runes) > 64 {
name = string(runes[:64])
}
return name
}
// maybeInjectNudge injects iteration budget warnings at 70% and 90%.
func (s *ThinkStage) maybeInjectNudge(state *RunState) {
maxIter := s.deps.Config.MaxIterations
+39 -33
View File
@@ -2,23 +2,23 @@ package protocol
// WebSocket event names pushed from server to client.
const (
EventAgent = "agent"
EventChat = "chat"
EventHealth = "health"
EventCron = "cron"
EventHeartbeat = "heartbeat"
EventExecApprovalReq = "exec.approval.requested"
EventExecApprovalRes = "exec.approval.resolved"
EventPresence = "presence"
EventTick = "tick"
EventShutdown = "shutdown"
EventNodePairRequested = "node.pair.requested"
EventNodePairResolved = "node.pair.resolved"
EventDevicePairReq = "device.pair.requested"
EventDevicePairRes = "device.pair.resolved"
EventVoicewakeChanged = "voicewake.changed"
EventConnectChallenge = "connect.challenge"
EventTalkMode = "talk.mode"
EventAgent = "agent"
EventChat = "chat"
EventHealth = "health"
EventCron = "cron"
EventHeartbeat = "heartbeat"
EventExecApprovalReq = "exec.approval.requested"
EventExecApprovalRes = "exec.approval.resolved"
EventPresence = "presence"
EventTick = "tick"
EventShutdown = "shutdown"
EventNodePairRequested = "node.pair.requested"
EventNodePairResolved = "node.pair.resolved"
EventDevicePairReq = "device.pair.requested"
EventDevicePairRes = "device.pair.resolved"
EventVoicewakeChanged = "voicewake.changed"
EventConnectChallenge = "connect.challenge"
EventTalkMode = "talk.mode"
// Agent summoning events (predefined agent setup via LLM).
EventAgentSummoning = "agent.summoning"
@@ -38,19 +38,19 @@ const (
EventDelegationAnnounce = "delegation.announce"
// Team task lifecycle events.
EventTeamTaskClaimed = "team.task.claimed"
EventTeamTaskCancelled = "team.task.cancelled"
EventTeamTaskFailed = "team.task.failed"
EventTeamTaskReviewed = "team.task.reviewed"
EventTeamTaskApproved = "team.task.approved"
EventTeamTaskRejected = "team.task.rejected"
EventTeamTaskProgress = "team.task.progress"
EventTeamTaskCommented = "team.task.commented"
EventTeamTaskAssigned = "team.task.assigned"
EventTeamTaskDispatched = "team.task.dispatched"
EventTeamTaskUpdated = "team.task.updated"
EventTeamTaskDeleted = "team.task.deleted"
EventTeamTaskStale = "team.task.stale"
EventTeamTaskClaimed = "team.task.claimed"
EventTeamTaskCancelled = "team.task.cancelled"
EventTeamTaskFailed = "team.task.failed"
EventTeamTaskReviewed = "team.task.reviewed"
EventTeamTaskApproved = "team.task.approved"
EventTeamTaskRejected = "team.task.rejected"
EventTeamTaskProgress = "team.task.progress"
EventTeamTaskCommented = "team.task.commented"
EventTeamTaskAssigned = "team.task.assigned"
EventTeamTaskDispatched = "team.task.dispatched"
EventTeamTaskUpdated = "team.task.updated"
EventTeamTaskDeleted = "team.task.deleted"
EventTeamTaskStale = "team.task.stale"
EventTeamTaskAttachmentAdded = "team.task.attachment_added"
// Emitted when leader starts processing completed team task results (before announce run).
@@ -136,9 +136,15 @@ const (
AgentEventActivity = "activity" // agent phase transitions: thinking, tool_exec, compacting
)
// block.reply payload source values.
const (
BlockReplySourceLLMProgress = "llm_progress"
BlockReplySourceToolAnnouncement = "tool_announcement"
)
// Chat event subtypes (in payload.type)
const (
ChatEventChunk = "chunk"
ChatEventMessage = "message"
ChatEventThinking = "thinking"
ChatEventChunk = "chunk"
ChatEventMessage = "message"
ChatEventThinking = "thinking"
)
+1 -1
View File
@@ -348,7 +348,7 @@
"allow_from": { "label": "Allowed Users" },
"block_reply": {
"label": "Block Reply",
"help": "Deliver intermediate text during tool iterations"
"help": "Deliver tool announcements and intermediate text around tool iterations"
},
"domain": { "label": "Domain" },
"connection_mode": {
+2 -2
View File
@@ -124,8 +124,8 @@
"behavior.uxDescription": "High-impact toggles that change how the gateway interacts with users",
"behavior.toolStatusHint": "Show real-time tool execution status messages to users during agent runs.",
"behavior.toolStatusInfo": "Users will see live updates as the agent reads files, runs commands, and calls APIs.",
"behavior.blockReplyHint": "Send partial assistant text to channels while tools are being invoked.",
"behavior.blockReplyInfo": "Intermediate text is delivered to users during tool execution, not just the final response.",
"behavior.blockReplyHint": "Send tool announcements and partial assistant text to channels before or during tool calls.",
"behavior.blockReplyInfo": "Intermediate text is delivered before or during tool execution, not just the final response.",
"behavior.intentClassifyHint": "Classify user intent before routing to reduce unnecessary agent invocations.",
"behavior.intentClassifyInfo": "Agent will only be invoked when the classifier detects actionable intent.",
"behavior.chatTitle": "Human-like Channel Delivery",
@@ -33,9 +33,9 @@
"toolStatus": "Show tool name during execution",
"toolStatusHint": "Display the active tool name while the agent is running.",
"toolStatusInfo": "Tool names are shown in streaming preview while the agent runs.",
"blockReply": "Show intermediate text during tool calls",
"blockReplyHint": "Stream partial assistant text while tools are being invoked.",
"blockReplyInfo": "Intermediate text is streamed to users during tool execution.",
"blockReply": "Show intermediate text around tool calls",
"blockReplyHint": "Send tool announcements and partial assistant text before or while tools run.",
"blockReplyInfo": "Intermediate text is delivered to users before or during tool execution.",
"intentClassify": "Intent classification",
"intentClassifyHint": "Classify user intent before routing to the agent.",
"intentClassifyInfo": "Messages are classified to optimize agent routing and behavior."
+1 -1
View File
@@ -309,7 +309,7 @@
"media_max_mb": { "label": "Kích thước media tối đa (MB)" },
"link_preview": { "label": "Xem trước liên kết" },
"allow_from": { "label": "Người dùng được phép" },
"block_reply": { "label": "Phản hồi khối", "help": "Gửi văn bản trung gian trong quá trình lặp công cụ" },
"block_reply": { "label": "Phản hồi khối", "help": "Gửi thông báo dùng công cụ và văn bản trung gian quanh các vòng gọi công cụ" },
"domain": { "label": "Tên miền" },
"connection_mode": { "label": "Chế độ kết nối", "help": "WebSocket không cần IP công khai — chỉ kết nối ra ngoài" },
"webhook_port": { "label": "Cổng webhook", "help": "0 = chia sẻ cổng gateway chính (khuyến nghị)" },
+2 -2
View File
@@ -124,8 +124,8 @@
"behavior.uxDescription": "Các công tắc có tác động lớn thay đổi cách gateway tương tác với người dùng",
"behavior.toolStatusHint": "Hiện thông báo trạng thái thực thi công cụ theo thời gian thực cho người dùng trong quá trình chạy agent.",
"behavior.toolStatusInfo": "Người dùng sẽ thấy cập nhật trực tiếp khi agent đọc tệp, chạy lệnh và gọi API.",
"behavior.blockReplyHint": "Gửi văn bản phụ trợ cho kênh trong khi công cụ đang được gọi.",
"behavior.blockReplyInfo": "Văn bản trung gian được gửi cho người dùng trong quá trình thực thi công cụ.",
"behavior.blockReplyHint": "Gửi thông báo dùng công cụ và văn bản phụ trợ cho kênh trước hoặc trong khi gọi công cụ.",
"behavior.blockReplyInfo": "Văn bản trung gian được gửi cho người dùng trước hoặc trong quá trình thực thi công cụ.",
"behavior.intentClassifyHint": "Phân loại ý định người dùng trước khi định tuyến để giảm các lần gọi agent không cần thiết.",
"behavior.intentClassifyInfo": "Agent chỉ được gọi khi bộ phân loại phát hiện ý định có thể thực hiện.",
"behavior.chatTitle": "Gửi tin nhắn giống người hơn",
@@ -33,9 +33,9 @@
"toolStatus": "Hiện tên công cụ khi thực thi",
"toolStatusHint": "Hiển thị tên công cụ đang chạy khi agent hoạt động.",
"toolStatusInfo": "Tên công cụ được hiện khi agent đang chạy.",
"blockReply": "Hiện văn bản trung gian khi gọi công cụ",
"blockReplyHint": "Gửi nội dung phụ trợ khi công cụ đang được gọi.",
"blockReplyInfo": "Văn bản trung gian được gửi cho người dùng khi thực thi công cụ.",
"blockReply": "Hiện văn bản trung gian quanh lúc gọi công cụ",
"blockReplyHint": "Gửi thông báo dùng công cụ và nội dung phụ trợ trước hoặc trong khi chạy công cụ.",
"blockReplyInfo": "Văn bản trung gian được gửi cho người dùng trước hoặc trong khi thực thi công cụ.",
"intentClassify": "Phân loại ý định",
"intentClassifyHint": "Phân loại ý định người dùng trước khi chuyển tới agent.",
"intentClassifyInfo": "Tin nhắn được phân loại để tối ưu định tuyến agent."
+1 -1
View File
@@ -309,7 +309,7 @@
"media_max_mb": { "label": "最大媒体大小 (MB)" },
"link_preview": { "label": "链接预览" },
"allow_from": { "label": "允许的用户" },
"block_reply": { "label": "分块回复", "help": "在工具迭代期间发送中间文本" },
"block_reply": { "label": "分块回复", "help": "在工具迭代前后发送工具公告和中间文本" },
"domain": { "label": "域名" },
"connection_mode": { "label": "连接模式", "help": "WebSocket 无需公网 IP — 仅需出站连接" },
"webhook_port": { "label": "Webhook 端口", "help": "0 = 共享主网关端口(推荐)" },
+2 -2
View File
@@ -124,8 +124,8 @@
"behavior.uxDescription": "影响网关与用户交互方式的高影响开关",
"behavior.toolStatusHint": "在 Agent 运行期间向用户显示实时工具执行状态消息。",
"behavior.toolStatusInfo": "当 Agent 读取文件、运行命令和调用 API 时,用户将看到实时更新。",
"behavior.blockReplyHint": "在工具调用过程中向频道发送部分助手文本。",
"behavior.blockReplyInfo": "工具执行期间向用户发送中间文本,而非仅发送最终响应。",
"behavior.blockReplyHint": "在工具调用前或调用过程中向频道发送工具公告和部分助手文本。",
"behavior.blockReplyInfo": "工具执行前或执行期间向用户发送中间文本,而非仅发送最终响应。",
"behavior.intentClassifyHint": "在路由之前对用户意图进行分类,以减少不必要的 Agent 调用。",
"behavior.intentClassifyInfo": "仅当分类器检测到可执行意图时才调用 Agent。",
"behavior.chatTitle": "拟人化频道投递",
@@ -33,9 +33,9 @@
"toolStatus": "执行时显示工具名称",
"toolStatusHint": "Agent运行时显示当前工具名称。",
"toolStatusInfo": "Agent运行时在流式预览中显示工具名称。",
"blockReply": "工具调用时显示中间文本",
"blockReplyHint": "工具调用过程中流式传输部分助手文本。",
"blockReplyInfo": "工具执行期间向用户流式传输中间文本。",
"blockReply": "工具调用前后显示中间文本",
"blockReplyHint": "在工具运行前或运行时发送工具公告和部分助手文本。",
"blockReplyInfo": "工具执行前或执行期间向用户发送中间文本。",
"intentClassify": "意图分类",
"intentClassifyHint": "在路由到Agent之前分类用户意图。",
"intentClassifyInfo": "消息被分类以优化Agent路由和行为。"