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
92 changes: 70 additions & 22 deletions go/core/internal/controller/translator/agent/compiler.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ type tState struct {
func (s *tState) with(agent v1alpha2.AgentObject) *tState {
visited := make([]string, len(s.visitedAgents), len(s.visitedAgents)+1)
copy(visited, s.visitedAgents)
visited = append(visited, utils.GetObjectRef(agent))
visited = append(visited, agentStateKey(agent))
return &tState{
depth: s.depth + 1,
visitedAgents: visited,
Expand All @@ -45,6 +45,59 @@ func (t *tState) isVisited(agentName string) bool {
return slices.Contains(t.visitedAgents, agentName)
}

// agentObjectKind returns the Kubernetes kind backing an AgentObject.
func agentObjectKind(agent v1alpha2.AgentObject) string {
switch agent.(type) {
case *v1alpha2.SandboxAgent:
return "SandboxAgent"
default:
return "Agent"
}
}

// agentStateKey is a kind-qualified identity used for cycle/self-reference checks.
func agentStateKey(agent v1alpha2.AgentObject) string {
return agentObjectKind(agent) + "/" + utils.GetObjectRef(agent)
}

// getToolAgent resolves an Agent tool reference to its backing object, honoring
// the reference Kind. An empty Kind defaults to Agent.
func (a *adkApiTranslator) getToolAgent(
ctx context.Context,
ref *v1alpha2.TypedReference,
defaultNamespace string,
) (v1alpha2.AgentObject, error) {
key := ref.NamespacedName(defaultNamespace)
fetchAgent := func(obj v1alpha2.AgentObject) (v1alpha2.AgentObject, error) {
return obj, a.kube.Get(ctx, key, obj)
}

switch ref.Kind {
case "", "Agent":
return fetchAgent(&v1alpha2.Agent{})
case "SandboxAgent":
return fetchAgent(&v1alpha2.SandboxAgent{})

default:
return nil, fmt.Errorf("unsupported agent tool kind %q for agent %s", ref.Kind, key)
}
}

// sandboxA2APathPrefix mirrors httpserver.APIPathA2ASandboxes (not imported to
// avoid an import cycle). Sandbox agents have no stable Service, so A2A calls
// to them are proxied through the controller.
const sandboxA2APathPrefix = "/api/a2a-sandboxes"

// toolAgentURL returns the A2A URL a parent agent should use to call a sub-agent.
func toolAgentURL(agent v1alpha2.AgentObject) string {
if agent.GetWorkloadMode() == v1alpha2.WorkloadModeSandbox {
return fmt.Sprintf("http://%s.%s:8083%s/%s/%s",
utils.GetControllerName(), utils.GetResourceNamespace(),
sandboxA2APathPrefix, agent.GetNamespace(), agent.GetName())
}
return fmt.Sprintf("http://%s.%s:8080", agent.GetName(), agent.GetNamespace())
}

func TranslateAgent(
ctx context.Context,
translator AdkApiTranslator,
Expand Down Expand Up @@ -118,7 +171,7 @@ func (a *adkApiTranslator) validateAgent(ctx context.Context, agent v1alpha2.Age
agentRef := utils.GetObjectRef(agent)
spec := agent.GetAgentSpec()

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

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

agentRef := tool.Agent.NamespacedName(agent.GetNamespace())

if agentRef.Namespace == agent.GetNamespace() && agentRef.Name == agent.GetName() {
return fmt.Errorf("agent tool cannot be used to reference itself, %s", agentRef)
}

toolAgent := &v1alpha2.Agent{}
err := a.kube.Get(ctx, agentRef, toolAgent)
toolAgent, err := a.getToolAgent(ctx, tool.Agent, agent.GetNamespace())
if err != nil {
return err
}

if agentStateKey(toolAgent) == agentStateKey(agent) {
return fmt.Errorf("agent tool cannot be used to reference itself, %s", utils.GetObjectRef(toolAgent))
}

err = a.validateAgent(ctx, toolAgent, state.with(agent))
if err != nil {
return err
Expand Down Expand Up @@ -271,21 +321,19 @@ func (a *adkApiTranslator) translateInlineAgent(ctx context.Context, agent v1alp
secretHashBytes = append(secretHashBytes, toolHashBytes...)
}
case tool.Agent != nil:
agentRef := tool.Agent.NamespacedName(agent.GetNamespace())

if agentRef.Namespace == agent.GetNamespace() && agentRef.Name == agent.GetName() {
return nil, nil, nil, fmt.Errorf("agent tool cannot be used to reference itself, %s", agentRef)
}

toolAgent := &v1alpha2.Agent{}
err := a.kube.Get(ctx, agentRef, toolAgent)
toolAgent, err := a.getToolAgent(ctx, tool.Agent, agent.GetNamespace())
if err != nil {
return nil, nil, nil, err
}

switch toolAgent.Spec.Type {
if agentStateKey(toolAgent) == agentStateKey(agent) {
return nil, nil, nil, fmt.Errorf("agent tool cannot be used to reference itself, %s", utils.GetObjectRef(toolAgent))
}

toolSpec := toolAgent.GetAgentSpec()
switch toolSpec.Type {
case v1alpha2.AgentType_BYO, v1alpha2.AgentType_Declarative:
originalURL := fmt.Sprintf("http://%s.%s:8080", toolAgent.Name, toolAgent.Namespace)
originalURL := toolAgentURL(toolAgent)

targetURL := originalURL
if a.globalProxyURL != "" {
Expand All @@ -299,10 +347,10 @@ func (a *adkApiTranslator) translateInlineAgent(ctx context.Context, agent v1alp
Name: utils.ConvertToPythonIdentifier(utils.GetObjectRef(toolAgent)),
Url: targetURL,
Headers: headers,
Description: toolAgent.Spec.Description,
Description: toolSpec.Description,
})
default:
return nil, nil, nil, fmt.Errorf("unknown agent type: %s", toolAgent.Spec.Type)
return nil, nil, nil, fmt.Errorf("unknown agent type: %s", toolSpec.Type)
}

default:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
package agent_test

import (
"context"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
schemev1 "k8s.io/client-go/kubernetes/scheme"
"sigs.k8s.io/controller-runtime/pkg/client/fake"

"github.com/kagent-dev/kagent/go/api/v1alpha2"
agenttranslator "github.com/kagent-dev/kagent/go/core/internal/controller/translator/agent"
)

// Test_AdkApiTranslator_SandboxAgentTool tests that agent tool references are
// resolved by Kind, so both Agent and SandboxAgent objects can be used as tools.
func Test_AdkApiTranslator_SandboxAgentTool(t *testing.T) {
ctx := context.Background()
scheme := schemev1.Scheme
require.NoError(t, v1alpha2.AddToScheme(scheme))
Comment on lines +22 to +24

declarativeSpec := func(tools ...*v1alpha2.Tool) v1alpha2.AgentSpec {
return v1alpha2.AgentSpec{
Type: v1alpha2.AgentType_Declarative,
Description: "test agent",
Declarative: &v1alpha2.DeclarativeAgentSpec{
SystemMessage: "Test",
ModelConfig: "default-model",
Tools: tools,
},
}
}

agentToolRef := func(name, kind string) *v1alpha2.Tool {
return &v1alpha2.Tool{
Type: v1alpha2.ToolProviderType_Agent,
Agent: &v1alpha2.TypedReference{
Name: name,
Kind: kind,
},
}
}

modelConfig := &v1alpha2.ModelConfig{
ObjectMeta: metav1.ObjectMeta{Name: "default-model", Namespace: "test"},
Spec: v1alpha2.ModelConfigSpec{
Provider: "OpenAI",
Model: "gpt-4o",
},
}
testNamespace := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "test"}}

// A SandboxAgent and a regular Agent sharing the same name, to verify
// kind-based resolution and kind-qualified self-reference checks.
sandboxTool := &v1alpha2.SandboxAgent{
ObjectMeta: metav1.ObjectMeta{Name: "shared-name", Namespace: "test"},
Spec: v1alpha2.SandboxAgentSpec{AgentSpec: declarativeSpec()},
}
regularTool := &v1alpha2.Agent{
ObjectMeta: metav1.ObjectMeta{Name: "shared-name", Namespace: "test"},
Spec: declarativeSpec(),
}

tests := []struct {
name string
agent v1alpha2.AgentObject
wantURL string
wantErr bool
errContains string
}{
{
name: "kind SandboxAgent resolves SandboxAgent and routes via controller proxy",
agent: &v1alpha2.Agent{
ObjectMeta: metav1.ObjectMeta{Name: "parent", Namespace: "test"},
Spec: declarativeSpec(agentToolRef("shared-name", "SandboxAgent")),
},
wantURL: "http://kagent-controller.kagent:8083/api/a2a-sandboxes/test/shared-name",
},
{
name: "empty kind defaults to Agent",
agent: &v1alpha2.Agent{
ObjectMeta: metav1.ObjectMeta{Name: "parent", Namespace: "test"},
Spec: declarativeSpec(agentToolRef("shared-name", "")),
},
wantURL: "http://shared-name.test:8080",
},
{
name: "kind Agent resolves Agent and uses direct service URL",
agent: &v1alpha2.Agent{
ObjectMeta: metav1.ObjectMeta{Name: "parent", Namespace: "test"},
Spec: declarativeSpec(agentToolRef("shared-name", "Agent")),
},
wantURL: "http://shared-name.test:8080",
},
{
name: "unsupported kind is rejected",
agent: &v1alpha2.Agent{
ObjectMeta: metav1.ObjectMeta{Name: "parent", Namespace: "test"},
Spec: declarativeSpec(agentToolRef("shared-name", "AgentHarness")),
},
wantErr: true,
errContains: `unsupported agent tool kind "AgentHarness"`,
},
{
name: "missing SandboxAgent returns not found",
agent: &v1alpha2.Agent{
ObjectMeta: metav1.ObjectMeta{Name: "parent", Namespace: "test"},
Spec: declarativeSpec(agentToolRef("does-not-exist", "SandboxAgent")),
},
wantErr: true,
errContains: "not found",
},
{
name: "SandboxAgent referencing itself is rejected",
agent: &v1alpha2.SandboxAgent{
ObjectMeta: metav1.ObjectMeta{Name: "shared-name", Namespace: "test"},
Spec: v1alpha2.SandboxAgentSpec{
AgentSpec: declarativeSpec(agentToolRef("shared-name", "SandboxAgent")),
},
},
wantErr: true,
errContains: "reference itself",
},
{
name: "Agent referencing a SandboxAgent with the same name is not a self-reference",
agent: &v1alpha2.Agent{
ObjectMeta: metav1.ObjectMeta{Name: "shared-name", Namespace: "test"},
Spec: declarativeSpec(agentToolRef("shared-name", "SandboxAgent")),
},
wantURL: "http://kagent-controller.kagent:8083/api/a2a-sandboxes/test/shared-name",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
kubeClient := fake.NewClientBuilder().
WithScheme(scheme).
WithObjects(modelConfig, testNamespace, sandboxTool, regularTool).
Build()

translator := agenttranslator.NewAdkApiTranslator(
kubeClient,
types.NamespacedName{Name: "default-model", Namespace: "test"},
nil,
"",
nil,
)

inputs, err := translator.CompileAgent(ctx, tt.agent)

if tt.wantErr {
require.Error(t, err)
if tt.errContains != "" {
assert.Contains(t, err.Error(), tt.errContains)
}
return
}

require.NoError(t, err)
require.NotNil(t, inputs)
require.NotNil(t, inputs.Config)
require.Len(t, inputs.Config.RemoteAgents, 1)
assert.Equal(t, tt.wantURL, inputs.Config.RemoteAgents[0].Url)
})
}
}
Loading
Loading