diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 1ddf5103b..5f941ecc4 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -80,7 +80,7 @@ jobs: make create-kind-cluster echo "Cache key: ${{ needs.setup.outputs.cache-key }}" make helm-install - make push-test-agent + make push-test-agent push-test-skill kubectl wait --for=condition=Ready agents.kagent.dev -n kagent --all --timeout=60s || kubectl get po -n kagent -o wide ||: kubectl wait --for=condition=Ready agents.kagent.dev -n kagent --all --timeout=60s diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index d6fb910ab..6a4a0a294 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -103,7 +103,7 @@ Then run the `make helm-install` command again. create a minimal cluster with kind. scale kagent to 0 replicas, as we will run it locally. ```bash -make create-kind-cluster helm-install-provider helm-tools push-test-agent +make create-kind-cluster helm-install-provider helm-tools push-test-agent push-test-skill kubectl scale -n kagent deployment kagent-controller --replicas 0 ``` diff --git a/Makefile b/Makefile index 4648c8aed..2758c060d 100644 --- a/Makefile +++ b/Makefile @@ -143,6 +143,11 @@ push-test-agent: buildx-create build-kagent-adk kubectl apply --namespace kagent --context kind-$(KIND_CLUSTER_NAME) -f go/test/e2e/agents/kebab/agent.yaml $(DOCKER_BUILDER) build --push $(BUILD_ARGS) $(TOOLS_IMAGE_BUILD_ARGS) -t $(DOCKER_REGISTRY)/poem-flow:latest -f python/samples/crewai/poem_flow/Dockerfile ./python +.PHONY: push-test-skill +push-test-skill: buildx-create + echo "Building FROM DOCKER_REGISTRY=$(DOCKER_REGISTRY)/$(DOCKER_REPO)/kebab-maker:$(VERSION)" + $(DOCKER_BUILDER) build --push $(BUILD_ARGS) $(TOOLS_IMAGE_BUILD_ARGS) -t $(DOCKER_REGISTRY)/kebab-maker:latest -f go/test/e2e/testdata/skills/kebab/Dockerfile ./go/test/e2e/testdata/skills/kebab + .PHONY: create-kind-cluster create-kind-cluster: bash ./scripts/kind/setup-kind.sh diff --git a/go/api/v1alpha2/agent_types.go b/go/api/v1alpha2/agent_types.go index 906d1b7d0..96c93a747 100644 --- a/go/api/v1alpha2/agent_types.go +++ b/go/api/v1alpha2/agent_types.go @@ -53,6 +53,23 @@ type AgentSpec struct { // +optional Description string `json:"description,omitempty"` + + // Skills to load into the agent. They will be pulled from the specified container images. + // and made available to the agent under the `/skills` folder. + // +optional + Skills *SkillForAgent `json:"skills,omitempty"` +} + +type SkillForAgent struct { + // Fetch images insecurely from registries (allowing HTTP and skipping TLS verification). + // Meant for development and testing purposes only. + // +optional + InsecureSkipVerify bool `json:"insecureSkipVerify,omitempty"` + + // The list of skill images to fetch. + // +kubebuilder:validation:MinItems=1 + // +kubebuilder:validation:MaxItems=20 + Refs []string `json:"refs,omitempty"` } // +kubebuilder:validation:XValidation:rule="!has(self.systemMessage) || !has(self.systemMessageFrom)",message="systemMessage and systemMessageFrom are mutually exclusive" @@ -85,6 +102,12 @@ type DeclarativeAgentSpec struct { // +optional Deployment *DeclarativeDeploymentSpec `json:"deployment,omitempty"` + + // Allow code execution for python code blocks with this agent. + // If true, the agent will automatically execute python code blocks in the LLM responses. + // Code will be executed in a sandboxed environment. + // +optional + ExecuteCodeBlocks *bool `json:"executeCodeBlocks,omitempty"` } type DeclarativeDeploymentSpec struct { @@ -112,10 +135,7 @@ type ByoDeploymentSpec struct { } type SharedDeploymentSpec struct { - // If not specified, the default value is 1. // +optional - // +kubebuilder:validation:Minimum=1 - // +kubebuilder:default=1 Replicas *int32 `json:"replicas,omitempty"` // +optional ImagePullSecrets []corev1.LocalObjectReference `json:"imagePullSecrets,omitempty"` diff --git a/go/api/v1alpha2/zz_generated.deepcopy.go b/go/api/v1alpha2/zz_generated.deepcopy.go index a44cd9a37..d23ae9ca0 100644 --- a/go/api/v1alpha2/zz_generated.deepcopy.go +++ b/go/api/v1alpha2/zz_generated.deepcopy.go @@ -160,6 +160,11 @@ func (in *AgentSpec) DeepCopyInto(out *AgentSpec) { *out = new(DeclarativeAgentSpec) (*in).DeepCopyInto(*out) } + if in.Skills != nil { + in, out := &in.Skills, &out.Skills + *out = new(SkillForAgent) + (*in).DeepCopyInto(*out) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AgentSpec. @@ -345,6 +350,11 @@ func (in *DeclarativeAgentSpec) DeepCopyInto(out *DeclarativeAgentSpec) { *out = new(DeclarativeDeploymentSpec) (*in).DeepCopyInto(*out) } + if in.ExecuteCodeBlocks != nil { + in, out := &in.ExecuteCodeBlocks, &out.ExecuteCodeBlocks + *out = new(bool) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DeclarativeAgentSpec. @@ -829,6 +839,26 @@ func (in *SharedDeploymentSpec) DeepCopy() *SharedDeploymentSpec { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *SkillForAgent) DeepCopyInto(out *SkillForAgent) { + *out = *in + if in.Refs != nil { + in, out := &in.Refs, &out.Refs + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SkillForAgent. +func (in *SkillForAgent) DeepCopy() *SkillForAgent { + if in == nil { + return nil + } + out := new(SkillForAgent) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Tool) DeepCopyInto(out *Tool) { *out = *in diff --git a/go/config/crd/bases/kagent.dev_agents.yaml b/go/config/crd/bases/kagent.dev_agents.yaml index 768d6f72c..aa7f8b351 100644 --- a/go/config/crd/bases/kagent.dev_agents.yaml +++ b/go/config/crd/bases/kagent.dev_agents.yaml @@ -2579,10 +2579,7 @@ spec: type: string type: object replicas: - default: 1 - description: If not specified, the default value is 1. format: int32 - minimum: 1 type: integer resources: description: ResourceRequirements describes the compute resource @@ -4858,10 +4855,7 @@ spec: type: string type: object replicas: - default: 1 - description: If not specified, the default value is 1. format: int32 - minimum: 1 type: integer resources: description: ResourceRequirements describes the compute resource @@ -6887,6 +6881,12 @@ spec: type: object type: array type: object + executeCodeBlocks: + description: |- + Allow code execution for python code blocks with this agent. + If true, the agent will automatically execute python code blocks in the LLM responses. + Code will be executed in a sandboxed environment. + type: boolean modelConfig: description: |- The name of the model config to use. @@ -7026,6 +7026,24 @@ spec: rule: '!has(self.systemMessage) || !has(self.systemMessageFrom)' description: type: string + skills: + description: |- + Skills to load into the agent. They will be pulled from the specified container images. + and made available to the agent under the `/skills` folder. + properties: + insecureSkipVerify: + description: |- + Fetch images insecurely from registries (allowing HTTP and skipping TLS verification). + Meant for development and testing purposes only. + type: boolean + refs: + description: The list of skill images to fetch. + items: + type: string + maxItems: 20 + minItems: 1 + type: array + type: object type: allOf: - enum: diff --git a/go/internal/adk/types.go b/go/internal/adk/types.go index 7f1832be9..c59302c17 100644 --- a/go/internal/adk/types.go +++ b/go/internal/adk/types.go @@ -239,6 +239,7 @@ type RemoteAgentConfig struct { Description string `json:"description,omitempty"` } +// See `python/packages/kagent-adk/src/kagent/adk/types.py` for the python version of this type AgentConfig struct { Model Model `json:"model"` Description string `json:"description"` @@ -246,6 +247,7 @@ type AgentConfig struct { HttpTools []HttpMcpServerConfig `json:"http_tools"` SseTools []SseMcpServerConfig `json:"sse_tools"` RemoteAgents []RemoteAgentConfig `json:"remote_agents"` + ExecuteCode bool `json:"execute_code,omitempty"` } func (a *AgentConfig) UnmarshalJSON(data []byte) error { diff --git a/go/internal/controller/translator/agent/adk_api_translator.go b/go/internal/controller/translator/agent/adk_api_translator.go index 9601f7c04..92aaf8022 100644 --- a/go/internal/controller/translator/agent/adk_api_translator.go +++ b/go/internal/controller/translator/agent/adk_api_translator.go @@ -330,9 +330,54 @@ func (a *adkApiTranslator) buildManifest( }, ) + var skills []string + if agent.Spec.Skills != nil && len(agent.Spec.Skills.Refs) != 0 { + skills = agent.Spec.Skills.Refs + } + // Build Deployment volumes := append(secretVol, dep.Volumes...) volumeMounts := append(secretMounts, dep.VolumeMounts...) + needSandbox := cfg != nil && cfg.ExecuteCode + + var initContainers []corev1.Container + + if len(skills) > 0 { + skillsEnv := corev1.EnvVar{ + Name: "KAGENT_SKILLS_FOLDER", + Value: "/skills", + } + needSandbox = true + insecure := agent.Spec.Skills.InsecureSkipVerify + command := []string{"kagent-adk", "pull-skills"} + if insecure { + command = append(command, "--insecure") + } + initContainers = append(initContainers, corev1.Container{ + Name: "skills-init", + Image: dep.Image, + Command: command, + Args: skills, + VolumeMounts: []corev1.VolumeMount{ + {Name: "kagent-skills", MountPath: "/skills"}, + }, + Env: []corev1.EnvVar{ + skillsEnv, + }, + }) + volumes = append(volumes, corev1.Volume{ + Name: "kagent-skills", + VolumeSource: corev1.VolumeSource{ + EmptyDir: &corev1.EmptyDirVolumeSource{}, + }, + }) + volumeMounts = append(volumeMounts, corev1.VolumeMount{ + Name: "kagent-skills", + MountPath: "/skills", + ReadOnly: true, + }) + sharedEnv = append(sharedEnv, skillsEnv) + } // Token volume volumes = append(volumes, corev1.Volume{ @@ -368,6 +413,12 @@ func (a *adkApiTranslator) buildManifest( } // Add config hash annotation to pod template to force rollout on config changes podTemplateAnnotations["kagent.dev/config-hash"] = fmt.Sprintf("%d", configHash) + var securityContext *corev1.SecurityContext + if needSandbox { + securityContext = &corev1.SecurityContext{ + Privileged: ptr.To(true), + } + } deployment := &appsv1.Deployment{ TypeMeta: metav1.TypeMeta{APIVersion: "apps/v1", Kind: "Deployment"}, @@ -387,6 +438,7 @@ func (a *adkApiTranslator) buildManifest( Spec: corev1.PodSpec{ ServiceAccountName: agent.Name, ImagePullSecrets: dep.ImagePullSecrets, + InitContainers: initContainers, Containers: []corev1.Container{{ Name: "kagent", Image: dep.Image, @@ -404,7 +456,8 @@ func (a *adkApiTranslator) buildManifest( TimeoutSeconds: 15, PeriodSeconds: 15, }, - VolumeMounts: volumeMounts, + SecurityContext: securityContext, + VolumeMounts: volumeMounts, }}, Volumes: volumes, }, @@ -460,6 +513,7 @@ func (a *adkApiTranslator) translateInlineAgent(ctx context.Context, agent *v1al Description: agent.Spec.Description, Instruction: systemMessage, Model: model, + ExecuteCode: ptr.Deref(agent.Spec.Declarative.ExecuteCodeBlocks, false), } agentCard := &server.AgentCard{ Name: strings.ReplaceAll(agent.Name, "-", "_"), @@ -1084,9 +1138,7 @@ func getDefaultLabels(agentName string, incoming map[string]string) map[string]s func (a *adkApiTranslator) resolveInlineDeployment(agent *v1alpha2.Agent, mdd *modelDeploymentData) (*resolvedDeployment, error) { // Defaults port := int32(8080) - cmd := "kagent-adk" args := []string{ - "static", "--host", "0.0.0.0", "--port", @@ -1122,7 +1174,6 @@ func (a *adkApiTranslator) resolveInlineDeployment(agent *v1alpha2.Agent, mdd *m dep := &resolvedDeployment{ Image: image, - Cmd: cmd, Args: args, Port: port, ImagePullPolicy: imagePullPolicy, @@ -1136,11 +1187,6 @@ func (a *adkApiTranslator) resolveInlineDeployment(agent *v1alpha2.Agent, mdd *m Resources: getDefaultResources(spec.Resources), // Set default resources if not specified } - // Set default replicas if not specified - if dep.Replicas == nil { - dep.Replicas = ptr.To(int32(1)) - } - return dep, nil } diff --git a/go/internal/controller/translator/agent/testdata/inputs/agent_with_code.yaml b/go/internal/controller/translator/agent/testdata/inputs/agent_with_code.yaml new file mode 100644 index 000000000..ee3807861 --- /dev/null +++ b/go/internal/controller/translator/agent/testdata/inputs/agent_with_code.yaml @@ -0,0 +1,49 @@ +operation: translateAgent +targetObject: agent-with-code +namespace: test +objects: + - apiVersion: v1 + kind: Secret + metadata: + name: openai-secret + namespace: test + data: + api-key: c2stdGVzdC1hcGkta2V5 # base64 encoded "sk-test-api-key" + - apiVersion: kagent.dev/v1alpha2 + kind: ModelConfig + metadata: + name: basic-model + namespace: test + spec: + provider: OpenAI + model: gpt-4o + apiKeySecret: openai-secret + apiKeySecretKey: api-key + openAI: + temperature: "0.7" + maxTokens: 1024 + topP: "0.95" + reasoningEffort: "low" + defaultHeaders: + User-Agent: "kagent/1.0" + - apiVersion: kagent.dev/v1alpha2 + kind: Agent + metadata: + name: agent-with-code + namespace: test + spec: + type: Declarative + declarative: + executeCodeBlocks: true + description: A basic test agent + systemMessage: You are a helpful assistant. + modelConfig: basic-model + deployment: + resources: + requests: + cpu: 200m + memory: 684Mi + limits: + cpu: 3000m + memory: 2Gi + tools: [] \ No newline at end of file diff --git a/go/internal/controller/translator/agent/testdata/inputs/agent_with_skills.yaml b/go/internal/controller/translator/agent/testdata/inputs/agent_with_skills.yaml new file mode 100644 index 000000000..7b276d25a --- /dev/null +++ b/go/internal/controller/translator/agent/testdata/inputs/agent_with_skills.yaml @@ -0,0 +1,51 @@ +operation: translateAgent +targetObject: skills-agent +namespace: test +objects: + - apiVersion: v1 + kind: Secret + metadata: + name: openai-secret + namespace: test + data: + api-key: c2stdGVzdC1hcGkta2V5 # base64 encoded "sk-test-api-key" + - apiVersion: kagent.dev/v1alpha2 + kind: ModelConfig + metadata: + name: basic-model + namespace: test + spec: + provider: OpenAI + model: gpt-4o + apiKeySecret: openai-secret + apiKeySecretKey: api-key + openAI: + temperature: "0.7" + maxTokens: 1024 + topP: "0.95" + reasoningEffort: "low" + defaultHeaders: + User-Agent: "kagent/1.0" + - apiVersion: kagent.dev/v1alpha2 + kind: Agent + metadata: + name: skills-agent + namespace: test + spec: + skills: + refs: + - foo:latest + type: Declarative + declarative: + description: A basic test agent + systemMessage: You are a helpful assistant. + modelConfig: basic-model + deployment: + resources: + requests: + cpu: 200m + memory: 684Mi + limits: + cpu: 3000m + memory: 2Gi + tools: [] \ No newline at end of file diff --git a/go/internal/controller/translator/agent/testdata/outputs/agent_with_code.json b/go/internal/controller/translator/agent/testdata/outputs/agent_with_code.json new file mode 100644 index 000000000..58e47fca4 --- /dev/null +++ b/go/internal/controller/translator/agent/testdata/outputs/agent_with_code.json @@ -0,0 +1,302 @@ +{ + "agentCard": { + "capabilities": { + "pushNotifications": false, + "stateTransitionHistory": true, + "streaming": true + }, + "defaultInputModes": [ + "text" + ], + "defaultOutputModes": [ + "text" + ], + "description": "", + "name": "agent_with_code", + "skills": null, + "url": "http://agent-with-code.test:8080", + "version": "" + }, + "config": { + "description": "", + "execute_code": true, + "http_tools": null, + "instruction": "You are a helpful assistant.", + "model": { + "base_url": "", + "headers": { + "User-Agent": "kagent/1.0" + }, + "max_tokens": 1024, + "model": "gpt-4o", + "reasoning_effort": "low", + "temperature": 0.7, + "top_p": 0.95, + "type": "openai" + }, + "remote_agents": null, + "sse_tools": null + }, + "manifest": [ + { + "apiVersion": "v1", + "kind": "Secret", + "metadata": { + "labels": { + "app": "kagent", + "app.kubernetes.io/managed-by": "kagent", + "app.kubernetes.io/name": "agent-with-code", + "app.kubernetes.io/part-of": "kagent", + "kagent": "agent-with-code" + }, + "name": "agent-with-code", + "namespace": "test", + "ownerReferences": [ + { + "apiVersion": "kagent.dev/v1alpha2", + "blockOwnerDeletion": true, + "controller": true, + "kind": "Agent", + "name": "agent-with-code", + "uid": "" + } + ] + }, + "stringData": { + "agent-card.json": "{\"name\":\"agent_with_code\",\"description\":\"\",\"url\":\"http://agent-with-code.test:8080\",\"version\":\"\",\"capabilities\":{\"streaming\":true,\"pushNotifications\":false,\"stateTransitionHistory\":true},\"defaultInputModes\":[\"text\"],\"defaultOutputModes\":[\"text\"],\"skills\":[]}", + "config.json": "{\"model\":{\"type\":\"openai\",\"model\":\"gpt-4o\",\"headers\":{\"User-Agent\":\"kagent/1.0\"},\"base_url\":\"\",\"max_tokens\":1024,\"reasoning_effort\":\"low\",\"temperature\":0.7,\"top_p\":0.95},\"description\":\"\",\"instruction\":\"You are a helpful assistant.\",\"http_tools\":null,\"sse_tools\":null,\"remote_agents\":null,\"execute_code\":true}" + } + }, + { + "apiVersion": "v1", + "kind": "ServiceAccount", + "metadata": { + "labels": { + "app": "kagent", + "app.kubernetes.io/managed-by": "kagent", + "app.kubernetes.io/name": "agent-with-code", + "app.kubernetes.io/part-of": "kagent", + "kagent": "agent-with-code" + }, + "name": "agent-with-code", + "namespace": "test", + "ownerReferences": [ + { + "apiVersion": "kagent.dev/v1alpha2", + "blockOwnerDeletion": true, + "controller": true, + "kind": "Agent", + "name": "agent-with-code", + "uid": "" + } + ] + } + }, + { + "apiVersion": "apps/v1", + "kind": "Deployment", + "metadata": { + "labels": { + "app": "kagent", + "app.kubernetes.io/managed-by": "kagent", + "app.kubernetes.io/name": "agent-with-code", + "app.kubernetes.io/part-of": "kagent", + "kagent": "agent-with-code" + }, + "name": "agent-with-code", + "namespace": "test", + "ownerReferences": [ + { + "apiVersion": "kagent.dev/v1alpha2", + "blockOwnerDeletion": true, + "controller": true, + "kind": "Agent", + "name": "agent-with-code", + "uid": "" + } + ] + }, + "spec": { + "selector": { + "matchLabels": { + "app": "kagent", + "kagent": "agent-with-code" + } + }, + "strategy": { + "rollingUpdate": { + "maxSurge": 1, + "maxUnavailable": 0 + }, + "type": "RollingUpdate" + }, + "template": { + "metadata": { + "annotations": { + "kagent.dev/config-hash": "14577661320509307051" + }, + "labels": { + "app": "kagent", + "app.kubernetes.io/managed-by": "kagent", + "app.kubernetes.io/name": "agent-with-code", + "app.kubernetes.io/part-of": "kagent", + "kagent": "agent-with-code" + } + }, + "spec": { + "containers": [ + { + "args": [ + "--host", + "0.0.0.0", + "--port", + "8080", + "--filepath", + "/config" + ], + "env": [ + { + "name": "OPENAI_API_KEY", + "valueFrom": { + "secretKeyRef": { + "key": "api-key", + "name": "openai-secret" + } + } + }, + { + "name": "KAGENT_NAMESPACE", + "valueFrom": { + "fieldRef": { + "fieldPath": "metadata.namespace" + } + } + }, + { + "name": "KAGENT_NAME", + "valueFrom": { + "fieldRef": { + "fieldPath": "spec.serviceAccountName" + } + } + }, + { + "name": "KAGENT_URL", + "value": "http://kagent-controller.kagent:8083" + } + ], + "image": "cr.kagent.dev/kagent-dev/kagent/app:dev", + "imagePullPolicy": "IfNotPresent", + "name": "kagent", + "ports": [ + { + "containerPort": 8080, + "name": "http" + } + ], + "readinessProbe": { + "httpGet": { + "path": "/health", + "port": "http" + }, + "initialDelaySeconds": 15, + "periodSeconds": 15, + "timeoutSeconds": 15 + }, + "resources": { + "limits": { + "cpu": "3", + "memory": "2Gi" + }, + "requests": { + "cpu": "200m", + "memory": "684Mi" + } + }, + "securityContext": { + "privileged": true + }, + "volumeMounts": [ + { + "mountPath": "/config", + "name": "config" + }, + { + "mountPath": "/var/run/secrets/tokens", + "name": "kagent-token" + } + ] + } + ], + "serviceAccountName": "agent-with-code", + "volumes": [ + { + "name": "config", + "secret": { + "secretName": "agent-with-code" + } + }, + { + "name": "kagent-token", + "projected": { + "sources": [ + { + "serviceAccountToken": { + "audience": "kagent", + "expirationSeconds": 3600, + "path": "kagent-token" + } + } + ] + } + } + ] + } + } + }, + "status": {} + }, + { + "apiVersion": "v1", + "kind": "Service", + "metadata": { + "labels": { + "app": "kagent", + "app.kubernetes.io/managed-by": "kagent", + "app.kubernetes.io/name": "agent-with-code", + "app.kubernetes.io/part-of": "kagent", + "kagent": "agent-with-code" + }, + "name": "agent-with-code", + "namespace": "test", + "ownerReferences": [ + { + "apiVersion": "kagent.dev/v1alpha2", + "blockOwnerDeletion": true, + "controller": true, + "kind": "Agent", + "name": "agent-with-code", + "uid": "" + } + ] + }, + "spec": { + "ports": [ + { + "name": "http", + "port": 8080, + "targetPort": 8080 + } + ], + "selector": { + "app": "kagent", + "kagent": "agent-with-code" + }, + "type": "ClusterIP" + }, + "status": { + "loadBalancer": {} + } + } + ] +} \ No newline at end of file diff --git a/go/internal/controller/translator/agent/testdata/outputs/agent_with_http_toolserver.json b/go/internal/controller/translator/agent/testdata/outputs/agent_with_http_toolserver.json index a62852c23..f7cb1ec40 100644 --- a/go/internal/controller/translator/agent/testdata/outputs/agent_with_http_toolserver.json +++ b/go/internal/controller/translator/agent/testdata/outputs/agent_with_http_toolserver.json @@ -123,7 +123,6 @@ ] }, "spec": { - "replicas": 1, "selector": { "matchLabels": { "app": "kagent", @@ -154,7 +153,6 @@ "containers": [ { "args": [ - "static", "--host", "0.0.0.0", "--port", @@ -162,9 +160,6 @@ "--filepath", "/config" ], - "command": [ - "kagent-adk" - ], "env": [ { "name": "OPENAI_API_KEY", diff --git a/go/internal/controller/translator/agent/testdata/outputs/agent_with_mcp_service.json b/go/internal/controller/translator/agent/testdata/outputs/agent_with_mcp_service.json index 844707069..891aecc40 100644 --- a/go/internal/controller/translator/agent/testdata/outputs/agent_with_mcp_service.json +++ b/go/internal/controller/translator/agent/testdata/outputs/agent_with_mcp_service.json @@ -119,7 +119,6 @@ ] }, "spec": { - "replicas": 1, "selector": { "matchLabels": { "app": "kagent", @@ -150,7 +149,6 @@ "containers": [ { "args": [ - "static", "--host", "0.0.0.0", "--port", @@ -158,9 +156,6 @@ "--filepath", "/config" ], - "command": [ - "kagent-adk" - ], "env": [ { "name": "OPENAI_API_KEY", diff --git a/go/internal/controller/translator/agent/testdata/outputs/agent_with_nested_agent.json b/go/internal/controller/translator/agent/testdata/outputs/agent_with_nested_agent.json index ee201a8e7..8f99ef2e8 100644 --- a/go/internal/controller/translator/agent/testdata/outputs/agent_with_nested_agent.json +++ b/go/internal/controller/translator/agent/testdata/outputs/agent_with_nested_agent.json @@ -117,7 +117,6 @@ ] }, "spec": { - "replicas": 1, "selector": { "matchLabels": { "app": "kagent", @@ -148,7 +147,6 @@ "containers": [ { "args": [ - "static", "--host", "0.0.0.0", "--port", @@ -156,9 +154,6 @@ "--filepath", "/config" ], - "command": [ - "kagent-adk" - ], "env": [ { "name": "OPENAI_API_KEY", diff --git a/go/internal/controller/translator/agent/testdata/outputs/agent_with_skills.json b/go/internal/controller/translator/agent/testdata/outputs/agent_with_skills.json new file mode 100644 index 000000000..5abc91e1d --- /dev/null +++ b/go/internal/controller/translator/agent/testdata/outputs/agent_with_skills.json @@ -0,0 +1,340 @@ +{ + "agentCard": { + "capabilities": { + "pushNotifications": false, + "stateTransitionHistory": true, + "streaming": true + }, + "defaultInputModes": [ + "text" + ], + "defaultOutputModes": [ + "text" + ], + "description": "", + "name": "skills_agent", + "skills": null, + "url": "http://skills-agent.test:8080", + "version": "" + }, + "config": { + "description": "", + "http_tools": null, + "instruction": "You are a helpful assistant.", + "model": { + "base_url": "", + "headers": { + "User-Agent": "kagent/1.0" + }, + "max_tokens": 1024, + "model": "gpt-4o", + "reasoning_effort": "low", + "temperature": 0.7, + "top_p": 0.95, + "type": "openai" + }, + "remote_agents": null, + "sse_tools": null + }, + "manifest": [ + { + "apiVersion": "v1", + "kind": "Secret", + "metadata": { + "labels": { + "app": "kagent", + "app.kubernetes.io/managed-by": "kagent", + "app.kubernetes.io/name": "skills-agent", + "app.kubernetes.io/part-of": "kagent", + "kagent": "skills-agent" + }, + "name": "skills-agent", + "namespace": "test", + "ownerReferences": [ + { + "apiVersion": "kagent.dev/v1alpha2", + "blockOwnerDeletion": true, + "controller": true, + "kind": "Agent", + "name": "skills-agent", + "uid": "" + } + ] + }, + "stringData": { + "agent-card.json": "{\"name\":\"skills_agent\",\"description\":\"\",\"url\":\"http://skills-agent.test:8080\",\"version\":\"\",\"capabilities\":{\"streaming\":true,\"pushNotifications\":false,\"stateTransitionHistory\":true},\"defaultInputModes\":[\"text\"],\"defaultOutputModes\":[\"text\"],\"skills\":[]}", + "config.json": "{\"model\":{\"type\":\"openai\",\"model\":\"gpt-4o\",\"headers\":{\"User-Agent\":\"kagent/1.0\"},\"base_url\":\"\",\"max_tokens\":1024,\"reasoning_effort\":\"low\",\"temperature\":0.7,\"top_p\":0.95},\"description\":\"\",\"instruction\":\"You are a helpful assistant.\",\"http_tools\":null,\"sse_tools\":null,\"remote_agents\":null}" + } + }, + { + "apiVersion": "v1", + "kind": "ServiceAccount", + "metadata": { + "labels": { + "app": "kagent", + "app.kubernetes.io/managed-by": "kagent", + "app.kubernetes.io/name": "skills-agent", + "app.kubernetes.io/part-of": "kagent", + "kagent": "skills-agent" + }, + "name": "skills-agent", + "namespace": "test", + "ownerReferences": [ + { + "apiVersion": "kagent.dev/v1alpha2", + "blockOwnerDeletion": true, + "controller": true, + "kind": "Agent", + "name": "skills-agent", + "uid": "" + } + ] + } + }, + { + "apiVersion": "apps/v1", + "kind": "Deployment", + "metadata": { + "labels": { + "app": "kagent", + "app.kubernetes.io/managed-by": "kagent", + "app.kubernetes.io/name": "skills-agent", + "app.kubernetes.io/part-of": "kagent", + "kagent": "skills-agent" + }, + "name": "skills-agent", + "namespace": "test", + "ownerReferences": [ + { + "apiVersion": "kagent.dev/v1alpha2", + "blockOwnerDeletion": true, + "controller": true, + "kind": "Agent", + "name": "skills-agent", + "uid": "" + } + ] + }, + "spec": { + "selector": { + "matchLabels": { + "app": "kagent", + "kagent": "skills-agent" + } + }, + "strategy": { + "rollingUpdate": { + "maxSurge": 1, + "maxUnavailable": 0 + }, + "type": "RollingUpdate" + }, + "template": { + "metadata": { + "annotations": { + "kagent.dev/config-hash": "11542988705138343495" + }, + "labels": { + "app": "kagent", + "app.kubernetes.io/managed-by": "kagent", + "app.kubernetes.io/name": "skills-agent", + "app.kubernetes.io/part-of": "kagent", + "kagent": "skills-agent" + } + }, + "spec": { + "containers": [ + { + "args": [ + "--host", + "0.0.0.0", + "--port", + "8080", + "--filepath", + "/config" + ], + "env": [ + { + "name": "OPENAI_API_KEY", + "valueFrom": { + "secretKeyRef": { + "key": "api-key", + "name": "openai-secret" + } + } + }, + { + "name": "KAGENT_NAMESPACE", + "valueFrom": { + "fieldRef": { + "fieldPath": "metadata.namespace" + } + } + }, + { + "name": "KAGENT_NAME", + "valueFrom": { + "fieldRef": { + "fieldPath": "spec.serviceAccountName" + } + } + }, + { + "name": "KAGENT_URL", + "value": "http://kagent-controller.kagent:8083" + }, + { + "name": "KAGENT_SKILLS_FOLDER", + "value": "/skills" + } + ], + "image": "cr.kagent.dev/kagent-dev/kagent/app:dev", + "imagePullPolicy": "IfNotPresent", + "name": "kagent", + "ports": [ + { + "containerPort": 8080, + "name": "http" + } + ], + "readinessProbe": { + "httpGet": { + "path": "/health", + "port": "http" + }, + "initialDelaySeconds": 15, + "periodSeconds": 15, + "timeoutSeconds": 15 + }, + "resources": { + "limits": { + "cpu": "3", + "memory": "2Gi" + }, + "requests": { + "cpu": "200m", + "memory": "684Mi" + } + }, + "securityContext": { + "privileged": true + }, + "volumeMounts": [ + { + "mountPath": "/config", + "name": "config" + }, + { + "mountPath": "/skills", + "name": "kagent-skills", + "readOnly": true + }, + { + "mountPath": "/var/run/secrets/tokens", + "name": "kagent-token" + } + ] + } + ], + "initContainers": [ + { + "args": [ + "foo:latest" + ], + "command": [ + "kagent-adk", + "pull-skills" + ], + "env": [ + { + "name": "KAGENT_SKILLS_FOLDER", + "value": "/skills" + } + ], + "image": "cr.kagent.dev/kagent-dev/kagent/app:dev", + "name": "skills-init", + "resources": {}, + "volumeMounts": [ + { + "mountPath": "/skills", + "name": "kagent-skills" + } + ] + } + ], + "serviceAccountName": "skills-agent", + "volumes": [ + { + "name": "config", + "secret": { + "secretName": "skills-agent" + } + }, + { + "emptyDir": {}, + "name": "kagent-skills" + }, + { + "name": "kagent-token", + "projected": { + "sources": [ + { + "serviceAccountToken": { + "audience": "kagent", + "expirationSeconds": 3600, + "path": "kagent-token" + } + } + ] + } + } + ] + } + } + }, + "status": {} + }, + { + "apiVersion": "v1", + "kind": "Service", + "metadata": { + "labels": { + "app": "kagent", + "app.kubernetes.io/managed-by": "kagent", + "app.kubernetes.io/name": "skills-agent", + "app.kubernetes.io/part-of": "kagent", + "kagent": "skills-agent" + }, + "name": "skills-agent", + "namespace": "test", + "ownerReferences": [ + { + "apiVersion": "kagent.dev/v1alpha2", + "blockOwnerDeletion": true, + "controller": true, + "kind": "Agent", + "name": "skills-agent", + "uid": "" + } + ] + }, + "spec": { + "ports": [ + { + "name": "http", + "port": 8080, + "targetPort": 8080 + } + ], + "selector": { + "app": "kagent", + "kagent": "skills-agent" + }, + "type": "ClusterIP" + }, + "status": { + "loadBalancer": {} + } + } + ] +} \ No newline at end of file diff --git a/go/internal/controller/translator/agent/testdata/outputs/agent_with_system_message_from_configmap.json b/go/internal/controller/translator/agent/testdata/outputs/agent_with_system_message_from_configmap.json index 3a84a7b8e..08a39ae77 100644 --- a/go/internal/controller/translator/agent/testdata/outputs/agent_with_system_message_from_configmap.json +++ b/go/internal/controller/translator/agent/testdata/outputs/agent_with_system_message_from_configmap.json @@ -109,7 +109,6 @@ ] }, "spec": { - "replicas": 1, "selector": { "matchLabels": { "app": "kagent", @@ -140,7 +139,6 @@ "containers": [ { "args": [ - "static", "--host", "0.0.0.0", "--port", @@ -148,9 +146,6 @@ "--filepath", "/config" ], - "command": [ - "kagent-adk" - ], "env": [ { "name": "OPENAI_API_KEY", diff --git a/go/internal/controller/translator/agent/testdata/outputs/agent_with_system_message_from_secret.json b/go/internal/controller/translator/agent/testdata/outputs/agent_with_system_message_from_secret.json index 9b833e2cd..df54ed633 100644 --- a/go/internal/controller/translator/agent/testdata/outputs/agent_with_system_message_from_secret.json +++ b/go/internal/controller/translator/agent/testdata/outputs/agent_with_system_message_from_secret.json @@ -109,7 +109,6 @@ ] }, "spec": { - "replicas": 1, "selector": { "matchLabels": { "app": "kagent", @@ -140,7 +139,6 @@ "containers": [ { "args": [ - "static", "--host", "0.0.0.0", "--port", @@ -148,9 +146,6 @@ "--filepath", "/config" ], - "command": [ - "kagent-adk" - ], "env": [ { "name": "OPENAI_API_KEY", diff --git a/go/internal/controller/translator/agent/testdata/outputs/anthropic_agent.json b/go/internal/controller/translator/agent/testdata/outputs/anthropic_agent.json index 99230c0f0..51a86c73f 100644 --- a/go/internal/controller/translator/agent/testdata/outputs/anthropic_agent.json +++ b/go/internal/controller/translator/agent/testdata/outputs/anthropic_agent.json @@ -110,7 +110,6 @@ ] }, "spec": { - "replicas": 1, "selector": { "matchLabels": { "app": "kagent", @@ -141,7 +140,6 @@ "containers": [ { "args": [ - "static", "--host", "0.0.0.0", "--port", @@ -149,9 +147,6 @@ "--filepath", "/config" ], - "command": [ - "kagent-adk" - ], "env": [ { "name": "ANTHROPIC_API_KEY", diff --git a/go/internal/controller/translator/agent/testdata/outputs/basic_agent.json b/go/internal/controller/translator/agent/testdata/outputs/basic_agent.json index fdcac6b0a..ba2d701b2 100644 --- a/go/internal/controller/translator/agent/testdata/outputs/basic_agent.json +++ b/go/internal/controller/translator/agent/testdata/outputs/basic_agent.json @@ -116,7 +116,6 @@ ] }, "spec": { - "replicas": 1, "selector": { "matchLabels": { "app": "kagent", @@ -147,7 +146,6 @@ "containers": [ { "args": [ - "static", "--host", "0.0.0.0", "--port", @@ -155,9 +153,6 @@ "--filepath", "/config" ], - "command": [ - "kagent-adk" - ], "env": [ { "name": "OPENAI_API_KEY", diff --git a/go/internal/controller/translator/agent/testdata/outputs/ollama_agent.json b/go/internal/controller/translator/agent/testdata/outputs/ollama_agent.json index a7f2b07b2..3235735f1 100644 --- a/go/internal/controller/translator/agent/testdata/outputs/ollama_agent.json +++ b/go/internal/controller/translator/agent/testdata/outputs/ollama_agent.json @@ -111,7 +111,6 @@ ] }, "spec": { - "replicas": 1, "selector": { "matchLabels": { "app": "kagent", @@ -142,7 +141,6 @@ "containers": [ { "args": [ - "static", "--host", "0.0.0.0", "--port", @@ -150,9 +148,6 @@ "--filepath", "/config" ], - "command": [ - "kagent-adk" - ], "env": [ { "name": "OLLAMA_API_BASE", diff --git a/go/internal/controller/translator/mutate.go b/go/internal/controller/translator/mutate.go index 2271b0560..8a820d269 100644 --- a/go/internal/controller/translator/mutate.go +++ b/go/internal/controller/translator/mutate.go @@ -92,7 +92,10 @@ func mutateDeployment(existing, desired *appsv1.Deployment) error { existing.Spec.MinReadySeconds = desired.Spec.MinReadySeconds existing.Spec.Paused = desired.Spec.Paused existing.Spec.ProgressDeadlineSeconds = desired.Spec.ProgressDeadlineSeconds - existing.Spec.Replicas = desired.Spec.Replicas + if desired.Spec.Replicas != nil { + // only set replicas if explicitly specified, so not to override HPA settings + existing.Spec.Replicas = desired.Spec.Replicas + } existing.Spec.RevisionHistoryLimit = desired.Spec.RevisionHistoryLimit existing.Spec.Strategy = desired.Spec.Strategy diff --git a/go/test/e2e/agents/kebab/agent.yaml b/go/test/e2e/agents/kebab/agent.yaml index 025d92c77..2610f00ba 100644 --- a/go/test/e2e/agents/kebab/agent.yaml +++ b/go/test/e2e/agents/kebab/agent.yaml @@ -7,4 +7,4 @@ spec: type: BYO byo: deployment: - image: localhost:5001/kebab:latest + image: localhost:5001/kebab:latest \ No newline at end of file diff --git a/go/test/e2e/agents/kebab/kebab/agent.py b/go/test/e2e/agents/kebab/kebab/agent.py index 560e8148d..21d16b2a9 100644 --- a/go/test/e2e/agents/kebab/kebab/agent.py +++ b/go/test/e2e/agents/kebab/kebab/agent.py @@ -1,4 +1,3 @@ - import logging import random @@ -10,38 +9,39 @@ logger = logging.getLogger(__name__) + class KebabAgent(BaseAgent): + def __init__(self): + super().__init__( + name="kebab_agent", + description="Kebab agent that responds with 'kebab' when invoked.", + ) + + @override + async def _run_async_impl( + self, ctx: InvocationContext + ) -> AsyncGenerator[Event, None]: + """Core logic to run this agent via text-based conversation. + + Args: + ctx: InvocationContext, the invocation context for this agent. + + Yields: + Event: the events generated by the agent. + """ + session = ctx.session + text = f"kebab for {session.user_id} in session {session.id} " + + logger.info(f"Generating response: {text}") + + model_response_event = Event( + id=Event.new_id(), + invocation_id=ctx.invocation_id, + author=ctx.agent.name, + branch=ctx.branch, + content=types.ModelContent(parts=[types.Part.from_text(text=text)]), + ) + yield model_response_event - def __init__(self): - super().__init__( - name="kebab_agent", - description="Kebab agent that responds with 'kebab' when invoked.", - ) - - @override - async def _run_async_impl( - self, ctx: InvocationContext - ) -> AsyncGenerator[Event, None]: - """Core logic to run this agent via text-based conversation. - - Args: - ctx: InvocationContext, the invocation context for this agent. - - Yields: - Event: the events generated by the agent. - """ - session = ctx.session - text = f"kebab for {session.user_id} in session {session.id} " - - logger.info(f"Generating response: {text}") - - model_response_event = Event( - id=Event.new_id(), - invocation_id=ctx.invocation_id, - author=ctx.agent.name, - branch=ctx.branch, - content=types.ModelContent(parts=[types.Part.from_text(text=text)]), - ) - yield model_response_event root_agent = KebabAgent() diff --git a/go/test/e2e/invoke_api_test.go b/go/test/e2e/invoke_api_test.go index d10ead99a..bd5fc097e 100644 --- a/go/test/e2e/invoke_api_test.go +++ b/go/test/e2e/invoke_api_test.go @@ -18,6 +18,7 @@ import ( k8s_runtime "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/util/wait" "k8s.io/client-go/util/retry" + "k8s.io/utils/ptr" "sigs.k8s.io/controller-runtime/pkg/client" "github.com/kagent-dev/kagent/go/api/v1alpha2" @@ -116,6 +117,8 @@ type AgentOptions struct { SystemMessage string Stream *bool Env []corev1.EnvVar + Skills *v1alpha2.SkillForAgent + ExecuteCode *bool } // setupAgentWithOptions creates and returns an agent resource with custom options @@ -315,10 +318,21 @@ func generateAgent(tools []*v1alpha2.Tool, opts AgentOptions) *v1alpha2.Agent { Spec: v1alpha2.AgentSpec{ Type: v1alpha2.AgentType_Declarative, Declarative: &v1alpha2.DeclarativeAgentSpec{ - ModelConfig: "test-model-config", - SystemMessage: systemMessage, - Tools: tools, + ModelConfig: "test-model-config", + SystemMessage: systemMessage, + Tools: tools, + ExecuteCodeBlocks: opts.ExecuteCode, + Deployment: &v1alpha2.DeclarativeDeploymentSpec{ + SharedDeploymentSpec: v1alpha2.SharedDeploymentSpec{ + ImagePullPolicy: corev1.PullAlways, + Env: []corev1.EnvVar{{ + Name: "LOG_LEVEL", + Value: "DEBUG", + }}, + }, + }, }, + Skills: opts.Skills, }, } @@ -705,3 +719,58 @@ func TestE2EInvokeSTSIntegration(t *testing.T) { require.Equal(t, subjectToken, stsRequest.SubjectToken) }) } + +func TestE2EInvokeSkillInAgent(t *testing.T) { + // Setup mock server + baseURL, stopServer := setupMockServer(t, "mocks/invoke_skill.json") + defer stopServer() + + // Setup Kubernetes client + cli := setupK8sClient(t, false) + + // Setup specific resources + modelCfg := setupModelConfig(t, cli, baseURL) + agent := setupAgentWithOptions(t, cli, nil, AgentOptions{ + Skills: &v1alpha2.SkillForAgent{ + InsecureSkipVerify: true, + Refs: []string{"kind-registry:5000/kebab-maker:latest"}, + }, + }) + + defer func() { + cli.Delete(t.Context(), agent) //nolint:errcheck + cli.Delete(t.Context(), modelCfg) //nolint:errcheck + }() + + // Setup A2A client + a2aClient := setupA2AClient(t) + + // Run tests + runSyncTest(t, a2aClient, "make me a kebab", "Pick it up from around the corner", nil) +} + +func TestE2EIAgentRunsCode(t *testing.T) { + // Setup mock server + baseURL, stopServer := setupMockServer(t, "mocks/run_code.json") + defer stopServer() + + // Setup Kubernetes client + cli := setupK8sClient(t, false) + + // Setup specific resources + modelCfg := setupModelConfig(t, cli, baseURL) + agent := setupAgentWithOptions(t, cli, nil, AgentOptions{ + ExecuteCode: ptr.To(true), + }) + + defer func() { + cli.Delete(t.Context(), agent) //nolint:errcheck + cli.Delete(t.Context(), modelCfg) //nolint:errcheck + }() + + // Setup A2A client + a2aClient := setupA2AClient(t) + + // Run tests + runSyncTest(t, a2aClient, "write some code", "hello, world!", nil) +} diff --git a/go/test/e2e/mocks/invoke_skill.json b/go/test/e2e/mocks/invoke_skill.json new file mode 100644 index 000000000..f1b0471bc --- /dev/null +++ b/go/test/e2e/mocks/invoke_skill.json @@ -0,0 +1,102 @@ +{ + "openai": [ + { + "name": "initial_request", + "match": { + "match_type": "exact", + "message": { + "content": "make me a kebab", + "role": "user" + } + }, + "response": { + "id": "chatcmpl-1", + "object": "chat.completion", + "created": 1677652288, + "model": "gpt-4.1-mini", + "choices": [ + { + "index": 0, + "role": "assistant", + "message": { + "content": "", + "tool_calls": [ + { + "id": "call_1", + "function": { + "name": "skills", + "arguments": "{\"command\": \"kebab-maker\"}" + } + } + ] + }, + "finish_reason": "tool_calls" + } + ] + } + }, + { + "name": "skills_response", + "match": { + "match_type": "contains", + "message": { + "content": "To make a delicious kebab,", + "role": "tool", + "tool_call_id": "call_1" + } + }, + "response": { + "id": "call_2", + "object": "chat.completion", + "created": 1677652288, + "model": "gpt-4.1-mini", + "choices": [ + { + "index": 0, + "message": { + "content": "", + "tool_calls": [ + { + "id": "call_2", + "function": { + "name": "bash", + "arguments": "{\"command\": \"cd skills/kebab-maker && python scripts/make_kebab.py\"}" + } + } + ] + }, + "finish_reason": "tool_calls", + "role": "assistant" + } + ] + } + }, + { + "name": "execute_script_response", + "match": { + "match_type": "exact", + "message": { + "content": "Kebab is ready... Pick it up from around the corner.", + "role": "tool", + "tool_call_id": "call_2" + } + }, + "response": { + "id": "chatcmpl-2", + "object": "chat.completion", + "created": 1677652288, + "model": "gpt-4.1-mini", + "choices": [ + { + "index": 0, + "role": "assistant", + "message": { + "content": "I have made a kebab. pick it up around the corner", + "role": "assistant" + } + } + ] + } + } + ] +} \ No newline at end of file diff --git a/go/test/e2e/mocks/run_code.json b/go/test/e2e/mocks/run_code.json new file mode 100644 index 000000000..7383efcda --- /dev/null +++ b/go/test/e2e/mocks/run_code.json @@ -0,0 +1,29 @@ +{ + "openai": [ + { + "name": "initial_request", + "match": { + "match_type": "exact", + "message": { + "content": "write some code", + "role": "user" + } + }, + "response": { + "id": "chatcmpl-1", + "object": "chat.completion", + "created": 1677652288, + "model": "gpt-4.1-mini", + "choices": [ + { + "index": 0, + "role": "assistant", + "message": { + "content": "```python\nprint('Hello, World!'.lower())\n```" + } + } + ] + } + } + ] +} \ No newline at end of file diff --git a/go/test/e2e/testdata/skills/kebab/Dockerfile b/go/test/e2e/testdata/skills/kebab/Dockerfile new file mode 100644 index 000000000..a80b9b4f2 --- /dev/null +++ b/go/test/e2e/testdata/skills/kebab/Dockerfile @@ -0,0 +1,2 @@ +FROM scratch +COPY . / \ No newline at end of file diff --git a/go/test/e2e/testdata/skills/kebab/LICENSE.txt b/go/test/e2e/testdata/skills/kebab/LICENSE.txt new file mode 100644 index 000000000..76851c516 --- /dev/null +++ b/go/test/e2e/testdata/skills/kebab/LICENSE.txt @@ -0,0 +1 @@ +Kebab License 1.0 \ No newline at end of file diff --git a/go/test/e2e/testdata/skills/kebab/SKILL.md b/go/test/e2e/testdata/skills/kebab/SKILL.md new file mode 100644 index 000000000..3b363edc5 --- /dev/null +++ b/go/test/e2e/testdata/skills/kebab/SKILL.md @@ -0,0 +1,10 @@ +--- +name: kebab-maker +description: A skill that makes a kebab for the user. +license: Complete terms in LICENSE.txt +--- + +# Kebab maker + +To make a delicious kebab, follow these steps, run the script ``scripts/make_kebab.py`` and return its result to the user, +so the user knows where to pick up the kebab from. \ No newline at end of file diff --git a/go/test/e2e/testdata/skills/kebab/scripts/make_kebab.py b/go/test/e2e/testdata/skills/kebab/scripts/make_kebab.py new file mode 100755 index 000000000..0b5e4dd70 --- /dev/null +++ b/go/test/e2e/testdata/skills/kebab/scripts/make_kebab.py @@ -0,0 +1,4 @@ +#!/usr/bin/env python3 + + +print("Kebab is ready... Pick it up from around the corner.") diff --git a/helm/kagent-crds/templates/kagent.dev_agents.yaml b/helm/kagent-crds/templates/kagent.dev_agents.yaml index 768d6f72c..aa7f8b351 100644 --- a/helm/kagent-crds/templates/kagent.dev_agents.yaml +++ b/helm/kagent-crds/templates/kagent.dev_agents.yaml @@ -2579,10 +2579,7 @@ spec: type: string type: object replicas: - default: 1 - description: If not specified, the default value is 1. format: int32 - minimum: 1 type: integer resources: description: ResourceRequirements describes the compute resource @@ -4858,10 +4855,7 @@ spec: type: string type: object replicas: - default: 1 - description: If not specified, the default value is 1. format: int32 - minimum: 1 type: integer resources: description: ResourceRequirements describes the compute resource @@ -6887,6 +6881,12 @@ spec: type: object type: array type: object + executeCodeBlocks: + description: |- + Allow code execution for python code blocks with this agent. + If true, the agent will automatically execute python code blocks in the LLM responses. + Code will be executed in a sandboxed environment. + type: boolean modelConfig: description: |- The name of the model config to use. @@ -7026,6 +7026,24 @@ spec: rule: '!has(self.systemMessage) || !has(self.systemMessageFrom)' description: type: string + skills: + description: |- + Skills to load into the agent. They will be pulled from the specified container images. + and made available to the agent under the `/skills` folder. + properties: + insecureSkipVerify: + description: |- + Fetch images insecurely from registries (allowing HTTP and skipping TLS verification). + Meant for development and testing purposes only. + type: boolean + refs: + description: The list of skill images to fetch. + items: + type: string + maxItems: 20 + minItems: 1 + type: array + type: object type: allOf: - enum: diff --git a/python/Dockerfile b/python/Dockerfile index 6b91ff370..9168cf56e 100644 --- a/python/Dockerfile +++ b/python/Dockerfile @@ -47,6 +47,31 @@ RUN addgroup -g 1001 pythongroup && \ mkdir -p /python && \ chown -vR 1001:1001 /.kagent /python +# Install anthropic sandbox runtime and dependencies +RUN --mount=type=cache,target=/var/cache/apk,rw \ + apk add \ + nodejs npm bubblewrap socat ripgrep + +# Install sandbox runtime from a specific commit of the GitHub repo without using global prefix +# This avoids scope-related rename issues in global node_modules +# Using BuildKit cache for npm to speed up rebuilds +RUN --mount=type=cache,target=/root/.npm \ + mkdir -p /opt && \ + cd opt && \ + git clone --depth 1 --revision=771f18664f97f6606c9ed64fc65f988b3ecf2e69 https://github.com/anthropic-experimental/sandbox-runtime.git && \ + cd sandbox-runtime && \ + npm install && \ + npm run build && \ + npm install -g + +# Ensure the sandbox runtime binaries are on PATH +ENV PATH="/opt/sandbox-runtime/node_modules/.bin:$PATH" + +# Install anthropic sandbox runtime and dependencies +RUN --mount=type=cache,target=/var/cache/apk,rw \ + apk add \ + crane + USER python WORKDIR /.kagent @@ -59,7 +84,7 @@ WORKDIR /.kagent ENV PATH=$PATH:/.kagent/bin:/.kagent/.venv/bin # Copy dependency files first for better layer caching -COPY --chown=python:pythongroup pyproject.toml . +COPY --chown=python:pythongroup pyproject.toml . COPY --chown=python:pythongroup .python-version . COPY --chown=python:pythongroup uv.lock . COPY --chown=python:pythongroup packages/kagent-adk packages/kagent-adk diff --git a/python/Dockerfile.app b/python/Dockerfile.app index 994901375..8cbfac077 100644 --- a/python/Dockerfile.app +++ b/python/Dockerfile.app @@ -14,4 +14,5 @@ LABEL org.opencontainers.image.description="Kagent app is the Kagent agent runti LABEL org.opencontainers.image.authors="Kagent Creators 🤖" LABEL org.opencontainers.image.version="$VERSION" -CMD ["kagent-adk", "static", "--host", "0.0.0.0", "--port", "8080"] \ No newline at end of file +ENTRYPOINT ["kagent-adk", "static"] +CMD ["--host", "0.0.0.0", "--port", "8080"] \ No newline at end of file diff --git a/python/packages/kagent-adk/pyproject.toml b/python/packages/kagent-adk/pyproject.toml index 7b71860a6..8862a0dea 100644 --- a/python/packages/kagent-adk/pyproject.toml +++ b/python/packages/kagent-adk/pyproject.toml @@ -36,7 +36,7 @@ dependencies = [ kagent-core = {workspace = true} [project.scripts] -kagent-adk = "kagent.adk.cli:app" +kagent-adk = "kagent.adk.cli:run_cli" [project.optional-dependencies] test = [ diff --git a/python/packages/kagent-adk/src/kagent/adk/_a2a.py b/python/packages/kagent-adk/src/kagent/adk/_a2a.py index e9900f0b5..5f4ef0615 100644 --- a/python/packages/kagent-adk/src/kagent/adk/_a2a.py +++ b/python/packages/kagent-adk/src/kagent/adk/_a2a.py @@ -28,18 +28,6 @@ from ._token import KAgentTokenService -# --- Configure Logging --- -def configure_logging() -> None: - """Configure logging based on LOG_LEVEL environment variable.""" - log_level = os.getenv("LOG_LEVEL", "INFO").upper() - numeric_level = getattr(logging, log_level, logging.INFO) - logging.basicConfig( - level=numeric_level, - ) - logging.info(f"Logging configured with level: {log_level}") - - -configure_logging() logger = logging.getLogger(__name__) @@ -131,6 +119,7 @@ def create_runner() -> Runner: agent=self.root_agent, app_name=self.app_name, session_service=session_service, + artifact_service=InMemoryArtifactService(), ) agent_executor = A2aAgentExecutor( @@ -178,6 +167,7 @@ async def test(self, task: str): agent=root_agent, app_name=self.app_name, session_service=session_service, + artifact_service=InMemoryArtifactService(), ) logger.info(f"\n>>> User Query: {task}") diff --git a/python/packages/kagent-adk/src/kagent/adk/cli.py b/python/packages/kagent-adk/src/kagent/adk/cli.py index c007b2614..b56b095e9 100644 --- a/python/packages/kagent-adk/src/kagent/adk/cli.py +++ b/python/packages/kagent-adk/src/kagent/adk/cli.py @@ -10,8 +10,10 @@ from google.adk.cli.utils.agent_loader import AgentLoader from kagent.core import KAgentConfig, configure_tracing +from .skill_fetcher import fetch_skill from . import AgentConfig, KAgentApp +from .skills.skills_plugin import add_skills_tool_to_agent logger = logging.getLogger(__name__) @@ -35,6 +37,10 @@ def static( agent_card = json.load(f) agent_card = AgentCard.model_validate(agent_card) root_agent = agent_config.to_agent(app_cfg.name) + skills_directory = os.getenv("KAGENT_SKILLS_FOLDER", None) + if skills_directory: + logger.info(f"Adding skills from directory: {skills_directory}") + add_skills_tool_to_agent(skills_directory, root_agent) kagent_app = KAgentApp(root_agent, agent_card, app_cfg.url, app_cfg.app_name) @@ -50,6 +56,20 @@ def static( ) +@app.command() +def pull_skills( + skills: Annotated[list[str], typer.Argument()], + insecure: Annotated[ + bool, + typer.Option("--insecure", help="Allow insecure connections to registries"), + ] = False, +): + skill_dir = os.environ.get("KAGENT_SKILLS_FOLDER", ".") + logger.info("Pulling skills") + for skill in skills: + fetch_skill(skill, skill_dir, insecure) + + @app.command() def run( name: Annotated[str, typer.Argument(help="The name of the agent to run")], @@ -111,9 +131,19 @@ def test( asyncio.run(test_agent(agent_config, agent_card, task)) +# --- Configure Logging --- +def configure_logging() -> None: + """Configure logging based on LOG_LEVEL environment variable.""" + log_level = os.getenv("LOG_LEVEL", "INFO").upper() + logging.basicConfig( + level=log_level, + ) + logging.info(f"Logging configured with level: {log_level}") + + def run_cli(): - logging.basicConfig(level=logging.INFO) - logging.info("Starting KAgent") + configure_logging() + logger.info("Starting KAgent") app() diff --git a/python/packages/kagent-adk/src/kagent/adk/sandbox_code_executer.py b/python/packages/kagent-adk/src/kagent/adk/sandbox_code_executer.py new file mode 100644 index 000000000..9e9c3a4f6 --- /dev/null +++ b/python/packages/kagent-adk/src/kagent/adk/sandbox_code_executer.py @@ -0,0 +1,79 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import subprocess + +from pydantic import Field +from typing_extensions import override + +from google.adk.agents.invocation_context import InvocationContext +from google.adk.code_executors.base_code_executor import BaseCodeExecutor +from google.adk.code_executors.code_execution_utils import CodeExecutionInput +from google.adk.code_executors.code_execution_utils import CodeExecutionResult + + +class SandboxedLocalCodeExecutor(BaseCodeExecutor): + """A code executor that execute code in a sandbox in the current local context.""" + + # Overrides the BaseCodeExecutor attribute: this executor cannot be stateful. + stateful: bool = Field(default=False, frozen=True, exclude=True) + + # Overrides the BaseCodeExecutor attribute: this executor cannot + # optimize_data_file. + optimize_data_file: bool = Field(default=False, frozen=True, exclude=True) + + def __init__(self, **data): + """Initializes the SandboxedLocalCodeExecutor.""" + if "stateful" in data and data["stateful"]: + raise ValueError("Cannot set `stateful=True` in SandboxedLocalCodeExecutor.") + if "optimize_data_file" in data and data["optimize_data_file"]: + raise ValueError("Cannot set `optimize_data_file=True` in SandboxedLocalCodeExecutor.") + super().__init__(**data) + + @override + def execute_code( + self, + invocation_context: InvocationContext, + code_execution_input: CodeExecutionInput, + ) -> CodeExecutionResult: + """Executes the given code in a sandboxed local context. uses the srt command to sandbox""" + output = "" + error = "" + + try: + # Execute the provided code by piping it to `python -` inside the sandbox. + proc = subprocess.run( + ["srt", "python", "-"], + input=code_execution_input.code, + capture_output=True, + text=True, + ) + output = proc.stdout or "" + error = proc.stderr or "" + except FileNotFoundError as e: + # srt or python not found + output = "" + error = f"Execution failed: {e}" + except Exception as e: + output = "" + error = f"Unexpected error during execution: {e}" + + # Collect the final result. + return CodeExecutionResult( + stdout=output, + stderr=error, + output_files=[], + ) diff --git a/python/packages/kagent-adk/src/kagent/adk/skill_fetcher.py b/python/packages/kagent-adk/src/kagent/adk/skill_fetcher.py new file mode 100644 index 000000000..ad5c73048 --- /dev/null +++ b/python/packages/kagent-adk/src/kagent/adk/skill_fetcher.py @@ -0,0 +1,103 @@ +from __future__ import annotations + +import os +import logging +import tarfile +from typing import Tuple + +logger = logging.getLogger(__name__) + + +def _parse_image_ref(image: str) -> Tuple[str, str, str]: + """ + Parse an OCI/Docker image reference into (registry, repository, reference). + + reference is either a tag (default "latest") or a digest (e.g., "sha256:..."). + Rules (compatible with Docker/OCI name parsing): + - If the reference contains a digest ("@"), prefer a tag if also present (repo:tag@digest), + otherwise keep the digest as the reference. + - If there is no tag nor digest, default the reference to "latest". + - If the first path component contains a '.' or ':' or equals 'localhost', it is treated as the registry. + Otherwise the registry defaults to docker hub (docker.io), with the special library namespace for single-component names. + """ + name_part = image + ref = "latest" + + if "@" in image: + # Split digest + name_part, digest = image.split("@", 1) + ref = digest + + # Possibly has a tag: detect a colon after the last slash + slash = name_part.rfind("/") + colon = name_part.rfind(":") + if colon > slash: + ref = name_part[colon + 1 :] + name_part = name_part[:colon] + # else: keep default "latest" + + # Determine registry and repo path + parts = name_part.split("/") + if len(parts) == 1: + # Implicit docker hub library image + registry = "registry-1.docker.io" + repo = f"library/{parts[0]}" + else: + first = parts[0] + if first == "localhost" or "." in first or ":" in first: + # Explicit registry (may include port) + registry = first + repo = "/".join(parts[1:]) + else: + # Docker hub with user/org namespace + registry = "docker.io" + repo = "/".join(parts) + + return registry, repo, ref + + +def fetch_using_crane_to_dir(image: str, destination_folder: str, insecure: bool = False) -> None: + """Fetch a skill using crane and extract it to destination_folder.""" + import subprocess + + tar_path = os.path.join(destination_folder, "skill.tar") + os.makedirs(destination_folder, exist_ok=True) + command = ["crane", "export", image, tar_path] + if insecure: + command.insert(1, "--insecure") + # Use crane to pull the image as a tarball + subprocess.run( + command, + check=True, + ) + + # Extract the tarball + with tarfile.open(tar_path, "r") as tar: + tar.extractall(path=destination_folder, filter=tarfile.data_filter) + + # Remove the tarball + os.remove(tar_path) + + +def fetch_skill(skill_image: str, destination_folder: str, insecure: bool = False) -> None: + """ + Fetch a skill packaged as an OCI/Docker image and write its files to destination_folder. + + To build a compatible skill image from a folder (containing SKILL.md), use a simple Dockerfile: + FROM scratch + COPY . / + + Args: + skill_image: The image reference (e.g., "alpine:latest", "ghcr.io/org/skill:tag", or with a digest). + destination_folder: The folder where the skill files should be written. + """ + registry, repo, ref = _parse_image_ref(skill_image) + + # skill name is the last part of the repo + repo_parts = repo.split("/") + skill_name = repo_parts[-1] + logger.info( + f"about to fetching skill {skill_name} from image {skill_image} (registry: {registry}, repo: {repo}, ref: {ref})" + ) + + fetch_using_crane_to_dir(skill_image, os.path.join(destination_folder, skill_name), insecure) diff --git a/python/packages/kagent-adk/src/kagent/adk/skills/skill_tool.py b/python/packages/kagent-adk/src/kagent/adk/skills/skill_tool.py index ae5f86d06..95684ce74 100644 --- a/python/packages/kagent-adk/src/kagent/adk/skills/skill_tool.py +++ b/python/packages/kagent-adk/src/kagent/adk/skills/skill_tool.py @@ -193,7 +193,7 @@ def _format_skill_content(self, skill_name: str, content: str) -> str: """Format skill content for display to the agent.""" header = ( f'The "{skill_name}" skill is loading\n\n' - f"Base directory for this skill: skills/{skill_name}\n\n" + f"Base directory for this skill: {self.skills_directory}/{skill_name}\n\n" ) footer = ( "\n\n---\n" diff --git a/python/packages/kagent-adk/src/kagent/adk/skills/skills_plugin.py b/python/packages/kagent-adk/src/kagent/adk/skills/skills_plugin.py index 70479622d..64680cadd 100644 --- a/python/packages/kagent-adk/src/kagent/adk/skills/skills_plugin.py +++ b/python/packages/kagent-adk/src/kagent/adk/skills/skills_plugin.py @@ -64,27 +64,35 @@ def __init__(self, skills_directory: str | Path, name: str = "skills_plugin"): """ super().__init__(name) self.skills_directory = Path(skills_directory) - self.skills_invoke_tool = SkillsTool(skills_directory) - self.bash_tool = BashTool(skills_directory) async def before_agent_callback( self, *, agent: BaseAgent, callback_context: CallbackContext ) -> Optional[types.Content]: """Add skills tools to agents if not already present.""" - if not isinstance(agent, LlmAgent): - return None + add_skills_tool_to_agent(self.skills_directory, agent) - existing_tool_names = {getattr(t, "name", None) for t in agent.tools} - # Add SkillsTool if not already present - if "skills" not in existing_tool_names: - agent.tools.append(self.skills_invoke_tool) - logger.debug(f"Added skills invoke tool to agent: {agent.name}") +def add_skills_tool_to_agent(skills_directory: str | Path, agent: BaseAgent) -> None: + """Utility function to add Skills and Bash tools to a given agent. - # Add BashTool if not already present - if "bash" not in existing_tool_names: - agent.tools.append(self.bash_tool) - logger.debug(f"Added bash tool to agent: {agent.name}") + Args: + agent: The LlmAgent instance to which the tools will be added. + skills_directory: Path to directory containing skill folders. + """ + + if not isinstance(agent, LlmAgent): + return + + skills_directory = Path(skills_directory) + existing_tool_names = {getattr(t, "name", None) for t in agent.tools} + + # Add SkillsTool if not already present + if "skills" not in existing_tool_names: + agent.tools.append(SkillsTool(skills_directory)) + logger.debug(f"Added skills invoke tool to agent: {agent.name}") - return None + # Add BashTool if not already present + if "bash" not in existing_tool_names: + agent.tools.append(BashTool(skills_directory)) + logger.debug(f"Added bash tool to agent: {agent.name}") diff --git a/python/packages/kagent-adk/src/kagent/adk/types.py b/python/packages/kagent-adk/src/kagent/adk/types.py index a310dc6e7..308fb3748 100644 --- a/python/packages/kagent-adk/src/kagent/adk/types.py +++ b/python/packages/kagent-adk/src/kagent/adk/types.py @@ -11,7 +11,10 @@ from google.adk.models.lite_llm import LiteLlm from google.adk.tools.agent_tool import AgentTool from google.adk.tools.mcp_tool import MCPToolset, SseConnectionParams, StreamableHTTPConnectionParams +from google.adk.code_executors.base_code_executor import BaseCodeExecutor +from kagent.adk.sandbox_code_executer import SandboxedLocalCodeExecutor from pydantic import BaseModel, Field +from typing import Optional from .models import AzureOpenAI as OpenAIAzure from .models import OpenAI as OpenAINative @@ -92,6 +95,7 @@ class AgentConfig(BaseModel): http_tools: list[HttpMcpServerConfig] | None = None # Streamable HTTP MCP tools sse_tools: list[SseMcpServerConfig] | None = None # SSE MCP tools remote_agents: list[RemoteAgentConfig] | None = None # remote agents + execute_code: bool | None = None def to_agent(self, name: str) -> Agent: if name is None or not str(name).strip(): @@ -123,6 +127,8 @@ def to_agent(self, name: str) -> Agent: extra_headers = self.model.headers or {} + code_executor = SandboxedLocalCodeExecutor() if self.execute_code else None + if self.model.type == "openai": model = OpenAINative( type="openai", @@ -161,4 +167,5 @@ def to_agent(self, name: str) -> Agent: description=self.description, instruction=self.instruction, tools=tools, + code_executor=code_executor, ) diff --git a/python/packages/kagent-adk/tests/unittests/models/test_openai.py b/python/packages/kagent-adk/tests/unittests/models/test_openai.py index a1d12dceb..ae68bb096 100644 --- a/python/packages/kagent-adk/tests/unittests/models/test_openai.py +++ b/python/packages/kagent-adk/tests/unittests/models/test_openai.py @@ -76,7 +76,7 @@ def generate_llm_response(): @pytest.fixture def openai_llm(): - return OpenAI(model="gpt-3.5-turbo", type="openai") + return OpenAI(model="gpt-3.5-turbo", type="openai", api_key="fake") @pytest.fixture @@ -323,7 +323,7 @@ async def mock_coro(*args, **kwargs): @pytest.mark.asyncio async def test_generate_content_async_with_max_tokens(llm_request, generate_content_response, generate_llm_response): - openai_llm = OpenAI(model="gpt-3.5-turbo", max_tokens=4096, type="openai") + openai_llm = OpenAI(model="gpt-3.5-turbo", max_tokens=4096, type="openai", api_key="fake") with mock.patch.object(openai_llm, "_client") as mock_client: # Create a mock coroutine that returns the generate_content_response. async def mock_coro(*args, **kwargs): diff --git a/python/packages/kagent-adk/tests/unittests/test_skill_fetcher_parse.py b/python/packages/kagent-adk/tests/unittests/test_skill_fetcher_parse.py new file mode 100644 index 000000000..66123f309 --- /dev/null +++ b/python/packages/kagent-adk/tests/unittests/test_skill_fetcher_parse.py @@ -0,0 +1,55 @@ +import os +import sys +from pathlib import Path +import pytest + +# Ensure the package's src/ is on sys.path for "src" layout +_PKG_ROOT = Path(__file__).resolve().parents[2] # .../packages/kagent-adk +_SRC = _PKG_ROOT / "src" +if str(_SRC) not in sys.path: + sys.path.insert(0, str(_SRC)) + +from kagent.adk.skill_fetcher import _parse_image_ref # noqa: E402 + + +@pytest.mark.parametrize( + "image,expected", + [ + # Docker Hub implicit library and latest tag + ("alpine", ("registry-1.docker.io", "library/alpine", "latest")), + ("ubuntu", ("registry-1.docker.io", "library/ubuntu", "latest")), + # Explicit tag on Docker Hub implicit library + ("alpine:3.19", ("registry-1.docker.io", "library/alpine", "3.19")), + # User namespace on Docker Hub + ("user/image", ("docker.io", "user/image", "latest")), + ("user/image:tag", ("docker.io", "user/image", "tag")), + # Fully-qualified registry without tag -> default latest + ("ghcr.io/org/skill", ("ghcr.io", "org/skill", "latest")), + ("ghcr.io/org/skill:1.2.3", ("ghcr.io", "org/skill", "1.2.3")), + # Digest reference + ( + "ghcr.io/org/skill@sha256:abcdef", + ("ghcr.io", "org/skill", "sha256:abcdef"), + ), + # Tag + digest present: keep the tag as ref (current behavior) + ( + "ghcr.io/org/skill:1@sha256:abcdef", + ("ghcr.io", "org/skill", "1"), + ), + # Registry with port + ( + "registry.example.com:5000/repo/image:tag", + ("registry.example.com:5000", "repo/image", "tag"), + ), + ( + "registry.example.com:5000/repo/image", + ("registry.example.com:5000", "repo/image", "latest"), + ), + ( + "localhost:5000/image", + ("localhost:5000", "image", "latest"), + ), + ], +) +def test_parse_image_ref(image, expected): + assert _parse_image_ref(image) == expected