Skip to content
44 changes: 44 additions & 0 deletions go/api/client/agenttemplate.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package client

import (
"context"

apiv1alpha1 "github.com/kagent-dev/kagent/go/api/gen/kagent/api/v1alpha1"
)

// AgentTemplateClient provides supported AgentTemplate operations.
type AgentTemplateClient struct {
client *BaseClient
}

// NewAgentTemplateClient creates an AgentTemplate client over the shared gRPC connection.
func NewAgentTemplateClient(client *BaseClient) *AgentTemplateClient {
return &AgentTemplateClient{client: client}
}

func (c *AgentTemplateClient) CreateAgentTemplate(ctx context.Context, request *apiv1alpha1.CreateAgentTemplateRequest) (*apiv1alpha1.CreateAgentTemplateResponse, error) {
client, callContext, cancel, err := c.client.agentTemplateCall(ctx)
if err != nil {
return nil, err
}
defer cancel()
return client.CreateAgentTemplate(callContext, request)
}

func (c *AgentTemplateClient) UpdateAgentTemplate(ctx context.Context, request *apiv1alpha1.UpdateAgentTemplateRequest) (*apiv1alpha1.UpdateAgentTemplateResponse, error) {
client, callContext, cancel, err := c.client.agentTemplateCall(ctx)
if err != nil {
return nil, err
}
defer cancel()
return client.UpdateAgentTemplate(callContext, request)
}

func (c *BaseClient) agentTemplateCall(ctx context.Context) (apiv1alpha1.AgentTemplateServiceClient, context.Context, context.CancelFunc, error) {
connection, err := c.grpcConnection()
if err != nil {
return nil, nil, nil, err
}
callContext, cancel := c.grpcCallContext(ctx)
return apiv1alpha1.NewAgentTemplateServiceClient(connection), callContext, cancel, nil
}
84 changes: 84 additions & 0 deletions go/api/client/agenttemplategrpc_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
package client

import (
"context"
"net"
"sync"
"testing"
"time"

apiv1alpha1 "github.com/kagent-dev/kagent/go/api/gen/kagent/api/v1alpha1"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/grpc"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/test/bufconn"
"google.golang.org/protobuf/proto"
)

type recordingAgentTemplateService struct {
apiv1alpha1.UnimplementedAgentTemplateServiceServer

mu sync.Mutex
observations []callObservation
create *apiv1alpha1.CreateAgentTemplateRequest
update *apiv1alpha1.UpdateAgentTemplateRequest
}

func (s *recordingAgentTemplateService) CreateAgentTemplate(ctx context.Context, request *apiv1alpha1.CreateAgentTemplateRequest) (*apiv1alpha1.CreateAgentTemplateResponse, error) {
s.observe(ctx)
s.mu.Lock()
defer s.mu.Unlock()
s.create = request
return &apiv1alpha1.CreateAgentTemplateResponse{}, nil
}

func (s *recordingAgentTemplateService) UpdateAgentTemplate(ctx context.Context, request *apiv1alpha1.UpdateAgentTemplateRequest) (*apiv1alpha1.UpdateAgentTemplateResponse, error) {
s.observe(ctx)
s.mu.Lock()
defer s.mu.Unlock()
s.update = request
return &apiv1alpha1.UpdateAgentTemplateResponse{}, nil
}

func (s *recordingAgentTemplateService) observe(ctx context.Context) {
values, _ := metadata.FromIncomingContext(ctx)
_, hasDeadline := ctx.Deadline()
s.mu.Lock()
defer s.mu.Unlock()
s.observations = append(s.observations, callObservation{userID: first(values.Get(userIDHeader)), hasDeadline: hasDeadline})
}

func TestAgentTemplateClientUsesGeneratedGRPC(t *testing.T) {
listener := bufconn.Listen(1024 * 1024)
service := &recordingAgentTemplateService{}
server := grpc.NewServer()
apiv1alpha1.RegisterAgentTemplateServiceServer(server, service)
go func() { _ = server.Serve(listener) }()
t.Cleanup(func() {
server.Stop()
_ = listener.Close()
})

clientSet := New(
"http://rest-must-not-be-used.invalid",
WithUserID("caller"),
WithGRPCTarget("passthrough:///bufnet"),
WithGRPCTimeout(5*time.Second),
WithGRPCDialOptions(grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) {
return listener.Dial()
})),
)
t.Cleanup(func() { require.NoError(t, clientSet.Close()) })

