Skip to content

Commit b9aae28

Browse files
authored
fix the issue where sandbox agents couldn't be added as tools (#2000)
Fixes an issue where we always assumed agents where of Agent kind. The agent in the tool dialog shows the correct type now too. (a lot of changes in the tsx file, but it's mostly formatting) <img width="616" height="370" alt="Screenshot 2026-06-11 at 11 24 01 AM" src="https://github.com/user-attachments/assets/32c33cd7-795e-452e-8e7a-14ed366380cd" /> Signed-off-by: Peter Jausovec <peter.jausovec@solo.io>
1 parent 3c960ba commit b9aae28

3 files changed

Lines changed: 806 additions & 298 deletions

File tree

go/core/internal/controller/translator/agent/compiler.go

Lines changed: 70 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ type tState struct {
3434
func (s *tState) with(agent v1alpha2.AgentObject) *tState {
3535
visited := make([]string, len(s.visitedAgents), len(s.visitedAgents)+1)
3636
copy(visited, s.visitedAgents)
37-
visited = append(visited, utils.GetObjectRef(agent))
37+
visited = append(visited, agentStateKey(agent))
3838
return &tState{
3939
depth: s.depth + 1,
4040
visitedAgents: visited,
@@ -45,6 +45,59 @@ func (t *tState) isVisited(agentName string) bool {
4545
return slices.Contains(t.visitedAgents, agentName)
4646
}
4747

48+
// agentObjectKind returns the Kubernetes kind backing an AgentObject.
49+
func agentObjectKind(agent v1alpha2.AgentObject) string {
50+
switch agent.(type) {
51+
case *v1alpha2.SandboxAgent:
52+
return "SandboxAgent"
53+
default:
54+
return "Agent"
55+
}
56+
}
57+
58+
// agentStateKey is a kind-qualified identity used for cycle/self-reference checks.
59+
func agentStateKey(agent v1alpha2.AgentObject) string {
60+
return agentObjectKind(agent) + "/" + utils.GetObjectRef(agent)
61+
}
62+
63+
// getToolAgent resolves an Agent tool reference to its backing object, honoring
64+
// the reference Kind. An empty Kind defaults to Agent.
65+
func (a *adkApiTranslator) getToolAgent(
66+
ctx context.Context,
67+
ref *v1alpha2.TypedReference,
68+
defaultNamespace string,
69+
) (v1alpha2.AgentObject, error) {
70+
key := ref.NamespacedName(defaultNamespace)
71+
fetchAgent := func(obj v1alpha2.AgentObject) (v1alpha2.AgentObject, error) {
72+
return obj, a.kube.Get(ctx, key, obj)
73+
}
74+
75+
switch ref.Kind {
76+
case "", "Agent":
77+
return fetchAgent(&v1alpha2.Agent{})
78+
case "SandboxAgent":
79+
return fetchAgent(&v1alpha2.SandboxAgent{})
80+
81+
default:
82+
return nil, fmt.Errorf("unsupported agent tool kind %q for agent %s", ref.Kind, key)
83+
}
84+
}
85+
86+
// sandboxA2APathPrefix mirrors httpserver.APIPathA2ASandboxes (not imported to
87+
// avoid an import cycle). Sandbox agents have no stable Service, so A2A calls
88+
// to them are proxied through the controller.
89+
const sandboxA2APathPrefix = "/api/a2a-sandboxes"
90+
91+
// toolAgentURL returns the A2A URL a parent agent should use to call a sub-agent.
92+
func toolAgentURL(agent v1alpha2.AgentObject) string {
93+
if agent.GetWorkloadMode() == v1alpha2.WorkloadModeSandbox {
94+
return fmt.Sprintf("http://%s.%s:8083%s/%s/%s",
95+
utils.GetControllerName(), utils.GetResourceNamespace(),
96+
sandboxA2APathPrefix, agent.GetNamespace(), agent.GetName())
97+
}
98+
return fmt.Sprintf("http://%s.%s:8080", agent.GetName(), agent.GetNamespace())
99+
}
100+
48101
func TranslateAgent(
49102
ctx context.Context,
50103
translator AdkApiTranslator,
@@ -118,7 +171,7 @@ func (a *adkApiTranslator) validateAgent(ctx context.Context, agent v1alpha2.Age
118171
agentRef := utils.GetObjectRef(agent)
119172
spec := agent.GetAgentSpec()
120173

121-
if state.isVisited(agentRef) {
174+
if state.isVisited(agentStateKey(agent)) {
122175
return fmt.Errorf("cycle detected in agent tool chain: %s -> %s", agentRef, agentRef)
123176
}
124177

@@ -138,18 +191,15 @@ func (a *adkApiTranslator) validateAgent(ctx context.Context, agent v1alpha2.Age
138191
return fmt.Errorf("tool must have an agent reference")
139192
}
140193

141-
agentRef := tool.Agent.NamespacedName(agent.GetNamespace())
142-
143-
if agentRef.Namespace == agent.GetNamespace() && agentRef.Name == agent.GetName() {
144-
return fmt.Errorf("agent tool cannot be used to reference itself, %s", agentRef)
145-
}
146-
147-
toolAgent := &v1alpha2.Agent{}
148-
err := a.kube.Get(ctx, agentRef, toolAgent)
194+
toolAgent, err := a.getToolAgent(ctx, tool.Agent, agent.GetNamespace())
149195
if err != nil {
150196
return err
151197
}
152198

199+
if agentStateKey(toolAgent) == agentStateKey(agent) {
200+
return fmt.Errorf("agent tool cannot be used to reference itself, %s", utils.GetObjectRef(toolAgent))
201+
}
202+
153203
err = a.validateAgent(ctx, toolAgent, state.with(agent))
154204
if err != nil {
155205
return err
@@ -271,21 +321,19 @@ func (a *adkApiTranslator) translateInlineAgent(ctx context.Context, agent v1alp
271321
secretHashBytes = append(secretHashBytes, toolHashBytes...)
272322
}
273323
case tool.Agent != nil:
274-
agentRef := tool.Agent.NamespacedName(agent.GetNamespace())
275-
276-
if agentRef.Namespace == agent.GetNamespace() && agentRef.Name == agent.GetName() {
277-
return nil, nil, nil, fmt.Errorf("agent tool cannot be used to reference itself, %s", agentRef)
278-
}
279-
280-
toolAgent := &v1alpha2.Agent{}
281-
err := a.kube.Get(ctx, agentRef, toolAgent)
324+
toolAgent, err := a.getToolAgent(ctx, tool.Agent, agent.GetNamespace())
282325
if err != nil {
283326
return nil, nil, nil, err
284327
}
285328

286-
switch toolAgent.Spec.Type {
329+
if agentStateKey(toolAgent) == agentStateKey(agent) {
330+
return nil, nil, nil, fmt.Errorf("agent tool cannot be used to reference itself, %s", utils.GetObjectRef(toolAgent))
331+
}
332+
333+
toolSpec := toolAgent.GetAgentSpec()
334+
switch toolSpec.Type {
287335
case v1alpha2.AgentType_BYO, v1alpha2.AgentType_Declarative:
288-
originalURL := fmt.Sprintf("http://%s.%s:8080", toolAgent.Name, toolAgent.Namespace)
336+
originalURL := toolAgentURL(toolAgent)
289337

290338
targetURL := originalURL
291339
if a.globalProxyURL != "" {
@@ -299,10 +347,10 @@ func (a *adkApiTranslator) translateInlineAgent(ctx context.Context, agent v1alp
299347
Name: utils.ConvertToPythonIdentifier(utils.GetObjectRef(toolAgent)),
300348
Url: targetURL,
301349
Headers: headers,
302-
Description: toolAgent.Spec.Description,
350+
Description: toolSpec.Description,
303351
})
304352
default:
305-
return nil, nil, nil, fmt.Errorf("unknown agent type: %s", toolAgent.Spec.Type)
353+
return nil, nil, nil, fmt.Errorf("unknown agent type: %s", toolSpec.Type)
306354
}
307355

308356
default:
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
package agent_test
2+
3+
import (
4+
"context"
5+
"testing"
6+
7+
"github.com/stretchr/testify/assert"
8+
"github.com/stretchr/testify/require"
9+
corev1 "k8s.io/api/core/v1"
10+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
11+
"k8s.io/apimachinery/pkg/types"
12+
schemev1 "k8s.io/client-go/kubernetes/scheme"
13+
"sigs.k8s.io/controller-runtime/pkg/client/fake"
14+
15+
"github.com/kagent-dev/kagent/go/api/v1alpha2"
16+
agenttranslator "github.com/kagent-dev/kagent/go/core/internal/controller/translator/agent"
17+
)
18+
19+
// Test_AdkApiTranslator_SandboxAgentTool tests that agent tool references are
20+
// resolved by Kind, so both Agent and SandboxAgent objects can be used as tools.
21+
func Test_AdkApiTranslator_SandboxAgentTool(t *testing.T) {
22+
ctx := context.Background()
23+
scheme := schemev1.Scheme
24+
require.NoError(t, v1alpha2.AddToScheme(scheme))
25+
26+
declarativeSpec := func(tools ...*v1alpha2.Tool) v1alpha2.AgentSpec {
27+
return v1alpha2.AgentSpec{
28+
Type: v1alpha2.AgentType_Declarative,
29+
Description: "test agent",
30+
Declarative: &v1alpha2.DeclarativeAgentSpec{
31+
SystemMessage: "Test",
32+
ModelConfig: "default-model",
33+
Tools: tools,
34+
},
35+
}
36+
}
37+
38+
agentToolRef := func(name, kind string) *v1alpha2.Tool {
39+
return &v1alpha2.Tool{
40+
Type: v1alpha2.ToolProviderType_Agent,
41+
Agent: &v1alpha2.TypedReference{
42+
Name: name,
43+
Kind: kind,
44+
},
45+
}
46+
}
47+
48+
modelConfig := &v1alpha2.ModelConfig{
49+
ObjectMeta: metav1.ObjectMeta{Name: "default-model", Namespace: "test"},
50+
Spec: v1alpha2.ModelConfigSpec{
51+
Provider: "OpenAI",
52+
Model: "gpt-4o",
53+
},
54+
}
55+
testNamespace := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "test"}}
56+
57+
// A SandboxAgent and a regular Agent sharing the same name, to verify
58+
// kind-based resolution and kind-qualified self-reference checks.
59+
sandboxTool := &v1alpha2.SandboxAgent{
60+
ObjectMeta: metav1.ObjectMeta{Name: "shared-name", Namespace: "test"},
61+
Spec: v1alpha2.SandboxAgentSpec{AgentSpec: declarativeSpec()},
62+
}
63+
regularTool := &v1alpha2.Agent{
64+
ObjectMeta: metav1.ObjectMeta{Name: "shared-name", Namespace: "test"},
65+
Spec: declarativeSpec(),
66+
}
67+
68+
tests := []struct {
69+
name string
70+
agent v1alpha2.AgentObject
71+
wantURL string
72+
wantErr bool
73+
errContains string
74+
}{
75+
{
76+
name: "kind SandboxAgent resolves SandboxAgent and routes via controller proxy",
77+
agent: &v1alpha2.Agent{
78+
ObjectMeta: metav1.ObjectMeta{Name: "parent", Namespace: "test"},
79+
Spec: declarativeSpec(agentToolRef("shared-name", "SandboxAgent")),
80+
},
81+
wantURL: "http://kagent-controller.kagent:8083/api/a2a-sandboxes/test/shared-name",
82+
},
83+
{
84+
name: "empty kind defaults to Agent",
85+
agent: &v1alpha2.Agent{
86+
ObjectMeta: metav1.ObjectMeta{Name: "parent", Namespace: "test"},
87+
Spec: declarativeSpec(agentToolRef("shared-name", "")),
88+
},
89+
wantURL: "http://shared-name.test:8080",
90+
},
91+
{
92+
name: "kind Agent resolves Agent and uses direct service URL",
93+
agent: &v1alpha2.Agent{
94+
ObjectMeta: metav1.ObjectMeta{Name: "parent", Namespace: "test"},
95+
Spec: declarativeSpec(agentToolRef("shared-name", "Agent")),
96+
},
97+
wantURL: "http://shared-name.test:8080",
98+
},
99+
{
100+
name: "unsupported kind is rejected",
101+
agent: &v1alpha2.Agent{
102+
ObjectMeta: metav1.ObjectMeta{Name: "parent", Namespace: "test"},
103+
Spec: declarativeSpec(agentToolRef("shared-name", "AgentHarness")),
104+
},
105+
wantErr: true,
106+
errContains: `unsupported agent tool kind "AgentHarness"`,
107+
},
108+
{
109+
name: "missing SandboxAgent returns not found",
110+
agent: &v1alpha2.Agent{
111+
ObjectMeta: metav1.ObjectMeta{Name: "parent", Namespace: "test"},
112+
Spec: declarativeSpec(agentToolRef("does-not-exist", "SandboxAgent")),
113+
},
114+
wantErr: true,
115+
errContains: "not found",
116+
},
117+
{
118+
name: "SandboxAgent referencing itself is rejected",
119+
agent: &v1alpha2.SandboxAgent{
120+
ObjectMeta: metav1.ObjectMeta{Name: "shared-name", Namespace: "test"},
121+
Spec: v1alpha2.SandboxAgentSpec{
122+
AgentSpec: declarativeSpec(agentToolRef("shared-name", "SandboxAgent")),
123+
},
124+
},
125+
wantErr: true,
126+
errContains: "reference itself",
127+
},
128+
{
129+
name: "Agent referencing a SandboxAgent with the same name is not a self-reference",
130+
agent: &v1alpha2.Agent{
131+
ObjectMeta: metav1.ObjectMeta{Name: "shared-name", Namespace: "test"},
132+
Spec: declarativeSpec(agentToolRef("shared-name", "SandboxAgent")),
133+
},
134+
wantURL: "http://kagent-controller.kagent:8083/api/a2a-sandboxes/test/shared-name",
135+
},
136+
}
137+
138+
for _, tt := range tests {
139+
t.Run(tt.name, func(t *testing.T) {
140+
kubeClient := fake.NewClientBuilder().
141+
WithScheme(scheme).
142+
WithObjects(modelConfig, testNamespace, sandboxTool, regularTool).
143+
Build()
144+
145+
translator := agenttranslator.NewAdkApiTranslator(
146+
kubeClient,
147+
types.NamespacedName{Name: "default-model", Namespace: "test"},
148+
nil,
149+
"",
150+
nil,
151+
)
152+
153+
inputs, err := translator.CompileAgent(ctx, tt.agent)
154+
155+
if tt.wantErr {
156+
require.Error(t, err)
157+
if tt.errContains != "" {
158+
assert.Contains(t, err.Error(), tt.errContains)
159+
}
160+
return
161+
}
162+
163+
require.NoError(t, err)
164+
require.NotNil(t, inputs)
165+
require.NotNil(t, inputs.Config)
166+
require.Len(t, inputs.Config.RemoteAgents, 1)
167+
assert.Equal(t, tt.wantURL, inputs.Config.RemoteAgents[0].Url)
168+
})
169+
}
170+
}

0 commit comments

Comments
 (0)