mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-06-11 12:10:58 +00:00
4c7db6e09b
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)
303 lines
8.2 KiB
Go
303 lines
8.2 KiB
Go
package agent
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// ResolverFunc is called when an agent isn't found in the cache.
|
|
// Used to lazy-create agents from DB.
|
|
type ResolverFunc func(agentKey string) (Agent, error)
|
|
|
|
const defaultRouterTTL = 10 * time.Minute
|
|
|
|
// agentEntry wraps a cached Agent with a timestamp for TTL-based expiration.
|
|
type agentEntry struct {
|
|
agent Agent
|
|
cachedAt time.Time
|
|
}
|
|
|
|
// AgentActivityStatus tracks the current phase of a running agent for status queries.
|
|
type AgentActivityStatus struct {
|
|
RunID string
|
|
Phase string // "thinking", "tool_exec", "compacting"
|
|
Tool string // current tool name (when Phase == "tool_exec")
|
|
Iteration int
|
|
StartedAt time.Time
|
|
}
|
|
|
|
// Router manages multiple agent Loop instances.
|
|
// Each agent has a unique ID and its own provider/model/tools config.
|
|
// Cached Loops expire after TTL (safety net for multi-instance).
|
|
type Router struct {
|
|
agents map[string]*agentEntry
|
|
mu sync.RWMutex
|
|
activeRuns sync.Map // runID → *ActiveRun
|
|
sessionRuns sync.Map // sessionKey → runID (secondary index for O(1) IsSessionBusy)
|
|
agentActivity sync.Map // sessionKey → *AgentActivityStatus
|
|
resolver ResolverFunc // optional: lazy creation from DB
|
|
ttl time.Duration
|
|
}
|
|
|
|
func NewRouter() *Router {
|
|
return &Router{
|
|
agents: make(map[string]*agentEntry),
|
|
ttl: defaultRouterTTL,
|
|
}
|
|
}
|
|
|
|
// SetResolver sets a resolver function for lazy agent creation.
|
|
func (r *Router) SetResolver(fn ResolverFunc) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
r.resolver = fn
|
|
}
|
|
|
|
// Register adds an agent to the router.
|
|
func (r *Router) Register(ag Agent) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
r.agents[ag.ID()] = &agentEntry{agent: ag, cachedAt: time.Now()}
|
|
}
|
|
|
|
// Get returns an agent by ID. Lazy-creates from DB via resolver if needed.
|
|
// Cached entries expire after TTL as a safety net for multi-instance deployments.
|
|
func (r *Router) Get(agentID string) (Agent, error) {
|
|
r.mu.RLock()
|
|
entry, ok := r.agents[agentID]
|
|
resolver := r.resolver
|
|
r.mu.RUnlock()
|
|
|
|
if ok && (r.ttl == 0 || time.Since(entry.cachedAt) < r.ttl) {
|
|
return entry.agent, nil
|
|
}
|
|
|
|
// TTL expired → remove stale entry so resolver re-creates
|
|
if ok {
|
|
r.mu.Lock()
|
|
delete(r.agents, agentID)
|
|
r.mu.Unlock()
|
|
}
|
|
|
|
// Try resolver (create from DB)
|
|
if resolver != nil {
|
|
ag, err := resolver(agentID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
r.mu.Lock()
|
|
// Double-check: another goroutine might have created it
|
|
if existing, ok := r.agents[agentID]; ok {
|
|
r.mu.Unlock()
|
|
return existing.agent, nil
|
|
}
|
|
r.agents[agentID] = &agentEntry{agent: ag, cachedAt: time.Now()}
|
|
r.mu.Unlock()
|
|
return ag, nil
|
|
}
|
|
|
|
return nil, fmt.Errorf("agent not found: %s", agentID)
|
|
}
|
|
|
|
// Remove removes an agent from the router.
|
|
func (r *Router) Remove(agentID string) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
delete(r.agents, agentID)
|
|
}
|
|
|
|
// List returns all registered agent IDs.
|
|
func (r *Router) List() []string {
|
|
r.mu.RLock()
|
|
defer r.mu.RUnlock()
|
|
ids := make([]string, 0, len(r.agents))
|
|
for id := range r.agents {
|
|
ids = append(ids, id)
|
|
}
|
|
return ids
|
|
}
|
|
|
|
// AgentInfo is lightweight metadata about an agent.
|
|
type AgentInfo struct {
|
|
ID string `json:"id"`
|
|
Model string `json:"model"`
|
|
IsRunning bool `json:"isRunning"`
|
|
}
|
|
|
|
// ListInfo returns metadata for all agents.
|
|
func (r *Router) ListInfo() []AgentInfo {
|
|
r.mu.RLock()
|
|
defer r.mu.RUnlock()
|
|
infos := make([]AgentInfo, 0, len(r.agents))
|
|
for _, entry := range r.agents {
|
|
infos = append(infos, AgentInfo{
|
|
ID: entry.agent.ID(),
|
|
Model: entry.agent.Model(),
|
|
IsRunning: entry.agent.IsRunning(),
|
|
})
|
|
}
|
|
return infos
|
|
}
|
|
|
|
// IsRunning checks if a specific agent is currently running (cached in router).
|
|
func (r *Router) IsRunning(agentID string) bool {
|
|
r.mu.RLock()
|
|
defer r.mu.RUnlock()
|
|
if entry, ok := r.agents[agentID]; ok {
|
|
return entry.agent.IsRunning()
|
|
}
|
|
return false
|
|
}
|
|
|
|
// --- Active Run Tracking (matching TS chat-abort.ts) ---
|
|
|
|
// ActiveRun tracks a running agent invocation so it can be aborted via chat.abort
|
|
// and supports mid-run message injection via InjectCh.
|
|
type ActiveRun struct {
|
|
RunID string
|
|
SessionKey string
|
|
AgentID string
|
|
Cancel context.CancelFunc
|
|
StartedAt time.Time
|
|
InjectCh chan InjectedMessage // buffered channel for mid-run user message injection
|
|
}
|
|
|
|
// RegisterRun records an active run so it can be aborted later.
|
|
// Returns a receive-only channel for mid-run message injection.
|
|
func (r *Router) RegisterRun(runID, sessionKey, agentID string, cancel context.CancelFunc) <-chan InjectedMessage {
|
|
injectCh := make(chan InjectedMessage, injectBufferSize)
|
|
r.activeRuns.Store(runID, &ActiveRun{
|
|
RunID: runID,
|
|
SessionKey: sessionKey,
|
|
AgentID: agentID,
|
|
Cancel: cancel,
|
|
StartedAt: time.Now(),
|
|
InjectCh: injectCh,
|
|
})
|
|
r.sessionRuns.Store(sessionKey, runID)
|
|
return injectCh
|
|
}
|
|
|
|
// UnregisterRun removes a completed/cancelled run from tracking.
|
|
func (r *Router) UnregisterRun(runID string) {
|
|
if val, ok := r.activeRuns.Load(runID); ok {
|
|
run := val.(*ActiveRun)
|
|
r.sessionRuns.Delete(run.SessionKey)
|
|
}
|
|
r.activeRuns.Delete(runID)
|
|
}
|
|
|
|
// AbortRun cancels a single run by ID. sessionKey is validated for authorization
|
|
// (matching TS chat-abort.ts: verify sessionKey matches before aborting).
|
|
// Returns true if the run was found and cancelled.
|
|
func (r *Router) AbortRun(runID, sessionKey string) bool {
|
|
val, ok := r.activeRuns.Load(runID)
|
|
if !ok {
|
|
return false
|
|
}
|
|
run := val.(*ActiveRun)
|
|
|
|
// Authorization: sessionKey must match (matching TS behavior)
|
|
if sessionKey != "" && run.SessionKey != sessionKey {
|
|
return false
|
|
}
|
|
|
|
run.Cancel()
|
|
r.sessionRuns.Delete(run.SessionKey)
|
|
r.activeRuns.Delete(runID)
|
|
return true
|
|
}
|
|
|
|
// InjectMessage sends a user message to the running loop for a session.
|
|
// Returns true if the message was accepted, false if no active run or channel full.
|
|
func (r *Router) InjectMessage(sessionKey string, msg InjectedMessage) bool {
|
|
runIDVal, ok := r.sessionRuns.Load(sessionKey)
|
|
if !ok {
|
|
return false
|
|
}
|
|
runVal, ok := r.activeRuns.Load(runIDVal)
|
|
if !ok {
|
|
return false
|
|
}
|
|
run := runVal.(*ActiveRun)
|
|
select {
|
|
case run.InjectCh <- msg:
|
|
return true
|
|
default:
|
|
return false // channel full
|
|
}
|
|
}
|
|
|
|
// InvalidateUserWorkspace clears the cached workspace for a user across all cached agent loops.
|
|
// Used when user_agent_profiles.workspace changes (e.g. admin reassignment).
|
|
func (r *Router) InvalidateUserWorkspace(userID string) {
|
|
r.mu.RLock()
|
|
defer r.mu.RUnlock()
|
|
for _, entry := range r.agents {
|
|
if loop, ok := entry.agent.(*Loop); ok {
|
|
loop.InvalidateUserWorkspace(userID)
|
|
}
|
|
}
|
|
}
|
|
|
|
// SessionKeyForRun returns the session key associated with a run ID, or "" if not found.
|
|
func (r *Router) SessionKeyForRun(runID string) string {
|
|
val, ok := r.activeRuns.Load(runID)
|
|
if !ok {
|
|
return ""
|
|
}
|
|
return val.(*ActiveRun).SessionKey
|
|
}
|
|
|
|
// UpdateActivity records the current phase of a running agent for status queries.
|
|
// Called from the bus subscriber on agent.activity events.
|
|
func (r *Router) UpdateActivity(sessionKey, runID, phase, tool string, iteration int) {
|
|
r.agentActivity.Store(sessionKey, &AgentActivityStatus{
|
|
RunID: runID,
|
|
Phase: phase,
|
|
Tool: tool,
|
|
Iteration: iteration,
|
|
StartedAt: time.Now(),
|
|
})
|
|
}
|
|
|
|
// ClearActivity removes the activity status for a session (on run completion).
|
|
func (r *Router) ClearActivity(sessionKey string) {
|
|
r.agentActivity.Delete(sessionKey)
|
|
}
|
|
|
|
// GetActivity returns the current activity status for a session, or nil if idle.
|
|
func (r *Router) GetActivity(sessionKey string) *AgentActivityStatus {
|
|
val, ok := r.agentActivity.Load(sessionKey)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
return val.(*AgentActivityStatus)
|
|
}
|
|
|
|
// IsSessionBusy returns true if there's an active run for the given session key.
|
|
// O(1) via sessionRuns secondary index.
|
|
func (r *Router) IsSessionBusy(sessionKey string) bool {
|
|
_, ok := r.sessionRuns.Load(sessionKey)
|
|
return ok
|
|
}
|
|
|
|
// AbortRunsForSession cancels all active runs for a session key.
|
|
// Returns the list of aborted run IDs.
|
|
func (r *Router) AbortRunsForSession(sessionKey string) []string {
|
|
var aborted []string
|
|
r.activeRuns.Range(func(key, val any) bool {
|
|
run := val.(*ActiveRun)
|
|
if run.SessionKey == sessionKey {
|
|
run.Cancel()
|
|
r.activeRuns.Delete(key)
|
|
r.sessionRuns.Delete(sessionKey)
|
|
aborted = append(aborted, run.RunID)
|
|
}
|
|
return true
|
|
})
|
|
return aborted
|
|
}
|