ref := &apiv1alpha1.ResourceReference{Namespace: "team-a", Name: "researcher"}
_, err := clientSet.AgentTemplate.CreateAgentTemplate(t.Context(), &apiv1alpha1.CreateAgentTemplateRequest{Ref: ref})
require.NoError(t, err)
_, err = clientSet.AgentTemplate.UpdateAgentTemplate(t.Context(), &apiv1alpha1.UpdateAgentTemplateRequest{Ref: ref})
require.NoError(t, err)
service.mu.Lock()
defer service.mu.Unlock()
assert.True(t, proto.Equal(ref, service.create.GetRef()))
assert.True(t, proto.Equal(ref, service.update.GetRef()))
assert.Equal(t, []callObservation{{userID: "caller", hasDeadline: true}, {userID: "caller", hasDeadline: true}}, service.observations)
}
2 changes: 2 additions & 0 deletions go/api/client/clientset.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ type ClientSet struct {
Namespace Namespace
Feedback Feedback
AgentInstance *AgentInstanceClient
AgentTemplate *AgentTemplateClient
A2A *A2AClient
}

Expand All @@ -37,6 +38,7 @@ func New(baseURL string, options ...ClientOption) *ClientSet {
Namespace: NewNamespaceClient(baseClient),
Feedback: NewFeedbackClient(baseClient),
AgentInstance: NewAgentInstanceClient(baseClient),
AgentTemplate: NewAgentTemplateClient(baseClient),
A2A: NewA2AClient(baseClient),
}
}
Expand Down
140 changes: 139 additions & 1 deletion go/core/cli/internal/commands/agent_template.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,21 +5,31 @@ import (
"errors"
"fmt"
"io"
"os"
"strings"
"time"

"github.com/jedib0t/go-pretty/v6/table"
typedapiv1alpha3 "github.com/kagent-dev/kagent/go/api/clientset/versioned/typed/api/v1alpha3"
apiv1alpha1 "github.com/kagent-dev/kagent/go/api/gen/kagent/api/v1alpha1"
"github.com/kagent-dev/kagent/go/api/structuredobject"
apiv1alpha3 "github.com/kagent-dev/kagent/go/api/v1alpha3"
commonk8s "github.com/kagent-dev/kagent/go/core/cli/internal/common/k8s"
"github.com/kagent-dev/kagent/go/core/cli/internal/connection"
clioutput "github.com/kagent-dev/kagent/go/core/cli/internal/output"
"github.com/spf13/cobra"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/proto"
"k8s.io/apimachinery/pkg/api/meta"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"sigs.k8s.io/yaml"
)

const agentTemplateMaxPageSize = 100
const (
agentTemplateKind = "AgentTemplate"
agentTemplateMaxPageSize = 100
)

