Skip to content

Commit 0fb2f95

Browse files
authored
Merge pull request #2132 from dgageot/board/sub-agents-can-be-oci-reference-but-they-134a0dbe
Derive meaningful names for external sub-agents instead of using 'root'
2 parents ddf834c + 0ab3fed commit 0fb2f95

7 files changed

Lines changed: 266 additions & 11 deletions

File tree

agent-schema.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -157,14 +157,14 @@
157157
},
158158
"sub_agents": {
159159
"type": "array",
160-
"description": "List of sub-agents. Can be names of agents defined in this config or external references (OCI images like 'namespace/repo' or URLs).",
160+
"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:agentcatalog/review-pr'). External agents without an explicit name are named after their last path segment.",
161161
"items": {
162162
"type": "string"
163163
}
164164
},
165165
"handoffs": {
166166
"type": "array",
167-
"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).",
167+
"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:agentcatalog/review-pr'). External agents without an explicit name are named after their last path segment.",
168168
"items": {
169169
"type": "string"
170170
}

examples/sub-agents-from-catalog.yaml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,11 @@
33
# This example demonstrates using agents from the catalog as sub-agents.
44
# Sub-agents can be defined locally in the same config, or referenced from
55
# external sources such as OCI registries (e.g., the Docker agent catalog).
6+
#
7+
# External sub-agents are automatically named after their last path segment
8+
# (e.g., "agentcatalog/pirate" becomes "pirate"). You can also give them
9+
# an explicit name using the "name:reference" syntax:
10+
# - reviewer:agentcatalog/review-pr
611

712
models:
813
model:
@@ -17,7 +22,7 @@ agents:
1722
You are a coordinator agent. You have access to both local and external sub-agents.
1823
1924
- Use the "local_helper" agent for simple tasks.
20-
- Use the "agentcatalog/pirate" agent when users want responses in a pirate style.
25+
- Use the "pirate" agent when users want responses in a pirate style.
2126
2227
Delegate tasks to the most appropriate sub-agent based on the user's request.
2328
sub_agents:

pkg/config/config.go

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -130,15 +130,27 @@ func validateConfig(cfg *latest.Config) error {
130130
}
131131

132132
for _, agent := range cfg.Agents {
133-
for _, subAgentName := range agent.SubAgents {
134-
if _, exists := allNames[subAgentName]; !exists && !IsExternalReference(subAgentName) {
135-
return fmt.Errorf("agent '%s' references non-existent sub-agent '%s'", agent.Name, subAgentName)
133+
for _, subAgentRef := range agent.SubAgents {
134+
if _, exists := allNames[subAgentRef]; !exists && !IsExternalReference(subAgentRef) {
135+
return fmt.Errorf("agent '%s' references non-existent sub-agent '%s'", agent.Name, subAgentRef)
136+
}
137+
if IsExternalReference(subAgentRef) {
138+
name, _ := ParseExternalAgentRef(subAgentRef)
139+
if allNames[name] {
140+
return fmt.Errorf("agent '%s': external sub-agent '%s' resolves to name '%s' which conflicts with a locally-defined agent", agent.Name, subAgentRef, name)
141+
}
136142
}
137143
}
138144

139-
for _, handoffName := range agent.Handoffs {
140-
if _, exists := allNames[handoffName]; !exists && !IsExternalReference(handoffName) {
141-
return fmt.Errorf("agent '%s' references non-existent handoff agent '%s'", agent.Name, handoffName)
145+
for _, handoffRef := range agent.Handoffs {
146+
if _, exists := allNames[handoffRef]; !exists && !IsExternalReference(handoffRef) {
147+
return fmt.Errorf("agent '%s' references non-existent handoff agent '%s'", agent.Name, handoffRef)
148+
}
149+
if IsExternalReference(handoffRef) {
150+
name, _ := ParseExternalAgentRef(handoffRef)
151+
if allNames[name] {
152+
return fmt.Errorf("agent '%s': external handoff '%s' resolves to name '%s' which conflicts with a locally-defined agent", agent.Name, handoffRef, name)
153+
}
142154
}
143155
}
144156

pkg/config/config_test.go

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -533,6 +533,60 @@ func TestValidateConfig_ExternalSubAgentReferences(t *testing.T) {
533533
},
534534
wantErr: "non-existent handoff agent 'does_not_exist'",
535535
},
536+
{
537+
name: "named OCI reference in sub_agents is allowed",
538+
cfg: &latest.Config{
539+
Agents: []latest.AgentConfig{
540+
{Name: "root", Model: "openai/gpt-4o", SubAgents: []string{"reviewer:agentcatalog/review-pr"}},
541+
},
542+
},
543+
},
544+
{
545+
name: "named URL reference in sub_agents is allowed",
546+
cfg: &latest.Config{
547+
Agents: []latest.AgentConfig{
548+
{Name: "root", Model: "openai/gpt-4o", SubAgents: []string{"myagent:https://example.com/agent.yaml"}},
549+
},
550+
},
551+
},
552+
{
553+
name: "named OCI reference in handoffs is allowed",
554+
cfg: &latest.Config{
555+
Agents: []latest.AgentConfig{
556+
{Name: "root", Model: "openai/gpt-4o", Handoffs: []string{"reviewer:agentcatalog/review-pr"}},
557+
},
558+
},
559+
},
560+
{
561+
name: "external sub-agent name collides with local agent",
562+
cfg: &latest.Config{
563+
Agents: []latest.AgentConfig{
564+
{Name: "root", Model: "openai/gpt-4o", SubAgents: []string{"pirate", "agentcatalog/pirate"}},
565+
{Name: "pirate", Model: "openai/gpt-4o"},
566+
},
567+
},
568+
wantErr: "conflicts with a locally-defined agent",
569+
},
570+
{
571+
name: "named external sub-agent collides with local agent",
572+
cfg: &latest.Config{
573+
Agents: []latest.AgentConfig{
574+
{Name: "root", Model: "openai/gpt-4o", SubAgents: []string{"helper", "helper:agentcatalog/review-pr"}},
575+
{Name: "helper", Model: "openai/gpt-4o"},
576+
},
577+
},
578+
wantErr: "conflicts with a locally-defined agent",
579+
},
580+
{
581+
name: "external handoff name collides with local agent",
582+
cfg: &latest.Config{
583+
Agents: []latest.AgentConfig{
584+
{Name: "root", Model: "openai/gpt-4o", Handoffs: []string{"agentcatalog/pirate"}},
585+
{Name: "pirate", Model: "openai/gpt-4o"},
586+
},
587+
},
588+
wantErr: "conflicts with a locally-defined agent",
589+
},
536590
{
537591
name: "local handoff to another agent passes",
538592
cfg: &latest.Config{

pkg/config/resolve.go

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,83 @@ func fileNameWithoutExt(path string) string {
216216
// (OCI image or URL) rather than a local agent name defined in the same config.
217217
// Local agent names never contain "/", so the slash check distinguishes them
218218
// from OCI references like "agentcatalog/pirate" or "docker.io/org/agent:v1".
219+
// It also handles the "name:ref" syntax (e.g. "reviewer:agentcatalog/review-pr").
219220
func IsExternalReference(input string) bool {
221+
_, ref := ParseExternalAgentRef(input)
222+
return isExternalRef(ref)
223+
}
224+
225+
// ParseExternalAgentRef parses an external agent reference that may include an
226+
// explicit name prefix. The syntax is "name:reference" where name is a simple
227+
// identifier (no slashes) and reference is an OCI reference or URL.
228+
//
229+
// If no explicit name is provided, the base name is derived from the reference:
230+
// - OCI refs: last path segment without tag (e.g. "agentcatalog/review-pr" → "review-pr")
231+
// - URLs: filename without extension (e.g. "https://example.com/agent.yaml" → "agent")
232+
//
233+
// Examples:
234+
//
235+
// ParseExternalAgentRef("reviewer:agentcatalog/review-pr") → ("reviewer", "agentcatalog/review-pr")
236+
// ParseExternalAgentRef("agentcatalog/review-pr") → ("review-pr", "agentcatalog/review-pr")
237+
// ParseExternalAgentRef("docker.io/myorg/myagent:v1") → ("myagent", "docker.io/myorg/myagent:v1")
238+
// ParseExternalAgentRef("https://example.com/agent.yaml") → ("agent", "https://example.com/agent.yaml")
239+
func ParseExternalAgentRef(input string) (agentName, ref string) {
240+
// If the whole input is already a valid external reference, derive the name
241+
// from it without trying to split on ":".
242+
if isExternalRef(input) {
243+
return externalRefBaseName(input), input
244+
}
245+
246+
// Check for explicit "name:reference" syntax.
247+
// A name prefix is identified by not containing "/" (distinguishing it from
248+
// OCI references or URLs which always contain slashes).
249+
if i := strings.Index(input, ":"); i > 0 {
250+
candidate := input[:i]
251+
if !strings.Contains(candidate, "/") {
252+
remainder := input[i+1:]
253+
if isExternalRef(remainder) {
254+
return candidate, remainder
255+
}
256+
}
257+
}
258+
259+
// Fallback: return input as both name and ref (for local agent names).
260+
return input, input
261+
}
262+
263+
// isExternalRef is the core check for whether a string is an external reference.
264+
// It is used by both IsExternalReference and ParseExternalAgentRef to avoid
265+
// circular dependencies.
266+
func isExternalRef(input string) bool {
220267
return IsURLReference(input) || (strings.Contains(input, "/") && IsOCIReference(input))
221268
}
269+
270+
// externalRefBaseName extracts a short agent name from an external reference.
271+
//
272+
// - OCI: last path segment, tag/digest stripped
273+
// "agentcatalog/review-pr" → "review-pr"
274+
// "docker.io/myorg/myagent:v1" → "myagent"
275+
//
276+
// - URL: filename without extension
277+
// "https://example.com/agent.yaml" → "agent"
278+
func externalRefBaseName(ref string) string {
279+
if IsURLReference(ref) {
280+
return fileNameWithoutExt(ref)
281+
}
282+
283+
// OCI reference: strip tag or digest, then take last path segment.
284+
base := ref
285+
if i := strings.LastIndex(base, "@"); i >= 0 {
286+
base = base[:i]
287+
}
288+
if i := strings.LastIndex(base, ":"); i >= 0 {
289+
// Only strip if the colon is after the last slash (i.e. it's a tag, not a port).
290+
if j := strings.LastIndex(base, "/"); j < i {
291+
base = base[:i]
292+
}
293+
}
294+
if i := strings.LastIndex(base, "/"); i >= 0 {
295+
base = base[i+1:]
296+
}
297+
return base
298+
}

pkg/config/resolve_test.go

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -665,6 +665,16 @@ func TestIsExternalReference(t *testing.T) {
665665
input: "",
666666
expected: false,
667667
},
668+
{
669+
name: "named OCI reference is external",
670+
input: "reviewer:agentcatalog/review-pr",
671+
expected: true,
672+
},
673+
{
674+
name: "named URL reference is external",
675+
input: "myagent:https://example.com/agent.yaml",
676+
expected: true,
677+
},
668678
}
669679

670680
for _, tt := range tests {
@@ -676,3 +686,85 @@ func TestIsExternalReference(t *testing.T) {
676686
})
677687
}
678688
}
689+
690+
func TestParseExternalAgentRef(t *testing.T) {
691+
t.Parallel()
692+
693+
tests := []struct {
694+
name string
695+
input string
696+
expectedName string
697+
expectedRef string
698+
}{
699+
{
700+
name: "simple OCI reference derives base name",
701+
input: "agentcatalog/pirate",
702+
expectedName: "pirate",
703+
expectedRef: "agentcatalog/pirate",
704+
},
705+
{
706+
name: "OCI reference with tag derives base name without tag",
707+
input: "docker.io/myorg/myagent:v1",
708+
expectedName: "myagent",
709+
expectedRef: "docker.io/myorg/myagent:v1",
710+
},
711+
{
712+
name: "OCI reference with digest derives base name",
713+
input: "docker.io/myorg/myagent@sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
714+
expectedName: "myagent",
715+
expectedRef: "docker.io/myorg/myagent@sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
716+
},
717+
{
718+
name: "explicit name prefix",
719+
input: "reviewer:agentcatalog/review-pr",
720+
expectedName: "reviewer",
721+
expectedRef: "agentcatalog/review-pr",
722+
},
723+
{
724+
name: "explicit name with tagged OCI ref",
725+
input: "myreviewer:docker.io/myorg/review-pr:v2",
726+
expectedName: "myreviewer",
727+
expectedRef: "docker.io/myorg/review-pr:v2",
728+
},
729+
{
730+
name: "URL reference derives filename",
731+
input: "https://example.com/agent.yaml",
732+
expectedName: "agent",
733+
expectedRef: "https://example.com/agent.yaml",
734+
},
735+
{
736+
name: "named URL reference",
737+
input: "myagent:https://example.com/agent.yaml",
738+
expectedName: "myagent",
739+
expectedRef: "https://example.com/agent.yaml",
740+
},
741+
{
742+
name: "simple name without slash is not split",
743+
input: "my_agent",
744+
expectedName: "my_agent",
745+
expectedRef: "my_agent",
746+
},
747+
{
748+
name: "OCI ref with registry port is not confused with name prefix",
749+
input: "localhost:5000/test/agent",
750+
expectedName: "agent",
751+
expectedRef: "localhost:5000/test/agent",
752+
},
753+
{
754+
name: "deeply nested OCI path",
755+
input: "registry.example.com/org/sub/agent:latest",
756+
expectedName: "agent",
757+
expectedRef: "registry.example.com/org/sub/agent:latest",
758+
},
759+
}
760+
761+
for _, tt := range tests {
762+
t.Run(tt.name, func(t *testing.T) {
763+
t.Parallel()
764+
765+
name, ref := ParseExternalAgentRef(tt.input)
766+
assert.Equal(t, tt.expectedName, name)
767+
assert.Equal(t, tt.expectedRef, ref)
768+
})
769+
}
770+
}

pkg/teamloader/teamloader.go

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -509,6 +509,8 @@ func configNameFromSource(sourceName string) string {
509509
// References that match a locally-defined agent name are looked up directly.
510510
// References that are external (OCI or URL) are loaded on-demand and cached
511511
// in externalAgents so the same reference isn't loaded twice.
512+
// External references may include an explicit name prefix ("name:ref") or
513+
// derive a short name from the reference (e.g. "agentcatalog/review-pr" → "review-pr").
512514
func resolveAgentRefs(
513515
ctx context.Context,
514516
refs []string,
@@ -536,12 +538,25 @@ func resolveAgentRefs(
536538
continue
537539
}
538540

539-
a, err := loadExternalAgent(ctx, ref, runConfig, loadOpts)
541+
agentName, externalRef := config.ParseExternalAgentRef(ref)
542+
543+
// Check for name collisions before loading the external agent.
544+
if existing, ok := agentsByName[agentName]; ok {
545+
return nil, fmt.Errorf("external agent %q resolves to name %q which conflicts with agent %q", ref, agentName, existing.Name())
546+
}
547+
548+
a, err := loadExternalAgent(ctx, externalRef, runConfig, loadOpts)
540549
if err != nil {
541-
return nil, fmt.Errorf("loading %q: %w", ref, err)
550+
return nil, fmt.Errorf("loading %q: %w", externalRef, err)
542551
}
552+
553+
// Rename the external agent so it doesn't collide with locally-defined
554+
// agents (external agents typically have the name "root").
555+
agent.WithName(agentName)(a)
556+
543557
*agents = append(*agents, a)
544558
externalAgents[ref] = a
559+
agentsByName[agentName] = a
545560
resolved = append(resolved, a)
546561
}
547562
return resolved, nil

0 commit comments

Comments
 (0)