From 2cb3cdb6f3f42d88cfb94ebb7ab2ba0e3e05a984 Mon Sep 17 00:00:00 2001 From: Sayt-0 Date: Thu, 30 Jul 2026 18:13:03 +0200 Subject: [PATCH 1/4] feat(core): support local team composition --- agent-schema.json | 6 +- pkg/agent/agent.go | 26 +++ pkg/agent/opts.go | 16 ++ pkg/config/resolve.go | 58 ++++- pkg/config/resolve_test.go | 102 +++++++++ pkg/team/team.go | 81 ++++++- pkg/team/team_test.go | 27 ++- pkg/teamloader/external_teams_test.go | 115 ++++++++++ pkg/teamloader/teamloader.go | 301 +++++++++++++++++++++++--- pkg/teamloader/teamloader_test.go | 274 +++++++++++++++++++++++ 10 files changed, 954 insertions(+), 52 deletions(-) create mode 100644 pkg/teamloader/external_teams_test.go diff --git a/agent-schema.json b/agent-schema.json index d73d6591e..71fffbc87 100644 --- a/agent-schema.json +++ b/agent-schema.json @@ -631,21 +631,21 @@ }, "sub_agents": { "type": "array", - "description": "List of sub-agents. Can be names of agents defined in this config, external references (OCI images like 'namespace/repo' or URLs), or named external references using 'name:reference' syntax (e.g. 'reviewer:myorg/review-pr'). External agents without an explicit name are named after their last path segment. Pin external OCI references to an immutable digest (e.g. 'namespace/repo@sha256:...') to serve them from cache; tag references (including the implicit ':latest') are re-resolved against the registry on every run, even when the sub-agent is never invoked.", + "description": "List of sub-agents. Can be names of agents defined in this config, external references (OCI images like 'namespace/repo', URLs, or local config file paths like './team.yaml'), or named external references using 'name:reference' syntax (e.g. 'reviewer:myorg/review-pr' or 'specialists:./team.yaml'). External agents without an explicit name are named after their last path segment (file references after the file name without extension). A local file reference (.yaml, .yml or .hcl, resolved relative to this config file) exposes only that team's default agent ('root' if present, otherwise the first declared), which keeps its own sub-agents so it orchestrates its own team; transfer_task blocks until it returns. Pin external OCI references to an immutable digest (e.g. 'namespace/repo@sha256:...') to serve them from cache; tag references (including the implicit ':latest') are re-resolved against the registry on every run, even when the sub-agent is never invoked.", "items": { "type": "string" } }, "handoffs": { "type": "array", - "description": "List of agents this agent can hand off the conversation to. Can be names of agents defined in this config, external references (OCI images like 'namespace/repo' or URLs), or named external references using 'name:reference' syntax (e.g. 'reviewer:myorg/review-pr'). External agents without an explicit name are named after their last path segment. Pin external OCI references to an immutable digest (e.g. 'namespace/repo@sha256:...') to serve them from cache; tag references are re-resolved against the registry on every run.", + "description": "List of agents this agent can hand off the conversation to. Can be names of agents defined in this config, external references (OCI images like 'namespace/repo', URLs, or local config file paths like './team.yaml'), or named external references using 'name:reference' syntax (e.g. 'reviewer:myorg/review-pr'). External agents without an explicit name are named after their last path segment (file references after the file name without extension). Pin external OCI references to an immutable digest (e.g. 'namespace/repo@sha256:...') to serve them from cache; tag references are re-resolved against the registry on every run.", "items": { "type": "string" } }, "force_handoff": { "type": "string", - "description": "Name of an agent that unconditionally receives the conversation whenever this agent produces a final response, bypassing the LLM's tool-calling entirely. Guarantees deterministic routing for strict pipelines (e.g. extractor -> summarizer). Can be the name of an agent defined in this config or an external reference. Pin an external OCI reference to an immutable digest (e.g. 'namespace/repo@sha256:...') to avoid a per-run registry lookup. Must not reference the agent itself, and force_handoff chains must not form a cycle." + "description": "Name of an agent that unconditionally receives the conversation whenever this agent produces a final response, bypassing the LLM's tool-calling entirely. Guarantees deterministic routing for strict pipelines (e.g. extractor -> summarizer). Can be the name of an agent defined in this config or an external reference (OCI image, URL, or local config file path). Pin an external OCI reference to an immutable digest (e.g. 'namespace/repo@sha256:...') to avoid a per-run registry lookup. Must not reference the agent itself, and force_handoff chains must not form a cycle." }, "add_date": { "type": "boolean", diff --git a/pkg/agent/agent.go b/pkg/agent/agent.go index e787bddb1..7bab7ef89 100644 --- a/pkg/agent/agent.go +++ b/pkg/agent/agent.go @@ -21,9 +21,13 @@ import ( // Agent represents an AI agent type Agent struct { name string + displayName string description string welcomeMessage string instruction string + teamName string + teamLead bool + internal bool toolsets []*tools.StartableToolSet models []provider.Provider fallbackModels []provider.Provider // Fallback models to try if primary fails @@ -86,6 +90,28 @@ func (a *Agent) Name() string { return a.name } +// DisplayName is the presentation label for this agent. Empty means Name. +// Imported team leads keep their manifest-local name (usually "root") here +// even when routing exposes them under a generated unique ID. +func (a *Agent) DisplayName() string { + if a.displayName != "" { + return a.displayName + } + return a.name +} + +// TeamName is the presentation name of the manifest/team this agent belongs +// to. It does not affect routing or agent identity. +func (a *Agent) TeamName() string { return a.teamName } + +// TeamLead reports whether this agent is the lead exposed for an imported +// team. +func (a *Agent) TeamLead() bool { return a.teamLead } + +// Internal reports whether this agent is private to an imported team. Internal +// agents may run through scoped delegation but are not public switch targets. +func (a *Agent) Internal() bool { return a.internal } + // Instruction returns the agent's instructions func (a *Agent) Instruction() string { return a.instruction diff --git a/pkg/agent/opts.go b/pkg/agent/opts.go index bd7464620..9e3c0395b 100644 --- a/pkg/agent/opts.go +++ b/pkg/agent/opts.go @@ -53,6 +53,22 @@ func WithName(name string) Opt { } } +func WithDisplayName(name string) Opt { + return func(a *Agent) { + a.displayName = name + } +} + +// WithTeamInfo attaches presentation-only team metadata. It never changes +// routing visibility; the parent team registry remains authoritative for that. +func WithTeamInfo(name string, lead, internal bool) Opt { + return func(a *Agent) { + a.teamName = name + a.teamLead = lead + a.internal = internal + } +} + func WithModel(model provider.Provider) Opt { return func(a *Agent) { a.models = append(a.models, model) diff --git a/pkg/config/resolve.go b/pkg/config/resolve.go index 265310485..d6262d4fa 100644 --- a/pkg/config/resolve.go +++ b/pkg/config/resolve.go @@ -239,15 +239,50 @@ func IsOCIReference(input string) bool { // isLocalFile checks if the input is a local file func isLocalFile(input string) bool { - ext := strings.ToLower(filepath.Ext(input)) // Check for known config file extensions or file descriptors - if ext == ".yaml" || ext == ".yml" || ext == ".hcl" || strings.HasPrefix(input, "/dev/fd/") { + if hasConfigFileExt(input) || strings.HasPrefix(input, "/dev/fd/") { return true } // Check if it exists as a file on disk return fileExists(input) } +// hasConfigFileExt reports whether path ends with a known agent config file +// extension (.yaml, .yml or .hcl). +func hasConfigFileExt(path string) bool { + ext := strings.ToLower(filepath.Ext(path)) + return ext == ".yaml" || ext == ".yml" || ext == ".hcl" +} + +// IsLocalConfigReference reports whether input references a local agent +// config file: a relative or absolute path ending in .yaml, .yml or .hcl. +// The check is purely syntactic — the file does not have to exist — so the +// result never depends on the current directory's contents. A ":" is only +// accepted as part of a Windows drive designator followed by a path +// separator (e.g. "C:\agents\team.yaml"); any other colon means the input +// may carry a "name:reference" prefix (including single-letter names like +// "b:./team.yaml") or a URL scheme and is not a plain path. +func IsLocalConfigReference(input string) bool { + if !hasConfigFileExt(input) { + return false + } + rest := input + if len(input) >= 3 && input[1] == ':' && isDriveLetter(input[0]) && isPathSeparator(input[2]) { + rest = input[2:] + } + return !strings.Contains(rest, ":") +} + +// isDriveLetter reports whether c can be a Windows drive letter. +func isDriveLetter(c byte) bool { + return ('a' <= c && c <= 'z') || ('A' <= c && c <= 'Z') +} + +// isPathSeparator reports whether c is a Windows or POSIX path separator. +func isPathSeparator(c byte) bool { + return c == '\\' || c == '/' +} + func fileNameWithoutExt(path string) string { base := filepath.Base(path) ext := filepath.Ext(base) @@ -255,9 +290,10 @@ func fileNameWithoutExt(path string) string { } // IsExternalReference reports whether the input is an external agent reference -// (OCI image or URL) rather than a local agent name defined in the same config. -// Local agent names never contain "/", so the slash check distinguishes them -// from OCI references like "myorg/agent:tag" or "docker.io/org/agent:v1". +// (OCI image, URL, or local config file path) rather than an agent name +// defined in the same config. Agent names never contain "/" or a config file +// extension, which distinguishes them from OCI references like +// "myorg/agent:tag" and file paths like "./team.yaml". // It also handles the "name:ref" syntax (e.g. "reviewer:myorg/review-pr"). func IsExternalReference(input string) bool { _, ref := ParseExternalAgentRef(input) @@ -266,11 +302,13 @@ func IsExternalReference(input string) bool { // ParseExternalAgentRef parses an external agent reference that may include an // explicit name prefix. The syntax is "name:reference" where name is a simple -// identifier (no slashes) and reference is an OCI reference or URL. +// identifier (no slashes) and reference is an OCI reference, URL, or local +// config file path. // // If no explicit name is provided, the base name is derived from the reference: // - OCI refs: last path segment without tag (e.g. "myorg/review-pr" → "review-pr") // - URLs: filename without extension (e.g. "https://example.com/agent.yaml" → "agent") +// - local files: filename without extension (e.g. "./secondary-team.yaml" → "secondary-team") // // Examples: // @@ -278,6 +316,7 @@ func IsExternalReference(input string) bool { // ParseExternalAgentRef("myorg/review-pr") → ("review-pr", "myorg/review-pr") // ParseExternalAgentRef("docker.io/myorg/myagent:v1") → ("myagent", "docker.io/myorg/myagent:v1") // ParseExternalAgentRef("https://example.com/agent.yaml") → ("agent", "https://example.com/agent.yaml") +// ParseExternalAgentRef("specialists:./secondary-team.yaml") → ("specialists", "./secondary-team.yaml") func ParseExternalAgentRef(input string) (agentName, ref string) { // If the whole input is already a valid external reference, derive the name // from it without trying to split on ":". @@ -306,7 +345,7 @@ func ParseExternalAgentRef(input string) (agentName, ref string) { // It is used by both IsExternalReference and ParseExternalAgentRef to avoid // circular dependencies. func isExternalRef(input string) bool { - return IsURLReference(input) || (strings.Contains(input, "/") && IsOCIReference(input)) + return IsURLReference(input) || IsLocalConfigReference(input) || (strings.Contains(input, "/") && IsOCIReference(input)) } // externalRefBaseName extracts a short agent name from an external reference. @@ -315,10 +354,11 @@ func isExternalRef(input string) bool { // "myorg/review-pr" → "review-pr" // "docker.io/myorg/myagent:v1" → "myagent" // -// - URL: filename without extension +// - URL or local file: filename without extension // "https://example.com/agent.yaml" → "agent" +// "./secondary-team.yaml" → "secondary-team" func externalRefBaseName(ref string) string { - if IsURLReference(ref) { + if IsURLReference(ref) || IsLocalConfigReference(ref) { return fileNameWithoutExt(ref) } diff --git a/pkg/config/resolve_test.go b/pkg/config/resolve_test.go index 8e66d2b6e..369996750 100644 --- a/pkg/config/resolve_test.go +++ b/pkg/config/resolve_test.go @@ -694,6 +694,66 @@ func TestIsExternalReference(t *testing.T) { input: "myagent:https://example.com/agent.yaml", expected: true, }, + { + name: "relative local yaml path is external", + input: "./secondary-team.yaml", + expected: true, + }, + { + name: "bare local yaml path is external", + input: "secondary-team.yaml", + expected: true, + }, + { + name: "relative local yml path is external", + input: "./team.yml", + expected: true, + }, + { + name: "bare local hcl path is external", + input: "team.hcl", + expected: true, + }, + { + name: "absolute local yaml path is external", + input: "/agents/secondary-team.yaml", + expected: true, + }, + { + name: "windows drive local yaml path is external", + input: `C:\agents\secondary-team.yaml`, + expected: true, + }, + { + name: "named local yaml path is external", + input: "specialists:./secondary-team.yaml", + expected: true, + }, + { + name: "named windows drive hcl path is external", + input: `specialists:C:\agents\team.hcl`, + expected: true, + }, + { + name: "single-letter named local yaml path is external", + input: "b:./team-b.yaml", + expected: true, + }, + { + name: "named absolute hcl path is external", + input: "specialists:/agents/team.hcl", + expected: true, + }, + { + name: "name resembling a config file is not external", + input: "secondary-team-yaml", + expected: false, + }, + { + name: "file name with unknown extension is not external", + input: "notes.txt", + expected: false, + }, } for _, tt := range tests { @@ -775,6 +835,48 @@ func TestParseExternalAgentRef(t *testing.T) { expectedName: "agent", expectedRef: "registry.example.com/org/sub/agent:latest", }, + { + name: "relative local yaml path derives file name", + input: "./secondary-team.yaml", + expectedName: "secondary-team", + expectedRef: "./secondary-team.yaml", + }, + { + name: "named local yaml path", + input: "specialists:./secondary-team.yaml", + expectedName: "specialists", + expectedRef: "./secondary-team.yaml", + }, + { + name: "named windows drive hcl path", + input: `specialists:C:\agents\team.hcl`, + expectedName: "specialists", + expectedRef: `C:\agents\team.hcl`, + }, + { + name: "single-letter name is an alias, not a windows drive", + input: "b:./team-b.yaml", + expectedName: "b", + expectedRef: "./team-b.yaml", + }, + { + name: "absolute local yaml path derives file name", + input: "/agents/secondary-team.yaml", + expectedName: "secondary-team", + expectedRef: "/agents/secondary-team.yaml", + }, + { + name: "named parent-relative yml path", + input: "helpers:../shared/helpers.yml", + expectedName: "helpers", + expectedRef: "../shared/helpers.yml", + }, + { + name: "bare local hcl path derives file name", + input: "team.hcl", + expectedName: "team", + expectedRef: "team.hcl", + }, } for _, tt := range tests { diff --git a/pkg/team/team.go b/pkg/team/team.go index e533596c3..e84481d66 100644 --- a/pkg/team/team.go +++ b/pkg/team/team.go @@ -75,32 +75,101 @@ func (t *Team) AgentNames() []string { return names } +// AllAgents returns the public agents plus scoped descendants, deduplicated by +// pointer. It is for runtime lifecycle/presentation only; Agent and AgentNames +// remain the authoritative public routing registry. +func (t *Team) AllAgents() []*agent.Agent { + seen := make(map[*agent.Agent]struct{}) + expanded := make(map[*agent.Agent]struct{}) + all := make([]*agent.Agent, 0, len(t.agents)) + for _, a := range t.agents { + if a == nil { + continue + } + if _, ok := seen[a]; !ok { + seen[a] = struct{}{} + all = append(all, a) + } + } + var walk func(*agent.Agent) + walk = func(a *agent.Agent) { + if a == nil { + return + } + if _, ok := expanded[a]; ok { + return + } + expanded[a] = struct{}{} + children := append([]*agent.Agent{}, a.SubAgents()...) + children = append(children, a.Handoffs()...) + if forced := a.ForceHandoff(); forced != nil { + children = append(children, forced) + } + for _, child := range children { + if child == nil { + continue + } + if _, ok := seen[child]; !ok { + seen[child] = struct{}{} + all = append(all, child) + } + walk(child) + } + } + for _, a := range t.agents { + walk(a) + } + return all +} + // AgentInfo contains information about an agent type AgentInfo struct { + Agent *agent.Agent Name string + DisplayName string Description string Provider string Model string Commands types.Commands + TeamName string + TeamLead bool + Internal bool } -// AgentsInfo returns information about all agents in the team +// AgentsInfo returns the public roster plus private imported-team members for +// presentation. Private members remain absent from AgentNames and Agent. func (t *Team) AgentsInfo(ctx context.Context) []AgentInfo { - var infos []AgentInfo + public := make(map[*agent.Agent]struct{}, len(t.agents)) for _, a := range t.agents { + public[a] = struct{}{} + } + seen := make(map[*agent.Agent]struct{}) + var infos []AgentInfo + for _, a := range t.AllAgents() { + if _, ok := seen[a]; ok { + continue + } + seen[a] = struct{}{} + _, isPublic := public[a] + displayName := "" + if a.DisplayName() != a.Name() { + displayName = a.DisplayName() + } info := AgentInfo{ + Agent: a, Name: a.Name(), + DisplayName: displayName, Description: a.Description(), Commands: a.Commands(), + TeamName: a.TeamName(), + TeamLead: a.TeamLead(), + Internal: a.Internal() || !isPublic, } if model := a.Model(ctx); model != nil { id := model.ID() info.Provider = id.Provider info.Model = id.Model } else if harnessType := a.HarnessType(); harnessType != "" { - // Harness-backed agents have no provider.Provider; surface the - // harness type (e.g. "claude-code") as the display model and leave - // Thinking empty so no badge/card line is shown. info.Model = harnessType } infos = append(infos, info) @@ -156,7 +225,7 @@ func (t *Team) Size() int { } func (t *Team) StopToolSets(ctx context.Context) error { - for _, agent := range t.agents { + for _, agent := range t.AllAgents() { if err := agent.StopToolSets(ctx); err != nil { return fmt.Errorf("failed to stop tool sets: %w", err) } diff --git a/pkg/team/team_test.go b/pkg/team/team_test.go index fe8a38781..fd4eb5d53 100644 --- a/pkg/team/team_test.go +++ b/pkg/team/team_test.go @@ -93,11 +93,32 @@ func TestAgentOrDefault(t *testing.T) { }) } +// TestAgentsInfoIncludesPrivateImportedMembers verifies the presentation roster +// includes scoped members without adding them to the public routing registry. +func TestAgentsInfoIncludesPrivateImportedMembers(t *testing.T) { + t.Parallel() + private := agent.New("researcher", "", agent.WithTeamInfo("specialists", false, true)) + lead := agent.New("specialists", "", agent.WithTeamInfo("specialists", true, false), agent.WithSubAgents(private)) + root := agent.New("root", "", agent.WithTeamInfo("primary", false, false), agent.WithSubAgents(lead)) + tm := New(WithAgents(root, lead)) + + assert.Equal(t, []string{"root", "specialists"}, tm.AgentNames()) + _, err := tm.Agent("researcher") + require.Error(t, err) + + infos := tm.AgentsInfo(t.Context()) + require.Len(t, infos, 3) + assert.Equal(t, "root", infos[0].Name) + assert.Equal(t, "specialists", infos[1].Name) + assert.True(t, infos[1].TeamLead) + assert.Equal(t, "researcher", infos[2].Name) + assert.True(t, infos[2].Internal) + assert.Equal(t, "specialists", infos[2].TeamName) +} + // TestAgentConfig verifies the raw per-agent config retained via // WithAgentConfigs is returned by name, and that callers can distinguish a -// team built without configs (remote runtime) from one built with them: both -// the unknown-agent and no-configs cases report false so the inspector omits -// config-derived sections. +// team built without configs (remote runtime) from one built with them. func TestAgentConfig(t *testing.T) { t.Parallel() diff --git a/pkg/teamloader/external_teams_test.go b/pkg/teamloader/external_teams_test.go new file mode 100644 index 000000000..03be55cec --- /dev/null +++ b/pkg/teamloader/external_teams_test.go @@ -0,0 +1,115 @@ +package teamloader + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/docker/docker-agent/pkg/config" +) + +func TestWithExternalTeamsComposesLocalManifest(t *testing.T) { + t.Setenv("OPENAI_API_KEY", "dummy") + dir := t.TempDir() + primary := `models: + model: {provider: openai, model: gpt-4o} +agents: + root: + model: model + description: Primary lead + instruction: Coordinate teams. + helper: + model: model + description: Existing helper + instruction: Help. +` + secondary := `models: + model: {provider: openai, model: gpt-4o} +agents: + root: + model: model + description: Secondary lead + instruction: Coordinate specialists. + sub_agents: [researcher] + researcher: + model: model + description: Researcher + instruction: Research. +` + primaryPath := filepath.Join(dir, "primary.yaml") + require.NoError(t, os.WriteFile(primaryPath, []byte(primary), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "secondary.yaml"), []byte(secondary), 0o644)) + + tm, err := Load(t.Context(), config.NewFileSource(primaryPath), &config.RuntimeConfig{}, append(withTestProviderRegistry(), WithExternalTeams([]string{"Research team=./secondary.yaml"}))...) + require.NoError(t, err) + + root, err := tm.Agent("root") + require.NoError(t, err) + require.Len(t, root.SubAgents(), 1) + assert.Equal(t, "research-team", root.SubAgents()[0].Name()) + assert.Equal(t, "root", root.SubAgents()[0].DisplayName()) + assert.ElementsMatch(t, []string{"root", "helper", "research-team"}, tm.AgentNames()) + _, err = tm.Agent("researcher") + require.Error(t, err, "private members must not become public switch targets") + + infos := tm.AgentsInfo(t.Context()) + byName := map[string]struct { + team string + lead bool + internal bool + }{} + for _, info := range infos { + byName[info.Name] = struct { + team string + lead bool + internal bool + }{info.TeamName, info.TeamLead, info.Internal} + } + assert.Equal(t, "Primary team", byName["root"].team) + assert.Equal(t, "Research team", byName["research-team"].team) + assert.True(t, byName["research-team"].lead) + assert.Equal(t, "Research team", byName["researcher"].team) + assert.True(t, byName["researcher"].internal) +} + +func TestWithExternalTeamsRejectsInvalidAndDuplicateRefs(t *testing.T) { + t.Setenv("OPENAI_API_KEY", "dummy") + dir := t.TempDir() + primary := `models: + model: {provider: openai, model: gpt-4o} +agents: + root: + model: model + description: Primary lead + instruction: Coordinate. + sub_agents: [specialists:./secondary.yaml] +` + secondary := `models: + model: {provider: openai, model: gpt-4o} +agents: + root: {model: model, description: Secondary, instruction: Help.} +` + primaryPath := filepath.Join(dir, "primary.yaml") + require.NoError(t, os.WriteFile(primaryPath, []byte(primary), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "secondary.yaml"), []byte(secondary), 0o644)) + + for _, tc := range []struct { + name string + ref string + want string + }{ + {"url", "Research team=https://example.com/team.yaml", "local"}, + {"oci", "Research team=myorg/team:latest", "local"}, + {"duplicate name", "Specialists=specialists:./other.yaml", "duplicate agent ID"}, + {"duplicate ref", "Specialists=specialists:./secondary.yaml", "already configured"}, + } { + t.Run(tc.name, func(t *testing.T) { + _, err := Load(t.Context(), config.NewFileSource(primaryPath), &config.RuntimeConfig{}, append(withTestProviderRegistry(), WithExternalTeams([]string{tc.ref}))...) + require.Error(t, err) + assert.Contains(t, err.Error(), tc.want) + }) + } +} diff --git a/pkg/teamloader/teamloader.go b/pkg/teamloader/teamloader.go index 5aadbd1aa..8bf27b648 100644 --- a/pkg/teamloader/teamloader.go +++ b/pkg/teamloader/teamloader.go @@ -44,12 +44,14 @@ import ( var defaultMaxTokens int64 = 32000 type loadOptions struct { - workingDir string - modelOverrides []string - promptFiles []string - toolsetRegistry ToolsetRegistry - providerRegistry *provider.Registry - modelOpts []options.Opt + workingDir string + modelOverrides []string + promptFiles []string + externalTeams []string + externalTeamNames map[string]string + toolsetRegistry ToolsetRegistry + providerRegistry *provider.Registry + modelOpts []options.Opt } type Opt func(*loadOptions) error @@ -82,6 +84,18 @@ func WithPromptFiles(files []string) Opt { } } +// WithExternalTeams adds local agent manifests as sub-teams of the primary +// manifest's default agent. Each reference may use the same optional +// "name:path" syntax as sub_agents; paths are resolved relative to the +// primary manifest. This option is intended for the CLI's repeatable --team +// flag and deliberately accepts local YAML/HCL files only. +func WithExternalTeams(refs []string) Opt { + return func(opts *loadOptions) error { + opts.externalTeams = slices.Clone(refs) + return nil + } +} + // WithToolsetRegistry allows using a custom toolset registry instead of the default. func WithToolsetRegistry(registry ToolsetRegistry) Opt { return func(opts *loadOptions) error { @@ -276,6 +290,7 @@ func LoadWithConfig(ctx context.Context, agentSource config.Source, runConfig *c workingDir := runConfig.WorkingDir parentDir := cmp.Or(agentSource.ParentDir(), workingDir) configName := configNameFromSource(agentSource.Name()) + primaryTeamName := "Primary team" var agents []*agent.Agent agentsByName := make(map[string]*agent.Agent) @@ -288,6 +303,22 @@ func LoadWithConfig(ctx context.Context, agentSource config.Source, runConfig *c globalHooks := runConfig.GlobalHooks cliHooks := runConfig.CLIHooks() + // CLI-composed teams are appended to the primary/default lead before + // concrete agents and toolsets are built. That makes transfer_task + // injection follow exactly the same path as declarative sub_agents. + if len(loadOpts.externalTeams) > 0 { + primaryIndex := defaultAgentConfigIndex(cfg.Agents) + if primaryIndex < 0 { + return nil, errors.New("cannot attach external teams: primary manifest has no agents") + } + refs, names, err := mergeExternalTeamRefs(cfg.Agents[primaryIndex].SubAgents, loadOpts.externalTeams) + if err != nil { + return nil, err + } + cfg.Agents[primaryIndex].SubAgents = refs + loadOpts.externalTeamNames = names + } + for _, agentConfig := range cfg.Agents { // Merge CLI prompt files with agent config prompt files, deduplicating promptFiles := slices.Concat(agentConfig.AddPromptFiles, loadOpts.promptFiles) @@ -304,6 +335,7 @@ func LoadWithConfig(ctx context.Context, agentSource config.Source, runConfig *c opts := []agent.Opt{ agent.WithName(agentConfig.Name), + agent.WithTeamInfo(primaryTeamName, false, false), agent.WithDescription(expander.Expand(ctx, agentConfig.Description, nil)), agent.WithWelcomeMessage(expander.Expand(ctx, agentConfig.WelcomeMessage, nil)), agent.WithAddDate(agentConfig.AddDate), @@ -428,10 +460,11 @@ func LoadWithConfig(ctx context.Context, agentSource config.Source, runConfig *c } // Connect sub-agents and handoff agents. - // externalAgents caches agents loaded from external references (OCI/URL), - // keyed by the original reference string, to avoid loading the same - // external agent twice. This is kept separate from agentsByName to - // prevent external agents from shadowing locally-defined agents. + // externalAgents caches agents loaded from external references (OCI, URL, + // or local config file), keyed by the original reference string, to avoid + // loading the same external agent twice. This is kept separate from + // agentsByName to prevent external agents from shadowing locally-defined + // agents. externalAgents := make(map[string]*agent.Agent) for _, agentConfig := range cfg.Agents { a, exists := agentsByName[agentConfig.Name] @@ -439,7 +472,7 @@ func LoadWithConfig(ctx context.Context, agentSource config.Source, runConfig *c continue } - subAgents, err := resolveAgentRefs(ctx, agentConfig.SubAgents, agentsByName, externalAgents, &agents, runConfig, &loadOpts) + subAgents, err := resolveAgentRefs(ctx, agentConfig.SubAgents, agentsByName, externalAgents, &agents, parentDir, runConfig, &loadOpts) if err != nil { return nil, fmt.Errorf("agent '%s': resolving sub-agents: %w", agentConfig.Name, err) } @@ -447,7 +480,7 @@ func LoadWithConfig(ctx context.Context, agentSource config.Source, runConfig *c agent.WithSubAgents(subAgents...)(a) } - handoffs, err := resolveAgentRefs(ctx, agentConfig.Handoffs, agentsByName, externalAgents, &agents, runConfig, &loadOpts) + handoffs, err := resolveAgentRefs(ctx, agentConfig.Handoffs, agentsByName, externalAgents, &agents, parentDir, runConfig, &loadOpts) if err != nil { return nil, fmt.Errorf("agent '%s': resolving handoffs: %w", agentConfig.Name, err) } @@ -456,7 +489,7 @@ func LoadWithConfig(ctx context.Context, agentSource config.Source, runConfig *c } if agentConfig.ForceHandoff != "" { - targets, err := resolveAgentRefs(ctx, []string{agentConfig.ForceHandoff}, agentsByName, externalAgents, &agents, runConfig, &loadOpts) + targets, err := resolveAgentRefs(ctx, []string{agentConfig.ForceHandoff}, agentsByName, externalAgents, &agents, parentDir, runConfig, &loadOpts) if err != nil { return nil, fmt.Errorf("agent '%s': resolving force_handoff: %w", agentConfig.Name, err) } @@ -999,18 +1032,137 @@ func configNameFromSource(sourceName string) string { return base + "-" + hex.EncodeToString(h[:4]) } +func defaultAgentConfigIndex(agents latest.Agents) int { + for i := range agents { + if agents[i].Name == "root" { + return i + } + } + if len(agents) > 0 { + return 0 + } + return -1 +} + +// mergeExternalTeamRefs validates and appends CLI-composed local teams while +// preventing ambiguous exposed names. Existing local agent names and external +// refs on the lead reserve their exposed IDs. +func mergeExternalTeamRefs(existing, extra []string) ([]string, map[string]string, error) { + merged := slices.Clone(existing) + teamNames := make(map[string]string, len(extra)) + seenRef := make(map[string]struct{}, len(existing)+len(extra)) + seenName := make(map[string]string, len(existing)+len(extra)) + for _, ref := range existing { + seenRef[ref] = struct{}{} + name, _ := config.ParseExternalAgentRef(ref) + seenName[name] = ref + } + for _, input := range extra { + teamName, ref, err := parseExternalTeamSpec(input) + if err != nil { + return nil, nil, err + } + name, _ := config.ParseExternalAgentRef(ref) + if _, ok := seenRef[ref]; ok { + return nil, nil, fmt.Errorf("external team %q is already configured on the primary lead", input) + } + if previous, ok := seenName[name]; ok { + return nil, nil, fmt.Errorf("external team %q exposes duplicate agent ID %q already used by %q", input, name, previous) + } + seenRef[ref] = struct{}{} + seenName[name] = ref + teamNames[ref] = teamName + merged = append(merged, ref) + } + return merged, teamNames, nil +} + +// parseExternalTeamSpec parses `Team name=path`. The display name and the +// runtime agent ID are deliberately separate: an unaliased path receives a +// stable slug ID, while the TUI title keeps the exact human-readable name. +// The legacy `[alias:]path` form remains accepted. +func parseExternalTeamSpec(input string) (teamName, ref string, err error) { + input = strings.TrimSpace(input) + if input == "" { + return "", "", errors.New("external team must not be empty") + } + ref = input + if before, after, ok := strings.Cut(input, "="); ok { + teamName = strings.TrimSpace(before) + ref = strings.TrimSpace(after) + if teamName == "" || ref == "" { + return "", "", fmt.Errorf("external team %q must use 'Team name=path' with both values set", input) + } + } + + exposedName, target := config.ParseExternalAgentRef(ref) + if !config.IsLocalConfigReference(target) { + return "", "", fmt.Errorf("external team %q must reference a local .yaml, .yml, or .hcl file", input) + } + if teamName == "" { + teamName = exposedName + } + // No explicit runtime alias was supplied on the right-hand side. Generate + // one from the team title so duplicate `root` leads never collide. + if target == ref { + id := teamAgentID(teamName) + if id == "" { + return "", "", fmt.Errorf("external team name %q does not produce a usable agent ID", teamName) + } + ref = id + ":" + ref + } + return teamName, ref, nil +} + +func teamAgentID(name string) string { + var b strings.Builder + lastDash := false + for _, r := range strings.ToLower(name) { + switch { + case r >= 'a' && r <= 'z', r >= '0' && r <= '9': + b.WriteRune(r) + lastDash = false + default: + if b.Len() > 0 && !lastDash { + b.WriteByte('-') + lastDash = true + } + } + } + return strings.Trim(b.String(), "-") +} + +// labelImportedTeam labels agents local to one imported manifest. Nested +// imported leads already carry a different non-empty TeamName and keep it. +func labelImportedTeam(imported *team.Team, lead *agent.Agent, name string) { + if imported == nil || lead == nil { + return + } + originalTeamName := lead.TeamName() + for _, memberName := range imported.AgentNames() { + member, err := imported.Agent(memberName) + if err != nil || member.TeamName() != originalTeamName { + continue + } + agent.WithTeamInfo(name, member == lead, member != lead)(member) + } +} + // resolveAgentRefs resolves a list of agent references to agent instances. // References that match a locally-defined agent name are looked up directly. -// References that are external (OCI or URL) are loaded on-demand and cached -// in externalAgents so the same reference isn't loaded twice. -// External references may include an explicit name prefix ("name:ref") or -// derive a short name from the reference (e.g. "myorg/review-pr" → "review-pr"). +// References that are external (OCI, URL, or local config file) are loaded +// on-demand and cached in externalAgents so the same reference isn't loaded +// twice. External references may include an explicit name prefix ("name:ref") +// or derive a short name from the reference (e.g. "myorg/review-pr" → +// "review-pr", "./secondary-team.yaml" → "secondary-team"). Relative local +// file references resolve against parentDir, the importing config's directory. func resolveAgentRefs( ctx context.Context, refs []string, agentsByName map[string]*agent.Agent, externalAgents map[string]*agent.Agent, agents *[]*agent.Agent, + parentDir string, runConfig *config.RuntimeConfig, loadOpts *loadOptions, ) ([]*agent.Agent, error) { @@ -1039,17 +1191,24 @@ func resolveAgentRefs( return nil, fmt.Errorf("external agent %q resolves to name %q which conflicts with agent %q", ref, agentName, existing.Name()) } - a, err := loadExternalAgent(ctx, externalRef, runConfig, loadOpts) + a, importedTeam, err := loadExternalAgent(ctx, externalRef, parentDir, runConfig, loadOpts) if err != nil { return nil, fmt.Errorf("loading %q: %w", externalRef, err) } - // Rename the external agent so it doesn't collide with locally-defined - // agents. External agents resolve to their team's default agent (one - // explicitly named "root" if it exists, otherwise the first agent - // declared), which we may want to expose under a different name in - // the importing team. + // Rename the external lead and label its team for presentation. Only + // the lead joins the public parent registry; importedTeam's other local + // agents remain private and are reached through the lead's pointers. + teamDisplayName := agentName + if configured, ok := loadOpts.externalTeamNames[ref]; ok { + teamDisplayName = configured + } + originalLeadName := a.Name() agent.WithName(agentName)(a) + if originalLeadName != agentName { + agent.WithDisplayName(originalLeadName)(a) + } + labelImportedTeam(importedTeam, a, teamDisplayName) *agents = append(*agents, a) externalAgents[ref] = a @@ -1063,12 +1222,32 @@ func resolveAgentRefs( // This prevents infinite recursion when external agents reference each other. const maxExternalDepth = 10 -// loadExternalAgent loads an agent from an external reference (OCI or URL). -// It resolves the reference, loads its config, and returns the default agent. -func loadExternalAgent(ctx context.Context, ref string, runConfig *config.RuntimeConfig, loadOpts *loadOptions) (*agent.Agent, error) { +// loadExternalAgent loads an agent from an external reference (OCI, URL, or +// local config file). It resolves the reference, loads its config, and +// returns the default agent (the one named "root" if it exists, otherwise the +// first declared) with its own sub-agents still attached. +func loadExternalAgent(ctx context.Context, ref, parentDir string, runConfig *config.RuntimeConfig, loadOpts *loadOptions) (*agent.Agent, *team.Team, error) { depth := externalDepthFromContext(ctx) if depth >= maxExternalDepth { - return nil, fmt.Errorf("maximum external agent nesting depth (%d) exceeded — check for circular references", maxExternalDepth) + return nil, nil, fmt.Errorf("maximum external agent nesting depth (%d) exceeded — check for circular references", maxExternalDepth) + } + + isLocalFile := config.IsLocalConfigReference(ref) + if isLocalFile { + // Relative local file references resolve against the importing + // config's directory, not the process working directory, matching how + // other relative paths in agent configs behave. + if !filepath.IsAbs(ref) { + ref = filepath.Join(parentDir, ref) + } + // Fail circular chains of local files at the first repeat instead of + // re-initializing the whole chain until the depth cap trips. + chain := localChainFromContext(ctx) + cleaned := filepath.Clean(ref) + if slices.Contains(chain, cleaned) { + return nil, nil, fmt.Errorf("circular local team reference: %s", strings.Join(append(slices.Clone(chain), cleaned), " -> ")) + } + ctx = contextWithLocalChain(ctx, append(slices.Clone(chain), cleaned)) } // Tag references (including the implicit ":latest") are re-resolved against @@ -1081,7 +1260,7 @@ func loadExternalAgent(ctx context.Context, ref string, runConfig *config.Runtim source, err := config.Resolve(ref, runConfig.EnvProvider()) if err != nil { - return nil, err + return nil, nil, err } var opts []Opt @@ -1097,12 +1276,54 @@ func loadExternalAgent(ctx context.Context, ref string, runConfig *config.Runtim opts = append(opts, WithModelOptions(loadOpts.modelOpts...)) } - result, err := Load(contextWithExternalDepth(ctx, depth+1), source, runConfig, opts...) + result, err := LoadWithConfig(contextWithExternalDepth(ctx, depth+1), source, runConfig, opts...) if err != nil { - return nil, err + return nil, nil, err + } + + // Only the imported team's default agent joins the parent team, so + // config-wide policies of a local team file would be silently dropped. + // Fail loudly instead of merging them: the semantics of combining two + // manifests' policies are ambiguous. Scoped to local file references so + // existing OCI/URL imports keep their current behaviour. + if isLocalFile { + if err := rejectUnpreservedTeamPolicies(ref, result); err != nil { + return nil, nil, err + } } - return result.DefaultAgent() + lead, err := result.Team.DefaultAgent() + if err != nil { + return nil, nil, err + } + return lead, result.Team, nil +} + +// rejectUnpreservedTeamPolicies fails the import of a local team file whose +// manifest declares top-level policies that cannot be preserved when only +// its default agent is grafted onto the importing team: `permissions`, the +// run-wide `budget`, named `budgets` (and per-agent budget references), and +// `runtime.safety`. Agent-level settings (e.g. agents..safety) live on +// the agent objects themselves and are unaffected. +func rejectUnpreservedTeamPolicies(ref string, result *LoadResult) error { + var dropped []string + if p := result.Team.Permissions(); p != nil && !p.IsEmpty() { + dropped = append(dropped, "permissions") + } + if result.Budget != nil { + dropped = append(dropped, "budget") + } + if len(result.Budgets) > 0 || len(result.AgentBudgets) > 0 { + dropped = append(dropped, "budgets") + } + if result.Team.RuntimeSafety() != "" { + dropped = append(dropped, "runtime.safety") + } + if len(dropped) == 0 { + return nil + } + return fmt.Errorf("local team file %q declares top-level %s, which cannot be preserved when the file is imported as a sub-agent; declare these policies in the importing (main) manifest instead", + ref, strings.Join(dropped, ", ")) } // contextKey is an unexported type for context keys defined in this package. @@ -1111,6 +1332,13 @@ type contextKey int // externalDepthKey is the context key for tracking external agent loading depth. var externalDepthKey contextKey +// localChainKey is the context key carrying the chain of local config file +// paths (cleaned, importing-config-relative refs made absolute) currently +// being loaded, root-most first. Each recursive load branches its own copy, +// so diamond imports (two siblings importing the same file) stay legal while +// genuine cycles are caught at the first repeated path. +var localChainKey contextKey = 1 + func externalDepthFromContext(ctx context.Context) int { if v, ok := ctx.Value(externalDepthKey).(int); ok { return v @@ -1121,3 +1349,14 @@ func externalDepthFromContext(ctx context.Context) int { func contextWithExternalDepth(ctx context.Context, depth int) context.Context { return context.WithValue(ctx, externalDepthKey, depth) } + +func localChainFromContext(ctx context.Context) []string { + if v, ok := ctx.Value(localChainKey).([]string); ok { + return v + } + return nil +} + +func contextWithLocalChain(ctx context.Context, chain []string) context.Context { + return context.WithValue(ctx, localChainKey, chain) +} diff --git a/pkg/teamloader/teamloader_test.go b/pkg/teamloader/teamloader_test.go index a5d59d577..f74e5cc26 100644 --- a/pkg/teamloader/teamloader_test.go +++ b/pkg/teamloader/teamloader_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "fmt" "io/fs" "net/http" "net/http/httptest" @@ -991,6 +992,279 @@ func TestGetToolsForAgent_SingleLSPToolsetNotWrapped(t *testing.T) { assert.Contains(t, names, "lsp_definition") } +// TestLoadLocalFileSubAgents proves a lead can import a second team's lead by +// listing that team's YAML file in sub_agents. Only the secondary team's +// default agent ("root" if present, otherwise the first declared) is exposed +// to the parent — under the alias, or a name derived from the file name — and +// it keeps its own sub-agents so it still orchestrates its own team. The file +// reference resolves relative to the importing config, not the test's working +// directory. +func TestLoadLocalFileSubAgents(t *testing.T) { + t.Setenv("OPENAI_API_KEY", "dummy") + + secondary := `models: + model: + provider: openai + model: gpt-4o +agents: + root: + model: model + description: Secondary team lead + instruction: Coordinate your own team to answer tasks. + sub_agents: [researcher] + researcher: + model: model + description: Researcher of the secondary team + instruction: Research topics and report back. +` + primary := `models: + model: + provider: openai + model: gpt-4o +agents: + root: + model: model + description: Primary lead + instruction: Delegate specialist work to the secondary team lead. + sub_agents: + - %s +` + + tests := []struct { + name string + subAgentRef string + exposedName string + }{ + { + name: "aliased reference", + subAgentRef: "specialists:./secondary-team.yaml", + exposedName: "specialists", + }, + { + name: "unaliased reference derives the file name", + subAgentRef: "./secondary-team.yaml", + exposedName: "secondary-team", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "secondary-team.yaml"), []byte(secondary), 0o644)) + primaryPath := filepath.Join(dir, "primary-team.yaml") + require.NoError(t, os.WriteFile(primaryPath, fmt.Appendf(nil, primary, tt.subAgentRef), 0o644)) + + team, err := Load(t.Context(), config.NewFileSource(primaryPath), &config.RuntimeConfig{}, withTestProviderRegistry()...) + require.NoError(t, err) + + root, err := team.Agent("root") + require.NoError(t, err) + + // The primary lead sees exactly one sub-agent: the secondary + // team's lead, exposed under the expected name. + require.Len(t, root.SubAgents(), 1) + lead := root.SubAgents()[0] + assert.Equal(t, tt.exposedName, lead.Name()) + + // The imported lead keeps its own sub-agents so it can still + // orchestrate its own team. + require.Len(t, lead.SubAgents(), 1) + assert.Equal(t, "researcher", lead.SubAgents()[0].Name()) + + // Only the secondary lead joins the primary team's registry; its + // members stay private to the imported team. At runtime the + // delegation handlers execute them through the lead's SubAgents + // pointers (the exact private/scoped instances), never through a + // team.Agent name lookup. + _, err = team.Agent(tt.exposedName) + require.NoError(t, err) + _, err = team.Agent("researcher") + require.Error(t, err) + }) + } +} + +// TestLoadLocalFileSubAgents_RejectsUnpreservedPolicies proves that importing +// a local team file fails loudly when its manifest declares top-level +// policies that only its default agent's graft cannot preserve: permissions, +// the run-wide budget, named budgets (with per-agent references), and +// runtime.safety. Silently dropping them would run the imported team with +// weaker guarantees than its author declared. +func TestLoadLocalFileSubAgents_RejectsUnpreservedPolicies(t *testing.T) { + t.Setenv("OPENAI_API_KEY", "dummy") + + primary := `models: + model: + provider: openai + model: gpt-4o +agents: + root: + model: model + description: Primary lead + instruction: Delegate specialist work to the secondary team lead. + sub_agents: + - specialists:./secondary-team.yaml +` + secondaryBase := `models: + model: + provider: openai + model: gpt-4o +agents: + root: + model: model + description: Secondary team lead + instruction: Coordinate your own team to answer tasks. +` + + tests := []struct { + name string + secondary string + wantIn string + }{ + { + name: "permissions", + secondary: secondaryBase + `permissions: + deny: ["shell"] +`, + wantIn: "permissions", + }, + { + name: "run-wide budget", + secondary: secondaryBase + `budget: + max_cost: 5 +`, + wantIn: "budget", + }, + { + name: "named budgets with agent references", + secondary: `models: + model: + provider: openai + model: gpt-4o +agents: + root: + model: model + description: Secondary team lead + instruction: Coordinate your own team to answer tasks. + budgets: [tight] +budgets: + tight: + max_cost: 1 +`, + wantIn: "budgets", + }, + { + name: "runtime safety", + secondary: secondaryBase + `runtime: + safety: strict +`, + wantIn: "runtime.safety", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "secondary-team.yaml"), []byte(tt.secondary), 0o644)) + primaryPath := filepath.Join(dir, "primary-team.yaml") + require.NoError(t, os.WriteFile(primaryPath, []byte(primary), 0o644)) + + _, err := Load(t.Context(), config.NewFileSource(primaryPath), &config.RuntimeConfig{}, withTestProviderRegistry()...) + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantIn) + assert.Contains(t, err.Error(), "cannot be preserved", + "the error must explain why the import is rejected") + assert.Contains(t, err.Error(), "importing (main) manifest", + "the error must tell the user where to declare the policy instead") + }) + } +} + +// TestLoadLocalFileSubAgents_AgentLevelSafetyAllowed: agent-level safety +// (agents..safety) lives on the agent object itself, so it survives +// the import and must not trip the top-level policy rejection. +func TestLoadLocalFileSubAgents_AgentLevelSafetyAllowed(t *testing.T) { + t.Setenv("OPENAI_API_KEY", "dummy") + + secondary := `models: + model: + provider: openai + model: gpt-4o +agents: + root: + model: model + description: Secondary team lead + instruction: Coordinate your own team to answer tasks. + safety: strict +` + primary := `models: + model: + provider: openai + model: gpt-4o +agents: + root: + model: model + description: Primary lead + instruction: Delegate specialist work to the secondary team lead. + sub_agents: + - specialists:./secondary-team.yaml +` + + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "secondary-team.yaml"), []byte(secondary), 0o644)) + primaryPath := filepath.Join(dir, "primary-team.yaml") + require.NoError(t, os.WriteFile(primaryPath, []byte(primary), 0o644)) + + team, err := Load(t.Context(), config.NewFileSource(primaryPath), &config.RuntimeConfig{}, withTestProviderRegistry()...) + require.NoError(t, err) + + lead, err := team.Agent("specialists") + require.NoError(t, err) + assert.Equal(t, latest.SafetyModeStrict, lead.Safety(), + "agent-level safety must survive the import on the agent object") +} + +// TestLoadLocalFileSubAgents_CircularReferenceFails: two local files that +// import each other must fail with a clear circular-reference error at the +// first repeated path, not after ten nested initialisations. +func TestLoadLocalFileSubAgents_CircularReferenceFails(t *testing.T) { + t.Setenv("OPENAI_API_KEY", "dummy") + + teamA := `models: + model: + provider: openai + model: gpt-4o +agents: + root: + model: model + description: Team A lead + instruction: Lead team A. + sub_agents: + - b:./team-b.yaml +` + teamB := `models: + model: + provider: openai + model: gpt-4o +agents: + root: + model: model + description: Team B lead + instruction: Lead team B. + sub_agents: + - a:./team-a.yaml +` + + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "team-a.yaml"), []byte(teamA), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "team-b.yaml"), []byte(teamB), 0o644)) + + _, err := Load(t.Context(), config.NewFileSource(filepath.Join(dir, "team-a.yaml")), &config.RuntimeConfig{}, withTestProviderRegistry()...) + require.Error(t, err) + assert.Contains(t, err.Error(), "circular local team reference") + assert.Contains(t, err.Error(), filepath.Join(dir, "team-b.yaml")) +} + func TestExternalDepthContext(t *testing.T) { t.Parallel() From 2ec38b727ade40995c7b3d852e1b9efde7c43898 Mon Sep 17 00:00:00 2001 From: Sayt-0 Date: Thu, 30 Jul 2026 18:13:09 +0200 Subject: [PATCH 2/4] feat(runtime): support nested private team delegation --- pkg/runtime/agent_delegation.go | 192 +++++++-- pkg/runtime/agent_delegation_test.go | 476 ++++++++++++++++++++++ pkg/runtime/agent_router.go | 64 ++- pkg/runtime/event.go | 4 + pkg/runtime/hooks.go | 60 ++- pkg/runtime/localteam_integration_test.go | 229 +++++++++++ pkg/runtime/runtime.go | 15 +- pkg/runtime/sessionplan_handlers.go | 2 +- pkg/runtime/skill_runner.go | 5 +- pkg/session/branch.go | 1 + pkg/session/session.go | 16 + pkg/tools/builtin/agent/agent.go | 44 +- pkg/tools/builtin/agent/agent_test.go | 97 ++++- 13 files changed, 1095 insertions(+), 110 deletions(-) create mode 100644 pkg/runtime/localteam_integration_test.go diff --git a/pkg/runtime/agent_delegation.go b/pkg/runtime/agent_delegation.go index e011fbe74..c07fc20aa 100644 --- a/pkg/runtime/agent_delegation.go +++ b/pkg/runtime/agent_delegation.go @@ -29,6 +29,20 @@ func agentNames(agents []*agent.Agent) []string { return names } +// agentByName returns the agent with the given name from the list, or nil +// when absent. Delegation handlers use it to pick the exact validated +// instance out of the caller's sub-agent/handoff list rather than doing a +// team-registry lookup, so targets that are private to an imported team +// (e.g. members behind a local-file team lead) resolve correctly. +func agentByName(agents []*agent.Agent, name string) *agent.Agent { + for _, a := range agents { + if a.Name() == name { + return a + } + } + return nil +} + // validateAgentInList checks that targetAgent appears in the given agent list. // Returns a tool error result if not found, or nil if the target is valid. // The action describes the attempted operation (e.g. "transfer task to"), @@ -88,6 +102,13 @@ type SubSessionConfig struct { SystemMessage string // AgentName is the name of the agent that will execute the sub-session. AgentName string + // Agent, when non-nil, is the exact agent instance that will execute the + // sub-session, taking precedence over resolving AgentName against the + // team registry. Delegation handlers set it from the caller's validated + // sub-agent list so targets that are private to an imported team (not in + // the public registry) can still run. AgentName must stay set for labels, + // events and hooks. + Agent *agent.Agent // Title is a human-readable label for the sub-session (e.g. "Transferred task"). Title string // ToolsApproved overrides whether tools are pre-approved in the child session. @@ -105,9 +126,11 @@ type SubSessionConfig struct { // (e.g. MCP server, A2A adapter, background agent). This causes the runtime // to auto-stop on max iterations instead of blocking for user input. NonInteractive bool - // PinAgent, when true, pins the child session to AgentName via - // session.WithAgentName. This is required for concurrent background - // tasks that must not share the runtime's mutable currentAgent field. + // PinAgent, when true, pins the child session to the resolved agent via + // session.WithAgentName and session.WithPinnedAgent. This is required for + // concurrent background tasks that must not share the runtime's mutable + // currentAgent field; the instance pin also lets RunStream resolve agents + // that are private to the team registry. PinAgent bool // ImplicitUserMessage, when non-empty, overrides the default "Please proceed." // user message sent to the child session. This allows callers like skill @@ -145,13 +168,23 @@ type SubSessionConfig struct { type delegationRequest struct { SubSessionConfig + // Caller, when non-nil, is the exact agent that initiated the + // delegation, resolved pin-aware from the calling session. Handlers set + // it so a delegation issued inside a pinned background session is + // attributed to the pinned agent, not to whatever the shared router + // happens to point at. When nil, runForwarding falls back to the + // router's current agent. + Caller *agent.Agent + // SwitchCurrentAgent, when true, swaps r.currentAgent to AgentName // for the lifetime of the call and emits AgentSwitching/AgentInfo // events on entry and exit. Used by transfer_task. Mutually // exclusive in spirit with PinAgent: pinning is for concurrent // sub-sessions that must NOT share the runtime's mutable // currentAgent, while switching is for sequential delegations where - // the parent loop is blocked anyway. + // the parent loop is blocked anyway. When the parent session is + // itself pinned, runForwarding downgrades the switch to a pin so the + // shared router is never touched from a background session. SwitchCurrentAgent bool } @@ -192,7 +225,7 @@ func newSubSession(parent *session.Session, cfg SubSessionConfig, childAgent *ag session.WithAttachedFiles(attachedFiles), } if cfg.PinAgent { - opts = append(opts, session.WithAgentName(cfg.AgentName)) + opts = append(opts, session.WithAgentName(cfg.AgentName), session.WithPinnedAgent(childAgent)) } opts = append(opts, session.WithPermissions(cfg.Permissions)) // Merge parent's excluded tools with config's excluded tools so that @@ -240,15 +273,19 @@ func mergeExcludedTools(parent, child []string) []string { // `from`, emits the counterpart events and the matching return-side hooks) // when invoked. // +// The router carries the exact agent instances, not just their names, so a +// delegation target that is private to the team registry (e.g. a member of +// an imported local-file team) stays resolvable while it is current. +// // Use as `defer r.swapCurrentAgent(ctx, sessionID, from, to, evts)()` so the // swap takes effect immediately and the restore runs at function exit. func (r *LocalRuntime) swapCurrentAgent(ctx context.Context, sessionID string, from, to *agent.Agent, evts EventSink) func() { evts.Emit(AgentSwitching(true, from.Name(), to.Name())) r.executeOnAgentSwitchHooks(ctx, from, sessionID, from.Name(), to.Name(), agentSwitchKindTransferTask) - r.setCurrentAgent(to.Name()) + r.agents.SetAgent(to) evts.Emit(AgentInfo(to.Name(), agentModelLabel(ctx, to), to.Description(), to.WelcomeMessage())) return func() { - r.setCurrentAgent(from.Name()) + r.agents.SetAgent(from) evts.Emit(AgentSwitching(false, to.Name(), from.Name())) r.executeOnAgentSwitchHooks(ctx, from, sessionID, to.Name(), from.Name(), agentSwitchKindTransferTaskReturn) evts.Emit(AgentInfo(from.Name(), agentModelLabel(ctx, from), from.Description(), from.WelcomeMessage())) @@ -276,17 +313,38 @@ func (r *LocalRuntime) swapCurrentAgent(ctx context.Context, sessionID string, f func (r *LocalRuntime) runForwarding(ctx context.Context, parent *session.Session, evts EventSink, req delegationRequest) (*tools.ToolCallResult, error) { span := trace.SpanFromContext(ctx) - callerAgent, err := r.team.Agent(r.currentAgentName()) - if err != nil { - return nil, fmt.Errorf("current agent not found: %w", err) - } - child, err := r.team.Agent(req.AgentName) - if err != nil { - return nil, err + // Prefer the caller the handler resolved from its session; the router + // is only a fallback for requests that don't carry one. Either way the + // caller is an exact instance, so a nested delegation from an agent + // that is private to the team registry still resolves; a name lookup + // against the team would not. + callerAgent := req.Caller + if callerAgent == nil { + callerAgent = r.CurrentAgent() + } + if callerAgent == nil { + return nil, fmt.Errorf("current agent not found: %s", r.currentAgentName()) + } + child := req.Agent + if child == nil { + var err error + child, err = r.team.Agent(req.AgentName) + if err != nil { + return nil, err + } } if req.SwitchCurrentAgent { - defer r.swapCurrentAgent(ctx, parent.ID, callerAgent, child, evts)() + if parent.PinnedAgent != nil || parent.AgentName != "" { + // The parent session is pinned (e.g. a background agent task): + // the shared router belongs to concurrent foreground sessions + // and must never be swapped from here. Pin the exact child + // instead so its RunStream resolves it; the transfer stays + // blocking either way. + req.PinAgent = true + } else { + defer r.swapCurrentAgent(ctx, parent.ID, callerAgent, child, evts)() + } } s := newSubSession(parent, req.SubSessionConfig, child) @@ -340,26 +398,34 @@ func (r *LocalRuntime) runForwarding(ctx context.Context, parent *session.Sessio // events are dropped and only the final assistant message (or the first // error) matters. // +// caller is the agent that dispatched the task, captured by the caller at +// dispatch time; it owns the subagent_stop hook executor. It may be nil +// (dispatchHook then no-ops). +// // Unlike runForwarding it does not emit AgentSwitching/AgentInfo events: // callers like background agents PinAgent the child session so the // runtime never mutates the shared currentAgent state. -func (r *LocalRuntime) runCollecting(ctx context.Context, parent *session.Session, cfg SubSessionConfig, onContent func(string)) *agenttool.RunResult { - child, err := r.team.Agent(cfg.AgentName) - if err != nil { - return &agenttool.RunResult{ErrMsg: fmt.Sprintf("agent %q not found: %s", cfg.AgentName, err)} +func (r *LocalRuntime) runCollecting(ctx context.Context, parent *session.Session, cfg SubSessionConfig, caller *agent.Agent, onContent func(string)) *agenttool.RunResult { + child := cfg.Agent + if child == nil { + var err error + child, err = r.team.Agent(cfg.AgentName) + if err != nil { + return &agenttool.RunResult{ErrMsg: fmt.Sprintf("agent %q not found: %s", cfg.AgentName, err)} + } } s := newSubSession(parent, cfg, child) // subagent_stop fires after the background sub-session has fully - // drained — success or failure. The parent agent at the time of - // dispatch (whoever called run_background_agent) owns the executor; - // we resolve it via CurrentAgent because the background path doesn't - // carry the parent agent name. dispatchHook silently no-ops when - // CurrentAgent is nil. The deferred call ensures the hook fires even - // when an ErrorEvent or ctx cancellation breaks us out of the loop. + // drained — success or failure. The caller captured at dispatch time + // (whoever called run_background_agent) owns the executor; resolving + // the current agent here instead would misattribute the hook after a + // concurrent handoff. dispatchHook silently no-ops when caller is nil. + // The deferred call ensures the hook fires even when an ErrorEvent or + // ctx cancellation breaks us out of the loop. defer func() { - r.executeSubagentStopHooks(ctx, parent, s, r.CurrentAgent(), cfg.AgentName, s.GetLastAssistantMessageContent()) + r.executeSubagentStopHooks(ctx, parent, s, caller, cfg.AgentName, s.GetLastAssistantMessageContent()) }() var errMsg string @@ -517,13 +583,10 @@ func (r *LocalRuntime) persistBackgroundSubSession(ctx context.Context, parentID } } -// CurrentAgentSubAgentNames implements agenttool.Runner. -func (r *LocalRuntime) CurrentAgentSubAgentNames() []string { - a := r.CurrentAgent() - if a == nil { - return nil - } - return agentNames(a.SubAgents()) +// SessionAgent implements agenttool.Runner: the exact agent driving sess, +// honouring the session pin before the shared router. +func (r *LocalRuntime) SessionAgent(sess *session.Session) *agent.Agent { + return r.resolveSessionAgent(sess) } // RunAgent implements agenttool.Runner. It starts a sub-agent synchronously @@ -534,17 +597,32 @@ func (r *LocalRuntime) CurrentAgentSubAgentNames() []string { // Tool calls that result in an "Ask" outcome will be auto-denied by the dispatcher // due to the non-interactive context. func (r *LocalRuntime) RunAgent(ctx context.Context, params agenttool.RunParams) *agenttool.RunResult { + // HandleRun captures the exact caller and validated target at dispatch + // time; use them verbatim so a concurrent handoff that repoints the + // router can neither fail this run nor substitute another agent. + // Callers that only carry a name (direct invocations in tests) keep the + // legacy resolution: the router's current agent and its sub-agents + // first, then the team registry as fallback in runCollecting. + caller := params.Caller + if caller == nil { + caller = r.CurrentAgent() + } + target := params.Target + if target == nil && caller != nil { + target = agentByName(caller.SubAgents(), params.AgentName) + } return r.runCollecting(ctx, params.ParentSession, SubSessionConfig{ Task: params.Task, ExpectedOutput: params.ExpectedOutput, AgentName: params.AgentName, + Agent: target, Title: "Background agent task", ToolsApproved: params.ParentSession.IsToolsApproved(), SafetyPolicy: params.ParentSession.GetSafetyPolicy(), Permissions: params.ParentSession.ClonePermissions(), NonInteractive: true, PinAgent: true, - }, params.OnContent) + }, caller, params.OnContent) } func (r *LocalRuntime) handleTaskTransfer(ctx context.Context, sess *session.Session, toolCall tools.ToolCall, evts EventSink) (*tools.ToolCallResult, error) { @@ -557,10 +635,20 @@ func (r *LocalRuntime) handleTaskTransfer(ctx context.Context, sess *session.Ses return nil, fmt.Errorf("invalid arguments: %w", err) } - a := r.CurrentAgent() + // Pin-aware caller resolution: a transfer issued inside a pinned + // background session must be attributed to the pinned agent, not to the + // shared router, which a concurrent foreground delegation may repoint + // at any time. + a := r.resolveSessionAgent(sess) + if a == nil { + return nil, fmt.Errorf("current agent not found: %s", r.currentAgentName()) + } if errResult := validateAgentInList(a.Name(), params.Agent, "transfer task to", "sub-agents list", a.SubAgents()); errResult != nil { return errResult, nil } + // The exact instance from the validated list, not a team lookup: members + // of an imported team are only reachable through their lead's SubAgents. + target := agentByName(a.SubAgents(), params.Agent) slog.DebugContext(ctx, "Transferring task to agent", "from_agent", a.Name(), "to_agent", params.Agent, "task", params.Task) @@ -599,12 +687,14 @@ func (r *LocalRuntime) handleTaskTransfer(ctx context.Context, sess *session.Ses Task: params.Task, ExpectedOutput: params.ExpectedOutput, AgentName: params.Agent, + Agent: target, Title: "Transferred task", ToolsApproved: sess.IsToolsApproved(), SafetyPolicy: sess.GetSafetyPolicy(), Permissions: sess.ClonePermissions(), NonInteractive: sess.NonInteractive, }, + Caller: a, SwitchCurrentAgent: true, }) } @@ -615,20 +705,22 @@ func (r *LocalRuntime) handleHandoff(ctx context.Context, sess *session.Session, return nil, fmt.Errorf("invalid arguments: %w", err) } - ca := r.currentAgentName() - currentAgent, err := r.team.Agent(ca) - if err != nil { - return nil, fmt.Errorf("current agent not found: %w", err) + // Resolve the caller pin-aware from the session (falling back to the + // router, which carries the exact instance) and the target from the + // caller's validated handoffs list, so handoffs keep working when + // either side is private to the team registry and when they run inside + // a pinned background session. + currentAgent := r.resolveSessionAgent(sess) + if currentAgent == nil { + return nil, fmt.Errorf("current agent not found: %s", r.currentAgentName()) } + ca := currentAgent.Name() if errResult := validateAgentInList(ca, params.Agent, "hand off to", "handoffs list", currentAgent.Handoffs()); errResult != nil { return errResult, nil } - next, err := r.team.Agent(params.Agent) - if err != nil { - return nil, err - } + next := agentByName(currentAgent.Handoffs(), params.Agent) // Handoff is in-place agent swap (same session, different agent // from the next turn). Span name keeps the runtime.* family; @@ -651,7 +743,17 @@ func (r *LocalRuntime) handleHandoff(ctx context.Context, sess *session.Session, defer span.End() r.executeOnAgentSwitchHooks(ctx, currentAgent, sess.ID, ca, next.Name(), agentSwitchKindHandoff) - r.setCurrentAgent(next.Name()) + if sess.PinnedAgent != nil || sess.AgentName != "" { + // A pinned session owns its agent identity through the pin, and the + // shared router belongs to concurrent foreground sessions. Repoint + // the pin so the next turn runs the handoff target without touching + // the router. Safe without synchronisation: the run loop re-reads + // the pin only after this tool batch has joined. + sess.PinnedAgent = next + sess.AgentName = next.Name() + } else { + r.agents.SetAgent(next) + } handoffMessage := "The agent " + ca + " handed off the conversation to you. " + "Your available handoff agents and tools are specified in the system messages that follow. " + "Only use those capabilities - do not attempt to use tools or hand off to agents that you see " + @@ -675,7 +777,7 @@ func (r *LocalRuntime) applyForceHandoff(ctx context.Context, sess *session.Sess slog.InfoContext(ctx, "Forced handoff", "from_agent", from.Name(), "to_agent", to.Name(), "session_id", sess.ID) r.executeOnAgentSwitchHooks(ctx, from, sess.ID, from.Name(), to.Name(), agentSwitchKindForceHandoff) - r.setCurrentAgent(to.Name()) + r.agents.SetAgent(to) sess.AddMessage(session.ImplicitUserMessage( "The agent " + from.Name() + " finished its response and the conversation was automatically " + diff --git a/pkg/runtime/agent_delegation_test.go b/pkg/runtime/agent_delegation_test.go index f9c764fac..994c73b25 100644 --- a/pkg/runtime/agent_delegation_test.go +++ b/pkg/runtime/agent_delegation_test.go @@ -4,13 +4,19 @@ import ( "context" "path/filepath" "strings" + "sync" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/docker/docker-agent/pkg/agent" + "github.com/docker/docker-agent/pkg/chat" "github.com/docker/docker-agent/pkg/config/latest" + "github.com/docker/docker-agent/pkg/model/provider" + "github.com/docker/docker-agent/pkg/model/provider/base" + "github.com/docker/docker-agent/pkg/modelsdev" "github.com/docker/docker-agent/pkg/permissions" "github.com/docker/docker-agent/pkg/runtime/toolexec" "github.com/docker/docker-agent/pkg/safety" @@ -18,6 +24,7 @@ import ( "github.com/docker/docker-agent/pkg/team" "github.com/docker/docker-agent/pkg/tools" agenttool "github.com/docker/docker-agent/pkg/tools/builtin/agent" + "github.com/docker/docker-agent/pkg/tools/builtin/transfertask" ) func TestBuildTaskSystemMessage(t *testing.T) { @@ -643,3 +650,472 @@ func TestTransferTask_PropagatesPermissions(t *testing.T) { assert.Equal(t, []string{"safe_tool"}, parentClone.Allow, "parent permissions must remain isolated from child mutations after transfer_task") } + +// privateTeamFixture models a team imported from a local config file: the +// secondary lead ("specialists") joins the primary team's public registry, +// but its own member ("researcher") stays private — reachable only through +// the lead's SubAgents pointers, never via team.Agent. +type privateTeamFixture struct { + rt *LocalRuntime + tm *team.Team + root *agent.Agent + lead *agent.Agent + researcher *agent.Agent +} + +// newPrivateTeamFixture builds root -> specialists -> researcher where only +// root and specialists are registered in team.Team. Providers are supplied +// per agent so each test scripts its own streams. +func newPrivateTeamFixture(t *testing.T, rootProv, leadProv, researcherProv provider.Provider) privateTeamFixture { + t.Helper() + + researcher := agent.New("researcher", "Researcher of the secondary team", agent.WithModel(researcherProv)) + lead := agent.New("specialists", "Secondary team lead", + agent.WithModel(leadProv), + agent.WithToolSets(transfertask.New()), + ) + agent.WithSubAgents(researcher)(lead) + root := agent.New("root", "Primary lead", + agent.WithModel(rootProv), + agent.WithToolSets(transfertask.New()), + ) + agent.WithSubAgents(lead)(root) + + tm := team.New(team.WithAgents(root, lead)) + rt, err := NewLocalRuntime(t.Context(), tm, + WithSessionCompaction(false), + WithModelStore(mockModelStore{}), + ) + require.NoError(t, err) + + // The researcher must really be private for these tests to prove anything. + _, err = tm.Agent("researcher") + require.Error(t, err, "fixture invariant: researcher must not be in the public team registry") + + return privateTeamFixture{rt: rt, tm: tm, root: root, lead: lead, researcher: researcher} +} + +// TestHandleTaskTransfer_NestedPrivateSubAgent proves the imported-team flow: +// while the secondary lead is the current agent, transfer_task("researcher") +// must resolve the researcher through the lead's SubAgents pointers even +// though the researcher is not in team.Team, run it to completion, and +// restore the lead as the current agent afterwards. +func TestHandleTaskTransfer_NestedPrivateSubAgent(t *testing.T) { + t.Parallel() + + researcherStream := newStreamBuilder().AddContent("research notes").AddStopWithUsage(10, 5).Build() + fx := newPrivateTeamFixture(t, + &mockProvider{id: "test/mock-model", stream: &mockStream{}}, + &mockProvider{id: "test/mock-model", stream: &mockStream{}}, + &mockProvider{id: "test/mock-model", stream: researcherStream}, + ) + + // Simulate the state mid-delegation: root already transferred to the lead. + require.NoError(t, fx.rt.SetCurrentAgent(t.Context(), "specialists")) + + sess := session.New(session.WithUserMessage("Test"), session.WithToolsApproved(true)) + evts := make(chan Event, 256) + toolCall := tools.ToolCall{ + ID: "call_1", + Type: "function", + Function: tools.FunctionCall{ + Name: "transfer_task", + Arguments: `{"agent":"researcher","task":"research the topic","expected_output":"notes"}`, + }, + } + + result, err := fx.rt.handleTaskTransfer(t.Context(), sess, toolCall, NewChannelSink(evts)) + require.NoError(t, err) + require.NotNil(t, result) + assert.False(t, result.IsError, "nested transfer to a private sub-agent must succeed") + assert.Equal(t, "research notes", result.Output) + + assert.Equal(t, "specialists", fx.rt.CurrentAgentName(t.Context()), + "the secondary lead must be restored as current agent after the nested transfer") + + // The researcher stays private even after being run. + _, err = fx.tm.Agent("researcher") + require.Error(t, err) +} + +// TestHandleTaskTransfer_NestedPrivateBlocksUntilRelease proves the nested +// transfer is synchronous: handleTaskTransfer must not return before the +// private child's model stream completes. The child provider blocks on a +// channel; timeouts are used only as guards. +func TestHandleTaskTransfer_NestedPrivateBlocksUntilRelease(t *testing.T) { + t.Parallel() + + release := make(chan struct{}) + fx := newPrivateTeamFixture(t, + &mockProvider{id: "test/mock-model", stream: &mockStream{}}, + &mockProvider{id: "test/mock-model", stream: &mockStream{}}, + &activeRootBlockingProvider{id: "test/mock-model", release: release}, + ) + + require.NoError(t, fx.rt.SetCurrentAgent(t.Context(), "specialists")) + + sess := session.New(session.WithUserMessage("Test"), session.WithToolsApproved(true)) + evts := make(chan Event, 512) + toolCall := tools.ToolCall{ + ID: "call_1", + Type: "function", + Function: tools.FunctionCall{ + Name: "transfer_task", + Arguments: `{"agent":"researcher","task":"research the topic","expected_output":"notes"}`, + }, + } + + type outcome struct { + result *tools.ToolCallResult + err error + } + done := make(chan outcome, 1) + go func() { + result, err := fx.rt.handleTaskTransfer(t.Context(), sess, toolCall, NewChannelSink(evts)) + done <- outcome{result: result, err: err} + }() + + // Wait until the swap to the private child is observable, so the + // not-yet-returned assertion below checks a transfer that is provably + // in flight rather than one that has not started. + guard := time.After(10 * time.Second) + for fx.rt.CurrentAgentName(t.Context()) != "researcher" { + select { + case out := <-done: + t.Fatalf("transfer returned before the child was released (result=%+v, err=%v)", out.result, out.err) + case <-guard: + t.Fatal("timed out waiting for the transfer to switch to the researcher") + case <-time.After(5 * time.Millisecond): + } + } + + select { + case out := <-done: + t.Fatalf("transfer returned before the child was released (result=%+v, err=%v)", out.result, out.err) + default: + } + + close(release) + + select { + case out := <-done: + require.NoError(t, out.err) + require.NotNil(t, out.result) + assert.False(t, out.result.IsError) + case <-time.After(10 * time.Second): + t.Fatal("transfer did not return after the child was released") + } + + assert.Equal(t, "specialists", fx.rt.CurrentAgentName(t.Context()), + "the secondary lead must be restored as current agent after the nested transfer") +} + +// TestRunStream_NestedPrivateDelegation drives the full documented chain +// through the run loop: primary root -> transfer_task("specialists") +// (blocking) -> secondary lead -> transfer_task("researcher") (blocking) -> +// back to the lead -> back to root. The researcher exists only as a SubAgents +// pointer of the lead, never in team.Team. +func TestRunStream_NestedPrivateDelegation(t *testing.T) { + t.Parallel() + + // Each agent's provider serves one stream per model turn: the tool-call + // turn, then the final answer after the tool result comes back. + rootProv := &queueProvider{id: "test/mock-model", streams: []chat.MessageStream{ + newStreamBuilder(). + AddToolCallName("call_root", "transfer_task"). + AddToolCallArguments("call_root", `{"agent":"specialists","task":"coordinate the research"}`). + AddStopWithUsage(10, 5). + Build(), + newStreamBuilder().AddContent("root done").AddStopWithUsage(10, 5).Build(), + }} + leadProv := &queueProvider{id: "test/mock-model", streams: []chat.MessageStream{ + newStreamBuilder(). + AddToolCallName("call_lead", "transfer_task"). + AddToolCallArguments("call_lead", `{"agent":"researcher","task":"research the topic"}`). + AddStopWithUsage(10, 5). + Build(), + newStreamBuilder().AddContent("lead done").AddStopWithUsage(10, 5).Build(), + }} + researcherStream := newStreamBuilder(). + AddContent("research notes"). + AddStopWithUsage(10, 5). + Build() + + fx := newPrivateTeamFixture(t, + rootProv, + leadProv, + &mockProvider{id: "test/mock-model", stream: researcherStream}, + ) + + sess := session.New(session.WithUserMessage("Delegate the research."), session.WithToolsApproved(true)) + + var errEvents []string + for event := range fx.rt.RunStream(t.Context(), sess) { + if errEvent, ok := event.(*ErrorEvent); ok { + errEvents = append(errEvents, errEvent.Error) + } + } + require.Empty(t, errEvents, "the nested delegation chain must complete without errors") + + assert.Equal(t, "root done", sess.GetLastAssistantMessageContent()) + + // The lead's sub-session is attached to the root session, and the + // researcher's sub-session is attached to the lead's, mirroring the + // delegation chain. + leadSession := findSubSession(sess) + require.NotNil(t, leadSession, "root session must record the lead's sub-session") + assert.Equal(t, "lead done", leadSession.GetLastAssistantMessageContent()) + + researcherSession := findSubSession(leadSession) + require.NotNil(t, researcherSession, "lead session must record the researcher's sub-session") + assert.Equal(t, "research notes", researcherSession.GetLastAssistantMessageContent()) + + assert.Equal(t, "root", fx.rt.CurrentAgentName(t.Context()), + "the primary root must be the current agent again after the chain returns") +} + +// findSubSession returns the first sub-session recorded on sess, or nil. +func findSubSession(sess *session.Session) *session.Session { + for _, item := range sess.Messages { + if item.SubSession != nil { + return item.SubSession + } + } + return nil +} + +// TestRunAgent_PrivateSubAgentOfCurrentAgent covers run_background_agent from +// a secondary lead: the Runner API only carries a name, so RunAgent must +// resolve "researcher" from the current agent's SubAgents pointers (the team +// registry does not know it) and pin the child session to that exact +// instance. +func TestRunAgent_PrivateSubAgentOfCurrentAgent(t *testing.T) { + t.Parallel() + + researcherStream := newStreamBuilder().AddContent("research notes").AddStopWithUsage(10, 5).Build() + fx := newPrivateTeamFixture(t, + &mockProvider{id: "test/mock-model", stream: &mockStream{}}, + &mockProvider{id: "test/mock-model", stream: &mockStream{}}, + &mockProvider{id: "test/mock-model", stream: researcherStream}, + ) + + require.NoError(t, fx.rt.SetCurrentAgent(t.Context(), "specialists")) + + parentSession := session.New(session.WithUserMessage("Test"), session.WithToolsApproved(true)) + result := fx.rt.RunAgent(t.Context(), agenttool.RunParams{ + AgentName: "researcher", + Task: "research the topic", + ParentSession: parentSession, + }) + require.Empty(t, result.ErrMsg, "background run of a private sub-agent must succeed") + assert.Equal(t, "research notes", result.Result) + + childSession := findSubSession(parentSession) + require.NotNil(t, childSession, "parent must record the background sub-session") + assert.Equal(t, "researcher", childSession.AgentName) + assert.Same(t, fx.researcher, childSession.PinnedAgent, + "the child session must pin the exact private instance so RunStream resolves it") +} + +// signallingBlockingProvider closes started when its first model call +// begins, then blocks until release before serving a stream with the given +// content. It lets tests assert runtime state while a child agent's model +// call is provably in flight. +type signallingBlockingProvider struct { + id string + content string + started chan struct{} + release <-chan struct{} + once sync.Once +} + +func (p *signallingBlockingProvider) ID() modelsdev.ID { return modelsdev.ParseIDOrZero(p.id) } + +func (p *signallingBlockingProvider) CreateChatCompletionStream(ctx context.Context, _ []chat.Message, _ []tools.Tool) (chat.MessageStream, error) { + p.once.Do(func() { close(p.started) }) + select { + case <-p.release: + return newStreamBuilder().AddContent(p.content).AddStopWithUsage(1, 1).Build(), nil + case <-ctx.Done(): + return nil, ctx.Err() + } +} + +func (p *signallingBlockingProvider) BaseConfig() base.Config { return base.Config{} } +func (p *signallingBlockingProvider) MaxTokens() int { return 0 } + +// TestHandleTaskTransfer_PinnedBackgroundSessionKeepsRouterUntouched is the +// regression test for pin-aware delegation: a background session pinned to +// the secondary lead calls transfer_task("researcher") while the global +// router points at root for the whole run. Root has no "researcher" +// sub-agent, so resolving the caller from the router would reject the +// transfer outright; resolving it from the pin must succeed, block until the +// exact private child completes, and never touch the shared router. +func TestHandleTaskTransfer_PinnedBackgroundSessionKeepsRouterUntouched(t *testing.T) { + t.Parallel() + + started := make(chan struct{}) + release := make(chan struct{}) + fx := newPrivateTeamFixture(t, + &mockProvider{id: "test/mock-model", stream: &mockStream{}}, + &mockProvider{id: "test/mock-model", stream: &mockStream{}}, + &signallingBlockingProvider{id: "test/mock-model", content: "research notes", started: started, release: release}, + ) + + // Only the session pin carries the lead identity, exactly as + // runCollecting builds background sessions; the router stays on root. + require.Equal(t, "root", fx.rt.CurrentAgentName(t.Context())) + sess := session.New( + session.WithUserMessage("Test"), + session.WithToolsApproved(true), + session.WithAgentName("specialists"), + session.WithPinnedAgent(fx.lead), + ) + + evts := make(chan Event, 512) + toolCall := tools.ToolCall{ + ID: "call_1", + Type: "function", + Function: tools.FunctionCall{ + Name: "transfer_task", + Arguments: `{"agent":"researcher","task":"research the topic","expected_output":"notes"}`, + }, + } + + type outcome struct { + result *tools.ToolCallResult + err error + } + done := make(chan outcome, 1) + go func() { + result, err := fx.rt.handleTaskTransfer(t.Context(), sess, toolCall, NewChannelSink(evts)) + done <- outcome{result: result, err: err} + }() + + // Wait until the researcher's model call is provably in flight. + select { + case <-started: + case out := <-done: + t.Fatalf("transfer returned before the child started (result=%+v, err=%v)", out.result, out.err) + case <-time.After(10 * time.Second): + t.Fatal("timed out waiting for the researcher's model call to start") + } + + assert.Equal(t, "root", fx.rt.CurrentAgentName(t.Context()), + "a pinned session's transfer_task must not swap the global router while in flight") + + // The nested transfer stays blocking: no return before the child is released. + select { + case out := <-done: + t.Fatalf("transfer returned before the child was released (result=%+v, err=%v)", out.result, out.err) + default: + } + + close(release) + + var out outcome + select { + case out = <-done: + case <-time.After(10 * time.Second): + t.Fatal("transfer did not return after the child was released") + } + require.NoError(t, out.err) + require.NotNil(t, out.result) + assert.False(t, out.result.IsError, "pin-resolved transfer to a private sub-agent must succeed") + assert.Equal(t, "research notes", out.result.Output) + + assert.Equal(t, "root", fx.rt.CurrentAgentName(t.Context()), + "the global router must be untouched after the pinned session's transfer") + + // The child ran as the exact private instance, pinned for its RunStream. + childSession := findSubSession(sess) + require.NotNil(t, childSession, "the pinned parent must record the child sub-session") + assert.Equal(t, "researcher", childSession.AgentName) + assert.Same(t, fx.researcher, childSession.PinnedAgent, + "the child session must pin the exact private instance") +} + +// TestRunAgent_UsesCapturedCallerAndTarget verifies the runtime half of the +// HandleRun snapshot contract: RunAgent runs the exact Caller/Target captured +// at dispatch time even though the shared router points at an agent (root) +// that cannot resolve the target name, instead of re-deriving them from the +// router when the background goroutine finally runs. +func TestRunAgent_UsesCapturedCallerAndTarget(t *testing.T) { + t.Parallel() + + researcherStream := newStreamBuilder().AddContent("research notes").AddStopWithUsage(10, 5).Build() + fx := newPrivateTeamFixture(t, + &mockProvider{id: "test/mock-model", stream: &mockStream{}}, + &mockProvider{id: "test/mock-model", stream: &mockStream{}}, + &mockProvider{id: "test/mock-model", stream: researcherStream}, + ) + + // Root's sub-agents do not include the researcher: any late + // re-derivation from the router would fail or substitute here. + require.Equal(t, "root", fx.rt.CurrentAgentName(t.Context())) + + parentSession := session.New(session.WithUserMessage("Test"), session.WithToolsApproved(true)) + result := fx.rt.RunAgent(t.Context(), agenttool.RunParams{ + AgentName: "researcher", + Task: "research the topic", + Caller: fx.lead, + Target: fx.researcher, + ParentSession: parentSession, + }) + require.Empty(t, result.ErrMsg, "RunAgent must use the captured target, not re-resolve via the router") + assert.Equal(t, "research notes", result.Result) + + childSession := findSubSession(parentSession) + require.NotNil(t, childSession, "parent must record the background sub-session") + assert.Same(t, fx.researcher, childSession.PinnedAgent, + "the captured target instance must be pinned on the child session") + assert.Equal(t, "root", fx.rt.CurrentAgentName(t.Context()), + "a captured-target background run must leave the router untouched") +} + +// TestHandleHandoff_PinnedSessionRepointsPinNotRouter locks the pinned-session +// handoff semantics: the caller is resolved from the pin, the pin itself is +// repointed at the exact handoff target so the session's next turn runs it, +// and the shared router never moves. +func TestHandleHandoff_PinnedSessionRepointsPinNotRouter(t *testing.T) { + t.Parallel() + + prov := &mockProvider{id: "test/mock-model", stream: &mockStream{}} + researcher := agent.New("researcher", "Handoff target", agent.WithModel(prov)) + lead := agent.New("specialists", "Secondary team lead", agent.WithModel(prov)) + agent.WithHandoffs(researcher)(lead) + root := agent.New("root", "Primary lead", agent.WithModel(prov)) + + tm := team.New(team.WithAgents(root, lead)) + rt, err := NewLocalRuntime(t.Context(), tm, + WithSessionCompaction(false), + WithModelStore(mockModelStore{}), + ) + require.NoError(t, err) + require.Equal(t, "root", rt.CurrentAgentName(t.Context())) + + sess := session.New( + session.WithUserMessage("Test"), + session.WithAgentName("specialists"), + session.WithPinnedAgent(lead), + ) + toolCall := tools.ToolCall{ + ID: "call_1", + Type: "function", + Function: tools.FunctionCall{ + Name: "handoff", + Arguments: `{"agent":"researcher"}`, + }, + } + + evts := make(chan Event, 16) + result, err := rt.handleHandoff(t.Context(), sess, toolCall, NewChannelSink(evts)) + require.NoError(t, err) + require.NotNil(t, result) + assert.False(t, result.IsError, "handoff from the pinned lead to its handoff target must succeed") + + assert.Same(t, researcher, sess.PinnedAgent, "the session pin must be repointed at the exact target") + assert.Equal(t, "researcher", sess.AgentName) + assert.Equal(t, "root", rt.CurrentAgentName(t.Context()), + "a pinned session's handoff must not swap the global router") +} diff --git a/pkg/runtime/agent_router.go b/pkg/runtime/agent_router.go index 28243bb7a..52186c5c6 100644 --- a/pkg/runtime/agent_router.go +++ b/pkg/runtime/agent_router.go @@ -9,12 +9,25 @@ import ( "github.com/docker/docker-agent/pkg/team" ) +// routedAgent is the router's current-agent record: the agent name, plus +// the exact instance when the setter had one. Carrying the instance matters +// for agents that are not in the public team registry — e.g. members of a +// team imported from a local config file, which stay private to their own +// lead — where a name lookup against the team cannot resolve them. +type routedAgent struct { + name string + // agent is non-nil when the current agent was set by instance + // (SetAgent); Current then returns it directly instead of resolving + // name against the team. + agent *agent.Agent +} + // agentRouter owns the runtime's notion of "which agent is currently // driving the conversation". It is a thin wrapper around a team plus an -// atomically-updated current-agent name, but pulling it out of *LocalRuntime -// turns five methods (CurrentAgentName, setCurrentAgent, SetCurrentAgent, -// CurrentAgent, resolveSessionAgent) that all touched the same two raw -// fields into delegations to one type, and lets tests exercise the +// atomically-updated current-agent record, but pulling it out of *LocalRuntime +// turns four methods (CurrentAgentName, SetCurrentAgent, CurrentAgent, +// resolveSessionAgent) that all touched the same two raw fields into +// delegations to one type, and lets tests exercise the // session-pin-vs-current-agent fallback without instantiating a runtime. // // All methods are safe for concurrent use. @@ -22,7 +35,7 @@ type agentRouter struct { team *team.Team // current is the only mutable field; team is set once at construction // and read-only after, so an atomic pointer suffices to guard it. - current atomic.Pointer[string] + current atomic.Pointer[routedAgent] } // newAgentRouter builds an agentRouter with team t and an initial current @@ -30,14 +43,14 @@ type agentRouter struct { // name exists in t (NewLocalRuntime does this). func newAgentRouter(t *team.Team, initial string) *agentRouter { r := &agentRouter{team: t} - r.current.Store(&initial) + r.current.Store(&routedAgent{name: initial}) return r } // Name returns the name of the currently active agent. func (r *agentRouter) Name() string { - if name := r.current.Load(); name != nil { - return *name + if cur := r.current.Load(); cur != nil { + return cur.name } return "" } @@ -46,7 +59,16 @@ func (r *agentRouter) Name() string { // in the team. Used from agent_delegation.go where the validation has // already been performed against the team's transfer/handoff lists. func (r *agentRouter) Set(name string) { - r.current.Store(&name) + r.current.Store(&routedAgent{name: name}) +} + +// SetAgent replaces the current agent with an exact instance (must be +// non-nil). Unlike Set, Current then returns that instance without a team +// lookup, which is required for agents that are private to the team +// registry (e.g. sub-agents of an imported local-file team lead). Callers +// have already validated a against the caller's sub-agent/handoff lists. +func (r *agentRouter) SetAgent(a *agent.Agent) { + r.current.Store(&routedAgent{name: a.Name(), agent: a}) } // SetValidated checks that name exists in the team, then sets it as the @@ -63,17 +85,29 @@ func (r *agentRouter) SetValidated(name string) error { // Current returns the current agent. The returned agent is non-nil // because NewLocalRuntime validates the initial name and Set callers -// either use SetValidated or have already validated against the team. +// either use SetValidated, SetAgent (which carries the instance), or have +// already validated against the team. func (r *agentRouter) Current() *agent.Agent { - a, _ := r.team.Agent(r.Name()) + cur := r.current.Load() + if cur == nil { + return nil + } + if cur.agent != nil { + return cur.agent + } + a, _ := r.team.Agent(cur.name) return a } -// ResolveSession returns the agent for sess: when sess pins a specific -// agent (e.g. background agent tasks), that agent is returned directly -// instead of reading the shared current-agent field; otherwise Current -// is returned. +// ResolveSession returns the agent for sess: when sess pins an exact agent +// instance (e.g. background tasks targeting an imported team's private +// member), that instance wins; when sess pins an agent name (e.g. +// background agent tasks), that agent is returned directly instead of +// reading the shared current-agent field; otherwise Current is returned. func (r *agentRouter) ResolveSession(sess *session.Session) *agent.Agent { + if sess.PinnedAgent != nil { + return sess.PinnedAgent + } if sess.AgentName != "" { if a, err := r.team.Agent(sess.AgentName); err == nil { return a diff --git a/pkg/runtime/event.go b/pkg/runtime/event.go index e2da85565..2aebd1bcb 100644 --- a/pkg/runtime/event.go +++ b/pkg/runtime/event.go @@ -939,9 +939,13 @@ func AgentInfo(agentName, model, description, welcomeMessage string, contextLimi // AgentDetails contains information about an agent for display in the sidebar type AgentDetails struct { Name string `json:"name"` + DisplayName string `json:"display_name,omitempty"` Description string `json:"description"` Provider string `json:"provider"` Model string `json:"model"` + TeamName string `json:"team_name,omitempty"` + TeamLead bool `json:"team_lead,omitempty"` + Internal bool `json:"internal,omitempty"` // Thinking is a short label describing the model's current thinking-effort // configuration: an effort level (e.g. "high"), "adaptive", a decimal token // count for token-based budgets, or "off" when disabled. Empty when the diff --git a/pkg/runtime/hooks.go b/pkg/runtime/hooks.go index c5c5935e1..80782e09b 100644 --- a/pkg/runtime/hooks.go +++ b/pkg/runtime/hooks.go @@ -29,27 +29,38 @@ import ( // lock. func (r *LocalRuntime) buildHooksExecutors() { r.hooksExecByAgent = make(map[string]*hooks.Executor) - for _, name := range r.team.AgentNames() { - a, err := r.team.Agent(name) - if err != nil { + for _, a := range r.team.AllAgents() { + if _, exists := r.hooksExecByAgent[a.Name()]; exists { + // Private members from different imported teams may share a local ID. + // Their hooks are built lazily by exact pointer in hooksExec instead + // of overwriting another agent's name-keyed executor here. continue } - cfg := builtins.ApplyAgentDefaults(a.Hooks(), builtins.AgentDefaults{ - AddDate: a.AddDate(), - AddEnvironmentInfo: a.AddEnvironmentInfo(), - AddPromptFiles: a.AddPromptFiles(), - RedactSecrets: a.RedactSecrets(), - }) - cfg = applyAutoInjectors(cfg, r.autoInjectors) - cfg = applyCacheDefault(cfg, a) - if cfg == nil { - continue + if exec := r.newHooksExecutor(a); exec != nil { + r.hooksExecByAgent[a.Name()] = exec } - builtins.WarnIfSaferShellConfigured(cfg) - r.hooksExecByAgent[name] = hooks.NewExecutorWithRegistry(cfg, r.workingDir, r.env, r.hooksRegistry) } } +func (r *LocalRuntime) newHooksExecutor(a *agent.Agent) *hooks.Executor { + if a == nil { + return nil + } + cfg := builtins.ApplyAgentDefaults(a.Hooks(), builtins.AgentDefaults{ + AddDate: a.AddDate(), + AddEnvironmentInfo: a.AddEnvironmentInfo(), + AddPromptFiles: a.AddPromptFiles(), + RedactSecrets: a.RedactSecrets(), + }) + cfg = applyAutoInjectors(cfg, r.autoInjectors) + cfg = applyCacheDefault(cfg, a) + if cfg == nil { + return nil + } + builtins.WarnIfSaferShellConfigured(cfg) + return hooks.NewExecutorWithRegistry(cfg, r.workingDir, r.env, r.hooksRegistry) +} + // applyAutoInjectors runs each AutoInjector against cfg, allocating a // fresh Config when needed so a previously-empty agent picks up the // injector's hooks. Returns nil iff cfg ends up empty after every @@ -78,7 +89,24 @@ func (r *LocalRuntime) hooksExec(a *agent.Agent) *hooks.Executor { if a == nil { return nil } - return r.hooksExecByAgent[a.Name()] + if exec := r.hooksExecByAgent[a.Name()]; exec != nil { + // A public/name-unique agent uses the prebuilt executor. When another + // pointer shares this local ID, only reuse it if it is the public team + // instance; private collisions need their own exact executor. + if registered, err := r.team.Agent(a.Name()); err == nil && registered == a { + return exec + } + matches := 0 + for _, candidate := range r.team.AllAgents() { + if candidate.Name() == a.Name() { + matches++ + } + } + if matches == 1 { + return exec + } + } + return r.newHooksExecutor(a) } // dispatchHook is the common dispatch path shared by every hook diff --git a/pkg/runtime/localteam_integration_test.go b/pkg/runtime/localteam_integration_test.go new file mode 100644 index 000000000..f533d6278 --- /dev/null +++ b/pkg/runtime/localteam_integration_test.go @@ -0,0 +1,229 @@ +package runtime_test + +import ( + "context" + "fmt" + "io" + "os" + "path/filepath" + "sync" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/docker/docker-agent/pkg/chat" + "github.com/docker/docker-agent/pkg/config" + "github.com/docker/docker-agent/pkg/config/latest" + "github.com/docker/docker-agent/pkg/environment" + "github.com/docker/docker-agent/pkg/model/provider" + "github.com/docker/docker-agent/pkg/model/provider/base" + "github.com/docker/docker-agent/pkg/model/provider/options" + "github.com/docker/docker-agent/pkg/modelsdev" + "github.com/docker/docker-agent/pkg/runtime" + "github.com/docker/docker-agent/pkg/session" + "github.com/docker/docker-agent/pkg/teamloader" + "github.com/docker/docker-agent/pkg/tools" +) + +// This file lives in the external runtime_test package because it imports +// teamloader, which itself (transitively, via runtime/jscommands) imports +// pkg/runtime — an in-package test file would create an import cycle. + +// scriptedStream replays a fixed sequence of stream responses, then io.EOF. +type scriptedStream struct { + responses []chat.MessageStreamResponse + idx int +} + +func (s *scriptedStream) Recv() (chat.MessageStreamResponse, error) { + if s.idx >= len(s.responses) { + return chat.MessageStreamResponse{}, io.EOF + } + r := s.responses[s.idx] + s.idx++ + return r, nil +} + +func (s *scriptedStream) Close() {} + +// toolCallTurn scripts one model turn that calls transfer_task with args. +func toolCallTurn(callID, args string) *scriptedStream { + return &scriptedStream{responses: []chat.MessageStreamResponse{ + {Choices: []chat.MessageStreamChoice{{ + Delta: chat.MessageDelta{ToolCalls: []tools.ToolCall{{ + ID: callID, + Type: "function", + Function: tools.FunctionCall{Name: "transfer_task", Arguments: args}, + }}}, + }}}, + { + Choices: []chat.MessageStreamChoice{{FinishReason: chat.FinishReasonStop}}, + Usage: &chat.Usage{InputTokens: 10, OutputTokens: 5}, + }, + }} +} + +// finalTurn scripts one model turn that answers with content and stops. +func finalTurn(content string) *scriptedStream { + return &scriptedStream{responses: []chat.MessageStreamResponse{ + {Choices: []chat.MessageStreamChoice{{Delta: chat.MessageDelta{Content: content}}}}, + { + Choices: []chat.MessageStreamChoice{{FinishReason: chat.FinishReasonStop}}, + Usage: &chat.Usage{InputTokens: 10, OutputTokens: 5}, + }, + }} +} + +// scriptedProvider serves one scripted stream per model turn, in order. +type scriptedProvider struct { + id string + mu sync.Mutex + streams []*scriptedStream +} + +func (p *scriptedProvider) ID() modelsdev.ID { return modelsdev.ParseIDOrZero(p.id) } + +func (p *scriptedProvider) CreateChatCompletionStream(context.Context, []chat.Message, []tools.Tool) (chat.MessageStream, error) { + p.mu.Lock() + defer p.mu.Unlock() + if len(p.streams) == 0 { + return &scriptedStream{}, nil + } + s := p.streams[0] + p.streams = p.streams[1:] + return s, nil +} + +func (p *scriptedProvider) BaseConfig() base.Config { return base.Config{} } + +// stubModelStore satisfies runtime.ModelStore for models the catalogue does +// not know; only GetModel is exercised by these flows. +type stubModelStore struct{ runtime.ModelStore } + +func (stubModelStore) GetModel(context.Context, modelsdev.ID) (*modelsdev.Model, error) { + return nil, nil +} + +// TestLocalTeamImport_NestedDelegation is the end-to-end proof of the +// local-file team import feature: two YAML files loaded via teamloader.Load, +// then the full documented chain through the runtime — primary root -> +// transfer_task("specialists") (blocking) -> secondary lead -> +// transfer_task("researcher") (blocking) -> back to the lead -> back to the +// primary root. The researcher never joins the public team registry; it is +// reachable only through the imported lead's own sub-agent pointers. +func TestLocalTeamImport_NestedDelegation(t *testing.T) { + t.Setenv("OPENAI_API_KEY", "dummy") + + secondary := `models: + lead-model: + provider: openai + model: lead-model + researcher-model: + provider: openai + model: researcher-model +agents: + root: + model: lead-model + description: Secondary team lead + instruction: Coordinate your own team to answer tasks. + sub_agents: [researcher] + researcher: + model: researcher-model + description: Researcher of the secondary team + instruction: Research topics and report back. +` + primary := `models: + root-model: + provider: openai + model: root-model +agents: + root: + model: root-model + description: Primary lead + instruction: Delegate specialist work to the secondary team lead. + sub_agents: + - specialists:./secondary-team.yaml +` + + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "secondary-team.yaml"), []byte(secondary), 0o644)) + primaryPath := filepath.Join(dir, "primary-team.yaml") + require.NoError(t, os.WriteFile(primaryPath, []byte(primary), 0o644)) + + // One scripted provider per model: the leads run two turns each (the + // transfer_task call, then the final answer once the transfer returns), + // the researcher answers directly. + provs := map[string]provider.Provider{ + "root-model": &scriptedProvider{id: "openai/root-model", streams: []*scriptedStream{ + toolCallTurn("call_root", `{"agent":"specialists","task":"coordinate the research"}`), + finalTurn("root done"), + }}, + "lead-model": &scriptedProvider{id: "openai/lead-model", streams: []*scriptedStream{ + toolCallTurn("call_lead", `{"agent":"researcher","task":"research the topic"}`), + finalTurn("lead done"), + }}, + "researcher-model": &scriptedProvider{id: "openai/researcher-model", streams: []*scriptedStream{ + finalTurn("research notes"), + }}, + } + registry := provider.NewRegistry(map[string]provider.Factory{ + "openai": func(_ context.Context, cfg *latest.ModelConfig, _ environment.Provider, _ ...options.Opt) (provider.Provider, error) { + p, ok := provs[cfg.Model] + if !ok { + return nil, fmt.Errorf("no scripted provider for model %q", cfg.Model) + } + return p, nil + }, + }) + + tm, err := teamloader.Load(t.Context(), config.NewFileSource(primaryPath), &config.RuntimeConfig{}, teamloader.WithProviderRegistry(registry)) + require.NoError(t, err) + + // The imported lead is public; its member stays private to it. + _, err = tm.Agent("specialists") + require.NoError(t, err) + _, err = tm.Agent("researcher") + require.Error(t, err, "the secondary team's member must not join the public registry") + + rt, err := runtime.NewLocalRuntime(t.Context(), tm, + runtime.WithSessionCompaction(false), + runtime.WithModelStore(stubModelStore{}), + ) + require.NoError(t, err) + + sess := session.New(session.WithUserMessage("Delegate the research."), session.WithToolsApproved(true)) + + var errEvents []string + for event := range rt.RunStream(t.Context(), sess) { + if errEvent, ok := event.(*runtime.ErrorEvent); ok { + errEvents = append(errEvents, errEvent.Error) + } + } + require.Empty(t, errEvents, "the nested delegation chain must complete without errors") + + assert.Equal(t, "root done", sess.GetLastAssistantMessageContent()) + + // The sub-session chain mirrors the delegation: root records the lead's + // session, which records the researcher's. + leadSession := firstSubSession(sess) + require.NotNil(t, leadSession, "root session must record the lead's sub-session") + assert.Equal(t, "lead done", leadSession.GetLastAssistantMessageContent()) + + researcherSession := firstSubSession(leadSession) + require.NotNil(t, researcherSession, "lead session must record the researcher's sub-session") + assert.Equal(t, "research notes", researcherSession.GetLastAssistantMessageContent()) + + assert.Equal(t, "root", rt.CurrentAgentName(t.Context()), + "the primary root must be current again after the chain returns") +} + +// firstSubSession returns the first sub-session recorded on sess, or nil. +func firstSubSession(sess *session.Session) *session.Session { + for _, item := range sess.Messages { + if item.SubSession != nil { + return item.SubSession + } + } + return nil +} diff --git a/pkg/runtime/runtime.go b/pkg/runtime/runtime.go index 1a5cdb04b..67365a7a0 100644 --- a/pkg/runtime/runtime.go +++ b/pkg/runtime/runtime.go @@ -817,10 +817,6 @@ func (r *LocalRuntime) currentAgentName() string { return r.agents.Name() } -func (r *LocalRuntime) setCurrentAgent(name string) { - r.agents.Set(name) -} - func (r *LocalRuntime) CurrentAgentInfo(context.Context) CurrentAgentInfo { currentAgent := r.CurrentAgent() @@ -1402,8 +1398,11 @@ func (r *LocalRuntime) agentDetailsFromTeam(ctx context.Context) []AgentDetails modelName := info.Model var thinking string - // Get the agent to access fallbacks and the effective thinking level. - if a, err := r.team.Agent(info.Name); err == nil && a != nil { + // Public agents can be looked up in the team registry. Private imported + // members already carry their resolved provider/model in AgentInfo; their + // thinking label is populated when they become active via AgentInfo events. + if info.Agent != nil { + a := info.Agent // Check if this agent has an active fallback cooldown cooldownState := r.fallback.cooldowns.Get(info.Name) if cooldownState != nil { @@ -1419,11 +1418,15 @@ func (r *LocalRuntime) agentDetailsFromTeam(ctx context.Context) []AgentDetails details[i] = AgentDetails{ Name: info.Name, + DisplayName: info.DisplayName, Description: info.Description, Provider: providerName, Model: modelName, Thinking: thinking, Commands: info.Commands, + TeamName: info.TeamName, + TeamLead: info.TeamLead, + Internal: info.Internal, } } return details diff --git a/pkg/runtime/sessionplan_handlers.go b/pkg/runtime/sessionplan_handlers.go index d269a79cf..845567b0e 100644 --- a/pkg/runtime/sessionplan_handlers.go +++ b/pkg/runtime/sessionplan_handlers.go @@ -49,7 +49,7 @@ func (r *LocalRuntime) handleReadSessionPlan(_ context.Context, sess *session.Se // handleExitPlanMode marks the session's plan as ready and returns control to // the host. Switching agents is the host's decision — the runtime does not -// call setCurrentAgent here so a CLI that prints results inline, a chat UI +// call SetCurrentAgent here so a CLI that prints results inline, a chat UI // with a mode toggle, and a server with a configured handoff can all consume // the same marker without one stepping on the other. func (r *LocalRuntime) handleExitPlanMode(_ context.Context, sess *session.Session, _ tools.ToolCall, _ EventSink) (*tools.ToolCallResult, error) { diff --git a/pkg/runtime/skill_runner.go b/pkg/runtime/skill_runner.go index 300c76175..38b7401d8 100644 --- a/pkg/runtime/skill_runner.go +++ b/pkg/runtime/skill_runner.go @@ -101,13 +101,16 @@ func (r *LocalRuntime) RunSkillFork(ctx context.Context, sess *session.Session, } // Skills are sub-sessions of the caller, not delegations, so the - // runtime's currentAgent stays put. + // runtime's currentAgent stays put. Carry the exact caller instance so + // the child session resolves it even when the caller is private to the + // team registry (e.g. a member of an imported team). return r.runForwarding(ctx, sess, evts, delegationRequest{ SubSessionConfig: SubSessionConfig{ Task: prepared.Task, SystemMessage: skills.BuildSkillSystemMessage(prepared, sess.AttachedFilesSnapshot()), ImplicitUserMessage: skills.BuildSkillUserMessage(prepared), AgentName: ca, + Agent: r.CurrentAgent(), Title: "Skill: " + prepared.SkillName, ToolsApproved: sess.IsToolsApproved(), SafetyPolicy: sess.GetSafetyPolicy(), diff --git a/pkg/session/branch.go b/pkg/session/branch.go index 51537ceda..38aae0e7c 100644 --- a/pkg/session/branch.go +++ b/pkg/session/branch.go @@ -107,6 +107,7 @@ func (s *Session) Clone() *Session { AllowedTools: cloneStringSlice(s.AllowedTools), ExtraToolSets: slices.Clone(s.ExtraToolSets), AgentName: s.AgentName, + PinnedAgent: s.PinnedAgent, ParentID: s.ParentID, InstructionContext: cloneInstructionContext(s.InstructionContext), MessageUsageHistory: slices.Clone(s.MessageUsageHistory), diff --git a/pkg/session/session.go b/pkg/session/session.go index a758dbe5b..4575db80e 100644 --- a/pkg/session/session.go +++ b/pkg/session/session.go @@ -364,6 +364,13 @@ type Session struct { // concurrently on different agents. AgentName string `json:"-"` + // PinnedAgent, when non-nil, pins this session to an exact agent instance + // and takes precedence over AgentName. Required for sessions running agents + // that are not in the runtime's public team registry (e.g. members of a + // team imported from a local config file, which stay private to their own + // lead). Never persisted. + PinnedAgent *agent.Agent `json:"-"` + // ParentID indicates this is a sub-session created by task transfer. // Sub-sessions are not persisted as standalone entries; they are embedded // within the parent session's Messages array. @@ -1371,6 +1378,15 @@ func WithAgentName(name string) Opt { } } +// WithPinnedAgent pins this session to an exact agent instance. Unlike +// WithAgentName it does not rely on the runtime's team registry, so it can +// target agents that are private to an imported team. +func WithPinnedAgent(a *agent.Agent) Opt { + return func(s *Session) { + s.PinnedAgent = a + } +} + // WithParentID marks this session as a sub-session of the given parent. // Sub-sessions are not persisted as standalone entries in the session store. func WithParentID(parentID string) Opt { diff --git a/pkg/tools/builtin/agent/agent.go b/pkg/tools/builtin/agent/agent.go index 8a67b4372..4603c3e4d 100644 --- a/pkg/tools/builtin/agent/agent.go +++ b/pkg/tools/builtin/agent/agent.go @@ -5,7 +5,6 @@ import ( "encoding/json" "fmt" "log/slog" - "slices" "strings" "sync" "sync/atomic" @@ -17,6 +16,7 @@ import ( "go.opentelemetry.io/otel/codes" "go.opentelemetry.io/otel/trace" + coreagent "github.com/docker/docker-agent/pkg/agent" "github.com/docker/docker-agent/pkg/concurrent" "github.com/docker/docker-agent/pkg/session" "github.com/docker/docker-agent/pkg/telemetry/genai" @@ -66,8 +66,16 @@ type RunParams struct { AgentName string Task string ExpectedOutput string - ParentSession *session.Session - OnContent func(content string) + // Caller is the exact agent that dispatched the task, captured by + // HandleRun before the task goroutine starts. + Caller *coreagent.Agent + // Target is the exact validated sub-agent instance to run, taken from + // Caller's sub-agents list at dispatch time. The runtime must use it + // verbatim instead of re-resolving AgentName, which could fail or + // substitute another agent after a concurrent handoff. + Target *coreagent.Agent + ParentSession *session.Session + OnContent func(content string) } // RunResult holds the outcome of a sub-agent execution. @@ -78,8 +86,12 @@ type RunResult struct { // Runner abstracts the runtime dependency for background agent execution. type Runner interface { - // CurrentAgentSubAgentNames returns the names of the current agent's sub-agents. - CurrentAgentSubAgentNames() []string + // SessionAgent returns the exact agent driving sess: the session pin + // when set, the runtime's current agent otherwise. HandleRun snapshots + // it — and the validated target from its sub-agents — before spawning + // the task goroutine so a concurrent handoff cannot swap either + // identity. + SessionAgent(sess *session.Session) *coreagent.Agent // RunAgent starts a sub-agent and blocks until completion or cancellation. RunAgent(ctx context.Context, params RunParams) *RunResult } @@ -276,8 +288,24 @@ func (h *Handler) HandleRun(ctx context.Context, sess *session.Session, toolCall return tools.ResultError("task must not be empty"), nil } - subAgentNames := h.runner.CurrentAgentSubAgentNames() - if !slices.Contains(subAgentNames, params.Agent) { + // Snapshot the caller and its sub-agents once: the allow-list check and + // the target capture must see the same state, and both must happen + // before the goroutine below so a concurrent handoff cannot swap the + // caller or substitute the target mid-dispatch. + caller := h.runner.SessionAgent(sess) + if caller == nil { + return tools.ResultError("no current agent available to dispatch background tasks"), nil + } + subAgents := caller.SubAgents() + var target *coreagent.Agent + subAgentNames := make([]string, len(subAgents)) + for i, a := range subAgents { + subAgentNames[i] = a.Name() + if a.Name() == params.Agent { + target = a + } + } + if target == nil { if len(subAgentNames) > 0 { return tools.ResultError(fmt.Sprintf("agent %q is not in the sub-agents list. Available: %s", params.Agent, strings.Join(subAgentNames, ", "))), nil } @@ -372,6 +400,8 @@ func (h *Handler) HandleRun(ctx context.Context, sess *session.Session, toolCall AgentName: params.Agent, Task: params.Task, ExpectedOutput: params.ExpectedOutput, + Caller: caller, + Target: target, ParentSession: sess, OnContent: t.writeOutput, }) diff --git a/pkg/tools/builtin/agent/agent_test.go b/pkg/tools/builtin/agent/agent_test.go index 8c407a067..54eefdd07 100644 --- a/pkg/tools/builtin/agent/agent_test.go +++ b/pkg/tools/builtin/agent/agent_test.go @@ -13,6 +13,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + coreagent "github.com/docker/docker-agent/pkg/agent" "github.com/docker/docker-agent/pkg/concurrent" "github.com/docker/docker-agent/pkg/session" "github.com/docker/docker-agent/pkg/tools" @@ -20,13 +21,19 @@ import ( // mockRunner implements Runner for testing. type mockRunner struct { - subAgentNames []string - runResult *RunResult - runDelay time.Duration // optional delay to simulate work + caller *coreagent.Agent + runResult *RunResult + runDelay time.Duration // optional delay to simulate work + + mu sync.Mutex + lastParams *RunParams // params of the most recent RunAgent call } -func (m *mockRunner) CurrentAgentSubAgentNames() []string { return m.subAgentNames } +func (m *mockRunner) SessionAgent(*session.Session) *coreagent.Agent { return m.caller } func (m *mockRunner) RunAgent(ctx context.Context, params RunParams) *RunResult { + m.mu.Lock() + m.lastParams = ¶ms + m.mu.Unlock() if m.runDelay > 0 { select { case <-time.After(m.runDelay): @@ -44,6 +51,22 @@ func (m *mockRunner) RunAgent(ctx context.Context, params RunParams) *RunResult return &RunResult{} } +func (m *mockRunner) gotParams() *RunParams { + m.mu.Lock() + defer m.mu.Unlock() + return m.lastParams +} + +// callerWithSubAgents builds a caller agent whose sub-agents are stub agents +// with the given names. +func callerWithSubAgents(names ...string) *coreagent.Agent { + subs := make([]*coreagent.Agent, len(names)) + for i, n := range names { + subs[i] = coreagent.New(n, "") + } + return coreagent.New("root", "", coreagent.WithSubAgents(subs...)) +} + func newTestHandler() *Handler { return &Handler{ tasks: concurrent.NewMap[string, *task](), @@ -421,7 +444,7 @@ func TestStopAll_WaitsForGoroutines(t *testing.T) { func TestHandleRun_EmptyAgent(t *testing.T) { t.Parallel() - h := newTestHandlerWithRunner(&mockRunner{subAgentNames: []string{"sub"}}) + h := newTestHandlerWithRunner(&mockRunner{caller: callerWithSubAgents("sub")}) tc := makeToolCall(t, RunBackgroundAgentArgs{Agent: "", Task: "do something"}) result, err := h.HandleRun(t.Context(), session.New(), tc) require.NoError(t, err) @@ -431,7 +454,7 @@ func TestHandleRun_EmptyAgent(t *testing.T) { func TestHandleRun_EmptyTask(t *testing.T) { t.Parallel() - h := newTestHandlerWithRunner(&mockRunner{subAgentNames: []string{"sub"}}) + h := newTestHandlerWithRunner(&mockRunner{caller: callerWithSubAgents("sub")}) tc := makeToolCall(t, RunBackgroundAgentArgs{Agent: "sub", Task: ""}) result, err := h.HandleRun(t.Context(), session.New(), tc) require.NoError(t, err) @@ -441,7 +464,7 @@ func TestHandleRun_EmptyTask(t *testing.T) { func TestHandleRun_InvalidSubAgent(t *testing.T) { t.Parallel() - h := newTestHandlerWithRunner(&mockRunner{subAgentNames: []string{"sub"}}) + h := newTestHandlerWithRunner(&mockRunner{caller: callerWithSubAgents("sub")}) tc := makeToolCall(t, RunBackgroundAgentArgs{Agent: "nonexistent", Task: "do something"}) result, err := h.HandleRun(t.Context(), session.New(), tc) require.NoError(t, err) @@ -451,7 +474,7 @@ func TestHandleRun_InvalidSubAgent(t *testing.T) { func TestHandleRun_NoSubAgents(t *testing.T) { t.Parallel() - h := newTestHandlerWithRunner(&mockRunner{subAgentNames: nil}) + h := newTestHandlerWithRunner(&mockRunner{caller: callerWithSubAgents()}) tc := makeToolCall(t, RunBackgroundAgentArgs{Agent: "some-agent", Task: "do something"}) result, err := h.HandleRun(t.Context(), session.New(), tc) require.NoError(t, err) @@ -459,9 +482,19 @@ func TestHandleRun_NoSubAgents(t *testing.T) { assert.Contains(t, result.Output, "no sub-agents configured") } +func TestHandleRun_NoCurrentAgent(t *testing.T) { + t.Parallel() + h := newTestHandlerWithRunner(&mockRunner{}) + tc := makeToolCall(t, RunBackgroundAgentArgs{Agent: "sub", Task: "do something"}) + result, err := h.HandleRun(t.Context(), session.New(), tc) + require.NoError(t, err) + assert.True(t, result.IsError) + assert.Contains(t, result.Output, "no current agent") +} + func TestHandleRun_ConcurrencyCapEnforced(t *testing.T) { t.Parallel() - h := newTestHandlerWithRunner(&mockRunner{subAgentNames: []string{"sub"}}) + h := newTestHandlerWithRunner(&mockRunner{caller: callerWithSubAgents("sub")}) for i := range maxConcurrentTasks { insertTask(h, "fake"+string(rune('a'+i)), "sub", taskRunning) @@ -476,7 +509,7 @@ func TestHandleRun_ConcurrencyCapEnforced(t *testing.T) { func TestHandleRun_InvalidJSON(t *testing.T) { t.Parallel() - h := newTestHandlerWithRunner(&mockRunner{subAgentNames: []string{"sub"}}) + h := newTestHandlerWithRunner(&mockRunner{caller: callerWithSubAgents("sub")}) bad := tools.ToolCall{Function: tools.FunctionCall{Arguments: "not-json"}} _, err := h.HandleRun(t.Context(), session.New(), bad) require.Error(t, err, "invalid JSON should return an error") @@ -485,8 +518,8 @@ func TestHandleRun_InvalidJSON(t *testing.T) { func TestHandleRun_StartsTask(t *testing.T) { t.Parallel() h := newTestHandlerWithRunner(&mockRunner{ - subAgentNames: []string{"sub"}, - runResult: &RunResult{Result: "done"}, + caller: callerWithSubAgents("sub"), + runResult: &RunResult{Result: "done"}, }) tc := makeToolCall(t, RunBackgroundAgentArgs{Agent: "sub", Task: "write a poem"}) @@ -505,11 +538,37 @@ func TestHandleRun_StartsTask(t *testing.T) { }) } +// TestHandleRun_CapturesCallerAndTargetAtDispatch locks the snapshot +// contract: HandleRun resolves the caller and the validated target exactly +// once, synchronously, and hands those instances to RunAgent — the task +// goroutine never re-resolves them, so a concurrent handoff cannot fail the +// run or substitute another agent. +func TestHandleRun_CapturesCallerAndTargetAtDispatch(t *testing.T) { + t.Parallel() + caller := callerWithSubAgents("sub", "other") + target := caller.SubAgents()[0] + runner := &mockRunner{caller: caller, runResult: &RunResult{Result: "done"}} + h := newTestHandlerWithRunner(runner) + + tc := makeToolCall(t, RunBackgroundAgentArgs{Agent: "sub", Task: "work"}) + result, err := h.HandleRun(t.Context(), session.New(), tc) + require.NoError(t, err) + assert.False(t, result.IsError) + + h.wg.Wait() + + got := runner.gotParams() + require.NotNil(t, got, "RunAgent must have been invoked") + assert.Same(t, caller, got.Caller, "HandleRun must pass the exact caller captured at dispatch") + assert.Same(t, target, got.Target, "HandleRun must pass the exact validated target captured at dispatch") + assert.Equal(t, "sub", got.AgentName) +} + func TestHandleRun_ProviderError_TaskFails(t *testing.T) { t.Parallel() h := newTestHandlerWithRunner(&mockRunner{ - subAgentNames: []string{"sub"}, - runResult: &RunResult{ErrMsg: "model unavailable"}, + caller: callerWithSubAgents("sub"), + runResult: &RunResult{ErrMsg: "model unavailable"}, }) tc := makeToolCall(t, RunBackgroundAgentArgs{Agent: "sub", Task: "do something"}) @@ -529,8 +588,8 @@ func TestHandleRun_ProviderError_TaskFails(t *testing.T) { func TestHandleRun_WithExpectedOutput(t *testing.T) { t.Parallel() h := newTestHandlerWithRunner(&mockRunner{ - subAgentNames: []string{"sub"}, - runResult: &RunResult{Result: "result"}, + caller: callerWithSubAgents("sub"), + runResult: &RunResult{Result: "result"}, }) tc := makeToolCall(t, RunBackgroundAgentArgs{ @@ -553,8 +612,8 @@ func TestHandleRun_WithExpectedOutput(t *testing.T) { func TestHandleRun_TotalCapAutoPruneAdmits(t *testing.T) { t.Parallel() h := newTestHandlerWithRunner(&mockRunner{ - subAgentNames: []string{"sub"}, - runResult: &RunResult{Result: "done"}, + caller: callerWithSubAgents("sub"), + runResult: &RunResult{Result: "done"}, }) for i := range maxTotalTasks { @@ -572,7 +631,7 @@ func TestHandleRun_TotalCapAutoPruneAdmits(t *testing.T) { func TestHandleRun_TotalCapExhaustion_ConcurrencyCapFiresFirst(t *testing.T) { t.Parallel() - h := newTestHandlerWithRunner(&mockRunner{subAgentNames: []string{"sub"}}) + h := newTestHandlerWithRunner(&mockRunner{caller: callerWithSubAgents("sub")}) for i := range maxConcurrentTasks { insertTask(h, fmt.Sprintf("run%d", i), "sub", taskRunning) From 12c873a395caa173215af35e7e775166c5bbafb5 Mon Sep 17 00:00:00 2001 From: Sayt-0 Date: Thu, 30 Jul 2026 18:13:20 +0200 Subject: [PATCH 3/4] feat(cli): add team composition flag and TUI groups --- cmd/root/payload.go | 1 + cmd/root/run.go | 8 + cmd/root/run_team_test.go | 33 ++++ pkg/runtime/payload.go | 1 + pkg/runtime/payload_test.go | 2 + pkg/tui/components/sidebar/sidebar.go | 149 ++++++++++++++---- .../components/sidebar/team_groups_test.go | 100 ++++++++++++ pkg/tui/handlers.go | 14 +- pkg/tui/service/sessionstate.go | 8 +- 9 files changed, 279 insertions(+), 37 deletions(-) create mode 100644 cmd/root/run_team_test.go create mode 100644 pkg/tui/components/sidebar/team_groups_test.go diff --git a/cmd/root/payload.go b/cmd/root/payload.go index 0d760af58..79a644dd6 100644 --- a/cmd/root/payload.go +++ b/cmd/root/payload.go @@ -11,6 +11,7 @@ func (f *runExecFlags) loadTeamRequest(agentSource config.Source) runtime.LoadTe Source: agentSource, ModelOverrides: f.modelOverrides, PromptFiles: f.promptFiles, + ExternalTeams: f.teams, RunConfig: &f.runConfig, } } diff --git a/cmd/root/run.go b/cmd/root/run.go index 06bdda7ce..a22ced98b 100644 --- a/cmd/root/run.go +++ b/cmd/root/run.go @@ -72,6 +72,7 @@ type runExecFlags struct { remoteAddress string modelOverrides []string promptFiles []string + teams []string dryRun bool runConfig config.RuntimeConfig sessionDB string @@ -143,6 +144,7 @@ func newRunCmd() *cobra.Command { Long: "Run an agent with the specified configuration and prompt", Example: ` docker-agent run ./agent.yaml docker-agent run ./team.yaml --agent root + docker-agent run ./primary.yaml --team "Research team=./secondary.yaml" docker-agent run # project config or built-in default agent docker-agent run coder # built-in coding agent docker-agent run ./echo.yaml "INSTRUCTIONS" @@ -168,6 +170,7 @@ func addRunOrExecFlags(cmd *cobra.Command, flags *runExecFlags) { cmd.PersistentFlags().BoolVar(&flags.hideToolResults, "hide-tool-results", false, "Hide tool call results") cmd.PersistentFlags().StringVar(&flags.attachmentPath, "attach", "", "Attach an image file to the message") cmd.PersistentFlags().StringArrayVar(&flags.promptFiles, "prompt-file", nil, "Append file contents to the prompt (repeatable)") + cmd.PersistentFlags().StringArrayVar(&flags.teams, "team", nil, "Attach a local YAML/HCL team to the primary lead: 'Team name=path' (repeatable)") cmd.PersistentFlags().StringArrayVar(&flags.modelOverrides, "model", nil, "Override agent model: [agent=]provider/model (repeatable)") cmd.PersistentFlags().BoolVar(&flags.dryRun, "dry-run", false, "Initialize the agent without executing anything") cmd.PersistentFlags().StringVar(&flags.remoteAddress, "remote", "", "Use remote runtime with specified address") @@ -210,6 +213,8 @@ func addRunOrExecFlags(cmd *cobra.Command, flags *runExecFlags) { cmd.PersistentFlags().BoolVar(&flags.sessionReadOnly, "session-read-only", false, "Open the session in read-only mode (view conversation history but prevent new messages)") cmd.MarkFlagsMutuallyExclusive("fake", "record") cmd.MarkFlagsMutuallyExclusive("remote", "sandbox") + cmd.MarkFlagsMutuallyExclusive("remote", "team") + cmd.MarkFlagsMutuallyExclusive("sandbox", "team") cmd.MarkFlagsMutuallyExclusive("remote", "session-db") cmd.MarkFlagsMutuallyExclusive("remote", "session") cmd.MarkFlagsMutuallyExclusive("remote", "record") @@ -852,6 +857,9 @@ func (f *runExecFlags) loadAgentFrom(ctx context.Context, req runtime.LoadTeamRe if len(req.PromptFiles) > 0 { opts = append(opts, teamloader.WithPromptFiles(req.PromptFiles)) } + if len(req.ExternalTeams) > 0 { + opts = append(opts, teamloader.WithExternalTeams(req.ExternalTeams)) + } return teamloader.LoadWithConfig(ctx, req.Source, req.RunConfig, opts...) } diff --git a/cmd/root/run_team_test.go b/cmd/root/run_team_test.go new file mode 100644 index 000000000..3413c8e83 --- /dev/null +++ b/cmd/root/run_team_test.go @@ -0,0 +1,33 @@ +package root + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRunTeamFlagIsRepeatable(t *testing.T) { + cmd := newRunCmd() + require.NoError(t, cmd.ParseFlags([]string{ + "--team", "Research team=./secondary.yaml", + "--team", "QA team=./qa.hcl", + })) + + flag := cmd.Flags().Lookup("team") + require.NotNil(t, flag) + assert.Equal(t, "[Research team=./secondary.yaml,QA team=./qa.hcl]", flag.Value.String()) +} + +func TestLoadTeamRequestCarriesExternalTeams(t *testing.T) { + flags := &runExecFlags{teams: []string{"Research team=./secondary.yaml", "QA team=./qa.hcl"}} + req := flags.loadTeamRequest(nil) + assert.Equal(t, flags.teams, req.ExternalTeams) +} + +func TestRunSecondPositionalRemainsMessage(t *testing.T) { + flags := &runExecFlags{} + args := []string{"./primary.yaml", "./secondary.yaml"} + assert.Equal(t, "./primary.yaml", flags.resolveRunAgentFileName(args)) + assert.Equal(t, "./secondary.yaml", args[1], "the second positional remains a message; use --team to compose teams") +} diff --git a/pkg/runtime/payload.go b/pkg/runtime/payload.go index f53e92494..445d39cc8 100644 --- a/pkg/runtime/payload.go +++ b/pkg/runtime/payload.go @@ -23,6 +23,7 @@ type LoadTeamRequest struct { Source config.Source `json:"-"` ModelOverrides []string `json:"model_overrides,omitempty"` PromptFiles []string `json:"prompt_files,omitempty"` + ExternalTeams []string `json:"external_teams,omitempty"` RunConfig *config.RuntimeConfig `json:"-"` } diff --git a/pkg/runtime/payload_test.go b/pkg/runtime/payload_test.go index caa146b63..6ed8df99f 100644 --- a/pkg/runtime/payload_test.go +++ b/pkg/runtime/payload_test.go @@ -14,6 +14,7 @@ func TestLoadTeamRequest_RoundTrip(t *testing.T) { in := LoadTeamRequest{ ModelOverrides: []string{"openai/gpt-4o", "anthropic/claude-3-5-sonnet-20240620"}, PromptFiles: []string{"prompts/role.md", "prompts/tone.md"}, + ExternalTeams: []string{"specialists:./secondary.yaml", "./qa.hcl"}, } data, err := json.Marshal(in) @@ -24,6 +25,7 @@ func TestLoadTeamRequest_RoundTrip(t *testing.T) { assert.Equal(t, in.ModelOverrides, out.ModelOverrides) assert.Equal(t, in.PromptFiles, out.PromptFiles) + assert.Equal(t, in.ExternalTeams, out.ExternalTeams) } func TestLoadTeamRequest_OmitsEmptyFields(t *testing.T) { diff --git a/pkg/tui/components/sidebar/sidebar.go b/pkg/tui/components/sidebar/sidebar.go index 1b35dae7a..1da017804 100644 --- a/pkg/tui/components/sidebar/sidebar.go +++ b/pkg/tui/components/sidebar/sidebar.go @@ -1551,10 +1551,14 @@ func (m *model) agentSummaryCollapsed() string { summary.WriteString(styles.MutedStyle.Render(" " + m.agentModel)) } for _, entry := range m.rosterAgents() { - if entry.agent.Name == name { + if entry.agent.Internal || entry.agent.Name == name { continue } - summary.WriteString(styles.MutedStyle.Render(" · ") + styles.AgentAccentStyleFor(entry.agent.Name).Render(entry.agent.Name)) + display := agentDisplayName(entry.agent) + if entry.agent.TeamName != "" { + display = entry.agent.TeamName + ": " + display + } + summary.WriteString(styles.MutedStyle.Render(" · ") + styles.AgentAccentStyleFor(entry.agent.Name).Render(display)) } return summary.String() } @@ -2084,19 +2088,20 @@ func (m *model) queueSection(contentWidth int) string { // separators and the transfer box carry an empty owner so they stay // unclickable. func (m *model) agentInfo(contentWidth int) string { - // Read current agent from session state so sidebar updates when agent is switched currentAgent := m.sessionState.CurrentAgentName() if currentAgent == "" { return "" } roster := m.rosterAgents() - - agentTitle := "Agent" - if len(roster) > 1 { - agentTitle = "Agents" + teamNames := make(map[string]struct{}) + for _, agent := range m.availableAgents { + if agent.TeamName != "" { + teamNames[agent.TeamName] = struct{}{} + } } - if m.delegationInFlight() { - agentTitle += " ↔" + grouped := len(teamNames) > 1 + if !grouped { + return m.renderLegacyAgentTab(roster, contentWidth, currentAgent) } renderAgent := m.compactAgentRenderer(roster, contentWidth) @@ -2104,34 +2109,105 @@ func (m *model) agentInfo(contentWidth int) string { renderAgent = m.detailedAgentRenderer(roster, contentWidth) } - var bodyLines, owners []string - add := func(line, owner string) { - bodyLines = append(bodyLines, line) - owners = append(owners, owner) + type group struct { + name string + entries []rosterAgent + } + var groups []group + indexByName := make(map[string]int) + for _, entry := range roster { + name := entry.agent.TeamName + if name == "" { + name = "Team" + } + idx, ok := indexByName[name] + if !ok { + idx = len(groups) + indexByName[name] = idx + groups = append(groups, group{name: name}) + } + groups[idx].entries = append(groups[idx].entries, entry) + } + + var sections []string + var owners []string + for _, teamGroup := range groups { + var lines []string + for i, entry := range teamGroup.entries { + if i > 0 { + lines = append(lines, "") + owners = append(owners, "") + } + current := entry.agent.Name == currentAgent + owner := entry.agent.Name + if entry.agent.Internal { + owner = "" + } + for _, line := range renderAgent(entry.agent, entry.publicIndex, current) { + lines = append(lines, line) + owners = append(owners, owner) + } + } + sections = append(sections, m.renderTab(teamGroup.name, strings.Join(lines, "\n"), contentWidth)) + // Between one tab body's last line and the next tab body there is the + // join separator, then the next tab's title and top padding: three + // non-clickable rendered lines. + if len(sections) < len(groups) { + owners = append(owners, "", "", "") + } + } + if pres, ok := m.visibleTransfer(); ok { + var lines []string + for _, line := range m.renderTransferPanel(pres, contentWidth) { + lines = append(lines, line) + owners = append(owners, "") + } + sections = append(sections, strings.Join(lines, "\n")) + } + m.agentLineOwners = owners + return strings.Join(sections, "\n\n") +} + +func (m *model) renderLegacyAgentTab(roster []rosterAgent, contentWidth int, currentAgent string) string { + title := "Agent" + if len(roster) > 1 { + title = "Agents" + } + if m.delegationInFlight() { + title += " ↔" } + renderAgent := m.compactAgentRenderer(roster, contentWidth) + if m.agentInfoMode == AgentInfoDetailed { + renderAgent = m.detailedAgentRenderer(roster, contentWidth) + } + var lines, owners []string for _, entry := range roster { - // Separate entries with a blank, unowned line so they stay visually - // distinct without being attributed to (or made clickable for) any agent. - if len(bodyLines) > 0 { - add("", "") + if len(lines) > 0 { + lines = append(lines, "") + owners = append(owners, "") } - current := entry.agent.Name == currentAgent - for _, line := range renderAgent(entry.agent, entry.index, current) { - add(line, entry.agent.Name) + for _, line := range renderAgent(entry.agent, entry.publicIndex, entry.agent.Name == currentAgent) { + lines = append(lines, line) + owners = append(owners, entry.agent.Name) } } - // The visible transfer presentation renders as a compact box below the - // whole roster, after a blank breathing line; every one of its lines is - // unowned so the box stays unclickable. if pres, ok := m.visibleTransfer(); ok { - add("", "") + lines = append(lines, "") + owners = append(owners, "") for _, line := range m.renderTransferPanel(pres, contentWidth) { - add(line, "") + lines = append(lines, line) + owners = append(owners, "") } } m.agentLineOwners = owners + return m.renderTab(title, strings.Join(lines, "\n"), contentWidth) +} - return m.renderTab(agentTitle, strings.Join(bodyLines, "\n"), contentWidth) +func agentDisplayName(a runtime.AgentDetails) string { + if a.DisplayName != "" { + return a.DisplayName + } + return a.Name } // agentRenderer renders one roster agent's content lines at a layout @@ -2142,8 +2218,8 @@ type agentRenderer func(agent runtime.AgentDetails, index int, current bool) []s // original team index, preserved under filtering so the ^N switch shortcut // keeps addressing the same team position. type rosterAgent struct { - agent runtime.AgentDetails - index int + agent runtime.AgentDetails + publicIndex int } // rosterAgents returns the agents the Agents section presents, each with its @@ -2153,11 +2229,20 @@ type rosterAgent struct { // switching still operate on the full team. func (m *model) rosterAgents() []rosterAgent { roster := make([]rosterAgent, 0, len(m.availableAgents)) - for i, agent := range m.availableAgents { + publicIndex := 0 + for _, agent := range m.availableAgents { if m.activeAgentsOnly && !m.agentActiveInSession(agent.Name) { + if !agent.Internal { + publicIndex++ + } continue } - roster = append(roster, rosterAgent{agent: agent, index: i}) + idx := -1 + if !agent.Internal { + idx = publicIndex + publicIndex++ + } + roster = append(roster, rosterAgent{agent: agent, publicIndex: idx}) } return roster } @@ -2425,7 +2510,7 @@ func (m *model) renderAgentLine(agent runtime.AgentDetails, index, contentWidth, case current: marker = agentStyle.Render("▶") } - left := padRight(marker, agentMarkerWidth) + agentStyle.Render(toolcommon.TruncateText(agent.Name, nameWidth)) + left := padRight(marker, agentMarkerWidth) + agentStyle.Render(toolcommon.TruncateText(agentDisplayName(agent), nameWidth)) badge, compact := thinkingBadge(agent.Thinking) if glyphOnly { @@ -2490,7 +2575,7 @@ func (m *model) renderAgentCard(agent runtime.AgentDetails, index, contentWidth, case current: marker = agentStyle.Render("▶") } - left := padRight(marker, agentMarkerWidth) + agentStyle.Render(toolcommon.TruncateText(agent.Name, nameWidth)) + left := padRight(marker, agentMarkerWidth) + agentStyle.Render(toolcommon.TruncateText(agentDisplayName(agent), nameWidth)) var shortcut string if index >= 0 && index < 9 { diff --git a/pkg/tui/components/sidebar/team_groups_test.go b/pkg/tui/components/sidebar/team_groups_test.go new file mode 100644 index 000000000..6382dc608 --- /dev/null +++ b/pkg/tui/components/sidebar/team_groups_test.go @@ -0,0 +1,100 @@ +package sidebar + +import ( + "strings" + "testing" + + "github.com/charmbracelet/x/ansi" + "github.com/stretchr/testify/assert" + + "github.com/docker/docker-agent/pkg/runtime" +) + +func TestAgentPanelSingleTeamKeepsLegacyRendering(t *testing.T) { + m := newCompactPanelSidebar(t, 40, + runtime.AgentDetails{Name: "root", Provider: "openai", Model: "gpt-4o", TeamName: "plain-config"}, + runtime.AgentDetails{Name: "helper", Provider: "openai", Model: "gpt-4o", TeamName: "plain-config"}, + ) + m.sessionState.SetAvailableAgents(m.availableAgents) + m.sessionState.SetCurrentAgentName("root") + + out := ansi.Strip(m.agentInfo(m.contentWidth(false))) + assert.Contains(t, out, "Agents") + assert.NotContains(t, out, "Teams") + assert.NotContains(t, out, "PLAIN-CONFIG") + assert.NotContains(t, strings.ToLower(out), "plain-config ·") +} + +func TestAgentPanelGroupsTeamsAndKeepsInternalMembersUnclickable(t *testing.T) { + m := newCompactPanelSidebar(t, 40, + runtime.AgentDetails{Name: "root", Provider: "openai", Model: "gpt-4o", TeamName: "Primary team"}, + runtime.AgentDetails{Name: "research-team", DisplayName: "root", Provider: "openai", Model: "gpt-4o", TeamName: "Research team", TeamLead: true}, + runtime.AgentDetails{Name: "researcher", Provider: "openai", Model: "gpt-4o", TeamName: "Research team", Internal: true}, + runtime.AgentDetails{Name: "writer", Provider: "openai", Model: "gpt-4o", TeamName: "Research team", Internal: true}, + ) + m.sessionState.SetAvailableAgents(m.availableAgents) + m.sessionState.SetCurrentAgentName("root") + + out := ansi.Strip(m.agentInfo(m.contentWidth(false))) + assert.Contains(t, out, "Primary team") + assert.Contains(t, out, "Research team") + assert.NotContains(t, out, "Teams") + assert.Contains(t, out, "root") + assert.NotContains(t, out, "research-team", "the routing ID stays hidden; the team title provides identity") + assert.Equal(t, 2, strings.Count(out, "root"), "each team keeps the lead name from its own YAML") + assert.Contains(t, out, "researcher") + assert.Contains(t, out, "writer") + assert.Contains(t, out, "openai/gpt-4o", "internal agents keep the standard agent card") + assert.NotContains(t, out, "researcher ^") + + _ = m.View() + for _, owner := range m.agentLineOwners { + assert.NotEqual(t, "researcher", owner) + assert.NotEqual(t, "writer", owner) + } + foundResearchLead := false + for _, target := range m.agentClickZones { + if target == "research-team" { + foundResearchLead = true + } + assert.NotEqual(t, "researcher", target) + assert.NotEqual(t, "writer", target) + } + assert.True(t, foundResearchLead, "the second team lead remains clickable under its titled section") +} + +func TestCollapsedAgentSummaryOmitsPrivateMembers(t *testing.T) { + m := newCompactPanelSidebar(t, 80, + runtime.AgentDetails{Name: "root", TeamName: "Primary team"}, + runtime.AgentDetails{Name: "research-team", DisplayName: "root", TeamName: "Research team", TeamLead: true}, + runtime.AgentDetails{Name: "researcher", TeamName: "Research team", Internal: true}, + runtime.AgentDetails{Name: "writer", TeamName: "Research team", Internal: true}, + ) + m.sessionState.SetAvailableAgents(m.availableAgents) + m.sessionState.SetCurrentAgentName("root") + + out := ansi.Strip(m.agentSummaryCollapsed()) + assert.Contains(t, out, "Research team: root") + assert.NotContains(t, out, "researcher") + assert.NotContains(t, out, "writer") +} + +func TestAgentPanelMarksActiveInternalMember(t *testing.T) { + m := newCompactPanelSidebar(t, 40, + runtime.AgentDetails{Name: "root", TeamName: "Primary team"}, + runtime.AgentDetails{Name: "research-team", DisplayName: "root", TeamName: "Research team", TeamLead: true}, + runtime.AgentDetails{Name: "researcher", TeamName: "Research team", Internal: true}, + ) + m.sessionState.SetAvailableAgents(m.availableAgents) + m.sessionState.SetCurrentAgentName("researcher") + + out := ansi.Strip(m.agentInfo(m.contentWidth(false))) + line := "" + for candidate := range strings.SplitSeq(out, "\n") { + if strings.Contains(candidate, "researcher") { + line = candidate + break + } + } + assert.Contains(t, line, "▶") +} diff --git a/pkg/tui/handlers.go b/pkg/tui/handlers.go index 966c6a8e8..fb2ad75de 100644 --- a/pkg/tui/handlers.go +++ b/pkg/tui/handlers.go @@ -394,7 +394,7 @@ func (m *appModel) handleShowAgentDetails(agentName string) (tea.Model, tea.Cmd) } func (m *appModel) handleCycleAgent() (tea.Model, tea.Cmd) { - availableAgents := m.sessionState.AvailableAgents() + availableAgents := publicAgents(m.sessionState.AvailableAgents()) if len(availableAgents) <= 1 { return m, notification.InfoCmd("No other agents available") } @@ -410,7 +410,7 @@ func (m *appModel) handleCycleAgent() (tea.Model, tea.Cmd) { } func (m *appModel) handleSwitchToAgentByIndex(index int) (tea.Model, tea.Cmd) { - availableAgents := m.sessionState.AvailableAgents() + availableAgents := publicAgents(m.sessionState.AvailableAgents()) if index >= 0 && index < len(availableAgents) { agentName := availableAgents[index].Name if agentName != m.sessionState.CurrentAgentName() { @@ -420,6 +420,16 @@ func (m *appModel) handleSwitchToAgentByIndex(index int) (tea.Model, tea.Cmd) { return m, nil } +func publicAgents(agents []runtime.AgentDetails) []runtime.AgentDetails { + out := make([]runtime.AgentDetails, 0, len(agents)) + for _, a := range agents { + if !a.Internal { + out = append(out, a) + } + } + return out +} + // --- Toggles --- // handleToggleYolo goes through the safety mode, not the raw ToolsApproved diff --git a/pkg/tui/service/sessionstate.go b/pkg/tui/service/sessionstate.go index 8679381e4..ad2077aac 100644 --- a/pkg/tui/service/sessionstate.go +++ b/pkg/tui/service/sessionstate.go @@ -174,9 +174,11 @@ func (s *SessionState) AvailableAgents() []runtime.AgentDetails { func (s *SessionState) SetAvailableAgents(availableAgents []runtime.AgentDetails) { s.availableAgents = availableAgents - names := make([]string, len(availableAgents)) - for i, a := range availableAgents { - names[i] = a.Name + var names []string + for _, a := range availableAgents { + if !a.Internal { + names = append(names, a.Name) + } } styles.SetAgentOrder(names) } From b05084e0f6c7878883313dfaa70cd49e02c4988b Mon Sep 17 00:00:00 2001 From: Sayt-0 Date: Thu, 30 Jul 2026 18:13:29 +0200 Subject: [PATCH 4/4] docs: document local team composition --- docs/concepts/multi-agent/index.md | 55 +++++++++++++++++++++++++ docs/configuration/agents/index.md | 2 +- docs/features/cli/index.md | 4 ++ examples/README.md | 1 + examples/local-team/primary-cli.yaml | 18 ++++++++ examples/local-team/primary-team.yaml | 32 ++++++++++++++ examples/local-team/secondary-team.yaml | 30 ++++++++++++++ 7 files changed, 141 insertions(+), 1 deletion(-) create mode 100644 examples/local-team/primary-cli.yaml create mode 100644 examples/local-team/primary-team.yaml create mode 100644 examples/local-team/secondary-team.yaml diff --git a/docs/concepts/multi-agent/index.md b/docs/concepts/multi-agent/index.md index e9632ff02..513afcec6 100644 --- a/docs/concepts/multi-agent/index.md +++ b/docs/concepts/multi-agent/index.md @@ -255,6 +255,61 @@ External references in `handoffs` and `force_handoff` carry the same per-run cos > > See [`examples/sub-agents-from-registry.yaml`](https://github.com/docker/docker-agent/blob/main/examples/sub-agents-from-registry.yaml) for a complete example mixing local and external sub-agents. +## External Teams from Local Files + +`sub_agents` also accepts local file paths (`.yaml`, `.yml`, or `.hcl`), resolved relative to the importing file. This lets you split a large system across files: only the referenced team's **default agent** (the one named `root`, or the first declared) is exposed to the parent — the rest of that team stays private and keeps reporting to its own lead. + +```yaml +# primary-team.yaml +agents: + root: + model: openai/gpt-5 + description: Primary lead + instruction: Delegate specialist work to the secondary team lead. + sub_agents: + - specialists:./secondary-team.yaml # exposed as "specialists" +``` + +```yaml +# secondary-team.yaml +agents: + root: + model: openai/gpt-5 + description: Secondary team lead + instruction: Coordinate your own specialists and report the result. + sub_agents: [researcher] + + researcher: + model: openai/gpt-5 + description: Researcher + instruction: Research topics and return concise notes. +``` + +The imported lead keeps its own `sub_agents` (here `researcher`), so it still orchestrates its own team. Without an alias, `- ./secondary-team.yaml` is exposed as `secondary-team` (the file name without extension). Delegation stays synchronous either way: the primary lead's `transfer_task` call blocks until the secondary lead returns its result. + +### Compose teams from the CLI + +The same relationship can be added at launch time without editing the primary YAML. Use the repeatable `--team` flag; the second positional argument remains a user message for backward compatibility. + +```bash +docker agent run ./primary-team.yaml \ + --team "Research team=./secondary-team.yaml" + +# Add more teams +docker agent run ./primary-team.yaml \ + --team "Research team=./secondary-team.yaml" \ + --team "QA team=./qa-team.hcl" +``` + +`--team` accepts `Team name=path`, where the text before `=` is the human-readable TUI title. Paths may point to local `.yaml`, `.yml`, or `.hcl` files and resolve relative to the primary manifest. Routing IDs are generated separately, so the secondary lead keeps its normal display name (for example `root`) under the `Research team` title. This option is currently local-only and cannot be combined with `--remote` or `--sandbox`. + +The TUI keeps the usual agent cards and uses each team name as that card section's title. Imported members are shown under their lead so their activity is visible during nested transfers, but they have no shortcut and cannot be selected directly. + +Only the referenced team's agents are imported: an imported file must not declare top-level `permissions`, `budget`, `budgets`, or `runtime.safety` — those policies cannot be preserved across the import, so loading fails with an error asking you to declare them in the importing (main) manifest. Per-agent settings such as `agents..safety` travel with the agent and keep working. + +> [!TIP] +> See [`examples/local-team/`](https://github.com/docker/docker-agent/tree/main/examples/local-team) for a runnable pair of files. + ## Harness-Backed Sub-Agents Sub-agents can be backed by external coding CLIs — Claude Code, Codex, opencode, or pi — instead of a model API. Add a `harness:` block in place of a `model:` field to create a harness sub-agent: diff --git a/docs/configuration/agents/index.md b/docs/configuration/agents/index.md index 9af012382..e79884ddd 100644 --- a/docs/configuration/agents/index.md +++ b/docs/configuration/agents/index.md @@ -89,7 +89,7 @@ agents: | `description` | string | ✓ | Brief description of the agent's purpose. Used by coordinators to decide delegation. | | `instruction` | string | ✓ | System prompt that defines the agent's behavior, personality, and constraints. Required unless `instruction_file` is set. | | `instruction_file` | string \| array | ✗ | Path(s) to a file or files (relative to the config file's directory) whose contents become the agent's instruction, loaded at startup. Accepts a single path or a list; multiple files are concatenated in order, separated by a blank line. Mutually exclusive with `instruction`. Each path must be a local relative path inside the config directory (absolute paths and `..` traversal are rejected). Only supported for local file-based configs, not OCI/URL sources. See [External Instruction Files](#external-instruction-files) below. | -| `sub_agents` | array | ✗ | List of agent names or external OCI references this agent can delegate to. Supports local agents, registry references (e.g., `myorg/agent:tag`), and named references (`name:reference`). Automatically enables the `transfer_task` tool. Pin external OCI references to a digest (`name@sha256:…`) to skip the per-run registry lookup that tag references incur. See [External Sub-Agents](../../concepts/multi-agent/index.md#external-sub-agents-from-registries). | +| `sub_agents` | array | ✗ | List of agent names or external references this agent can delegate to. Supports local agents, registry references (e.g., `myorg/agent:tag`), local YAML/HCL file paths (e.g., `./team.yaml`, resolved relative to this file; only that team's lead/default agent is exposed and it keeps its own team), and named references (`name:reference`). Automatically enables the `transfer_task` tool. Pin external OCI references to a digest (`name@sha256:…`) to skip the per-run registry lookup that tag references incur. See [External Sub-Agents](../../concepts/multi-agent/index.md#external-sub-agents-from-registries) and [External Teams from Local Files](../../concepts/multi-agent/index.md#external-teams-from-local-files). | | `toolsets` | array | ✗ | List of tool configurations. See [Tool Config](../tools/index.md). | | `fallback` | object | ✗ | Automatic model failover configuration. | | `add_date` | boolean | ✗ | When `true`, injects the current date into the agent's context. | diff --git a/docs/features/cli/index.md b/docs/features/cli/index.md index 5292a3027..93746ea87 100644 --- a/docs/features/cli/index.md +++ b/docs/features/cli/index.md @@ -48,6 +48,8 @@ $ docker agent run [config] [message...] [flags] | `--json` | Output results as newline-delimited JSON (use with `--exec`) | | `--hide-tool-calls` | Hide tool calls in the output | | `--hide-tool-results` | Hide tool call results in the output | +| `--prompt-file ` | Append file contents to every agent prompt (repeatable) | +| `--team ` | Attach a local YAML/HCL team to the primary lead (repeatable). The text before `=` is the TUI section title; a unique routing ID is generated separately. Not available with `--remote` or `--sandbox`. | | `--sandbox` | Run the agent inside a Docker sandbox (see [Sandbox](../../configuration/sandbox/index.md)) | | `--template ` | Template image for the sandbox (default: `docker/docker-agent-sbx-templates:latest`) | | `--sbx` | Prefer the `sbx` CLI backend when available (default `true`; set `--sbx=false` to force `docker sandbox`) | @@ -84,6 +86,8 @@ $ docker agent run agent.yaml --model "dev=openai/gpt-4o,reviewer=anthropic/clau $ docker agent run agent.yaml --session -1 # resume last session $ docker agent run agent.yaml --session -1 --session-read-only # review last session without sending messages $ docker agent run agent.yaml --prompt-file ./context.md # include file as context +$ docker agent run primary.yaml --team "Research team=./secondary.yaml" +$ docker agent run primary.yaml --team "Research team=./secondary.yaml" --team "QA team=./qa.hcl" # Add hooks from the command line $ docker agent run agent.yaml --hook-session-start "./scripts/setup-env.sh" diff --git a/examples/README.md b/examples/README.md index 838f288a0..4c7d8f7ea 100644 --- a/examples/README.md +++ b/examples/README.md @@ -166,6 +166,7 @@ remote MCP endpoints. | [`coding_harnesses.yaml`](coding_harnesses.yaml) | Orchestrator delegating coding tasks to external harness-backed sub-agents. | | [`coding_harness_background_agents.yaml`](coding_harness_background_agents.yaml) | Orchestrator running external coding harnesses concurrently via background agents. | | [`dev-team.yaml`](dev-team.yaml) | Product-manager-led team (designer + engineer) with shared memory. | +| [`local-team/primary-team.yaml`](local-team/primary-team.yaml) | Imports the lead of a second team defined in [`local-team/secondary-team.yaml`](local-team/secondary-team.yaml) through declarative `sub_agents`; [`local-team/primary-cli.yaml`](local-team/primary-cli.yaml) demonstrates the same composition with `--team "Research team=./secondary-team.yaml"`. | | [`multi-code.yaml`](multi-code.yaml) | Tech-lead routing tasks to a frontend and a Go expert. | | [`coder.yaml`](coder.yaml) | Coding agent with planner, implementer, and librarian sub-agents. | | [`pr-reviewer-bedrock.yaml`](pr-reviewer-bedrock.yaml) | PR review toolkit pinned to Bedrock models. | diff --git a/examples/local-team/primary-cli.yaml b/examples/local-team/primary-cli.yaml new file mode 100644 index 000000000..3ca13b23b --- /dev/null +++ b/examples/local-team/primary-cli.yaml @@ -0,0 +1,18 @@ +# Minimal primary manifest for demonstrating CLI team composition. +# Run from the repository root: +# +# docker agent run ./examples/local-team/primary-cli.yaml \ +# --team "Research team=./secondary-team.yaml" + +models: + model: + provider: openai + model: gpt-4o + +agents: + root: + model: model + description: Primary lead that delegates specialist work to attached teams + instruction: | + Coordinate the work. Delegate research or writing tasks to an attached + team lead, wait for its result, and present it to the user. diff --git a/examples/local-team/primary-team.yaml b/examples/local-team/primary-team.yaml new file mode 100644 index 000000000..7006eea75 --- /dev/null +++ b/examples/local-team/primary-team.yaml @@ -0,0 +1,32 @@ +# This example demonstrates composing teams from local files: a lead agent +# can list another team's YAML file in `sub_agents`. Only that team's default +# agent (the one named "root", or the first declared) is exposed to the +# parent — here under the "specialists" alias — and it keeps its own +# sub-agents, so it orchestrates its own team behind the scenes. +# +# Without an alias, `- ./secondary-team.yaml` would be exposed as +# "secondary-team" (the file name without extension). Relative paths are +# resolved against this file's directory. Local HCL files (.hcl) work too. +# +# Delegation stays synchronous: `transfer_task` blocks the primary lead until +# the secondary lead returns its result. +# +# Instead of declaring `sub_agents` below, use the dedicated primary file: +# docker agent run ./examples/local-team/primary-cli.yaml \ +# --team "Research team=./secondary-team.yaml" + +models: + model: + provider: openai + model: gpt-4o + +agents: + root: + model: model + description: Primary lead that delegates specialist work to a second team + instruction: | + You coordinate the work. For any research or writing task, delegate to + the "specialists" sub-agent (the lead of a second team defined in + ./secondary-team.yaml), wait for its result, and present it to the user. + sub_agents: + - specialists:./secondary-team.yaml diff --git a/examples/local-team/secondary-team.yaml b/examples/local-team/secondary-team.yaml new file mode 100644 index 000000000..f6164d626 --- /dev/null +++ b/examples/local-team/secondary-team.yaml @@ -0,0 +1,30 @@ +# The secondary team imported by primary-team.yaml. Its default agent (root) +# is the only agent exposed to the importing team; researcher and writer stay +# private and keep reporting to this lead. The file is also a complete, +# runnable team on its own: +# +# docker agent run examples/local-team/secondary-team.yaml + +models: + model: + provider: openai + model: gpt-4o + +agents: + root: + model: model + description: Secondary team lead that orchestrates its own specialists + instruction: | + Split incoming tasks between your researcher and writer sub-agents, + then combine their output into a single answer. + sub_agents: [researcher, writer] + + researcher: + model: model + description: Gathers facts and background information + instruction: Research the topic and return concise, factual notes. + + writer: + model: model + description: Turns research notes into polished prose + instruction: Write a clear, well-structured answer from the notes you get.