mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-06-10 12:10:53 +00:00
9a9744077e
Major refactoring of the team system with multiple improvements:
## Removed legacy delegation tools
- Delete `delegate.go`, `delegate_async.go`, `delegate_sync.go`, `delegate_events.go`,
`delegate_policy.go`, `delegate_prep.go`, `delegate_state.go`, `delegate_search_tool.go`
- Delete `evaluate_loop_tool.go`, `handoff_tool.go`
- Remove all references and registrations from tool manager and policy
- Clean up TEAM_PLAYBOOK_IDEAS.md and TEAM_SYSTEM.md (moved to docs)
## Rename await_reply → ask_user
- Rename action `await_reply` → `ask_user`, `clear_followup` → `clear_ask_user`
- Rename functions `executeAwaitReply` → `executeAskUser`, `executeClearFollowup` → `executeClearAskUser`
- Update system prompt with stronger wording to prevent model misuse
- Model was confusing "await_reply" with general waiting; "ask_user" is unambiguous
## Fix auto-followup false positives
- Add `HasActiveMemberTasks(ctx, teamID, excludeAgentID)` store method
- Guard `autoSetFollowup()` in consumer: skip when lead has active member tasks
- Prevents auto-followup when lead is orchestrating teammates (not waiting for user)
## Task identifier zero-padding
- Change format from `T-1-xxxx` → `T-001-xxxx` (3-digit minimum)
## Refactor workspace WS handlers to filesystem-only
- Rewrite `teams.workspace.list/read/delete` to use pure filesystem (os.ReadDir/ReadFile/Remove)
- Remove DB dependency from workspace WS handlers
- Consistent with storage handler and workspace tools
- Simplify TeamWorkspaceFile type and frontend hook
## Add team events listing API
- New WS method `teams.events.list` with team_id, limit, offset params
- New HTTP endpoint `GET /v1/teams/{id}/events` with bearer auth
- New `ListTeamEvents(ctx, teamID, limit, offset)` store method
- JOIN with team_tasks for team-wide event filtering
## Extract team access policy
- New `team_access_policy.go` — centralized team tool access control
## Migration 000019: team_id columns
- Add team_id foreign key columns to relevant tables
## Other improvements
- Add team_id propagation through agent loop, tracing, sessions
- Update i18n locale files (en/vi/zh) for new tool labels
- Update frontend builtin-tools page and require-setup component
- Bump RequiredSchemaVersion for migration 000019
408 lines
12 KiB
Go
408 lines
12 KiB
Go
// Package agent — response sanitization pipeline.
|
|
//
|
|
// Matching TS sanitization chain:
|
|
//
|
|
// extractAssistantText() → per-block:
|
|
// 1. stripMinimaxToolCallXml() → Go: stripGarbledToolXML()
|
|
// 2. stripDowngradedToolCallText() → Go: stripDowngradedToolCallText()
|
|
// 3. stripThinkingTagsFromText() → Go: stripThinkingTags()
|
|
// then:
|
|
// 4. sanitizeUserFacingText() → Go: sanitizeUserFacingText()
|
|
// - stripFinalTagsFromText() → Go: stripFinalTags()
|
|
// - collapseConsecutiveDuplicateBlocks()
|
|
//
|
|
// Additional Go-specific:
|
|
// 5. stripEchoedSystemMessages() → strip hallucinated [System Message] blocks
|
|
// 6. stripGarbledToolXML() → strip garbled XML from models like DeepSeek
|
|
package agent
|
|
|
|
import (
|
|
"log/slog"
|
|
"regexp"
|
|
"strings"
|
|
)
|
|
|
|
// SanitizeAssistantContent applies the full sanitization pipeline to assistant
|
|
// response text before saving to session and sending to user.
|
|
// Matching TS extractAssistantText() + sanitizeUserFacingText().
|
|
func SanitizeAssistantContent(content string) string {
|
|
if content == "" {
|
|
return content
|
|
}
|
|
|
|
original := content
|
|
|
|
// 1. Strip garbled tool-call XML (DeepSeek, GLM, Minimax)
|
|
content = stripGarbledToolXML(content)
|
|
if content == "" {
|
|
return ""
|
|
}
|
|
|
|
// 2. Strip downgraded tool call text ([Tool Call: ...], [Tool Result ...])
|
|
content = stripDowngradedToolCallText(content)
|
|
|
|
// 3. Strip thinking/reasoning tags (<think>, <thinking>, <thought>, <antThinking>)
|
|
content = stripThinkingTags(content)
|
|
|
|
// 4. Strip <final> tags (keep content inside)
|
|
content = stripFinalTags(content)
|
|
|
|
// 5. Strip echoed [System Message] blocks
|
|
content = stripEchoedSystemMessages(content)
|
|
|
|
// 6. Collapse consecutive duplicate blocks
|
|
content = collapseConsecutiveDuplicateBlocks(content)
|
|
|
|
// 7. Strip MEDIA: paths from LLM output (media delivered separately)
|
|
content = stripMediaPaths(content)
|
|
|
|
// 8. Strip leading blank lines (preserve indentation)
|
|
content = stripLeadingBlankLines(content)
|
|
|
|
content = strings.TrimSpace(content)
|
|
|
|
if content != original {
|
|
slog.Debug("sanitized assistant content",
|
|
"original_len", len(original),
|
|
"cleaned_len", len(content),
|
|
)
|
|
}
|
|
|
|
return content
|
|
}
|
|
|
|
// --- 1. Garbled tool-call XML ---
|
|
|
|
// garbledToolXMLPattern matches XML-like tool call artifacts that some models
|
|
// (DeepSeek, GLM, etc.) emit as text content instead of proper tool calls.
|
|
var garbledToolXMLPattern = regexp.MustCompile(
|
|
`(?s)</?(?:function_calls?|functioninvoke|invoke|invfunction_calls|tool_call|tool_use|parameter|minimax:tool_call)[^>]*>`,
|
|
)
|
|
|
|
var garbledToolXMLIndicators = []string{
|
|
"invfunction_calls",
|
|
"functioninvoke",
|
|
"<parameter name=",
|
|
"</parameter",
|
|
"<function_call",
|
|
"<tool_call",
|
|
"<tool_use",
|
|
"<minimax:tool_call",
|
|
}
|
|
|
|
func stripGarbledToolXML(content string) string {
|
|
hasIndicator := false
|
|
lower := strings.ToLower(content)
|
|
for _, ind := range garbledToolXMLIndicators {
|
|
if strings.Contains(lower, strings.ToLower(ind)) {
|
|
hasIndicator = true
|
|
break
|
|
}
|
|
}
|
|
if !hasIndicator {
|
|
return content
|
|
}
|
|
|
|
cleaned := garbledToolXMLPattern.ReplaceAllString(content, "")
|
|
cleaned = strings.TrimSpace(cleaned)
|
|
|
|
if cleaned == "" {
|
|
slog.Warn("stripped entire response as garbled tool XML", "original_len", len(content))
|
|
return ""
|
|
}
|
|
|
|
slog.Warn("stripped garbled tool call XML from response",
|
|
"original_len", len(content),
|
|
"remaining_len", len(cleaned),
|
|
)
|
|
return cleaned
|
|
}
|
|
|
|
// --- 2. Downgraded tool call text ---
|
|
|
|
// stripDowngradedToolCallText removes [Tool Call: ...], [Tool Result ...],
|
|
// and [Historical context: ...] blocks that some models emit as text.
|
|
// Matching TS stripDowngradedToolCallText().
|
|
// Uses line-by-line scanning (Go regexp doesn't support lookahead).
|
|
func stripDowngradedToolCallText(content string) string {
|
|
if !strings.Contains(content, "[Tool Call:") &&
|
|
!strings.Contains(content, "[Tool Result") &&
|
|
!strings.Contains(content, "[Historical context:") {
|
|
return content
|
|
}
|
|
|
|
lines := strings.Split(content, "\n")
|
|
var result []string
|
|
skipping := false
|
|
|
|
for _, line := range lines {
|
|
trimmed := strings.TrimSpace(line)
|
|
|
|
// Start skipping on these markers
|
|
if strings.HasPrefix(trimmed, "[Tool Call:") ||
|
|
strings.HasPrefix(trimmed, "[Tool Result") ||
|
|
strings.HasPrefix(trimmed, "[Historical context:") {
|
|
skipping = true
|
|
continue
|
|
}
|
|
|
|
// Stop skipping on non-indented, non-empty line that isn't part of the block
|
|
if skipping {
|
|
// Arguments JSON and tool output are typically indented or empty
|
|
if trimmed == "" || strings.HasPrefix(trimmed, "Arguments:") ||
|
|
strings.HasPrefix(trimmed, "{") || strings.HasPrefix(trimmed, "}") {
|
|
continue
|
|
}
|
|
// Non-tool-block line → stop skipping
|
|
skipping = false
|
|
}
|
|
|
|
result = append(result, line)
|
|
}
|
|
|
|
return strings.TrimSpace(strings.Join(result, "\n"))
|
|
}
|
|
|
|
// --- 3. Thinking/reasoning tags ---
|
|
|
|
// Matches TS stripThinkingTagsFromText() with strict mode.
|
|
// Strips: <think>...</think>, <thinking>...</thinking>, <thought>...</thought>,
|
|
// <antThinking>...</antThinking>
|
|
// Go regexp doesn't support backreferences, so we use separate patterns.
|
|
var thinkingTagPatterns = []*regexp.Regexp{
|
|
regexp.MustCompile(`(?is)<think>.*?</think>`),
|
|
regexp.MustCompile(`(?is)<thinking>.*?</thinking>`),
|
|
regexp.MustCompile(`(?is)<thought>.*?</thought>`),
|
|
regexp.MustCompile(`(?is)<antThinking>.*?</antThinking>`),
|
|
regexp.MustCompile(`(?is)<antthinking>.*?</antthinking>`),
|
|
}
|
|
|
|
func stripThinkingTags(content string) string {
|
|
lower := strings.ToLower(content)
|
|
if !strings.Contains(lower, "<think") && !strings.Contains(lower, "<thought") &&
|
|
!strings.Contains(lower, "<antthinking") {
|
|
return content
|
|
}
|
|
result := content
|
|
for _, pat := range thinkingTagPatterns {
|
|
result = pat.ReplaceAllString(result, "")
|
|
}
|
|
return strings.TrimSpace(result)
|
|
}
|
|
|
|
// --- 4. <final> tags ---
|
|
|
|
// Matches TS stripFinalTagsFromText(). Removes <final> and </final> tags
|
|
// but keeps the content inside.
|
|
var finalTagPattern = regexp.MustCompile(`(?i)<\s*/?\s*final\s*>`)
|
|
|
|
func stripFinalTags(content string) string {
|
|
if !strings.Contains(strings.ToLower(content), "final") {
|
|
return content
|
|
}
|
|
return finalTagPattern.ReplaceAllString(content, "")
|
|
}
|
|
|
|
// --- 5. Echoed [System Message] ---
|
|
|
|
// stripEchoedSystemMessages removes "[System Message] ..." blocks that LLMs
|
|
// hallucinate/echo in their response text.
|
|
// Uses line-based scanning (Go regexp doesn't support lookahead).
|
|
func stripEchoedSystemMessages(content string) string {
|
|
if !strings.Contains(content, "[System Message]") {
|
|
return content
|
|
}
|
|
|
|
lines := strings.Split(content, "\n")
|
|
var result []string
|
|
skipping := false
|
|
|
|
for _, line := range lines {
|
|
if strings.HasPrefix(strings.TrimSpace(line), "[System Message]") {
|
|
skipping = true
|
|
continue
|
|
}
|
|
if skipping {
|
|
// Empty line ends the system message block
|
|
if strings.TrimSpace(line) == "" {
|
|
skipping = false
|
|
continue
|
|
}
|
|
// Still part of the system message block (Stats:, reply instructions, etc.)
|
|
continue
|
|
}
|
|
result = append(result, line)
|
|
}
|
|
|
|
cleaned := strings.TrimSpace(strings.Join(result, "\n"))
|
|
|
|
if cleaned != strings.TrimSpace(content) {
|
|
slog.Warn("stripped echoed [System Message] from assistant response",
|
|
"original_len", len(content),
|
|
"cleaned_len", len(cleaned),
|
|
)
|
|
}
|
|
|
|
return cleaned
|
|
}
|
|
|
|
// --- 6. Collapse consecutive duplicate blocks ---
|
|
|
|
// collapseConsecutiveDuplicateBlocks removes repeated paragraph blocks.
|
|
// Matching TS collapseConsecutiveDuplicateBlocks().
|
|
func collapseConsecutiveDuplicateBlocks(content string) string {
|
|
blocks := strings.Split(content, "\n\n")
|
|
if len(blocks) <= 1 {
|
|
return content
|
|
}
|
|
|
|
var result []string
|
|
for i, block := range blocks {
|
|
trimmed := strings.TrimSpace(block)
|
|
if trimmed == "" {
|
|
continue
|
|
}
|
|
if i > 0 && len(result) > 0 && trimmed == strings.TrimSpace(result[len(result)-1]) {
|
|
continue // skip duplicate
|
|
}
|
|
result = append(result, block)
|
|
}
|
|
|
|
collapsed := strings.Join(result, "\n\n")
|
|
if collapsed != content {
|
|
slog.Debug("collapsed duplicate blocks",
|
|
"original_blocks", len(blocks),
|
|
"result_blocks", len(result),
|
|
)
|
|
}
|
|
return collapsed
|
|
}
|
|
|
|
// --- 7. Strip MEDIA: paths ---
|
|
|
|
// mediaPathPattern matches "MEDIA:" followed by a path (absolute or relative).
|
|
var mediaPathPattern = regexp.MustCompile(`MEDIA:\S+`)
|
|
|
|
// stripMediaPaths removes lines containing MEDIA:/path references from LLM output.
|
|
// These are tool result artifacts that should not appear in user-facing text
|
|
// (media files are delivered separately via OutboundMessage.Media).
|
|
func stripMediaPaths(content string) string {
|
|
if !strings.Contains(content, "MEDIA:") {
|
|
return content
|
|
}
|
|
lines := strings.Split(content, "\n")
|
|
var result []string
|
|
for _, line := range lines {
|
|
trimmed := strings.TrimSpace(line)
|
|
if strings.HasPrefix(trimmed, "[[audio_as_voice]]") {
|
|
continue
|
|
}
|
|
// Strip any line containing a MEDIA: path reference, regardless of wrapping format.
|
|
// LLMs echo these in many forms: bare "MEDIA:/path", markdown "",
|
|
// JSON '{"image":"MEDIA:/path"}', etc. Match MEDIA: followed by any non-space path char.
|
|
if mediaPathPattern.MatchString(trimmed) {
|
|
continue
|
|
}
|
|
result = append(result, line)
|
|
}
|
|
return strings.TrimSpace(strings.Join(result, "\n"))
|
|
}
|
|
|
|
// --- 8. Strip leading blank lines ---
|
|
|
|
var leadingBlankLinesPattern = regexp.MustCompile(`^(?:[ \t]*\r?\n)+`)
|
|
|
|
func stripLeadingBlankLines(content string) string {
|
|
return leadingBlankLinesPattern.ReplaceAllString(content, "")
|
|
}
|
|
|
|
// --- 9. Config leak detection (predefined agents) ---
|
|
|
|
// configLeakFileNames are internal file names that should not appear in user-facing output
|
|
// when a predefined agent describes its procedures or configuration.
|
|
var configLeakFileNames = []string{
|
|
"SOUL.md", "IDENTITY.md", "AGENTS.md", "BOOTSTRAP.md",
|
|
"internal_config", "system prompt",
|
|
}
|
|
|
|
// Patterns to strip markdown code from content before config leak detection.
|
|
// Mentions inside code blocks/inline code are typically architecture docs, not leaks.
|
|
var fencedCodeBlockPattern = regexp.MustCompile("(?s)```[^`]*```")
|
|
var inlineCodePattern = regexp.MustCompile("`[^`\n]+`")
|
|
|
|
// stripMarkdownCode removes fenced code blocks and inline code from text.
|
|
func stripMarkdownCode(s string) string {
|
|
s = fencedCodeBlockPattern.ReplaceAllString(s, "")
|
|
s = inlineCodePattern.ReplaceAllString(s, "")
|
|
return s
|
|
}
|
|
|
|
// StripConfigLeak detects when a predefined agent dumps its internal configuration
|
|
// (e.g. referencing SOUL.md, AGENTS.md, IDENTITY.md) and replaces the entire
|
|
// response with a friendly decline.
|
|
//
|
|
// Only active for predefined agents. Single-gate detection:
|
|
// 3+ distinct internal file names mentioned in plain text → replace entire response.
|
|
// Mentions inside markdown code blocks and inline code are excluded from counting,
|
|
// as they typically appear in architecture explanations rather than actual leaks.
|
|
func StripConfigLeak(content, agentType string) string {
|
|
if agentType != "predefined" || content == "" {
|
|
return content
|
|
}
|
|
|
|
// Count hits only in plain text (outside code blocks/inline code)
|
|
plain := stripMarkdownCode(content)
|
|
|
|
hits := 0
|
|
for _, name := range configLeakFileNames {
|
|
if strings.Contains(plain, name) {
|
|
hits++
|
|
}
|
|
}
|
|
if hits < 3 {
|
|
return content
|
|
}
|
|
|
|
slog.Warn("security.config_leak_stripped",
|
|
"file_hits", hits,
|
|
"original_len", len(content),
|
|
)
|
|
|
|
return "🔒 Security check not passed."
|
|
}
|
|
|
|
// --- NO_REPLY detection ---
|
|
|
|
// IsSilentReply checks if the text is a NO_REPLY token.
|
|
// Matching TS isSilentReplyText() from auto-reply/tokens.ts.
|
|
func IsSilentReply(text string) bool {
|
|
trimmed := strings.TrimSpace(text)
|
|
if trimmed == "" {
|
|
return false
|
|
}
|
|
const token = "NO_REPLY"
|
|
// Exact match
|
|
if trimmed == token {
|
|
return true
|
|
}
|
|
// Starts with token followed by non-word char or end
|
|
if strings.HasPrefix(trimmed, token) {
|
|
rest := trimmed[len(token):]
|
|
if rest == "" || !isWordChar(rune(rest[0])) {
|
|
return true
|
|
}
|
|
}
|
|
// Ends with token preceded by non-word char
|
|
if strings.HasSuffix(trimmed, token) {
|
|
before := trimmed[:len(trimmed)-len(token)]
|
|
if before == "" || !isWordChar(rune(before[len(before)-1])) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func isWordChar(r rune) bool {
|
|
return (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_'
|
|
}
|