mirror of
https://github.com/YspCoder/clawgo.git
synced 2026-04-14 22:09:37 +08:00
feat: add subagent profiles, memory namespaces, and webui management
This commit is contained in:
@@ -93,6 +93,10 @@ func (cb *ContextBuilder) buildToolsSection() string {
|
||||
}
|
||||
|
||||
func (cb *ContextBuilder) BuildSystemPrompt() string {
|
||||
return cb.BuildSystemPromptWithMemoryNamespace("main")
|
||||
}
|
||||
|
||||
func (cb *ContextBuilder) BuildSystemPromptWithMemoryNamespace(memoryNamespace string) string {
|
||||
parts := []string{}
|
||||
|
||||
// Core identity section
|
||||
@@ -111,7 +115,11 @@ func (cb *ContextBuilder) BuildSystemPrompt() string {
|
||||
}
|
||||
|
||||
// Memory context
|
||||
memoryContext := cb.memory.GetMemoryContext()
|
||||
memStore := cb.memory
|
||||
if ns := normalizeMemoryNamespace(memoryNamespace); ns != "main" {
|
||||
memStore = NewMemoryStoreWithNamespace(cb.workspace, ns)
|
||||
}
|
||||
memoryContext := memStore.GetMemoryContext()
|
||||
if memoryContext != "" {
|
||||
parts = append(parts, memoryContext)
|
||||
}
|
||||
@@ -165,9 +173,13 @@ func (cb *ContextBuilder) shouldLoadBootstrap() bool {
|
||||
}
|
||||
|
||||
func (cb *ContextBuilder) BuildMessages(history []providers.Message, summary string, currentMessage string, media []string, channel, chatID, responseLanguage string) []providers.Message {
|
||||
return cb.BuildMessagesWithMemoryNamespace(history, summary, currentMessage, media, channel, chatID, responseLanguage, "main")
|
||||
}
|
||||
|
||||
func (cb *ContextBuilder) BuildMessagesWithMemoryNamespace(history []providers.Message, summary string, currentMessage string, media []string, channel, chatID, responseLanguage, memoryNamespace string) []providers.Message {
|
||||
messages := []providers.Message{}
|
||||
|
||||
systemPrompt := cb.BuildSystemPrompt()
|
||||
systemPrompt := cb.BuildSystemPromptWithMemoryNamespace(memoryNamespace)
|
||||
|
||||
// Add Current Session info if provided
|
||||
if channel != "" && chatID != "" {
|
||||
|
||||
@@ -172,6 +172,13 @@ func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers
|
||||
spawnTool := tools.NewSpawnTool(subagentManager)
|
||||
toolsRegistry.Register(spawnTool)
|
||||
toolsRegistry.Register(tools.NewSubagentsTool(subagentManager))
|
||||
if store := subagentManager.ProfileStore(); store != nil {
|
||||
toolsRegistry.Register(tools.NewSubagentProfileTool(store))
|
||||
}
|
||||
toolsRegistry.Register(tools.NewPipelineCreateTool(orchestrator))
|
||||
toolsRegistry.Register(tools.NewPipelineStatusTool(orchestrator))
|
||||
toolsRegistry.Register(tools.NewPipelineStateSetTool(orchestrator))
|
||||
toolsRegistry.Register(tools.NewPipelineDispatchTool(orchestrator, subagentManager))
|
||||
toolsRegistry.Register(tools.NewSessionsTool(
|
||||
func(limit int) []tools.SessionInfo {
|
||||
sessions := alSessionListForTool(sessionsManager, limit)
|
||||
@@ -268,9 +275,19 @@ func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers
|
||||
}
|
||||
|
||||
// Inject recursive run logic so subagents can use full tool-calling flows.
|
||||
subagentManager.SetRunFunc(func(ctx context.Context, task, channel, chatID string) (string, error) {
|
||||
sessionKey := fmt.Sprintf("subagent:%d", os.Getpid()) // Use PID/randomized key to reduce session key collisions.
|
||||
return loop.ProcessDirect(ctx, task, sessionKey)
|
||||
subagentManager.SetRunFunc(func(ctx context.Context, task *tools.SubagentTask) (string, error) {
|
||||
if task == nil {
|
||||
return "", fmt.Errorf("subagent task is nil")
|
||||
}
|
||||
sessionKey := strings.TrimSpace(task.SessionKey)
|
||||
if sessionKey == "" {
|
||||
sessionKey = fmt.Sprintf("subagent:%s", strings.TrimSpace(task.ID))
|
||||
}
|
||||
taskInput := task.Task
|
||||
if p := strings.TrimSpace(task.SystemPrompt); p != "" {
|
||||
taskInput = fmt.Sprintf("Role Profile Prompt:\n%s\n\nTask:\n%s", p, task.Task)
|
||||
}
|
||||
return loop.ProcessDirectWithOptions(ctx, taskInput, sessionKey, task.OriginChannel, task.OriginChatID, task.MemoryNS, task.ToolAllowlist)
|
||||
})
|
||||
|
||||
return loop
|
||||
@@ -682,12 +699,40 @@ func (al *AgentLoop) prepareOutbound(msg bus.InboundMessage, response string) (b
|
||||
}
|
||||
|
||||
func (al *AgentLoop) ProcessDirect(ctx context.Context, content, sessionKey string) (string, error) {
|
||||
return al.ProcessDirectWithOptions(ctx, content, sessionKey, "cli", "direct", "main", nil)
|
||||
}
|
||||
|
||||
func (al *AgentLoop) ProcessDirectWithOptions(ctx context.Context, content, sessionKey, channel, chatID, memoryNamespace string, toolAllowlist []string) (string, error) {
|
||||
channel = strings.TrimSpace(channel)
|
||||
if channel == "" {
|
||||
channel = "cli"
|
||||
}
|
||||
chatID = strings.TrimSpace(chatID)
|
||||
if chatID == "" {
|
||||
chatID = "direct"
|
||||
}
|
||||
sessionKey = strings.TrimSpace(sessionKey)
|
||||
if sessionKey == "" {
|
||||
sessionKey = "main"
|
||||
}
|
||||
ns := normalizeMemoryNamespace(memoryNamespace)
|
||||
var metadata map[string]string
|
||||
if ns != "main" {
|
||||
metadata = map[string]string{
|
||||
"memory_namespace": ns,
|
||||
"memory_ns": ns,
|
||||
}
|
||||
}
|
||||
ctx = withMemoryNamespaceContext(ctx, ns)
|
||||
ctx = withToolAllowlistContext(ctx, toolAllowlist)
|
||||
|
||||
msg := bus.InboundMessage{
|
||||
Channel: "cli",
|
||||
Channel: channel,
|
||||
SenderID: "user",
|
||||
ChatID: "direct",
|
||||
ChatID: chatID,
|
||||
Content: content,
|
||||
SessionKey: sessionKey,
|
||||
Metadata: metadata,
|
||||
}
|
||||
|
||||
return al.processPlannedMessage(ctx, msg)
|
||||
@@ -701,6 +746,8 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
|
||||
if msg.SessionKey == "" {
|
||||
msg.SessionKey = "main"
|
||||
}
|
||||
memoryNamespace := resolveInboundMemoryNamespace(msg)
|
||||
ctx = withMemoryNamespaceContext(ctx, memoryNamespace)
|
||||
release, err := al.acquireSessionResources(ctx, &msg)
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -733,7 +780,7 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
|
||||
preferredLang, lastLang := al.sessions.GetLanguagePreferences(msg.SessionKey)
|
||||
responseLang := DetectResponseLanguage(msg.Content, preferredLang, lastLang)
|
||||
|
||||
messages := al.contextBuilder.BuildMessages(
|
||||
messages := al.contextBuilder.BuildMessagesWithMemoryNamespace(
|
||||
history,
|
||||
summary,
|
||||
msg.Content,
|
||||
@@ -741,6 +788,7 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
|
||||
msg.Channel,
|
||||
msg.ChatID,
|
||||
responseLang,
|
||||
memoryNamespace,
|
||||
)
|
||||
|
||||
iteration := 0
|
||||
@@ -1554,12 +1602,174 @@ func withToolContextArgs(toolName string, args map[string]interface{}, channel,
|
||||
}
|
||||
|
||||
func (al *AgentLoop) executeToolCall(ctx context.Context, toolName string, args map[string]interface{}, currentChannel, currentChatID string) (string, error) {
|
||||
if err := ensureToolAllowedByContext(ctx, toolName, args); err != nil {
|
||||
return "", err
|
||||
}
|
||||
args = withToolMemoryNamespaceArgs(toolName, args, memoryNamespaceFromContext(ctx))
|
||||
if shouldSuppressSelfMessageSend(toolName, args, currentChannel, currentChatID) {
|
||||
return "Suppressed message tool self-send in current chat; assistant will reply via normal outbound.", nil
|
||||
}
|
||||
return al.tools.Execute(ctx, toolName, args)
|
||||
}
|
||||
|
||||
func withToolMemoryNamespaceArgs(toolName string, args map[string]interface{}, namespace string) map[string]interface{} {
|
||||
ns := normalizeMemoryNamespace(namespace)
|
||||
if ns == "main" {
|
||||
return args
|
||||
}
|
||||
switch strings.TrimSpace(toolName) {
|
||||
case "memory_search", "memory_get", "memory_write":
|
||||
default:
|
||||
return args
|
||||
}
|
||||
|
||||
if raw, ok := args["namespace"].(string); ok && strings.TrimSpace(raw) != "" {
|
||||
return args
|
||||
}
|
||||
next := make(map[string]interface{}, len(args)+1)
|
||||
for k, v := range args {
|
||||
next[k] = v
|
||||
}
|
||||
next["namespace"] = ns
|
||||
return next
|
||||
}
|
||||
|
||||
type agentContextKey string
|
||||
|
||||
const memoryNamespaceContextKey agentContextKey = "memory_namespace"
|
||||
const toolAllowlistContextKey agentContextKey = "tool_allowlist"
|
||||
|
||||
func withMemoryNamespaceContext(ctx context.Context, namespace string) context.Context {
|
||||
ns := normalizeMemoryNamespace(namespace)
|
||||
if ns == "main" {
|
||||
return ctx
|
||||
}
|
||||
return context.WithValue(ctx, memoryNamespaceContextKey, ns)
|
||||
}
|
||||
|
||||
func memoryNamespaceFromContext(ctx context.Context) string {
|
||||
if ctx == nil {
|
||||
return "main"
|
||||
}
|
||||
raw, _ := ctx.Value(memoryNamespaceContextKey).(string)
|
||||
return normalizeMemoryNamespace(raw)
|
||||
}
|
||||
|
||||
func withToolAllowlistContext(ctx context.Context, allowlist []string) context.Context {
|
||||
normalized := normalizeToolAllowlist(allowlist)
|
||||
if len(normalized) == 0 {
|
||||
return ctx
|
||||
}
|
||||
return context.WithValue(ctx, toolAllowlistContextKey, normalized)
|
||||
}
|
||||
|
||||
func toolAllowlistFromContext(ctx context.Context) map[string]struct{} {
|
||||
if ctx == nil {
|
||||
return nil
|
||||
}
|
||||
raw, _ := ctx.Value(toolAllowlistContextKey).(map[string]struct{})
|
||||
if len(raw) == 0 {
|
||||
return nil
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
func ensureToolAllowedByContext(ctx context.Context, toolName string, args map[string]interface{}) error {
|
||||
allow := toolAllowlistFromContext(ctx)
|
||||
if len(allow) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
name := strings.ToLower(strings.TrimSpace(toolName))
|
||||
if name == "" {
|
||||
return fmt.Errorf("tool name is empty")
|
||||
}
|
||||
if !isToolNameAllowed(allow, name) {
|
||||
return fmt.Errorf("tool '%s' is not allowed by subagent profile", toolName)
|
||||
}
|
||||
|
||||
if name == "parallel" {
|
||||
if err := validateParallelAllowlistArgs(allow, args); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateParallelAllowlistArgs(allow map[string]struct{}, args map[string]interface{}) error {
|
||||
callsRaw, ok := args["calls"].([]interface{})
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
for i, call := range callsRaw {
|
||||
m, ok := call.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
tool, _ := m["tool"].(string)
|
||||
name := strings.ToLower(strings.TrimSpace(tool))
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
if !isToolNameAllowed(allow, name) {
|
||||
return fmt.Errorf("tool 'parallel' contains disallowed call[%d]: %s", i, tool)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeToolAllowlist(in []string) map[string]struct{} {
|
||||
if len(in) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]struct{}, len(in))
|
||||
for _, item := range in {
|
||||
name := strings.ToLower(strings.TrimSpace(item))
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
out[name] = struct{}{}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func isToolNameAllowed(allow map[string]struct{}, name string) bool {
|
||||
if len(allow) == 0 {
|
||||
return true
|
||||
}
|
||||
name = strings.ToLower(strings.TrimSpace(name))
|
||||
if name == "" {
|
||||
return false
|
||||
}
|
||||
if _, ok := allow["*"]; ok {
|
||||
return true
|
||||
}
|
||||
if _, ok := allow["all"]; ok {
|
||||
return true
|
||||
}
|
||||
_, ok := allow[name]
|
||||
return ok
|
||||
}
|
||||
|
||||
func resolveInboundMemoryNamespace(msg bus.InboundMessage) string {
|
||||
if msg.Channel == "system" {
|
||||
return "main"
|
||||
}
|
||||
if msg.Metadata == nil {
|
||||
return "main"
|
||||
}
|
||||
if v := strings.TrimSpace(msg.Metadata["memory_namespace"]); v != "" {
|
||||
return normalizeMemoryNamespace(v)
|
||||
}
|
||||
if v := strings.TrimSpace(msg.Metadata["memory_ns"]); v != "" {
|
||||
return normalizeMemoryNamespace(v)
|
||||
}
|
||||
return "main"
|
||||
}
|
||||
|
||||
func shouldSuppressSelfMessageSend(toolName string, args map[string]interface{}, currentChannel, currentChatID string) bool {
|
||||
if strings.TrimSpace(toolName) != "message" {
|
||||
return false
|
||||
|
||||
44
pkg/agent/loop_allowlist_test.go
Normal file
44
pkg/agent/loop_allowlist_test.go
Normal file
@@ -0,0 +1,44 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEnsureToolAllowedByContext(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
if err := ensureToolAllowedByContext(ctx, "write_file", map[string]interface{}{}); err != nil {
|
||||
t.Fatalf("expected unrestricted context to allow tool, got: %v", err)
|
||||
}
|
||||
|
||||
restricted := withToolAllowlistContext(ctx, []string{"read_file", "memory_search"})
|
||||
if err := ensureToolAllowedByContext(restricted, "read_file", map[string]interface{}{}); err != nil {
|
||||
t.Fatalf("expected allowed tool to pass, got: %v", err)
|
||||
}
|
||||
if err := ensureToolAllowedByContext(restricted, "write_file", map[string]interface{}{}); err == nil {
|
||||
t.Fatalf("expected disallowed tool to fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureToolAllowedByContextParallelNested(t *testing.T) {
|
||||
restricted := withToolAllowlistContext(context.Background(), []string{"parallel", "read_file"})
|
||||
|
||||
okArgs := map[string]interface{}{
|
||||
"calls": []interface{}{
|
||||
map[string]interface{}{"tool": "read_file", "arguments": map[string]interface{}{"path": "README.md"}},
|
||||
},
|
||||
}
|
||||
if err := ensureToolAllowedByContext(restricted, "parallel", okArgs); err != nil {
|
||||
t.Fatalf("expected parallel with allowed nested tool to pass, got: %v", err)
|
||||
}
|
||||
|
||||
badArgs := map[string]interface{}{
|
||||
"calls": []interface{}{
|
||||
map[string]interface{}{"tool": "write_file", "arguments": map[string]interface{}{"path": "README.md", "content": "x"}},
|
||||
},
|
||||
}
|
||||
if err := ensureToolAllowedByContext(restricted, "parallel", badArgs); err == nil {
|
||||
t.Fatalf("expected parallel with disallowed nested tool to fail")
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -18,17 +19,28 @@ import (
|
||||
// - Daily notes: memory/YYYY-MM-DD.md
|
||||
// It also supports legacy locations for backward compatibility.
|
||||
type MemoryStore struct {
|
||||
workspace string
|
||||
memoryDir string
|
||||
memoryFile string
|
||||
workspace string
|
||||
namespace string
|
||||
memoryDir string
|
||||
memoryFile string
|
||||
legacyMemoryFile string
|
||||
}
|
||||
|
||||
// NewMemoryStore creates a new MemoryStore with the given workspace path.
|
||||
// It ensures the memory directory exists.
|
||||
func NewMemoryStore(workspace string) *MemoryStore {
|
||||
memoryDir := filepath.Join(workspace, "memory")
|
||||
memoryFile := filepath.Join(workspace, "MEMORY.md")
|
||||
return NewMemoryStoreWithNamespace(workspace, "main")
|
||||
}
|
||||
|
||||
func NewMemoryStoreWithNamespace(workspace, namespace string) *MemoryStore {
|
||||
ns := normalizeMemoryNamespace(namespace)
|
||||
baseDir := workspace
|
||||
if ns != "main" {
|
||||
baseDir = filepath.Join(workspace, "agents", ns)
|
||||
}
|
||||
|
||||
memoryDir := filepath.Join(baseDir, "memory")
|
||||
memoryFile := filepath.Join(baseDir, "MEMORY.md")
|
||||
legacyMemoryFile := filepath.Join(memoryDir, "MEMORY.md")
|
||||
|
||||
// Ensure memory directory exists
|
||||
@@ -36,6 +48,7 @@ func NewMemoryStore(workspace string) *MemoryStore {
|
||||
|
||||
return &MemoryStore{
|
||||
workspace: workspace,
|
||||
namespace: ns,
|
||||
memoryDir: memoryDir,
|
||||
memoryFile: memoryFile,
|
||||
legacyMemoryFile: legacyMemoryFile,
|
||||
@@ -169,3 +182,28 @@ func (ms *MemoryStore) GetMemoryContext() string {
|
||||
}
|
||||
return fmt.Sprintf("# Memory\n\n%s", result)
|
||||
}
|
||||
|
||||
func normalizeMemoryNamespace(namespace string) string {
|
||||
namespace = strings.TrimSpace(strings.ToLower(namespace))
|
||||
if namespace == "" || namespace == "main" {
|
||||
return "main"
|
||||
}
|
||||
var sb strings.Builder
|
||||
for _, r := range namespace {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z':
|
||||
sb.WriteRune(r)
|
||||
case r >= '0' && r <= '9':
|
||||
sb.WriteRune(r)
|
||||
case r == '-' || r == '_' || r == '.':
|
||||
sb.WriteRune(r)
|
||||
case r == ' ':
|
||||
sb.WriteRune('-')
|
||||
}
|
||||
}
|
||||
out := strings.Trim(sb.String(), "-_.")
|
||||
if out == "" {
|
||||
return "main"
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -215,10 +215,14 @@ func (al *AgentLoop) memoryHintForTask(ctx context.Context, task plannedTask) st
|
||||
if al == nil || al.tools == nil {
|
||||
return ""
|
||||
}
|
||||
res, err := al.tools.Execute(ctx, "memory_search", map[string]interface{}{
|
||||
args := map[string]interface{}{
|
||||
"query": task.Content,
|
||||
"maxResults": 2,
|
||||
})
|
||||
}
|
||||
if ns := memoryNamespaceFromContext(ctx); ns != "main" {
|
||||
args["namespace"] = ns
|
||||
}
|
||||
res, err := al.tools.Execute(ctx, "memory_search", args)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user