feat: expand mcp transports and skill execution

This commit is contained in:
lpf
2026-03-08 11:08:41 +08:00
parent db86b3471d
commit f043de5384
21 changed files with 1447 additions and 84 deletions

View File

@@ -105,6 +105,7 @@ func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers
toolsRegistry.Register(tools.NewAliasTool("edit", "Edit file content (OpenClaw-compatible alias of edit_file)", tools.NewEditFileTool(workspace), map[string]string{"file_path": "path", "old_string": "oldText", "new_string": "newText"}))
toolsRegistry.Register(tools.NewExecTool(cfg.Tools.Shell, workspace, processManager))
toolsRegistry.Register(tools.NewProcessTool(processManager))
toolsRegistry.Register(tools.NewSkillExecTool(workspace))
nodesManager := nodes.DefaultManager()
nodesManager.SetAuditPath(filepath.Join(workspace, "memory", "nodes-audit.jsonl"))
nodesManager.SetStatePath(filepath.Join(workspace, "memory", "nodes-state.json"))
@@ -935,7 +936,7 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
"max": al.maxIterations,
})
toolDefs := al.tools.GetDefinitions()
toolDefs := al.filteredToolDefinitionsForContext(ctx)
providerToolDefs := al.buildProviderToolDefs(toolDefs)
// Log LLM request details
@@ -1301,7 +1302,7 @@ func (al *AgentLoop) processSystemMessage(ctx context.Context, msg bus.InboundMe
for iteration < al.maxIterations {
iteration++
toolDefs := al.tools.GetDefinitions()
toolDefs := al.filteredToolDefinitionsForContext(ctx)
providerToolDefs := al.buildProviderToolDefs(toolDefs)
// Log LLM request details
@@ -1459,6 +1460,37 @@ func (al *AgentLoop) buildProviderToolDefs(toolDefs []map[string]interface{}) []
return providerToolDefs
}
func (al *AgentLoop) filteredToolDefinitionsForContext(ctx context.Context) []map[string]interface{} {
if al == nil || al.tools == nil {
return nil
}
return filterToolDefinitionsByContext(ctx, al.tools.GetDefinitions())
}
func filterToolDefinitionsByContext(ctx context.Context, toolDefs []map[string]interface{}) []map[string]interface{} {
allow := toolAllowlistFromContext(ctx)
if len(allow) == 0 {
return toolDefs
}
filtered := make([]map[string]interface{}, 0, len(toolDefs))
for _, td := range toolDefs {
fnRaw, ok := td["function"].(map[string]interface{})
if !ok {
continue
}
name, _ := fnRaw["name"].(string)
name = strings.ToLower(strings.TrimSpace(name))
if name == "" {
continue
}
if isToolNameAllowed(allow, name) || isImplicitlyAllowedSubagentTool(name) {
filtered = append(filtered, td)
}
}
return filtered
}
func (al *AgentLoop) buildResponsesOptions(sessionKey string, maxTokens int64, temperature float64) map[string]interface{} {
options := map[string]interface{}{
"max_tokens": maxTokens,
@@ -1702,10 +1734,22 @@ func (al *AgentLoop) GetToolCatalog() []map[string]interface{} {
if fmt.Sprint(item["source"]) != "mcp" {
item["source"] = "local"
}
name := strings.TrimSpace(fmt.Sprint(item["name"]))
item["visibility"] = map[string]interface{}{
"main_agent": true,
"subagent": subagentToolVisibilityMode(name),
}
}
return items
}
func subagentToolVisibilityMode(name string) string {
if isImplicitlyAllowedSubagentTool(name) {
return "inherited"
}
return "allowlist"
}
// formatMessagesForLog formats messages for logging
func formatMessagesForLog(messages []providers.Message) string {
if len(messages) == 0 {
@@ -1804,11 +1848,32 @@ func withToolContextArgs(toolName string, args map[string]interface{}, channel,
return next
}
func withToolRuntimeArgs(ctx context.Context, toolName string, args map[string]interface{}) map[string]interface{} {
if strings.TrimSpace(toolName) != "skill_exec" {
return args
}
ns := normalizeMemoryNamespace(memoryNamespaceFromContext(ctx))
callerAgent := ns
callerScope := "subagent"
if callerAgent == "" || callerAgent == "main" {
callerAgent = "main"
callerScope = "main_agent"
}
next := make(map[string]interface{}, len(args)+2)
for k, v := range args {
next[k] = v
}
next["caller_agent"] = callerAgent
next["caller_scope"] = callerScope
return next
}
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))
args = withToolRuntimeArgs(ctx, toolName, args)
if shouldSuppressSelfMessageSend(toolName, args, currentChannel, currentChatID) {
return "Suppressed message tool self-send in current chat; assistant will reply via normal outbound.", nil
}
@@ -1887,7 +1952,7 @@ func ensureToolAllowedByContext(ctx context.Context, toolName string, args map[s
if name == "" {
return fmt.Errorf("tool name is empty")
}
if !isToolNameAllowed(allow, name) {
if !isToolNameAllowed(allow, name) && !isImplicitlyAllowedSubagentTool(name) {
return fmt.Errorf("tool '%s' is not allowed by subagent profile", toolName)
}
@@ -1914,13 +1979,22 @@ func validateParallelAllowlistArgs(allow map[string]struct{}, args map[string]in
if name == "" {
continue
}
if !isToolNameAllowed(allow, name) {
if !isToolNameAllowed(allow, name) && !isImplicitlyAllowedSubagentTool(name) {
return fmt.Errorf("tool 'parallel' contains disallowed call[%d]: %s", i, tool)
}
}
return nil
}
func isImplicitlyAllowedSubagentTool(name string) bool {
switch strings.ToLower(strings.TrimSpace(name)) {
case "skill_exec":
return true
default:
return false
}
}
func normalizeToolAllowlist(in []string) map[string]struct{} {
expanded := tools.ExpandToolAllowlistEntries(in)
if len(expanded) == 0 {

View File

@@ -19,6 +19,9 @@ func TestEnsureToolAllowedByContext(t *testing.T) {
if err := ensureToolAllowedByContext(restricted, "write_file", map[string]interface{}{}); err == nil {
t.Fatalf("expected disallowed tool to fail")
}
if err := ensureToolAllowedByContext(restricted, "skill_exec", map[string]interface{}{}); err != nil {
t.Fatalf("expected skill_exec to bypass subagent allowlist, got: %v", err)
}
}
func TestEnsureToolAllowedByContextParallelNested(t *testing.T) {
@@ -41,6 +44,15 @@ func TestEnsureToolAllowedByContextParallelNested(t *testing.T) {
if err := ensureToolAllowedByContext(restricted, "parallel", badArgs); err == nil {
t.Fatalf("expected parallel with disallowed nested tool to fail")
}
skillArgs := map[string]interface{}{
"calls": []interface{}{
map[string]interface{}{"tool": "skill_exec", "arguments": map[string]interface{}{"skill": "demo", "script": "scripts/run.sh"}},
},
}
if err := ensureToolAllowedByContext(restricted, "parallel", skillArgs); err != nil {
t.Fatalf("expected parallel with nested skill_exec to pass, got: %v", err)
}
}
func TestEnsureToolAllowedByContext_GroupAllowlist(t *testing.T) {
@@ -52,3 +64,48 @@ func TestEnsureToolAllowedByContext_GroupAllowlist(t *testing.T) {
t.Fatalf("expected files_read group to block write_file")
}
}
func TestFilteredToolDefinitionsForContext(t *testing.T) {
ctx := withToolAllowlistContext(context.Background(), []string{"read_file", "parallel"})
toolDefs := []map[string]interface{}{
{"function": map[string]interface{}{"name": "read_file", "description": "read", "parameters": map[string]interface{}{}}},
{"function": map[string]interface{}{"name": "write_file", "description": "write", "parameters": map[string]interface{}{}}},
{"function": map[string]interface{}{"name": "parallel", "description": "parallel", "parameters": map[string]interface{}{}}},
{"function": map[string]interface{}{"name": "skill_exec", "description": "skill", "parameters": map[string]interface{}{}}},
}
filtered := filterToolDefinitionsByContext(ctx, toolDefs)
got := map[string]bool{}
for _, td := range filtered {
fnRaw, _ := td["function"].(map[string]interface{})
name, _ := fnRaw["name"].(string)
got[name] = true
}
if !got["read_file"] || !got["parallel"] || !got["skill_exec"] {
t.Fatalf("expected filtered tools to include read_file, parallel, and inherited skill_exec, got: %v", got)
}
if got["write_file"] {
t.Fatalf("expected filtered tools to exclude write_file, got: %v", got)
}
}
func TestWithToolRuntimeArgsForSkillExec(t *testing.T) {
mainArgs := withToolRuntimeArgs(context.Background(), "skill_exec", map[string]interface{}{"skill": "demo"})
if mainArgs["caller_agent"] != "main" || mainArgs["caller_scope"] != "main_agent" {
t.Fatalf("expected main agent runtime args, got: %#v", mainArgs)
}
subagentCtx := withMemoryNamespaceContext(context.Background(), "coder")
subArgs := withToolRuntimeArgs(subagentCtx, "skill_exec", map[string]interface{}{"skill": "demo"})
if subArgs["caller_agent"] != "coder" || subArgs["caller_scope"] != "subagent" {
t.Fatalf("expected subagent runtime args, got: %#v", subArgs)
}
}
func TestSubagentToolVisibilityMode(t *testing.T) {
if got := subagentToolVisibilityMode("skill_exec"); got != "inherited" {
t.Fatalf("expected skill_exec inherited, got %q", got)
}
if got := subagentToolVisibilityMode("write_file"); got != "allowlist" {
t.Fatalf("expected write_file allowlist, got %q", got)
}
}

View File

@@ -0,0 +1,37 @@
package agent
import (
"context"
"testing"
"clawgo/pkg/bus"
"clawgo/pkg/config"
"clawgo/pkg/providers"
)
type stubLLMProvider struct{}
func (stubLLMProvider) Chat(ctx context.Context, messages []providers.Message, tools []providers.ToolDefinition, model string, options map[string]interface{}) (*providers.LLMResponse, error) {
return &providers.LLMResponse{Content: "ok", FinishReason: "stop"}, nil
}
func (stubLLMProvider) GetDefaultModel() string { return "stub-model" }
func TestNewAgentLoopRegistersSkillExec(t *testing.T) {
cfg := config.DefaultConfig()
cfg.Agents.Defaults.Workspace = t.TempDir()
loop := NewAgentLoop(cfg, bus.NewMessageBus(), stubLLMProvider{}, nil)
catalog := loop.GetToolCatalog()
found := false
for _, item := range catalog {
if item["name"] == "skill_exec" {
found = true
break
}
}
if !found {
t.Fatalf("expected skill_exec in tool catalog, got %v", catalog)
}
}

View File

@@ -124,6 +124,7 @@ func (al *AgentLoop) HandleSubagentRuntime(ctx context.Context, action string, a
}
}
}
toolInfo := al.describeSubagentTools(subcfg.Tools.Allowlist)
items = append(items, map[string]interface{}{
"agent_id": agentID,
"enabled": subcfg.Enabled,
@@ -140,6 +141,9 @@ func (al *AgentLoop) HandleSubagentRuntime(ctx context.Context, action string, a
"prompt_file_found": promptFileFound,
"memory_namespace": subcfg.MemoryNamespace,
"tool_allowlist": append([]string(nil), subcfg.Tools.Allowlist...),
"tool_visibility": toolInfo,
"effective_tools": toolInfo["effective_tools"],
"inherited_tools": toolInfo["inherited_tools"],
"routing_keywords": routeKeywordsForRegistry(cfg.Agents.Router.Rules, agentID),
"managed_by": "config.json",
})
@@ -151,6 +155,7 @@ func (al *AgentLoop) HandleSubagentRuntime(ctx context.Context, action string, a
if strings.TrimSpace(profile.ManagedBy) != "node_registry" {
continue
}
toolInfo := al.describeSubagentTools(profile.ToolAllowlist)
items = append(items, map[string]interface{}{
"agent_id": profile.AgentID,
"enabled": strings.EqualFold(strings.TrimSpace(profile.Status), "active"),
@@ -167,6 +172,9 @@ func (al *AgentLoop) HandleSubagentRuntime(ctx context.Context, action string, a
"prompt_file_found": false,
"memory_namespace": profile.MemoryNamespace,
"tool_allowlist": append([]string(nil), profile.ToolAllowlist...),
"tool_visibility": toolInfo,
"effective_tools": toolInfo["effective_tools"],
"inherited_tools": toolInfo["inherited_tools"],
"routing_keywords": []string{},
"managed_by": profile.ManagedBy,
})
@@ -435,6 +443,51 @@ func (al *AgentLoop) HandleSubagentRuntime(ctx context.Context, action string, a
}
}
func (al *AgentLoop) describeSubagentTools(allowlist []string) map[string]interface{} {
inherited := implicitSubagentTools()
allTools := make([]string, 0)
if al != nil && al.tools != nil {
allTools = al.tools.List()
sort.Strings(allTools)
}
normalizedAllow := normalizeToolAllowlist(allowlist)
mode := "allowlist"
effective := make([]string, 0)
if len(normalizedAllow) == 0 {
mode = "unrestricted"
effective = append(effective, allTools...)
} else if _, ok := normalizedAllow["*"]; ok {
mode = "all"
effective = append(effective, allTools...)
} else if _, ok := normalizedAllow["all"]; ok {
mode = "all"
effective = append(effective, allTools...)
} else {
for _, name := range allTools {
if isToolNameAllowed(normalizedAllow, name) || isImplicitlyAllowedSubagentTool(name) {
effective = append(effective, name)
}
}
}
return map[string]interface{}{
"mode": mode,
"raw_allowlist": append([]string(nil), allowlist...),
"inherited_tools": inherited,
"inherited_tool_count": len(inherited),
"effective_tools": effective,
"effective_tool_count": len(effective),
}
}
func implicitSubagentTools() []string {
out := make([]string, 0, 1)
if isImplicitlyAllowedSubagentTool("skill_exec") {
out = append(out, "skill_exec")
}
return out
}
func mergeSubagentStream(events []tools.SubagentRunEvent, messages []tools.AgentMessage) []map[string]interface{} {
items := make([]map[string]interface{}, 0, len(events)+len(messages))
for _, evt := range events {

View File

@@ -161,6 +161,27 @@ func TestHandleSubagentRuntimeRegistryAndToggleEnabled(t *testing.T) {
if !ok || len(items) < 2 {
t.Fatalf("expected registry items, got %#v", payload["items"])
}
var tester map[string]interface{}
for _, item := range items {
if item["agent_id"] == "tester" {
tester = item
break
}
}
if tester == nil {
t.Fatalf("expected tester registry item, got %#v", items)
}
toolVisibility, _ := tester["tool_visibility"].(map[string]interface{})
if toolVisibility == nil {
t.Fatalf("expected tool_visibility in tester registry item, got %#v", tester)
}
if toolVisibility["mode"] != "allowlist" {
t.Fatalf("expected tester tool mode allowlist, got %#v", toolVisibility)
}
inherited, _ := tester["inherited_tools"].([]string)
if len(inherited) != 1 || inherited[0] != "skill_exec" {
t.Fatalf("expected inherited skill_exec, got %#v", tester["inherited_tools"])
}
_, err = loop.HandleSubagentRuntime(context.Background(), "set_config_subagent_enabled", map[string]interface{}{
"agent_id": "tester",