Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions agent-schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -148,14 +148,14 @@
},
"sub_agents": {
"type": "array",
"description": "List of sub-agents",
"description": "List of sub-agents. Can be names of agents defined in this config or external references (OCI images like 'namespace/repo' or URLs).",
"items": {
"type": "string"
}
},
"handoffs": {
"type": "array",
"description": "List of agents this agent can hand off the conversation to",
"description": "List of agents this agent can hand off the conversation to. Can be names of agents defined in this config or external references (OCI images like 'namespace/repo' or URLs).",
"items": {
"type": "string"
}
Expand Down
31 changes: 31 additions & 0 deletions examples/sub-agents-from-catalog.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
#!/usr/bin/env docker agent run

# This example demonstrates using agents from the catalog as sub-agents.
# Sub-agents can be defined locally in the same config, or referenced from
# external sources such as OCI registries (e.g., the Docker agent catalog).

models:
model:
provider: openai
model: gpt-4o

agents:
root:
model: model
description: Coordinator that delegates to local and catalog sub-agents
instruction: |
You are a coordinator agent. You have access to both local and external sub-agents.

- Use the "local_helper" agent for simple tasks.
- Use the "agentcatalog/pirate" agent when users want responses in a pirate style.

Delegate tasks to the most appropriate sub-agent based on the user's request.
sub_agents:
- local_helper
- agentcatalog/pirate

local_helper:
model: model
description: A local helper agent for simple tasks
instruction: |
You are a helpful assistant that answers questions concisely.
8 changes: 7 additions & 1 deletion pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -120,11 +120,17 @@ func validateConfig(cfg *latest.Config) error {

for _, agent := range cfg.Agents {
for _, subAgentName := range agent.SubAgents {
if _, exists := allNames[subAgentName]; !exists {
if _, exists := allNames[subAgentName]; !exists && !IsExternalReference(subAgentName) {
return fmt.Errorf("agent '%s' references non-existent sub-agent '%s'", agent.Name, subAgentName)
}
}

for _, handoffName := range agent.Handoffs {
if _, exists := allNames[handoffName]; !exists && !IsExternalReference(handoffName) {
return fmt.Errorf("agent '%s' references non-existent handoff agent '%s'", agent.Name, handoffName)
}
}

if err := validateSkillsConfiguration(agent.Name, &agent); err != nil {
return err
}
Expand Down
93 changes: 93 additions & 0 deletions pkg/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -440,6 +440,99 @@ func TestApplyModelOverrides(t *testing.T) {
}
}

func TestValidateConfig_ExternalSubAgentReferences(t *testing.T) {
t.Parallel()

tests := []struct {
name string
cfg *latest.Config
wantErr string
}{
{
name: "OCI reference in sub_agents is allowed",
cfg: &latest.Config{
Agents: []latest.AgentConfig{
{Name: "root", Model: "openai/gpt-4o", SubAgents: []string{"agentcatalog/pirate"}},
},
},
},
{
name: "OCI reference with tag in sub_agents is allowed",
cfg: &latest.Config{
Agents: []latest.AgentConfig{
{Name: "root", Model: "openai/gpt-4o", SubAgents: []string{"docker.io/myorg/myagent:v1"}},
},
},
},
{
name: "mix of local and external sub_agents",
cfg: &latest.Config{
Agents: []latest.AgentConfig{
{Name: "root", Model: "openai/gpt-4o", SubAgents: []string{"helper", "agentcatalog/pirate"}},
{Name: "helper", Model: "openai/gpt-4o"},
},
},
},
{
name: "non-existent local sub_agent still fails",
cfg: &latest.Config{
Agents: []latest.AgentConfig{
{Name: "root", Model: "openai/gpt-4o", SubAgents: []string{"does_not_exist"}},
},
},
wantErr: "non-existent sub-agent 'does_not_exist'",
},
{
name: "URL reference in sub_agents is allowed",
cfg: &latest.Config{
Agents: []latest.AgentConfig{
{Name: "root", Model: "openai/gpt-4o", SubAgents: []string{"https://example.com/agent.yaml"}},
},
},
},
{
name: "OCI reference in handoffs is allowed",
cfg: &latest.Config{
Agents: []latest.AgentConfig{
{Name: "root", Model: "openai/gpt-4o", Handoffs: []string{"agentcatalog/pirate"}},
},
},
},
{
name: "non-existent local handoff fails",
cfg: &latest.Config{
Agents: []latest.AgentConfig{
{Name: "root", Model: "openai/gpt-4o", Handoffs: []string{"does_not_exist"}},
},
},
wantErr: "non-existent handoff agent 'does_not_exist'",
},
{
name: "local handoff to another agent passes",
cfg: &latest.Config{
Agents: []latest.AgentConfig{
{Name: "root", Model: "openai/gpt-4o", Handoffs: []string{"helper"}},
{Name: "helper", Model: "openai/gpt-4o"},
},
},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

err := validateConfig(tt.cfg)
if tt.wantErr != "" {
require.Error(t, err)
assert.Contains(t, err.Error(), tt.wantErr)
} else {
require.NoError(t, err)
}
})
}
}

func TestProviders_Validation(t *testing.T) {
t.Parallel()

Expand Down
8 changes: 8 additions & 0 deletions pkg/config/resolve.go
Original file line number Diff line number Diff line change
Expand Up @@ -211,3 +211,11 @@ func fileNameWithoutExt(path string) string {
ext := filepath.Ext(base)
return strings.TrimSuffix(base, ext)
}

// 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 "agentcatalog/pirate" or "docker.io/org/agent:v1".
func IsExternalReference(input string) bool {
return IsURLReference(input) || (strings.Contains(input, "/") && IsOCIReference(input))
}
55 changes: 55 additions & 0 deletions pkg/config/resolve_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -621,3 +621,58 @@ func TestResolveAlias_WithAllOptions(t *testing.T) {
assert.Equal(t, "anthropic/claude-sonnet-4-0", alias.Model)
assert.True(t, alias.HideToolResults)
}

func TestIsExternalReference(t *testing.T) {
t.Parallel()

tests := []struct {
name string
input string
expected bool
}{
{
name: "OCI reference with namespace",
input: "agentcatalog/pirate",
expected: true,
},
{
name: "OCI reference with registry",
input: "docker.io/myorg/myagent:v1",
expected: true,
},
{
name: "HTTPS URL",
input: "https://example.com/agent.yaml",
expected: true,
},
{
name: "HTTP URL",
input: "http://example.com/agent.yaml",
expected: true,
},
{
name: "simple agent name is not external",
input: "my_agent",
expected: false,
},
{
name: "agent name with hyphen is not external",
input: "my-local-agent",
expected: false,
},
{
name: "empty string is not external",
input: "",
expected: false,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

result := IsExternalReference(tt.input)
assert.Equal(t, tt.expected, result)
})
}
}
123 changes: 107 additions & 16 deletions pkg/teamloader/teamloader.go
Original file line number Diff line number Diff line change
Expand Up @@ -239,29 +239,31 @@ func LoadWithConfig(ctx context.Context, agentSource config.Source, runConfig *c
agentsByName[agentConfig.Name] = ag
}

// Connect sub-agents and handoff agents
// 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 := make(map[string]*agent.Agent)
for _, agentConfig := range cfg.Agents {
name := agentConfig.Name

subAgents := make([]*agent.Agent, 0, len(agentConfig.SubAgents))
for _, subName := range agentConfig.SubAgents {
if subAgent, exists := agentsByName[subName]; exists {
subAgents = append(subAgents, subAgent)
}
a, exists := agentsByName[agentConfig.Name]
if !exists {
continue
}

if a, exists := agentsByName[name]; exists && len(subAgents) > 0 {
subAgents, err := resolveAgentRefs(ctx, agentConfig.SubAgents, agentsByName, externalAgents, &agents, runConfig, &loadOpts)
if err != nil {
return nil, fmt.Errorf("agent '%s': resolving sub-agents: %w", agentConfig.Name, err)
}
if len(subAgents) > 0 {
agent.WithSubAgents(subAgents...)(a)
}

handoffs := make([]*agent.Agent, 0, len(agentConfig.Handoffs))
for _, handoffName := range agentConfig.Handoffs {
if handoffAgent, exists := agentsByName[handoffName]; exists {
handoffs = append(handoffs, handoffAgent)
}
handoffs, err := resolveAgentRefs(ctx, agentConfig.Handoffs, agentsByName, externalAgents, &agents, runConfig, &loadOpts)
if err != nil {
return nil, fmt.Errorf("agent '%s': resolving handoffs: %w", agentConfig.Name, err)
}

if a, exists := agentsByName[name]; exists && len(handoffs) > 0 {
if len(handoffs) > 0 {
agent.WithHandoffs(handoffs...)(a)
}
}
Expand Down Expand Up @@ -477,6 +479,95 @@ func getToolsForAgent(ctx context.Context, a *latest.AgentConfig, parentDir stri
return toolSets, warnings
}

// 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.
func resolveAgentRefs(
ctx context.Context,
refs []string,
agentsByName map[string]*agent.Agent,
externalAgents map[string]*agent.Agent,
agents *[]*agent.Agent,
runConfig *config.RuntimeConfig,
loadOpts *loadOptions,
) ([]*agent.Agent, error) {
resolved := make([]*agent.Agent, 0, len(refs))
for _, ref := range refs {
// First, try local agents by name.
if a, ok := agentsByName[ref]; ok {
resolved = append(resolved, a)
continue
}

// Then, check whether this ref was already loaded as an external agent.
if a, ok := externalAgents[ref]; ok {
resolved = append(resolved, a)
continue
}

if !config.IsExternalReference(ref) {
continue
}

a, err := loadExternalAgent(ctx, ref, runConfig, loadOpts)
if err != nil {
return nil, fmt.Errorf("loading %q: %w", ref, err)
}
*agents = append(*agents, a)
externalAgents[ref] = a
resolved = append(resolved, a)
}
return resolved, nil
}

// maxExternalDepth is the maximum nesting depth for loading external agents.
// 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) {
depth := externalDepthFromContext(ctx)
if depth >= maxExternalDepth {
return nil, fmt.Errorf("maximum external agent nesting depth (%d) exceeded — check for circular references", maxExternalDepth)
}

source, err := config.Resolve(ref, runConfig.EnvProvider())
if err != nil {
return nil, err
}

var opts []Opt
if loadOpts.toolsetRegistry != nil {
opts = append(opts, WithToolsetRegistry(loadOpts.toolsetRegistry))
}

result, err := Load(contextWithExternalDepth(ctx, depth+1), source, runConfig, opts...)
if err != nil {
return nil, err
}

return result.DefaultAgent()
}

// contextKey is an unexported type for context keys defined in this package.
type contextKey int

// externalDepthKey is the context key for tracking external agent loading depth.
var externalDepthKey contextKey

func externalDepthFromContext(ctx context.Context) int {
if v, ok := ctx.Value(externalDepthKey).(int); ok {
return v
}
return 0
}

func contextWithExternalDepth(ctx context.Context, depth int) context.Context {
return context.WithValue(ctx, externalDepthKey, depth)
}

// createRAGToolsForAgent creates RAG tools for an agent, one for each referenced RAG source
func createRAGToolsForAgent(agentConfig *latest.AgentConfig, allManagers map[string]*rag.Manager) []tools.ToolSet {
if len(agentConfig.RAG) == 0 {
Expand Down
Loading