diff --git a/agent/agent.go b/agent/agent.go index 06159ee..8e5a732 100644 --- a/agent/agent.go +++ b/agent/agent.go @@ -50,7 +50,10 @@ func (a *Agent) toolDefs() []openai.ToolDef { return defs } -func (a *Agent) RunAgentLoop(ctx context.Context, sess *session.Session, sink EventSink) error { +// Run advances sess until the agent finishes its turn. Persistence is owned by +// the caller; the same loop can therefore run persistent user sessions or +// ephemeral delegated tasks. +func (a *Agent) Run(ctx context.Context, sess *session.Session, sink EventSink) error { if a.Stream == nil { return fmt.Errorf("agent has no model stream") } @@ -99,9 +102,6 @@ func (a *Agent) RunAgentLoop(ctx context.Context, sess *session.Session, sink Ev return err } a.AppendContextMaintenanceNotice(sess) - if err := sess.Save(); err != nil { - return fmt.Errorf("save session: %w", err) - } if err := sink.Done(); err != nil { return err } @@ -150,12 +150,14 @@ func (a *Agent) RunAgentLoop(ctx context.Context, sess *session.Session, sink Ev } sess.Messages = append(sess.Messages, openai.ToolResultMessage(tc.ID, tc.Function.Name, result)) } - if err := sess.Save(); err != nil { - return fmt.Errorf("save session: %w", err) - } } } +// RunAgentLoop is kept for callers using the previous name. +func (a *Agent) RunAgentLoop(ctx context.Context, sess *session.Session, sink EventSink) error { + return a.Run(ctx, sess, sink) +} + func emitAttachedFileResult(sink EventSink, result string) (string, bool, error) { var attachment tools.AttachFileResult if err := json.Unmarshal([]byte(result), &attachment); err != nil { @@ -210,25 +212,3 @@ func (a *Agent) readSystemPrompt() string { } return string(data) } - -func (a *Agent) BuildTools() { - workspace := a.Config.GetWorkspace() - if workspace != "" { - _ = os.MkdirAll(workspace, 0755) - } - a.Use( - &tools.WebFetchTool{}, - &tools.WebSearchTool{}, - &tools.ReadFileTool{Workspace: workspace}, - &tools.WriteFileTool{Workspace: workspace}, - &tools.AppendFileTool{Workspace: workspace}, - &tools.EditFileTool{Workspace: workspace}, - &tools.AttachFileTool{Workspace: workspace}, - &tools.ExecTool{ - Workspace: workspace, - DefaultTimeout: tools.ExecDefaultTimeoutSeconds, - RestrictToWorkspace: true, - }, - &tools.SkillsTool{Workspace: filepath.Join(config.ConfigPath, "skills")}, - ) -} diff --git a/agent/agent_loop_test.go b/agent/agent_loop_test.go index 078dec7..fe3f82f 100644 --- a/agent/agent_loop_test.go +++ b/agent/agent_loop_test.go @@ -2,8 +2,6 @@ package agent import ( "context" - "os" - "path/filepath" "strings" "testing" @@ -34,16 +32,16 @@ func (discardSink) SessionInfo(SessionInfoEvent) error { return nil } func (discardSink) Usage(UsageEvent) error { return nil } func (discardSink) Done() error { return nil } -func TestRunAgentLoopRejectsEmptyStream(t *testing.T) { +func TestRunRejectsEmptyStream(t *testing.T) { ag := New("test", &config.ProfileConfig{ModelName: "test"}, fakeStream()) - err := ag.RunAgentLoop(context.Background(), session.New("test"), discardSink{}) + err := ag.Run(context.Background(), session.New("test"), discardSink{}) if err == nil || !strings.Contains(err.Error(), "closed without a response") { - t.Fatalf("RunAgentLoop error = %v", err) + t.Fatalf("Run error = %v", err) } } -func TestRunAgentLoopRejectsInterruptedStream(t *testing.T) { +func TestRunRejectsInterruptedStream(t *testing.T) { message := openai.ChatCompletionMessage{Role: openai.RoleAssistant, Content: "partial"} ag := New("test", &config.ProfileConfig{ModelName: "test"}, fakeStream( openai.ChatCompletionResponse{Choices: []openai.ChatCompletionChoice{{Index: 0, Delta: &message}}}, @@ -51,16 +49,16 @@ func TestRunAgentLoopRejectsInterruptedStream(t *testing.T) { )) sess := session.New("test") - err := ag.RunAgentLoop(context.Background(), sess, discardSink{}) + err := ag.Run(context.Background(), sess, discardSink{}) if err == nil || !strings.Contains(err.Error(), "connection reset") { - t.Fatalf("RunAgentLoop error = %v", err) + t.Fatalf("Run error = %v", err) } if len(sess.Messages) != 0 { t.Fatalf("interrupted response was saved: %#v", sess.Messages) } } -func TestRunAgentLoopRejectsToolCallWithoutID(t *testing.T) { +func TestRunRejectsToolCallWithoutID(t *testing.T) { message := openai.ChatCompletionMessage{ Role: openai.RoleAssistant, ToolCalls: []openai.ToolCall{{ @@ -71,19 +69,15 @@ func TestRunAgentLoopRejectsToolCallWithoutID(t *testing.T) { openai.ChatCompletionResponse{Choices: []openai.ChatCompletionChoice{{Index: 0, Delta: &message}}}, )) - err := ag.RunAgentLoop(context.Background(), session.New("test"), discardSink{}) + err := ag.Run(context.Background(), session.New("test"), discardSink{}) if err == nil || !strings.Contains(err.Error(), "missing an id") { - t.Fatalf("RunAgentLoop error = %v", err) + t.Fatalf("Run error = %v", err) } } -func TestRunAgentLoopReturnsSaveError(t *testing.T) { +func TestRunDoesNotPersistSession(t *testing.T) { oldConfigPath := config.ConfigPath - configPath := filepath.Join(t.TempDir(), "config-file") - if err := os.WriteFile(configPath, []byte("not a directory"), 0600); err != nil { - t.Fatal(err) - } - config.ConfigPath = configPath + config.ConfigPath = t.TempDir() t.Cleanup(func() { config.ConfigPath = oldConfigPath }) message := openai.ChatCompletionMessage{Role: openai.RoleAssistant, Content: "done"} @@ -91,8 +85,14 @@ func TestRunAgentLoopReturnsSaveError(t *testing.T) { openai.ChatCompletionResponse{Choices: []openai.ChatCompletionChoice{{Index: 0, Delta: &message}}}, )) - err := ag.RunAgentLoop(context.Background(), session.New("test"), discardSink{}) - if err == nil || !strings.Contains(err.Error(), "save session") { - t.Fatalf("RunAgentLoop error = %v", err) + if err := ag.Run(context.Background(), session.New("test"), discardSink{}); err != nil { + t.Fatalf("Run: %v", err) + } + sessions, err := session.List() + if err != nil { + t.Fatalf("List: %v", err) + } + if len(sessions) != 0 { + t.Fatalf("Run persisted %d sessions, want 0", len(sessions)) } } diff --git a/agent/delegate_test.go b/agent/delegate_test.go new file mode 100644 index 0000000..9852035 --- /dev/null +++ b/agent/delegate_test.go @@ -0,0 +1,142 @@ +package agent + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/lsongdev/miya-agents/config" + "github.com/lsongdev/miya-agents/session" +) + +func TestUseAgentSelectsProfileTools(t *testing.T) { + m := NewAgentManager(&config.Config{ + Profiles: map[string]*config.ProfileConfig{ + "default": { + Provider: "openai", + ModelName: "test", + Description: "general coordinator", + Workspace: t.TempDir(), + Tools: []string{"web_search", "delegate"}, + }, + "researcher": { + Provider: "openai", + ModelName: "test", + Description: "web research", + }, + }, + Providers: map[string]*config.ProviderConfig{ + "openai": {APIBase: "http://example.invalid"}, + }, + }) + + ag, err := m.UseAgent("default") + if err != nil { + t.Fatalf("UseAgent: %v", err) + } + if _, ok := ag.tool("web_search"); !ok { + t.Fatal("web_search not configured") + } + delegate, ok := ag.tool("delegate") + if !ok { + t.Fatal("delegate not configured") + } + if _, ok := ag.tool("exec"); ok { + t.Fatal("exec should not be configured") + } + + params := delegate.Def().Function.Parameters + properties := params["properties"].(map[string]any) + agent := properties["agent"].(map[string]any) + names := agent["enum"].([]string) + if strings.Join(names, ",") != "default,researcher" { + t.Fatalf("delegate agents = %v", names) + } + if !strings.Contains(delegate.Def().Function.Description, "researcher: web research") { + t.Fatalf("delegate description = %q", delegate.Def().Function.Description) + } +} + +func TestUseAgentRejectsUnknownTool(t *testing.T) { + m := NewAgentManager(&config.Config{ + Profiles: map[string]*config.ProfileConfig{ + "default": {Provider: "openai", ModelName: "test", Tools: []string{"missing"}}, + }, + Providers: map[string]*config.ProviderConfig{ + "openai": {APIBase: "http://example.invalid"}, + }, + }) + + _, err := m.UseAgent("default") + if err == nil || !strings.Contains(err.Error(), `unknown tool "missing"`) { + t.Fatalf("UseAgent error = %v", err) + } +} + +func TestDelegateUsesEphemeralSession(t *testing.T) { + oldConfigPath := config.ConfigPath + config.ConfigPath = t.TempDir() + t.Cleanup(func() { config.ConfigPath = oldConfigPath }) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/chat/completions" { + t.Fatalf("path = %q", r.URL.Path) + } + var req map[string]any + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Fatal(err) + } + messages, _ := req["messages"].([]any) + if len(messages) == 0 { + t.Fatal("missing delegated task") + } + + w.Header().Set("Content-Type", "text/event-stream") + fmt.Fprint(w, "data: {\"id\":\"chat_1\",\"object\":\"chat.completion.chunk\",\"model\":\"test\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"delegated result\"}}]}\n\n") + fmt.Fprint(w, "data: {\"id\":\"chat_1\",\"object\":\"chat.completion.chunk\",\"model\":\"test\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}]}\n\n") + fmt.Fprint(w, "data: [DONE]\n\n") + })) + defer server.Close() + + m := NewAgentManager(&config.Config{ + Profiles: map[string]*config.ProfileConfig{ + "researcher": { + Provider: "openai", + ModelName: "test", + Workspace: t.TempDir(), + Tools: []string{"web_search"}, + }, + }, + Providers: map[string]*config.ProviderConfig{ + "openai": {APIBase: server.URL}, + }, + }) + + result, err := m.Delegate(context.Background(), "researcher", "investigate this") + if err != nil { + t.Fatalf("Delegate: %v", err) + } + if result != "delegated result" { + t.Fatalf("result = %q", result) + } + sessions, err := session.List() + if err != nil { + t.Fatalf("List: %v", err) + } + if len(sessions) != 0 { + t.Fatalf("Delegate persisted %d sessions, want 0", len(sessions)) + } +} + +func TestDelegateLimitsRecursion(t *testing.T) { + m := NewAgentManager(&config.Config{}) + ctx := context.WithValue(context.Background(), delegateDepthKey{}, maxDelegateDepth) + _, err := m.Delegate(ctx, "anything", "task") + if err == nil || !strings.Contains(err.Error(), "maximum delegation depth") { + t.Fatalf("Delegate error = %v", err) + } +} diff --git a/agent/manager.go b/agent/manager.go index 64c9fce..9f2d07b 100644 --- a/agent/manager.go +++ b/agent/manager.go @@ -18,6 +18,10 @@ import ( "github.com/lsongdev/miya-agents/tools" ) +const maxDelegateDepth = 4 + +type delegateDepthKey struct{} + type Manager struct { config *config.Config sessions map[string]*session.Session @@ -56,14 +60,76 @@ func (m *Manager) UseAgent(name string) (*Agent, error) { return nil, fmt.Errorf("unsupported provider type %q", provider.Type) } - a := New(name, profile, stream) - a.BuildTools() - mcpManager := tools.NewMcpManager(m.config.McpServers) - for _, tool := range mcpManager.Tools { - a.Use(tool) + agentTools, err := m.toolsFor(profile) + if err != nil { + return nil, fmt.Errorf("agent %q tools: %w", name, err) + } + return New(name, profile, stream).Use(agentTools...), nil +} + +func (m *Manager) toolsFor(profile *config.ProfileConfig) ([]openai.Tool, error) { + workspace := profile.GetWorkspace() + if workspace != "" { + _ = os.MkdirAll(workspace, 0755) + } + + available := []openai.Tool{ + &tools.WebFetchTool{}, + &tools.WebSearchTool{}, + &tools.ReadFileTool{Workspace: workspace}, + &tools.WriteFileTool{Workspace: workspace}, + &tools.AppendFileTool{Workspace: workspace}, + &tools.EditFileTool{Workspace: workspace}, + &tools.AttachFileTool{Workspace: workspace}, + &tools.ExecTool{ + Workspace: workspace, + DefaultTimeout: tools.ExecDefaultTimeoutSeconds, + RestrictToWorkspace: true, + }, + &tools.SkillsTool{Workspace: filepath.Join(config.ConfigPath, "skills")}, + tools.NewDelegateTool(m.Delegate, m.agentDescriptions()), + } + + needsMCP := len(profile.Tools) == 0 + if !needsMCP { + for _, name := range profile.Tools { + if strings.HasPrefix(name, "mcp_") { + needsMCP = true + break + } + } + } + if needsMCP { + mcpManager := tools.NewMcpManager(m.config.McpServers) + for _, tool := range mcpManager.Tools { + available = append(available, tool) + } + } + if len(profile.Tools) == 0 { + return available, nil + } + + byName := make(map[string]openai.Tool, len(available)) + for _, tool := range available { + byName[tool.Def().Function.Name] = tool } - a.Use(tools.NewSubagentTool(m)) - return a, nil + selected := make([]openai.Tool, 0, len(profile.Tools)) + for _, name := range profile.Tools { + tool, ok := byName[name] + if !ok { + return nil, fmt.Errorf("unknown tool %q", name) + } + selected = append(selected, tool) + } + return selected, nil +} + +func (m *Manager) agentDescriptions() map[string]string { + agents := make(map[string]string, len(m.config.Profiles)) + for name, profile := range m.config.Profiles { + agents[name] = profile.Description + } + return agents } func (m *Manager) defaultAgentName() (string, error) { @@ -116,33 +182,31 @@ func (w *captureWriter) SessionInfo(event SessionInfoEvent) error { return nil } func (w *captureWriter) Usage(event UsageEvent) error { return nil } func (w *captureWriter) Done() error { return nil } -func (m *Manager) RunAgent(ctx context.Context, name, prompt string) (string, error) { +// Delegate runs an agent with an isolated, in-memory session and returns only +// its final output. Delegated agents are otherwise normal profile-backed agents. +func (m *Manager) Delegate(ctx context.Context, name, task string) (string, error) { + depth, _ := ctx.Value(delegateDepthKey{}).(int) + if depth >= maxDelegateDepth { + return "", fmt.Errorf("maximum delegation depth %d reached", maxDelegateDepth) + } ag, err := m.UseAgent(name) if err != nil { return "", err } sess := ag.NewSession() - sess.AppendRequest(prompt) - RecordUserMessage(sess, prompt) - if sess.Title == "" { - sess.Title = sess.DisplayTitle() - } - + sess.AppendRequest(task) writer := &captureWriter{} - sink := NewRecordingSink(sess, writer) - if sess.Title != "" { - if err := sink.SessionInfo(SessionInfoEvent{Title: sess.Title}); err != nil { - return "", err + ctx = context.WithValue(ctx, delegateDepthKey{}, depth+1) + if err := ag.Run(ctx, sess, writer); err != nil { + return "", fmt.Errorf("agent %q failed: %w", name, err) + } + for i := len(sess.Messages) - 1; i >= 0; i-- { + message := sess.Messages[i] + if message.Role == openai.RoleAssistant && !message.HasToolCall() && message.Content != "" { + return message.Content, nil } } - if err := sess.Save(); err != nil { - return "", fmt.Errorf("save session: %w", err) - } - if err := ag.RunAgentLoop(ctx, sess, sink); err != nil { - return "", fmt.Errorf("agent '%s' failed: %v", name, err) - } - return writer.sb.String(), nil } @@ -246,7 +310,8 @@ func (m *Manager) Prompt(ctx context.Context, req *acp.PromptRequest, sender acp if err := sess.Save(); err != nil { return nil, fmt.Errorf("save session: %w", err) } - if err := ag.RunAgentLoop(ctx, sess, sink); err != nil { + if err := ag.Run(ctx, sess, sink); err != nil { + _ = sess.Save() return nil, fmt.Errorf("agent loop: %w", err) } diff --git a/agent/profile_tools_test.go b/agent/profile_tools_test.go new file mode 100644 index 0000000..8ad11d8 --- /dev/null +++ b/agent/profile_tools_test.go @@ -0,0 +1,43 @@ +package agent + +import ( + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + + "github.com/lsongdev/miya-agents/config" + "github.com/lsongdev/miya-agents/mcp" +) + +func TestUseAgentSkipsUnselectedMCPServers(t *testing.T) { + var requests atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + requests.Add(1) + })) + defer server.Close() + + m := NewAgentManager(&config.Config{ + Profiles: map[string]*config.ProfileConfig{ + "default": { + Provider: "openai", + ModelName: "test", + Workspace: t.TempDir(), + Tools: []string{"web_search"}, + }, + }, + Providers: map[string]*config.ProviderConfig{ + "openai": {APIBase: "http://example.invalid"}, + }, + McpServers: map[string]*mcp.McpServerConfig{ + "unused": {Type: "streamablehttp", URL: server.URL}, + }, + }) + + if _, err := m.UseAgent("default"); err != nil { + t.Fatalf("UseAgent: %v", err) + } + if got := requests.Load(); got != 0 { + t.Fatalf("unselected MCP server received %d requests", got) + } +} diff --git a/agent/recording.go b/agent/recording.go index 96b77c2..5635a22 100644 --- a/agent/recording.go +++ b/agent/recording.go @@ -70,6 +70,9 @@ func (s *recordingSink) Usage(event UsageEvent) error { } func (s *recordingSink) Done() error { + if err := s.sess.Save(); err != nil { + return err + } return s.next.Done() } diff --git a/agent/recording_test.go b/agent/recording_test.go new file mode 100644 index 0000000..0eaf4c5 --- /dev/null +++ b/agent/recording_test.go @@ -0,0 +1,31 @@ +package agent + +import ( + "testing" + + "github.com/lsongdev/miya-agents/config" + "github.com/lsongdev/miya-agents/session" +) + +func TestRecordingSinkPersistsOnDone(t *testing.T) { + oldConfigPath := config.ConfigPath + config.ConfigPath = t.TempDir() + t.Cleanup(func() { config.ConfigPath = oldConfigPath }) + + sess := session.New("default") + sink := NewRecordingSink(sess, discardSink{}) + if err := sink.AssistantDelta("hello"); err != nil { + t.Fatal(err) + } + if err := sink.Done(); err != nil { + t.Fatal(err) + } + + loaded, err := session.Load(sess.ID) + if err != nil { + t.Fatalf("Load: %v", err) + } + if len(loaded.Events) != 1 { + t.Fatalf("events = %d, want 1", len(loaded.Events)) + } +} diff --git a/config/config.go b/config/config.go index 8c141a3..80c693b 100644 --- a/config/config.go +++ b/config/config.go @@ -128,13 +128,15 @@ type ProviderConfig struct { // ProfileConfig contains miya-agents runtime defaults. type ProfileConfig struct { - Provider string `json:"provider" yaml:"provider"` // provider name, e.g. "openai" - ModelName string `json:"model,omitempty" yaml:"model"` // model name, e.g. "deepseek-chat" - Workspace string `json:"workspace,omitempty" yaml:"workspace,omitempty"` // defaults to ~/.miya/workspace - MaxTokens int `json:"maxTokens,omitempty" yaml:"maxTokens,omitempty"` // defaults to 8192 - Temperature float64 `json:"temperature,omitempty" yaml:"temperature,omitempty"` // defaults to 0.95 - ContextWindowTokens int `json:"contextWindowTokens,omitempty" yaml:"contextWindowTokens,omitempty"` // defaults to 128000 - ContextWarnRatio float64 `json:"contextWarnRatio,omitempty" yaml:"contextWarnRatio,omitempty"` // defaults to 0.9 + Provider string `json:"provider" yaml:"provider"` // provider name, e.g. "openai" + ModelName string `json:"model,omitempty" yaml:"model"` // model name, e.g. "deepseek-chat" + Description string `json:"description,omitempty" yaml:"description,omitempty"` + Workspace string `json:"workspace,omitempty" yaml:"workspace,omitempty"` // defaults to ~/.miya/workspace + Tools []string `json:"tools,omitempty" yaml:"tools,omitempty"` // empty uses the default tool set + MaxTokens int `json:"maxTokens,omitempty" yaml:"maxTokens,omitempty"` // defaults to 8192 + Temperature float64 `json:"temperature,omitempty" yaml:"temperature,omitempty"` // defaults to 0.95 + ContextWindowTokens int `json:"contextWindowTokens,omitempty" yaml:"contextWindowTokens,omitempty"` // defaults to 128000 + ContextWarnRatio float64 `json:"contextWarnRatio,omitempty" yaml:"contextWarnRatio,omitempty"` // defaults to 0.9 } func (ac *ProfileConfig) GetWorkspace() string { diff --git a/tools/agent.go b/tools/agent.go index 770ca5f..7219817 100644 --- a/tools/agent.go +++ b/tools/agent.go @@ -4,67 +4,85 @@ import ( "context" "encoding/json" "fmt" + "sort" + "strings" "github.com/lsongdev/miya-agents/openai" ) -// AgentRunner defines the interface for running an agent. -// This avoids circular dependencies between the agent and tools packages. -type AgentRunner interface { - RunAgent(ctx context.Context, name, prompt string) (string, error) +type DelegateFunc func(context.Context, string, string) (string, error) + +type DelegateTool struct { + Delegate DelegateFunc + Agents map[string]string } -// SubagentTool is a tool that allows an agent to invoke another agent. -type SubagentTool struct { - Runner AgentRunner +func NewDelegateTool(delegate DelegateFunc, agents map[string]string) *DelegateTool { + return &DelegateTool{Delegate: delegate, Agents: agents} } -// NewSubagentTool creates a new SubagentTool. -func NewSubagentTool(runner AgentRunner) *SubagentTool { - return &SubagentTool{ - Runner: runner, +func (t *DelegateTool) Def() openai.ToolDef { + names := make([]string, 0, len(t.Agents)) + for name := range t.Agents { + names = append(names, name) + } + sort.Strings(names) + + description := "Delegate a self-contained task to another agent. The delegated agent has an independent context and returns only its result." + if len(names) > 0 { + var agents strings.Builder + agents.WriteString(description + " Available agents:") + for _, name := range names { + agents.WriteString("\n- " + name) + if detail := strings.TrimSpace(t.Agents[name]); detail != "" { + agents.WriteString(": " + detail) + } + } + description = agents.String() } -} -// Def implements [openai.Tool]. -func (t *SubagentTool) Def() openai.ToolDef { return openai.ToolDef{ Type: "function", Function: openai.FunctionDef{ - Name: "invoke_agent", - Description: "Invoke a specialized sub-agent to perform a specific task or investigation. Use this to delegate complex or repetitive work.", + Name: "delegate", + Description: description, Parameters: map[string]any{ "type": "object", "properties": map[string]any{ - "agent_name": map[string]any{ - "type": "string", - "description": "Name of the sub-agent to invoke.", + "agent": map[string]any{ + "type": "string", + "enum": names, + "description": "Agent profile to run the task.", }, - "prompt": map[string]any{ + "task": map[string]any{ "type": "string", - "description": "The COMPLETE query to send the subagent. MUST be comprehensive and detailed.", + "description": "Complete, self-contained task for the delegated agent.", }, }, - "required": []string{"agent_name", "prompt"}, + "required": []string{"agent", "task"}, }, }, } } -// Run implements [openai.Tool]. -func (t *SubagentTool) Run(ctx context.Context, args string) string { +func (t *DelegateTool) Run(ctx context.Context, args string) string { var input struct { - AgentName string `json:"agent_name"` - Prompt string `json:"prompt"` + Agent string `json:"agent"` + Task string `json:"task"` } if err := json.Unmarshal([]byte(args), &input); err != nil { return fmt.Sprintf("Error: failed to parse arguments: %v", err) } + if strings.TrimSpace(input.Agent) == "" || strings.TrimSpace(input.Task) == "" { + return "Error: agent and task are required" + } + if t.Delegate == nil { + return "Error: delegation is not configured" + } - result, err := t.Runner.RunAgent(ctx, input.AgentName, input.Prompt) + result, err := t.Delegate(ctx, input.Agent, input.Task) if err != nil { return fmt.Sprintf("Error: %v", err) } - return result } diff --git a/tools/agent_test.go b/tools/agent_test.go new file mode 100644 index 0000000..40254d5 --- /dev/null +++ b/tools/agent_test.go @@ -0,0 +1,26 @@ +package tools + +import ( + "context" + "strings" + "testing" +) + +func TestDelegateToolCallsFunction(t *testing.T) { + var gotAgent, gotTask string + tool := NewDelegateTool(func(_ context.Context, agent, task string) (string, error) { + gotAgent, gotTask = agent, task + return "done", nil + }, map[string]string{"researcher": "research the web"}) + + if got := tool.Run(context.Background(), `{"agent":"researcher","task":"find it"}`); got != "done" { + t.Fatalf("Run = %q", got) + } + if gotAgent != "researcher" || gotTask != "find it" { + t.Fatalf("delegate = (%q, %q)", gotAgent, gotTask) + } + def := tool.Def() + if def.Function.Name != "delegate" || !strings.Contains(def.Function.Description, "researcher: research the web") { + t.Fatalf("definition = %#v", def.Function) + } +}