Skip to content

Commit 71e95db

Browse files
committed
Support OCI/catalog and URL references as sub-agents and handoffs
Allow sub_agents and handoffs in agent configs to reference external agents from OCI registries (e.g. agentcatalog/pirate) or URLs, in addition to locally-defined agent names. - Add IsExternalReference() to distinguish OCI/URL refs from local names - Update validateConfig to allow external refs in both sub_agents and handoffs - Add resolveAgentRefs() in teamloader to load external agents on demand - Cache external agents by reference string to avoid duplicate loads - Add recursion depth limit (max 10) to prevent circular reference loops - Keep external agents separate from local agents to prevent name collisions - Add example config and comprehensive tests Closes #1604 Assisted-By: cagent
1 parent 6621ea9 commit 71e95db

File tree

8 files changed

+319
-19
lines changed

8 files changed

+319
-19
lines changed

agent-schema.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -148,14 +148,14 @@
148148
},
149149
"sub_agents": {
150150
"type": "array",
151-
"description": "List of sub-agents",
151+
"description": "List of sub-agents. Can be names of agents defined in this config or external references (OCI images like 'namespace/repo' or URLs).",
152152
"items": {
153153
"type": "string"
154154
}
155155
},
156156
"handoffs": {
157157
"type": "array",
158-
"description": "List of agents this agent can hand off the conversation to",
158+
"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).",
159159
"items": {
160160
"type": "string"
161161
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
#!/usr/bin/env docker agent run
2+
3+
# This example demonstrates using agents from the catalog as sub-agents.
4+
# Sub-agents can be defined locally in the same config, or referenced from
5+
# external sources such as OCI registries (e.g., the Docker agent catalog).
6+
7+
models:
8+
model:
9+
provider: openai
10+
model: gpt-4o
11+
12+
agents:
13+
root:
14+
model: model
15+
description: Coordinator that delegates to local and catalog sub-agents
16+
instruction: |
17+
You are a coordinator agent. You have access to both local and external sub-agents.
18+
19+
- Use the "local_helper" agent for simple tasks.
20+
- Use the "agentcatalog/pirate" agent when users want responses in a pirate style.
21+
22+
Delegate tasks to the most appropriate sub-agent based on the user's request.
23+
sub_agents:
24+
- local_helper
25+
- agentcatalog/pirate
26+
27+
local_helper:
28+
model: model
29+
description: A local helper agent for simple tasks
30+
instruction: |
31+
You are a helpful assistant that answers questions concisely.

pkg/config/config.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,11 +120,17 @@ func validateConfig(cfg *latest.Config) error {
120120

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

128+
for _, handoffName := range agent.Handoffs {
129+
if _, exists := allNames[handoffName]; !exists && !IsExternalReference(handoffName) {
130+
return fmt.Errorf("agent '%s' references non-existent handoff agent '%s'", agent.Name, handoffName)
131+
}
132+
}
133+
128134
if err := validateSkillsConfiguration(agent.Name, &agent); err != nil {
129135
return err
130136
}

pkg/config/config_test.go

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -440,6 +440,99 @@ func TestApplyModelOverrides(t *testing.T) {
440440
}
441441
}
442442

443+
func TestValidateConfig_ExternalSubAgentReferences(t *testing.T) {
444+
t.Parallel()
445+
446+
tests := []struct {
447+
name string
448+
cfg *latest.Config
449+
wantErr string
450+
}{
451+
{
452+
name: "OCI reference in sub_agents is allowed",
453+
cfg: &latest.Config{
454+
Agents: []latest.AgentConfig{
455+
{Name: "root", Model: "openai/gpt-4o", SubAgents: []string{"agentcatalog/pirate"}},
456+
},
457+
},
458+
},
459+
{
460+
name: "OCI reference with tag in sub_agents is allowed",
461+
cfg: &latest.Config{
462+
Agents: []latest.AgentConfig{
463+
{Name: "root", Model: "openai/gpt-4o", SubAgents: []string{"docker.io/myorg/myagent:v1"}},
464+
},
465+
},
466+
},
467+
{
468+
name: "mix of local and external sub_agents",
469+
cfg: &latest.Config{
470+
Agents: []latest.AgentConfig{
471+
{Name: "root", Model: "openai/gpt-4o", SubAgents: []string{"helper", "agentcatalog/pirate"}},
472+
{Name: "helper", Model: "openai/gpt-4o"},
473+
},
474+
},
475+
},
476+
{
477+
name: "non-existent local sub_agent still fails",
478+
cfg: &latest.Config{
479+
Agents: []latest.AgentConfig{
480+
{Name: "root", Model: "openai/gpt-4o", SubAgents: []string{"does_not_exist"}},
481+
},
482+
},
483+
wantErr: "non-existent sub-agent 'does_not_exist'",
484+
},
485+
{
486+
name: "URL reference in sub_agents is allowed",
487+
cfg: &latest.Config{
488+
Agents: []latest.AgentConfig{
489+
{Name: "root", Model: "openai/gpt-4o", SubAgents: []string{"https://example.com/agent.yaml"}},
490+
},
491+
},
492+
},
493+
{
494+
name: "OCI reference in handoffs is allowed",
495+
cfg: &latest.Config{
496+
Agents: []latest.AgentConfig{
497+
{Name: "root", Model: "openai/gpt-4o", Handoffs: []string{"agentcatalog/pirate"}},
498+
},
499+
},
500+
},
501+
{
502+
name: "non-existent local handoff fails",
503+
cfg: &latest.Config{
504+
Agents: []latest.AgentConfig{
505+
{Name: "root", Model: "openai/gpt-4o", Handoffs: []string{"does_not_exist"}},
506+
},
507+
},
508+
wantErr: "non-existent handoff agent 'does_not_exist'",
509+
},
510+
{
511+
name: "local handoff to another agent passes",
512+
cfg: &latest.Config{
513+
Agents: []latest.AgentConfig{
514+
{Name: "root", Model: "openai/gpt-4o", Handoffs: []string{"helper"}},
515+
{Name: "helper", Model: "openai/gpt-4o"},
516+
},
517+
},
518+
},
519+
}
520+
521+
for _, tt := range tests {
522+
t.Run(tt.name, func(t *testing.T) {
523+
t.Parallel()
524+
525+
err := validateConfig(tt.cfg)
526+
if tt.wantErr != "" {
527+
require.Error(t, err)
528+
assert.Contains(t, err.Error(), tt.wantErr)
529+
} else {
530+
require.NoError(t, err)
531+
}
532+
})
533+
}
534+
}
535+
443536
func TestProviders_Validation(t *testing.T) {
444537
t.Parallel()
445538

pkg/config/resolve.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,3 +211,11 @@ func fileNameWithoutExt(path string) string {
211211
ext := filepath.Ext(base)
212212
return strings.TrimSuffix(base, ext)
213213
}
214+
215+
// IsExternalReference reports whether the input is an external agent reference
216+
// (OCI image or URL) rather than a local agent name defined in the same config.
217+
// Local agent names never contain "/", so the slash check distinguishes them
218+
// from OCI references like "agentcatalog/pirate" or "docker.io/org/agent:v1".
219+
func IsExternalReference(input string) bool {
220+
return IsURLReference(input) || (strings.Contains(input, "/") && IsOCIReference(input))
221+
}

pkg/config/resolve_test.go

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -621,3 +621,58 @@ func TestResolveAlias_WithAllOptions(t *testing.T) {
621621
assert.Equal(t, "anthropic/claude-sonnet-4-0", alias.Model)
622622
assert.True(t, alias.HideToolResults)
623623
}
624+
625+
func TestIsExternalReference(t *testing.T) {
626+
t.Parallel()
627+
628+
tests := []struct {
629+
name string
630+
input string
631+
expected bool
632+
}{
633+
{
634+
name: "OCI reference with namespace",
635+
input: "agentcatalog/pirate",
636+
expected: true,
637+
},
638+
{
639+
name: "OCI reference with registry",
640+
input: "docker.io/myorg/myagent:v1",
641+
expected: true,
642+
},
643+
{
644+
name: "HTTPS URL",
645+
input: "https://example.com/agent.yaml",
646+
expected: true,
647+
},
648+
{
649+
name: "HTTP URL",
650+
input: "http://example.com/agent.yaml",
651+
expected: true,
652+
},
653+
{
654+
name: "simple agent name is not external",
655+
input: "my_agent",
656+
expected: false,
657+
},
658+
{
659+
name: "agent name with hyphen is not external",
660+
input: "my-local-agent",
661+
expected: false,
662+
},
663+
{
664+
name: "empty string is not external",
665+
input: "",
666+
expected: false,
667+
},
668+
}
669+
670+
for _, tt := range tests {
671+
t.Run(tt.name, func(t *testing.T) {
672+
t.Parallel()
673+
674+
result := IsExternalReference(tt.input)
675+
assert.Equal(t, tt.expected, result)
676+
})
677+
}
678+
}

pkg/teamloader/teamloader.go

Lines changed: 107 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -239,29 +239,31 @@ func LoadWithConfig(ctx context.Context, agentSource config.Source, runConfig *c
239239
agentsByName[agentConfig.Name] = ag
240240
}
241241

242-
// Connect sub-agents and handoff agents
242+
// Connect sub-agents and handoff agents.
243+
// externalAgents caches agents loaded from external references (OCI/URL),
244+
// keyed by the original reference string, to avoid loading the same
245+
// external agent twice. This is kept separate from agentsByName to
246+
// prevent external agents from shadowing locally-defined agents.
247+
externalAgents := make(map[string]*agent.Agent)
243248
for _, agentConfig := range cfg.Agents {
244-
name := agentConfig.Name
245-
246-
subAgents := make([]*agent.Agent, 0, len(agentConfig.SubAgents))
247-
for _, subName := range agentConfig.SubAgents {
248-
if subAgent, exists := agentsByName[subName]; exists {
249-
subAgents = append(subAgents, subAgent)
250-
}
249+
a, exists := agentsByName[agentConfig.Name]
250+
if !exists {
251+
continue
251252
}
252253

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

257-
handoffs := make([]*agent.Agent, 0, len(agentConfig.Handoffs))
258-
for _, handoffName := range agentConfig.Handoffs {
259-
if handoffAgent, exists := agentsByName[handoffName]; exists {
260-
handoffs = append(handoffs, handoffAgent)
261-
}
262+
handoffs, err := resolveAgentRefs(ctx, agentConfig.Handoffs, agentsByName, externalAgents, &agents, runConfig, &loadOpts)
263+
if err != nil {
264+
return nil, fmt.Errorf("agent '%s': resolving handoffs: %w", agentConfig.Name, err)
262265
}
263-
264-
if a, exists := agentsByName[name]; exists && len(handoffs) > 0 {
266+
if len(handoffs) > 0 {
265267
agent.WithHandoffs(handoffs...)(a)
266268
}
267269
}
@@ -477,6 +479,95 @@ func getToolsForAgent(ctx context.Context, a *latest.AgentConfig, parentDir stri
477479
return toolSets, warnings
478480
}
479481

482+
// resolveAgentRefs resolves a list of agent references to agent instances.
483+
// References that match a locally-defined agent name are looked up directly.
484+
// References that are external (OCI or URL) are loaded on-demand and cached
485+
// in externalAgents so the same reference isn't loaded twice.
486+
func resolveAgentRefs(
487+
ctx context.Context,
488+
refs []string,
489+
agentsByName map[string]*agent.Agent,
490+
externalAgents map[string]*agent.Agent,
491+
agents *[]*agent.Agent,
492+
runConfig *config.RuntimeConfig,
493+
loadOpts *loadOptions,
494+
) ([]*agent.Agent, error) {
495+
resolved := make([]*agent.Agent, 0, len(refs))
496+
for _, ref := range refs {
497+
// First, try local agents by name.
498+
if a, ok := agentsByName[ref]; ok {
499+
resolved = append(resolved, a)
500+
continue
501+
}
502+
503+
// Then, check whether this ref was already loaded as an external agent.
504+
if a, ok := externalAgents[ref]; ok {
505+
resolved = append(resolved, a)
506+
continue
507+
}
508+
509+
if !config.IsExternalReference(ref) {
510+
continue
511+
}
512+
513+
a, err := loadExternalAgent(ctx, ref, runConfig, loadOpts)
514+
if err != nil {
515+
return nil, fmt.Errorf("loading %q: %w", ref, err)
516+
}
517+
*agents = append(*agents, a)
518+
externalAgents[ref] = a
519+
resolved = append(resolved, a)
520+
}
521+
return resolved, nil
522+
}
523+
524+
// maxExternalDepth is the maximum nesting depth for loading external agents.
525+
// This prevents infinite recursion when external agents reference each other.
526+
const maxExternalDepth = 10
527+
528+
// loadExternalAgent loads an agent from an external reference (OCI or URL).
529+
// It resolves the reference, loads its config, and returns the default agent.
530+
func loadExternalAgent(ctx context.Context, ref string, runConfig *config.RuntimeConfig, loadOpts *loadOptions) (*agent.Agent, error) {
531+
depth := externalDepthFromContext(ctx)
532+
if depth >= maxExternalDepth {
533+
return nil, fmt.Errorf("maximum external agent nesting depth (%d) exceeded — check for circular references", maxExternalDepth)
534+
}
535+
536+
source, err := config.Resolve(ref, runConfig.EnvProvider())
537+
if err != nil {
538+
return nil, err
539+
}
540+
541+
var opts []Opt
542+
if loadOpts.toolsetRegistry != nil {
543+
opts = append(opts, WithToolsetRegistry(loadOpts.toolsetRegistry))
544+
}
545+
546+
result, err := Load(contextWithExternalDepth(ctx, depth+1), source, runConfig, opts...)
547+
if err != nil {
548+
return nil, err
549+
}
550+
551+
return result.DefaultAgent()
552+
}
553+
554+
// contextKey is an unexported type for context keys defined in this package.
555+
type contextKey int
556+
557+
// externalDepthKey is the context key for tracking external agent loading depth.
558+
var externalDepthKey contextKey
559+
560+
func externalDepthFromContext(ctx context.Context) int {
561+
if v, ok := ctx.Value(externalDepthKey).(int); ok {
562+
return v
563+
}
564+
return 0
565+
}
566+
567+
func contextWithExternalDepth(ctx context.Context, depth int) context.Context {
568+
return context.WithValue(ctx, externalDepthKey, depth)
569+
}
570+
480571
// createRAGToolsForAgent creates RAG tools for an agent, one for each referenced RAG source
481572
func createRAGToolsForAgent(agentConfig *latest.AgentConfig, allManagers map[string]*rag.Manager) []tools.ToolSet {
482573
if len(agentConfig.RAG) == 0 {

0 commit comments

Comments
 (0)