Files
goclaw/internal/agent/inject.go
T
viettranx 4c7db6e09b feat(agent): add mid-run message injection for DM and WebSocket
Inject user follow-up messages into the running agent loop at turn
boundaries instead of queueing them for a new run. This preserves
context so the LLM sees both tool results and user follow-ups together.

- Add InjectedMessage type and drainInjectChannel helper
- Add InjectCh to ActiveRun with buffered channel (cap=5)
- Drain injection channel at two points in agent loop (after tool
  results and before no-tool-calls exit)
- Route steer/new_task intents to InjectMessage with scheduler fallback
- WebSocket: inject into running loop when session is busy
- Remove IntentClassify config toggle (always on)
- Web UI: show send + stop buttons side by side during agent run
- i18n: add injection acknowledgment messages (en/vi/zh)
2026-03-13 11:55:55 +07:00

109 lines
3.2 KiB
Go

package agent
import (
"fmt"
"log/slog"
"strings"
"github.com/nextlevelbuilder/goclaw/internal/providers"
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
)
// InjectedMessage represents a user message injected into a running agent loop
// at the turn boundary (after tool results, before next LLM call).
type InjectedMessage struct {
Content string
UserID string
}
// processedInjection holds the two message forms: one for the LLM (with context wrapper)
// and one for session persistence (original content).
type processedInjection struct {
forLLM providers.Message // wrapped with "[User sent a follow-up...]" prefix
forSession providers.Message // original content for session history
}
// injectBufferSize is the capacity of the per-run injection channel.
const injectBufferSize = 5
// processInjectedMessage validates and wraps an injected message for the LLM.
// Returns nil, false if the message should be skipped (blocked by input guard).
func (l *Loop) processInjectedMessage(injected InjectedMessage, emitRun func(AgentEvent)) (*processedInjection, bool) {
// Security: scan injected content with input guard
if l.inputGuard != nil {
if matches := l.inputGuard.Scan(injected.Content); len(matches) > 0 {
matchStr := strings.Join(matches, ",")
if l.injectionAction == "block" {
slog.Warn("security.injection_blocked_midrun",
"agent", l.id, "user", injected.UserID,
"patterns", matchStr)
return nil, false
}
slog.Warn("security.injection_detected_midrun",
"agent", l.id, "user", injected.UserID,
"patterns", matchStr)
}
}
// Truncate oversized content
content := injected.Content
maxChars := l.maxMessageChars
if maxChars <= 0 {
maxChars = 32000
}
if len(content) > maxChars {
content = content[:maxChars] + "\n[Message truncated]"
}
// Wrap with context hint so LLM knows this is a mid-run follow-up
wrapped := fmt.Sprintf("[User sent a follow-up message while you were working]\n%s", content)
slog.Info("mid-run injection",
"agent", l.id, "user", injected.UserID,
"msg_len", len(content))
// Emit activity event so UI/channels know about the injection
if emitRun != nil {
emitRun(AgentEvent{
Type: protocol.AgentEventActivity,
AgentID: l.id,
Payload: map[string]any{
"phase": "injected_message",
"content": truncateForLog(content, 200),
},
})
}
return &processedInjection{
forLLM: providers.Message{Role: "user", Content: wrapped},
forSession: providers.Message{Role: "user", Content: content},
}, true
}
// drainInjectChannel reads all available messages from the injection channel
// without blocking. Returns processed messages ready to append to the loop.
func (l *Loop) drainInjectChannel(ch <-chan InjectedMessage, emitRun func(AgentEvent)) (forLLM, forSession []providers.Message) {
if ch == nil {
return nil, nil
}
for {
select {
case injected := <-ch:
if result, ok := l.processInjectedMessage(injected, emitRun); ok {
forLLM = append(forLLM, result.forLLM)
forSession = append(forSession, result.forSession)
}
default:
return forLLM, forSession
}
}
}
// truncateForLog truncates a string for log/event payloads.
func truncateForLog(s string, maxLen int) string {
if len(s) <= maxLen {
return s
}
return s[:maxLen] + "..."
}