feat: add subagent profiles, memory namespaces, and webui management

This commit is contained in:
lpf
2026-03-05 21:05:29 +08:00
parent 1eacfaba41
commit 29d6480058
22 changed files with 2051 additions and 49 deletions

View File

@@ -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 != "" {

View File

@@ -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

View 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")
}
}

View File

@@ -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
}

View File

@@ -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 ""
}