mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
feat(commands): add MCP slash commands and tool details
This commit is contained in:
@@ -4,11 +4,15 @@ package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/bus"
|
||||
"github.com/sipeed/picoclaw/pkg/commands"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
"github.com/sipeed/picoclaw/pkg/logger"
|
||||
"github.com/sipeed/picoclaw/pkg/providers"
|
||||
)
|
||||
|
||||
@@ -133,6 +137,120 @@ func (al *AgentLoop) buildCommandsRuntime(
|
||||
Config: cfg,
|
||||
ListAgentIDs: registry.ListAgentIDs,
|
||||
ListDefinitions: al.cmdRegistry.Definitions,
|
||||
ListMCPServers: func(ctx context.Context) []commands.MCPServerInfo {
|
||||
if cfg == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if len(cfg.Tools.MCP.Servers) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := al.ensureMCPInitialized(ctx); err != nil {
|
||||
logger.WarnCF("agent", "Failed to refresh MCP status for command",
|
||||
map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
connected := make(map[string]int)
|
||||
if manager := al.mcp.getManager(); manager != nil {
|
||||
for serverName, conn := range manager.GetServers() {
|
||||
connected[serverName] = len(conn.Tools)
|
||||
}
|
||||
}
|
||||
|
||||
servers := make([]commands.MCPServerInfo, 0, len(cfg.Tools.MCP.Servers))
|
||||
for serverName, serverCfg := range cfg.Tools.MCP.Servers {
|
||||
toolCount, isConnected := connected[serverName]
|
||||
servers = append(servers, commands.MCPServerInfo{
|
||||
Name: serverName,
|
||||
Enabled: serverCfg.Enabled,
|
||||
Deferred: serverIsDeferred(cfg.Tools.MCP.Discovery.Enabled, serverCfg),
|
||||
Connected: isConnected,
|
||||
ToolCount: toolCount,
|
||||
})
|
||||
}
|
||||
|
||||
sort.Slice(servers, func(i, j int) bool {
|
||||
return strings.ToLower(servers[i].Name) < strings.ToLower(servers[j].Name)
|
||||
})
|
||||
|
||||
return servers
|
||||
},
|
||||
ListMCPTools: func(ctx context.Context, serverName string) ([]commands.MCPToolInfo, error) {
|
||||
if cfg == nil {
|
||||
return nil, fmt.Errorf("command unavailable: config not loaded")
|
||||
}
|
||||
|
||||
serverName = strings.TrimSpace(serverName)
|
||||
if serverName == "" {
|
||||
return nil, fmt.Errorf("server name is required")
|
||||
}
|
||||
|
||||
resolvedName := ""
|
||||
var serverCfg config.MCPServerConfig
|
||||
for name, candidate := range cfg.Tools.MCP.Servers {
|
||||
if strings.EqualFold(name, serverName) {
|
||||
resolvedName = name
|
||||
serverCfg = candidate
|
||||
break
|
||||
}
|
||||
}
|
||||
if resolvedName == "" {
|
||||
return nil, fmt.Errorf("MCP server '%s' is not configured", serverName)
|
||||
}
|
||||
if !serverCfg.Enabled {
|
||||
return nil, fmt.Errorf("MCP server '%s' is configured but disabled", resolvedName)
|
||||
}
|
||||
if !cfg.Tools.IsToolEnabled("mcp") {
|
||||
return nil, fmt.Errorf("MCP integration is disabled")
|
||||
}
|
||||
|
||||
if err := al.ensureMCPInitialized(ctx); err != nil {
|
||||
logger.WarnCF("agent", "Failed to initialize MCP runtime for command",
|
||||
map[string]any{
|
||||
"server": resolvedName,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
manager := al.mcp.getManager()
|
||||
if manager == nil {
|
||||
return nil, fmt.Errorf("MCP server '%s' is configured but not connected", resolvedName)
|
||||
}
|
||||
|
||||
conn, ok := manager.GetServer(resolvedName)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("MCP server '%s' is configured but not connected", resolvedName)
|
||||
}
|
||||
|
||||
toolInfos := make([]commands.MCPToolInfo, 0, len(conn.Tools))
|
||||
for _, tool := range conn.Tools {
|
||||
if tool == nil {
|
||||
continue
|
||||
}
|
||||
name := strings.TrimSpace(tool.Name)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
description := strings.TrimSpace(tool.Description)
|
||||
if description == "" {
|
||||
description = fmt.Sprintf("MCP tool from %s server", resolvedName)
|
||||
}
|
||||
|
||||
toolInfos = append(toolInfos, commands.MCPToolInfo{
|
||||
Name: name,
|
||||
Description: description,
|
||||
Parameters: summarizeMCPToolParameters(tool.InputSchema),
|
||||
})
|
||||
}
|
||||
sort.Slice(toolInfos, func(i, j int) bool {
|
||||
return toolInfos[i].Name < toolInfos[j].Name
|
||||
})
|
||||
return toolInfos, nil
|
||||
},
|
||||
GetEnabledChannels: func() []string {
|
||||
if al.channelManager == nil {
|
||||
return nil
|
||||
@@ -236,6 +354,96 @@ func (al *AgentLoop) buildCommandsRuntime(
|
||||
return rt
|
||||
}
|
||||
|
||||
func summarizeMCPToolParameters(schema any) []commands.MCPToolParameterInfo {
|
||||
schemaMap := normalizeMCPSchema(schema)
|
||||
properties, ok := schemaMap["properties"].(map[string]any)
|
||||
if !ok || len(properties) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
required := make(map[string]struct{})
|
||||
switch raw := schemaMap["required"].(type) {
|
||||
case []string:
|
||||
for _, name := range raw {
|
||||
required[name] = struct{}{}
|
||||
}
|
||||
case []any:
|
||||
for _, value := range raw {
|
||||
name, ok := value.(string)
|
||||
if ok {
|
||||
required[name] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
names := make([]string, 0, len(properties))
|
||||
for name := range properties {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
|
||||
params := make([]commands.MCPToolParameterInfo, 0, len(names))
|
||||
for _, name := range names {
|
||||
param := commands.MCPToolParameterInfo{Name: name}
|
||||
if propMap, ok := properties[name].(map[string]any); ok {
|
||||
if typeName, ok := propMap["type"].(string); ok {
|
||||
param.Type = strings.TrimSpace(typeName)
|
||||
}
|
||||
if desc, ok := propMap["description"].(string); ok {
|
||||
param.Description = strings.TrimSpace(desc)
|
||||
}
|
||||
}
|
||||
_, param.Required = required[name]
|
||||
params = append(params, param)
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
func normalizeMCPSchema(schema any) map[string]any {
|
||||
if schema == nil {
|
||||
return map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{},
|
||||
"required": []string{},
|
||||
}
|
||||
}
|
||||
|
||||
if schemaMap, ok := schema.(map[string]any); ok {
|
||||
return schemaMap
|
||||
}
|
||||
|
||||
var jsonData []byte
|
||||
switch raw := schema.(type) {
|
||||
case json.RawMessage:
|
||||
jsonData = raw
|
||||
case []byte:
|
||||
jsonData = raw
|
||||
}
|
||||
|
||||
if jsonData == nil {
|
||||
var err error
|
||||
jsonData, err = json.Marshal(schema)
|
||||
if err != nil {
|
||||
return map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{},
|
||||
"required": []string{},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var result map[string]any
|
||||
if err := json.Unmarshal(jsonData, &result); err != nil {
|
||||
return map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{},
|
||||
"required": []string{},
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func (al *AgentLoop) setPendingSkills(sessionKey string, skillNames []string) {
|
||||
sessionKey = strings.TrimSpace(sessionKey)
|
||||
if sessionKey == "" || len(skillNames) == 0 {
|
||||
|
||||
@@ -67,6 +67,12 @@ func (r *mcpRuntime) hasManager() bool {
|
||||
return r.manager != nil
|
||||
}
|
||||
|
||||
func (r *mcpRuntime) getManager() *mcp.Manager {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return r.manager
|
||||
}
|
||||
|
||||
// ensureMCPInitialized loads MCP servers/tools once so both Run() and direct
|
||||
// agent mode share the same initialization path.
|
||||
func (al *AgentLoop) ensureMCPInitialized(ctx context.Context) error {
|
||||
|
||||
@@ -2269,6 +2269,75 @@ func TestProcessMessage_CommandOutcomes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessMessage_MCPCommandsHandledWithoutLLMCall(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "agent-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
deferred := true
|
||||
cfg := &config.Config{
|
||||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
Workspace: tmpDir,
|
||||
ModelName: "test-model",
|
||||
MaxTokens: 4096,
|
||||
MaxToolIterations: 10,
|
||||
},
|
||||
},
|
||||
Session: config.SessionConfig{
|
||||
Dimensions: []string{"chat"},
|
||||
},
|
||||
Tools: config.ToolsConfig{
|
||||
MCP: config.MCPConfig{
|
||||
ToolConfig: config.ToolConfig{Enabled: true},
|
||||
Discovery: config.ToolDiscoveryConfig{Enabled: true},
|
||||
Servers: map[string]config.MCPServerConfig{
|
||||
"github": {
|
||||
Enabled: true,
|
||||
Deferred: &deferred,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
msgBus := bus.NewMessageBus()
|
||||
provider := &countingMockProvider{response: "LLM reply"}
|
||||
al := NewAgentLoop(cfg, msgBus, provider)
|
||||
helper := testHelper{al: al}
|
||||
|
||||
baseContext := bus.InboundContext{
|
||||
Channel: "whatsapp",
|
||||
ChatID: "chat1",
|
||||
ChatType: "direct",
|
||||
SenderID: "user1",
|
||||
}
|
||||
|
||||
listResp := helper.executeAndGetResponse(t, context.Background(), bus.InboundMessage{
|
||||
Context: baseContext,
|
||||
Content: "/list mcp",
|
||||
})
|
||||
if !strings.Contains(listResp, "- `github`") || !strings.Contains(listResp, "Deferred: yes") {
|
||||
t.Fatalf("unexpected /list mcp reply: %q", listResp)
|
||||
}
|
||||
if provider.calls != 0 {
|
||||
t.Fatalf("LLM should not be called for /list mcp, calls=%d", provider.calls)
|
||||
}
|
||||
|
||||
showResp := helper.executeAndGetResponse(t, context.Background(), bus.InboundMessage{
|
||||
Context: baseContext,
|
||||
Content: "/show mcp github",
|
||||
})
|
||||
if showResp != "MCP server 'github' is configured but not connected" {
|
||||
t.Fatalf("unexpected /show mcp reply: %q", showResp)
|
||||
}
|
||||
if provider.calls != 0 {
|
||||
t.Fatalf("LLM should not be called for /show mcp, calls=%d", provider.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessMessage_SwitchModelShowModelConsistency(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "agent-test-*")
|
||||
if err != nil {
|
||||
|
||||
@@ -36,10 +36,10 @@ func TestBuiltinHelpHandler_ReturnsFormattedMessage(t *testing.T) {
|
||||
t.Fatalf("/help handler error: %v", err)
|
||||
}
|
||||
// Now uses auto-generated EffectiveUsage which includes agents
|
||||
if !strings.Contains(reply, "/show [model|channel|agents]") {
|
||||
if !strings.Contains(reply, "/show [model|channel|agents|mcp <server>]") {
|
||||
t.Fatalf("/help reply missing /show usage, got %q", reply)
|
||||
}
|
||||
if !strings.Contains(reply, "/list [models|channels|agents|skills]") {
|
||||
if !strings.Contains(reply, "/list [models|channels|agents|skills|mcp]") {
|
||||
t.Fatalf("/help reply missing /list usage, got %q", reply)
|
||||
}
|
||||
if !strings.Contains(reply, "/use <skill> <message>") {
|
||||
@@ -174,6 +174,90 @@ func TestBuiltinListSkills_UsesRuntimeSkillNames(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuiltinListMCP_UsesRuntimeServerStatus(t *testing.T) {
|
||||
rt := &Runtime{
|
||||
ListMCPServers: func(context.Context) []MCPServerInfo {
|
||||
return []MCPServerInfo{
|
||||
{Name: "filesystem", Enabled: true, Deferred: true, Connected: false},
|
||||
{Name: "github", Enabled: true, Deferred: false, Connected: true, ToolCount: 3},
|
||||
}
|
||||
},
|
||||
}
|
||||
defs := BuiltinDefinitions()
|
||||
ex := NewExecutor(NewRegistry(defs), rt)
|
||||
|
||||
var reply string
|
||||
res := ex.Execute(context.Background(), Request{
|
||||
Text: "/list mcp",
|
||||
Reply: func(text string) error {
|
||||
reply = text
|
||||
return nil
|
||||
},
|
||||
})
|
||||
if res.Outcome != OutcomeHandled {
|
||||
t.Fatalf("/list mcp: outcome=%v, want=%v", res.Outcome, OutcomeHandled)
|
||||
}
|
||||
if !strings.Contains(reply, "- `filesystem`\n Enabled: yes\n Deferred: yes\n Connected: no\n Active tools: unavailable") {
|
||||
t.Fatalf("/list mcp reply=%q, want formatted filesystem block", reply)
|
||||
}
|
||||
if !strings.Contains(reply, "- `github`\n Enabled: yes\n Deferred: no\n Connected: yes\n Active tools: 3") {
|
||||
t.Fatalf("/list mcp reply=%q, want formatted github block", reply)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuiltinShowMCP_UsesRuntimeToolNames(t *testing.T) {
|
||||
rt := &Runtime{
|
||||
ListMCPTools: func(_ context.Context, serverName string) ([]MCPToolInfo, error) {
|
||||
if serverName != "github" {
|
||||
t.Fatalf("serverName=%q, want github", serverName)
|
||||
}
|
||||
return []MCPToolInfo{
|
||||
{
|
||||
Name: "create_issue",
|
||||
Description: "Create a GitHub issue",
|
||||
Parameters: []MCPToolParameterInfo{
|
||||
{Name: "body", Type: "string", Description: "Issue body"},
|
||||
{Name: "title", Type: "string", Description: "Issue title", Required: true},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "list_prs",
|
||||
Description: "List open pull requests",
|
||||
},
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
defs := BuiltinDefinitions()
|
||||
ex := NewExecutor(NewRegistry(defs), rt)
|
||||
|
||||
var reply string
|
||||
res := ex.Execute(context.Background(), Request{
|
||||
Text: "/show mcp github",
|
||||
Reply: func(text string) error {
|
||||
reply = text
|
||||
return nil
|
||||
},
|
||||
})
|
||||
if res.Outcome != OutcomeHandled {
|
||||
t.Fatalf("/show mcp: outcome=%v, want=%v", res.Outcome, OutcomeHandled)
|
||||
}
|
||||
if !strings.Contains(reply, "Active MCP tools for `github`:\n- `create_issue`") {
|
||||
t.Fatalf("/show mcp reply=%q, want tool header", reply)
|
||||
}
|
||||
if !strings.Contains(reply, "Description: Create a GitHub issue") {
|
||||
t.Fatalf("/show mcp reply=%q, want description", reply)
|
||||
}
|
||||
if !strings.Contains(reply, " - `title` (string, required): Issue title") {
|
||||
t.Fatalf("/show mcp reply=%q, want required parameter", reply)
|
||||
}
|
||||
if !strings.Contains(reply, " - `body` (string): Issue body") {
|
||||
t.Fatalf("/show mcp reply=%q, want optional parameter", reply)
|
||||
}
|
||||
if !strings.Contains(reply, "- `list_prs`\n Description: List open pull requests\n Parameters: none") {
|
||||
t.Fatalf("/show mcp reply=%q, want empty parameter block", reply)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuiltinUseCommand_PassthroughsToAgentLogic(t *testing.T) {
|
||||
defs := BuiltinDefinitions()
|
||||
ex := NewExecutor(NewRegistry(defs), nil)
|
||||
|
||||
@@ -64,6 +64,11 @@ func listCommand() Definition {
|
||||
))
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "mcp",
|
||||
Description: "Configured MCP servers",
|
||||
Handler: listMCPServersHandler(),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,12 @@ func showCommand() Definition {
|
||||
Description: "Registered agents",
|
||||
Handler: agentsHandler(),
|
||||
},
|
||||
{
|
||||
Name: "mcp",
|
||||
Description: "Active tools for an MCP server",
|
||||
ArgsUsage: "<server>",
|
||||
Handler: showMCPToolsHandler(),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func listMCPServersHandler() Handler {
|
||||
return func(ctx context.Context, req Request, rt *Runtime) error {
|
||||
if rt == nil || rt.ListMCPServers == nil {
|
||||
return req.Reply(unavailableMsg)
|
||||
}
|
||||
|
||||
servers := rt.ListMCPServers(ctx)
|
||||
if len(servers) == 0 {
|
||||
return req.Reply("No MCP servers configured")
|
||||
}
|
||||
|
||||
header := "Configured MCP Servers:"
|
||||
if rt.Config != nil && !rt.Config.Tools.IsToolEnabled("mcp") {
|
||||
header = "Configured MCP Servers (integration disabled):"
|
||||
}
|
||||
|
||||
lines := make([]string, 0, len(servers)*5+1)
|
||||
lines = append(lines, header)
|
||||
for idx, server := range servers {
|
||||
if idx > 0 {
|
||||
lines = append(lines, "")
|
||||
}
|
||||
lines = append(lines, fmt.Sprintf("- `%s`", server.Name))
|
||||
lines = append(lines, fmt.Sprintf(" Enabled: %s", yesNo(server.Enabled)))
|
||||
lines = append(lines, fmt.Sprintf(" Deferred: %s", yesNo(server.Deferred)))
|
||||
lines = append(lines, fmt.Sprintf(" Connected: %s", yesNo(server.Connected)))
|
||||
if server.Connected {
|
||||
lines = append(lines, fmt.Sprintf(" Active tools: %d", server.ToolCount))
|
||||
continue
|
||||
}
|
||||
lines = append(lines, " Active tools: unavailable")
|
||||
}
|
||||
|
||||
return req.Reply(strings.Join(lines, "\n"))
|
||||
}
|
||||
}
|
||||
|
||||
func showMCPToolsHandler() Handler {
|
||||
return func(ctx context.Context, req Request, rt *Runtime) error {
|
||||
if rt == nil || rt.ListMCPTools == nil {
|
||||
return req.Reply(unavailableMsg)
|
||||
}
|
||||
|
||||
serverName := nthToken(req.Text, 2)
|
||||
if serverName == "" {
|
||||
return req.Reply("Usage: /show mcp <server>")
|
||||
}
|
||||
|
||||
tools, err := rt.ListMCPTools(ctx, serverName)
|
||||
if err != nil {
|
||||
return req.Reply(err.Error())
|
||||
}
|
||||
if len(tools) == 0 {
|
||||
return req.Reply(fmt.Sprintf("MCP server '%s' has no active tools", serverName))
|
||||
}
|
||||
|
||||
lines := make([]string, 0, len(tools)*6+1)
|
||||
lines = append(lines, fmt.Sprintf("Active MCP tools for `%s`:", serverName))
|
||||
for idx, tool := range tools {
|
||||
if idx > 0 {
|
||||
lines = append(lines, "")
|
||||
}
|
||||
lines = append(lines, fmt.Sprintf("- `%s`", tool.Name))
|
||||
lines = append(lines, fmt.Sprintf(" Description: %s", tool.Description))
|
||||
if len(tool.Parameters) == 0 {
|
||||
lines = append(lines, " Parameters: none")
|
||||
continue
|
||||
}
|
||||
|
||||
lines = append(lines, " Parameters:")
|
||||
for _, param := range tool.Parameters {
|
||||
line := fmt.Sprintf(" - `%s`", param.Name)
|
||||
if param.Type != "" {
|
||||
line += fmt.Sprintf(" (%s", param.Type)
|
||||
if param.Required {
|
||||
line += ", required"
|
||||
}
|
||||
line += ")"
|
||||
} else if param.Required {
|
||||
line += " (required)"
|
||||
}
|
||||
if param.Description != "" {
|
||||
line += ": " + param.Description
|
||||
}
|
||||
lines = append(lines, line)
|
||||
}
|
||||
}
|
||||
|
||||
return req.Reply(strings.Join(lines, "\n"))
|
||||
}
|
||||
}
|
||||
|
||||
func yesNo(v bool) string {
|
||||
if v {
|
||||
return "yes"
|
||||
}
|
||||
return "no"
|
||||
}
|
||||
@@ -6,6 +6,27 @@ import (
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
)
|
||||
|
||||
type MCPServerInfo struct {
|
||||
Name string
|
||||
Enabled bool
|
||||
Deferred bool
|
||||
Connected bool
|
||||
ToolCount int
|
||||
}
|
||||
|
||||
type MCPToolParameterInfo struct {
|
||||
Name string
|
||||
Type string
|
||||
Description string
|
||||
Required bool
|
||||
}
|
||||
|
||||
type MCPToolInfo struct {
|
||||
Name string
|
||||
Description string
|
||||
Parameters []MCPToolParameterInfo
|
||||
}
|
||||
|
||||
// ContextStats describes current session context window usage.
|
||||
type ContextStats struct {
|
||||
UsedTokens int
|
||||
@@ -25,6 +46,8 @@ type Runtime struct {
|
||||
ListAgentIDs func() []string
|
||||
ListDefinitions func() []Definition
|
||||
ListSkillNames func() []string
|
||||
ListMCPServers func(ctx context.Context) []MCPServerInfo
|
||||
ListMCPTools func(ctx context.Context, serverName string) ([]MCPToolInfo, error)
|
||||
GetEnabledChannels func() []string
|
||||
GetActiveTurn func() any // Returning any to avoid circular dependency with agent package
|
||||
GetContextStats func() *ContextStats
|
||||
|
||||
Reference in New Issue
Block a user