// AgentTemplateGetCfg configures AgentTemplate get and list operations.
type AgentTemplateGetCfg struct {
Expand All @@ -30,6 +40,105 @@ type AgentTemplateGetCfg struct {
PageToken string
}

// AgentTemplateManifestCfg configures an AgentTemplate manifest operation.
type AgentTemplateManifestCfg struct {
OutputFormat string
File string
}

type lifecycleClient interface {
CreateAgentTemplate(context.Context, *apiv1alpha1.CreateAgentTemplateRequest) (*apiv1alpha1.CreateAgentTemplateResponse, error)
UpdateAgentTemplate(context.Context, *apiv1alpha1.UpdateAgentTemplateRequest) (*apiv1alpha1.UpdateAgentTemplateResponse, error)
}

type agentTemplateManifestOperation func(context.Context, lifecycleClient, *apiv1alpha1.ResourceReference, *apiv1alpha1.StructuredObject, clioutput.Format, io.Writer) error

func runAgentTemplateManifest(
ctx context.Context,
options connection.Options,
cfg *AgentTemplateManifestCfg,
out io.Writer,
operation agentTemplateManifestOperation,
) (err error) {
format, err := clioutput.Parse(cfg.OutputFormat)
if err != nil {
return err
}
ref, resource, err := readAgentTemplateManifest(cfg.File, options.Namespace)
if err != nil {
return err
}
session, err := connection.Open(ctx, options)
if err != nil {
return err
}
defer func() {
err = errors.Join(err, session.Close())
}()
return operation(ctx, session.Client.AgentTemplate, ref, resource, format, out)
}

func readAgentTemplateManifest(filename, namespace string) (*apiv1alpha1.ResourceReference, *apiv1alpha1.StructuredObject, error) {
data, err := os.ReadFile(filename)
if err != nil {
return nil, nil, fmt.Errorf("read AgentTemplate manifest %q: %w", filename, err)
}
manifest := &apiv1alpha3.AgentTemplate{}
if err := yaml.UnmarshalStrict(data, manifest); err != nil {
return nil, nil, fmt.Errorf("parse AgentTemplate manifest %q: %w", filename, err)
}
if manifest.APIVersion != apiv1alpha3.GroupVersion.String() || manifest.Kind != agentTemplateKind {
return nil, nil, fmt.Errorf("AgentTemplate manifest %q must have apiVersion %q and kind %q", filename, apiv1alpha3.GroupVersion.String(), agentTemplateKind)
}
if manifest.Name == "" {
return nil, nil, fmt.Errorf("AgentTemplate manifest %q must have metadata.name", filename)
}
if manifest.Namespace != "" && manifest.Namespace != namespace {
return nil, nil, fmt.Errorf("AgentTemplate manifest namespace %q does not match --namespace %q", manifest.Namespace, namespace)
}
resource, err := structuredobject.FromGo(manifest, apiv1alpha3.GroupVersion.String(), agentTemplateKind, 0)
if err != nil {
return nil, nil, fmt.Errorf("encode AgentTemplate manifest %q: %w", filename, err)
}
return &apiv1alpha1.ResourceReference{Namespace: namespace, Name: manifest.Name}, resource, nil
}

func applyAgentTemplate(
ctx context.Context,
client lifecycleClient,
ref *apiv1alpha1.ResourceReference,
resource *apiv1alpha1.StructuredObject,
format clioutput.Format,
out io.Writer,
) error {
created, err := client.CreateAgentTemplate(ctx, &apiv1alpha1.CreateAgentTemplateRequest{Ref: ref, Resource: resource})
if status.Code(err) != codes.AlreadyExists {
if err != nil {
return fmt.Errorf("apply AgentTemplate: %w", err)
}
return writeAgentTemplateResult(out, format, created, created.GetAgentTemplate())
}
updated, err := client.UpdateAgentTemplate(ctx, &apiv1alpha1.UpdateAgentTemplateRequest{Ref: ref, Resource: resource})
if err != nil {
return fmt.Errorf("apply AgentTemplate: %w", err)
}
return writeAgentTemplateResult(out, format, updated, updated.GetAgentTemplate())
}

func writeAgentTemplateResult(w io.Writer, format clioutput.Format, response proto.Message, result *apiv1alpha1.AgentTemplate) error {
if result == nil {
return errors.New("AgentTemplate operation returned no AgentTemplate")
}
if format == clioutput.FormatJSON {
return clioutput.WriteProto(w, response)
}
template := &apiv1alpha3.AgentTemplate{}
if err := structuredobject.ToGo(result.GetResource(), agentTemplateKind, template, 0); err != nil {
return fmt.Errorf("decode AgentTemplate result: %w", err)
}
return writeAgentTemplatesTable(w, []apiv1alpha3.AgentTemplate{*template}, false, "")
}

// runGetAgentTemplate gets one AgentTemplate or lists AgentTemplates through Kubernetes.
func runGetAgentTemplate(ctx context.Context, cfg *AgentTemplateGetCfg, out io.Writer) error {
format, err := clioutput.Parse(cfg.OutputFormat)
Expand Down Expand Up @@ -154,3 +263,32 @@ func NewGetAgentTemplateCmd() *cobra.Command {
cmd.Flags().StringVar(&cfg.PageToken, "page-token", "", "Token returned by the previous page")
return cmd
}

// NewApplyAgentTemplateCmd constructs the AgentTemplate apply command.
func NewApplyAgentTemplateCmd() *cobra.Command {
return newAgentTemplateManifestCmd("apply -f FILE", "Create or update an AgentTemplate", applyAgentTemplate)
}

func newAgentTemplateManifestCmd(use, short string, operation agentTemplateManifestOperation) *cobra.Command {
cfg := &AgentTemplateManifestCfg{}
cmd := &cobra.Command{
Use: use,
Short: short,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
options, err := connection.OptionsFromCommand(cmd)
if err != nil {
return err
}
format, err := clioutput.FromCommand(cmd)
if err != nil {
return err
}
cfg.OutputFormat = format
return runAgentTemplateManifest(cmd.Context(), options, cfg, cmd.OutOrStdout(), operation)
},
}
cmd.Flags().StringVarP(&cfg.File, "file", "f", "", "Path to AgentTemplate manifest")
_ = cmd.MarkFlagRequired("file")
return cmd
}
Loading
Loading