diff --git a/Makefile b/Makefile index a3b680d841..60962a4ac7 100644 --- a/Makefile +++ b/Makefile @@ -9,6 +9,9 @@ HELM_REPO ?= oci://ghcr.io/kagent-dev HELM_DIST_FOLDER ?= dist HELM_SOURCE_DIR ?= helm RUNTIME_IMAGE_REPOSITORY ?= ghcr.io/kagent-dev/kagent +# The kagent-tools image whose skills the chart's example Agents mount. Its tag +# is the kagent-tools chart version that helm/kagent depends on. +TOOLS_IMAGE_REPOSITORY ?= ghcr.io/kagent-dev/kagent/tools # Release packaging requires an explicit metadata directory; local builds have a default. RUNTIME_BUILD_METADATA_DIR = $(or $(RUNTIME_IMAGE_METADATA_DIR),$(CURDIR)/build/runtime-images) RUNTIME_IMAGE_METADATA_FILE = $(RUNTIME_BUILD_METADATA_DIR)/$(patsubst build-%,%,$@).json @@ -619,9 +622,15 @@ helm-release: ## Package release charts from RUNTIME_IMAGE_METADATA_DIR without @test -n "$(RUNTIME_IMAGE_METADATA_DIR)" || { echo "RUNTIME_IMAGE_METADATA_DIR is required for release chart packaging" >&2; exit 1; } bash scripts/package-release-charts.sh "$(VERSION)" "$(RUNTIME_IMAGE_METADATA_DIR)" "$(HELM_SOURCE_DIR)" "$(HELM_DIST_FOLDER)" "$(RUNTIME_IMAGE_REPOSITORY)" +.PHONY: tools-image-metadata +tools-image-metadata: ## Record the digest of the kagent-tools image the chart pins in RUNTIME_IMAGE_METADATA_DIR + @test -n "$(RUNTIME_IMAGE_METADATA_DIR)" || { echo "RUNTIME_IMAGE_METADATA_DIR is required for release chart packaging" >&2; exit 1; } + bash scripts/tools-image-metadata.sh "$(HELM_SOURCE_DIR)/kagent/Chart-template.yaml" "$(TOOLS_IMAGE_REPOSITORY)" "$(RUNTIME_IMAGE_METADATA_DIR)/kagent-tools.json" + .PHONY: helm-publish helm-publish: ## Package and push all Helm charts to the OCI registry -helm-publish: helm-release +helm-publish: tools-image-metadata + $(MAKE) --no-print-directory helm-release helm push "$(HELM_DIST_FOLDER)/kagent-crds-$(VERSION).tgz" $(HELM_REPO)/kagent/helm helm push "$(HELM_DIST_FOLDER)/kagent-$(VERSION).tgz" $(HELM_REPO)/kagent/helm diff --git a/docs/architecture/configuration-and-compilation.md b/docs/architecture/configuration-and-compilation.md index 1ea1a26da0..5c447f0a89 100644 --- a/docs/architecture/configuration-and-compilation.md +++ b/docs/architecture/configuration-and-compilation.md @@ -95,6 +95,16 @@ Local installations can override individual defaults with `config.harnessImages`; `global.imageRegistry` rewrites both packaged defaults and explicit overrides. +The chart's optional example Agents mount skills from the kagent-tools image. +kagent-tools releases from its own repository, so no build in this repository +records the image digest. Before packaging, `make helm-publish` runs `make +tools-image-metadata`. That target looks up the digest of the kagent-tools +version in the chart's dependencies and writes it to +`RUNTIME_IMAGE_METADATA_DIR/kagent-tools.json`. If that file exists, `make +helm-release` validates it and packages it as `files/kagent-tools-image.json`. +If the file is missing, the example Agents render only with an explicit +`exampleAgents.skillsImage`. + The outer compiler sets the selected image on the compiled revision before hashing it. Adapters receive the requested Harness spec; provenance records that spec without injected defaults. A missing or invalid diff --git a/docs/env.md b/docs/env.md index 00e4fe226a..91e5ad8b22 100644 --- a/docs/env.md +++ b/docs/env.md @@ -180,6 +180,7 @@ This reference covers user-configurable settings for the controller, CLI, standa | `KAGENT_E2E_RUN_UPGRADE_TESTS` | String | `false` | Run upgrade integration tests when exactly true. | | `KAGENT_E2E_SANDBOX_NAMESPACE` | String | `kagent` | Namespace for sandbox E2E resources. | | `KAGENT_E2E_SANDBOX_WORKER_POOL` | String | `kagent-default` | Worker pool used by sandbox E2E resources. | +| `KAGENT_E2E_TOOLS_SKILLS_IMAGE` | String | `(none)` | Digest-pinned kagent-tools image with the opt/kagent-tools-skills Agent Plugin for the example Agents E2E test. When unset, that test skips. | | `KAGENT_E2E_UI_LOOP_EXTENSION_PORT` | String | `(none)` | Example-extension browser-test port. Defaults to KAGENT_E2E_UI_LOOP_PORT plus 50. | | `KAGENT_E2E_UI_LOOP_LIVE` | Boolean | `false` | Run UI browser tests against a real cluster when exactly true. | | `KAGENT_E2E_UI_LOOP_LIVE_PORT` | String | `8301` | Live-cluster UI browser-test port. | diff --git a/go/core/pkg/env/testing.go b/go/core/pkg/env/testing.go index 13c4473d18..11ff88e8c4 100644 --- a/go/core/pkg/env/testing.go +++ b/go/core/pkg/env/testing.go @@ -56,6 +56,7 @@ var ( _ = RegisterStringVar("KAGENT_E2E_BYO_IMAGE", "", "Digest-pinned BYO image substituted into E2E lifecycle manifests.", ComponentTesting) _ = RegisterStringVar("KAGENT_E2E_CLAUDE_IMAGE", "", "Digest-pinned Claude harness image substituted into E2E lifecycle manifests.", ComponentTesting) _ = RegisterStringVar("KAGENT_E2E_CODEX_IMAGE", "", "Digest-pinned Codex harness image substituted into E2E lifecycle manifests.", ComponentTesting) + E2EToolsSkillsImage = RegisterStringVar("KAGENT_E2E_TOOLS_SKILLS_IMAGE", "", "Digest-pinned kagent-tools image with the opt/kagent-tools-skills Agent Plugin for the example Agents E2E test. When unset, that test skips.", ComponentTesting) E2ESandboxNamespace = RegisterStringVar("KAGENT_E2E_SANDBOX_NAMESPACE", "kagent", "Namespace for sandbox E2E resources.", ComponentTesting) E2ESandboxWorkerPool = RegisterStringVar("KAGENT_E2E_SANDBOX_WORKER_POOL", "kagent-default", "Worker pool used by sandbox E2E resources.", ComponentTesting) E2EOTLPListenAddress = RegisterStringVar("KAGENT_E2E_OTLP_LISTEN_ADDRESS", "", "Listen address for the E2E suite's OTLP trace receiver. Unset disables the shared receiver.", ComponentTesting) diff --git a/go/core/test/e2e/README.md b/go/core/test/e2e/README.md index 5bce3630a0..0ce961a903 100644 --- a/go/core/test/e2e/README.md +++ b/go/core/test/e2e/README.md @@ -7,6 +7,15 @@ the three `config.harnessImages` defaults matching the explicit Harness fixtures. The test checks runtime readiness, effective-image status, and that the controller leaves the user spec unchanged. +`TestExampleAgentToolsSkills` runs each of the chart's three example Agents +on every harness with a mock model. It reads each Agent's name, description, +and skills from `helm/kagent/templates/example-agents.yaml`. It needs +`KAGENT_E2E_TOOLS_SKILLS_IMAGE`: a digest-pinned kagent-tools image in the local +test registry that contains the `opt/kagent-tools-skills` Agent Plugin. Without +that variable, the test skips. For each Agent, the test checks that the model +sees only the Agent's own skills and that one of them loads. The test leaves +out the tool server binding because the test install does not run kagent-tools. + The standalone sandbox suite needs a Substrate WorkerPool with available capacity. Set `config.sandboxes.guestImage` to the guest image built for the test. The controller passes the pinned image reference diff --git a/go/core/test/e2e/example_agent_skills_test.go b/go/core/test/e2e/example_agent_skills_test.go new file mode 100644 index 0000000000..4ac5f8fa86 --- /dev/null +++ b/go/core/test/e2e/example_agent_skills_test.go @@ -0,0 +1,163 @@ +package e2e_test + +import ( + "encoding/json" + "os" + "regexp" + "slices" + "strings" + "testing" + + a2atype "github.com/a2aproject/a2a-go/v2/a2a" + "github.com/kagent-dev/mockllm" + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/kagent-dev/kagent/go/api/v1alpha3" + kagentenv "github.com/kagent-dev/kagent/go/core/pkg/env" +) + +const exampleAgentsTemplate = "../../../../helm/kagent/templates/example-agents.yaml" + +// exampleAgentLoads names, for each example Agent in the chart, a question the +// mock model answers by loading one of the Agent's skills, and a tool that only +// that skill's body names. +var exampleAgentLoads = map[string]struct{ question, skill, tool string }{ + "cluster-ops": {"Which Helm releases are installed?", "helm-release-management", "helm_list_releases"}, + "networking": {"Why do requests fail inside the Istio mesh?", "istio-traffic-debugging", "istio_proxy_status"}, + "security": {"Is Kubescape healthy in this cluster?", "kubescape-security-posture", "kubescape_check_health"}, +} + +type exampleAgent struct { + name, description string + skills []string +} + +var ( + exampleAgentNamePattern = regexp.MustCompile(`(?m)^ name: \{\{ printf "%s-([a-z-]+)" \$prefix`) + exampleAgentDescriptionPattern = regexp.MustCompile(`(?m)^ description: "([^"]+)"$`) + exampleAgentSkillsPattern = regexp.MustCompile(`(?m)^ skills:\n((?: - \S+(?:\n|\z))+)`) +) + +// readExampleAgents reads each example Agent's name, description and skills +// from the chart template, so the test follows the chart. +func readExampleAgents(t *testing.T) []exampleAgent { + t.Helper() + data, err := os.ReadFile(exampleAgentsTemplate) + require.NoError(t, err) + var agents []exampleAgent + for doc := range strings.SplitSeq(string(data), "\n---\n") { + if !strings.Contains(doc, "\nkind: Agent\n") { + continue + } + name := exampleAgentNamePattern.FindStringSubmatch(doc) + description := exampleAgentDescriptionPattern.FindStringSubmatch(doc) + skills := exampleAgentSkillsPattern.FindStringSubmatch(doc) + require.True(t, name != nil && description != nil && skills != nil, "cannot read an example Agent in %s:\n%s", exampleAgentsTemplate, doc) + agent := exampleAgent{name: name[1], description: description[1]} + for line := range strings.SplitSeq(strings.TrimSpace(skills[1]), "\n") { + agent.skills = append(agent.skills, strings.TrimPrefix(strings.TrimSpace(line), "- ")) + } + agents = append(agents, agent) + } + names := make([]string, 0, len(agents)) + for _, agent := range agents { + names = append(names, agent.name) + } + want := make([]string, 0, len(exampleAgentLoads)) + for name := range exampleAgentLoads { + want = append(want, name) + } + require.ElementsMatch(t, want, names, "every example Agent in %s needs an entry in exampleAgentLoads", exampleAgentsTemplate) + return agents +} + +// TestExampleAgentToolsSkills builds each of the chart's example Agents on +// every harness with a mock model. Each test Agent gets the kagent-tools image +// as an Agent Plugin, the chart Agent's skills, and the built-in prompts. The +// test leaves out the chart's tool server binding because the E2E install does +// not run kagent-tools. +func TestExampleAgentToolsSkills(t *testing.T) { + t.Parallel() + target := interactionTarget(t) + image := kagentenv.E2EToolsSkillsImage.Get() + if image == "" { + t.Skip("KAGENT_E2E_TOOLS_SKILLS_IMAGE is not set. Set it to a kagent-tools image with the opt/kagent-tools-skills folder.") + } + agents := readExampleAgents(t) + for _, agent := range agents { + load := exampleAgentLoads[agent.name] + require.Contains(t, agent.skills, load.skill, "%s must select the skill it loads", agent.name) + var others []string + for _, other := range agents { + if other.name != agent.name { + others = append(others, other.skills...) + } + } + t.Run(agent.name, func(t *testing.T) { + t.Parallel() + forEachHarness(t, func(t *testing.T, harness testHarness) { + t.Parallel() + model := startExampleAgentSkillsMock(t, harness.skillsRoot(), load.question, load.skill, load.tool) + kube := interactionKubeClient(t) + config := harness.createModel(t, kube, reachableModelURL(t, model.URL), nil) + template := &v1alpha3.AgentTemplate{ + ObjectMeta: metav1.ObjectMeta{GenerateName: "example-" + agent.name + "-", Namespace: "kagent", Labels: harness.labels()}, + Spec: v1alpha3.AgentTemplateSpec{ + ModelConfig: &v1alpha3.ObjectReference{Name: config.Name}, + Description: agent.description, + PromptTemplate: &v1alpha3.AgentTemplatePromptTemplateSpec{ + DataSources: []v1alpha3.AgentTemplatePromptSource{{Name: "kagent-builtin-prompts", Alias: "builtin"}}, + }, + SystemPrompt: "Answer questions about the Kubernetes cluster where kagent runs. When a\n" + + "question matches one of your skills, load that skill and follow it. Each\n" + + "skill names the kagent-tools MCP tools it uses.\n\n" + + "{{include \"builtin/skills-usage\"}}\n\n" + + "{{include \"builtin/kubernetes-context\"}}\n\n" + + "{{include \"builtin/tool-usage-best-practices\"}}\n\n" + + "{{include \"builtin/safety-guardrails\"}}\n", + Plugins: []v1alpha3.PluginBundle{{ + Source: v1alpha3.ArtifactSource{OCI: image, Path: "opt/kagent-tools-skills"}, + Skills: agent.skills, + }}, + }, + } + // Ready requires the runtime to mount the image and install the skills. + createAndWaitInteractionTemplate(t, harness, kube, template) + fixture := newInteractionFixtureForTemplate(t, harness, target, template.Name) + _, _, task := fixture.send(t, load.question) + require.Equal(t, a2atype.TaskStateCompleted, task.Status.State, taskText(task)) + require.Contains(t, taskText(task), "EXAMPLE_SKILL_LOADED") + + // Claude also sends requests without a body. Count only model turns. + requests := slices.DeleteFunc(model.Requests("", ""), func(r recordedModelRequest) bool { return len(r.Body) == 0 }) + require.GreaterOrEqual(t, len(requests), 2, "the model must be called again after loading the skill") + first := string(requests[0].Body) + for _, skill := range agent.skills { + require.Contains(t, first, skill, "skill must be advertised to the model") + } + for _, skill := range others { + require.NotContains(t, first, skill, "another example Agent's skill must not be advertised") + } + require.Contains(t, first, "## Safety Guidelines", "built-in prompts must be included") + // Discovery exposes metadata only. The skill body names its MCP tools. + require.NotContains(t, first, load.tool) + loaded := false + for _, request := range requests[1:] { + loaded = loaded || strings.Contains(string(request.Body), load.tool) + } + require.True(t, loaded, "the model did not receive the %s skill body", load.skill) + }) + }) + } +} + +func startExampleAgentSkillsMock(t *testing.T, root, question, skill, tool string) *modelRecorder { + t.Helper() + fixture, err := interactionMocks.ReadFile("mocks/invoke_example_agent_skills.json") + require.NoError(t, err) + replacer := strings.NewReplacer("ROOT", root, "QUESTION", question, "SKILL_NAME", skill, "SKILL_TOOL", tool) + var config mockllm.Config + require.NoError(t, json.Unmarshal([]byte(replacer.Replace(string(fixture))), &config)) + return startModelRecorder(t, mockLLMProxy(t, startMockLLMConfig(t, config)), nil) +} diff --git a/go/core/test/e2e/interaction_test.go b/go/core/test/e2e/interaction_test.go index 04a1cf8163..0bd1e8ee1f 100644 --- a/go/core/test/e2e/interaction_test.go +++ b/go/core/test/e2e/interaction_test.go @@ -50,7 +50,7 @@ import ( ) //go:embed mocks/invoke_agent.json mocks/invoke_golang_hitl_ask_user.json mocks/invoke_mcp_agent.json mocks/invoke_shared_agent.json mocks/invoke_structured_output.json mocks/invoke_native_ask_user.json -//go:embed mocks/invoke_mounted_skills.json mocks/invoke_egress_skill.json +//go:embed mocks/invoke_mounted_skills.json mocks/invoke_egress_skill.json mocks/invoke_example_agent_skills.json var interactionMocks embed.FS const structuredOutputSchema = `{"type":"object","properties":{"answer":{"type":"integer"},"explanation":{"type":"string"}},"required":["answer","explanation"],"additionalProperties":false}` diff --git a/go/core/test/e2e/mocks/invoke_example_agent_skills.json b/go/core/test/e2e/mocks/invoke_example_agent_skills.json new file mode 100644 index 0000000000..3dd97ba41b --- /dev/null +++ b/go/core/test/e2e/mocks/invoke_example_agent_skills.json @@ -0,0 +1,203 @@ +{ + "openai": [ + { + "name": "example_skills_load", + "match": { + "match_type": "contains", + "message": { + "role": "user", + "content": "QUESTION" + } + }, + "response": { + "id": "example_skills_load", + "object": "chat.completion", + "created": 1677652288, + "model": "gpt-4.1-mini", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "", + "tool_calls": [ + { + "id": "call_load_skill", + "type": "function", + "function": { + "name": "load_skill", + "arguments": "{\"name\": \"SKILL_NAME\"}" + } + } + ] + }, + "finish_reason": "tool_calls" + } + ] + } + }, + { + "name": "example_skills_loaded", + "match": { + "match_type": "contains", + "message": { + "role": "tool", + "content": "SKILL_TOOL" + } + }, + "response": { + "id": "example_skills_loaded", + "object": "chat.completion", + "created": 1677652288, + "model": "gpt-4.1-mini", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "EXAMPLE_SKILL_LOADED" + }, + "finish_reason": "stop" + } + ] + } + } + ], + "openai_response": [ + { + "name": "example_skills_load", + "match": { + "match_type": "contains", + "input": "QUESTION" + }, + "response": { + "id": "example_skills_load", + "object": "response", + "created_at": 1677652290, + "model": "gpt-5.2-codex", + "output": [ + { + "id": "fc_load_skill", + "type": "function_call", + "status": "completed", + "call_id": "call_load_skill", + "name": "exec_command", + "arguments": "{\"cmd\": \"cat ROOT/SKILL_NAME/SKILL.md\"}" + } + ], + "parallel_tool_calls": false, + "tools": [], + "usage": { + "input_tokens": 10, + "output_tokens": 8, + "total_tokens": 18 + } + } + }, + { + "name": "example_skills_loaded", + "match": { + "match_type": "contains", + "input": "SKILL_TOOL" + }, + "response": { + "id": "example_skills_loaded", + "object": "response", + "created_at": 1677652291, + "model": "gpt-5.2-codex", + "output": [ + { + "id": "msg_example_skills_loaded", + "type": "message", + "status": "completed", + "role": "assistant", + "content": [ + { + "type": "output_text", + "text": "EXAMPLE_SKILL_LOADED", + "annotations": [] + } + ] + } + ], + "parallel_tool_calls": false, + "tools": [], + "usage": { + "input_tokens": 14, + "output_tokens": 5, + "total_tokens": 19 + } + } + } + ], + "anthropic": [ + { + "name": "example_skills_load", + "match": { + "match_type": "contains", + "message": { + "role": "user", + "content": [ + { + "type": "text", + "text": "QUESTION" + } + ] + } + }, + "response": { + "id": "example_skills_load", + "type": "message", + "role": "assistant", + "content": [ + { + "type": "tool_use", + "id": "toolu_load_skill", + "name": "Skill", + "input": { + "skill": "SKILL_NAME" + } + } + ], + "model": "claude-sonnet-4-5", + "stop_reason": "tool_use", + "usage": { + "input_tokens": 10, + "output_tokens": 4 + } + } + }, + { + "name": "example_skills_loaded", + "match": { + "match_type": "contains", + "message": { + "role": "user", + "content": [ + { + "type": "text", + "text": "SKILL_TOOL" + } + ] + } + }, + "response": { + "id": "example_skills_loaded", + "type": "message", + "role": "assistant", + "content": [ + { + "type": "text", + "text": "EXAMPLE_SKILL_LOADED" + } + ], + "model": "claude-sonnet-4-5", + "stop_reason": "end_turn", + "usage": { + "input_tokens": 14, + "output_tokens": 4 + } + } + } + ] +} diff --git a/helm/README.md b/helm/README.md index 30030d1b96..56eb56d8ce 100644 --- a/helm/README.md +++ b/helm/README.md @@ -231,6 +231,70 @@ release namespace. **Note**: MicroVM requires a `microvm` SandboxConfig, runtime assets, and KVM-capable workers. kagent does not install these prerequisites. +#### Example Agents + +Set `exampleAgents.enabled: true` to install three example Agents. They are off +by default. The chart then creates: + +- A Harness named `-example` (default `kagent-tools-example`). It + runs the Go ADK image from the release's Harness image catalog on the + `substrateWorkerPool.name` WorkerPool. +- Three Agents on that Harness. Each Agent loads its skills from + `opt/kagent-tools-skills` in the kagent-tools image: + + | Agent | Skills | Tools | + |---|---|---| + | `-cluster-ops` | `k8s-troubleshooting`, `helm-release-management`, `argo-rollouts-progressive-delivery`, `prometheus-observability` | 29 | + | `-networking` | `istio-traffic-debugging`, `cilium-network-debugging` | 31 | + | `-security` | `kubescape-security-posture` | 10 | + +Each Agent uses `default-model-config` and includes the `skills-usage`, +`kubernetes-context`, `tool-usage-best-practices`, and `safety-guardrails` +prompts from the `kagent-builtin-prompts` ConfigMap. Each skill names the +kagent-tools MCP tools it uses. Each Agent binds only those tools, in one +binding to the chart's `-tool-server` RemoteMCPServer. The Agents need +`kagent-tools.enabled: true`. + +The chart splits the skills over three Agents because one Agent cannot hold all +seven. The seven skills name 64 tools, and three limits apply: + +- One binding lists at most 50 tools. +- The Claude and Codex harnesses accept each MCP server only once. +- OpenAI and Azure OpenAI accept at most 128 tools in one request. The whole + tool server has more than 128. + +The skills come from the kagent-tools image, pinned by digest. `make +helm-publish` runs `make tools-image-metadata`, which resolves the digest of the +kagent-tools version in the chart's dependencies. It then packages that digest +in `files/kagent-tools-image.json`. A source chart has no packaged digest. To +install from a source chart, or to use another image, set +`exampleAgents.skillsImage`: + +```yaml +exampleAgents: + enabled: true + skillsImage: ghcr.io/kagent-dev/kagent/tools@sha256: +``` + +Without a digest, the chart fails to render. `global.imageRegistry` rewrites +the registry of both the packaged image and `exampleAgents.skillsImage`. + +kagent-tools 0.3.0 and earlier have no `opt/kagent-tools-skills` folder, so +release packaging records no digest for them. While the chart depends on such a +version, set `exampleAgents.skillsImage` to an image that has the folder. + +The Harness saves snapshots to `exampleAgents.snapshotLocation`, by default +`gs://ate-snapshots/kagent/`. The Substrate chart's built-in rustfs store +creates that bucket. If you turned off rustfs, set your own bucket. + +Two limits apply: + +- The example uses an OCI source only. Git sources fail to load in the agent + runtime today, and nobody has tested S3 sources. +- Skill selection is not a security boundary. Substrate mounts the whole image + read-only, so the agent can still read the files of skills it did not + select. + ### Using Make ```bash diff --git a/helm/kagent/templates/example-agents.yaml b/helm/kagent/templates/example-agents.yaml new file mode 100644 index 0000000000..7b726aaa45 --- /dev/null +++ b/helm/kagent/templates/example-agents.yaml @@ -0,0 +1,249 @@ +{{- if .Values.exampleAgents.enabled }} +{{- if not (index .Values "kagent-tools" "enabled") }} +{{- fail "exampleAgents.enabled requires kagent-tools.enabled: the example skills call the kagent-tools MCP server" }} +{{- end }} +{{- if not .Values.exampleAgents.snapshotLocation }} +{{- fail "exampleAgents.snapshotLocation is required: set the snapshot storage location for the example Harness" }} +{{- end }} +{{- /* Release packaging resolves the digest of the kagent-tools version this chart pins. */}} +{{- $skillsImage := .Values.exampleAgents.skillsImage }} +{{- if not $skillsImage }} +{{- with .Files.Get "files/kagent-tools-image.json" }} +{{- $skillsImage = get (mustFromJson .) "image" }} +{{- end }} +{{- end }} +{{- if not $skillsImage }} +{{- fail "exampleAgents.enabled requires the kagent-tools image digest. Release charts package it when their kagent-tools version ships the skills. Otherwise, set exampleAgents.skillsImage to @sha256:." }} +{{- end }} +{{- if not (regexMatch "^[^[:space:]@]+@sha256:[0-9a-f]{64}$" $skillsImage) }} +{{- fail (printf "exampleAgents.skillsImage must be pinned by sha256 digest: %q" $skillsImage) }} +{{- end }} +{{- $skillsImage = include "kagent.mirroredImage" (dict "root" . "image" $skillsImage) }} +{{- $prefix := .Values.exampleAgents.namePrefix }} +{{- $toolServer := printf "%s-tool-server" (include "kagent.fullname" .) }} +{{- /* +Each Agent binds only the tools its skills name, in one binding. Claude and +Codex accept each MCP server once, one binding lists at most 50 tools, and +OpenAI and Azure OpenAI accept at most 128 tools in one request. +*/}} +apiVersion: api.kagent.dev/v1alpha3 +kind: Harness +metadata: + name: {{ printf "%s-example" $prefix | quote }} + namespace: {{ include "kagent.namespace" . }} + labels: + {{- include "kagent.labels" . | nindent 4 }} +spec: + kagent: {} + substrate: + workerPoolRef: + name: {{ .Values.substrateWorkerPool.name | quote }} + snapshotPolicy: + location: {{ .Values.exampleAgents.snapshotLocation | quote }} +--- +apiVersion: api.kagent.dev/v1alpha3 +kind: Agent +metadata: + name: {{ printf "%s-cluster-ops" $prefix | quote }} + namespace: {{ include "kagent.namespace" . }} + labels: + {{- include "kagent.labels" . | nindent 4 }} +spec: + harnessRef: + name: {{ printf "%s-example" $prefix | quote }} + template: + inline: + description: "Kubernetes workload, Helm release, Argo Rollouts, and Prometheus metrics assistant." + modelConfig: + name: {{ include "kagent.defaultModelConfigName" . }} + promptTemplate: + dataSources: + - name: kagent-builtin-prompts + alias: builtin + systemPrompt: | + Answer questions about the Kubernetes cluster where kagent runs. When a + question matches one of your skills, load that skill and follow it. Each + skill names the kagent-tools MCP tools it uses. + + {{`{{include "builtin/skills-usage"}}`}} + + {{`{{include "builtin/kubernetes-context"}}`}} + + {{`{{include "builtin/tool-usage-best-practices"}}`}} + + {{`{{include "builtin/safety-guardrails"}}`}} + tools: + - mcp: + server: + kind: RemoteMCPServer + name: {{ $toolServer }} + tools: + - k8s_apply_manifest + - k8s_check_service_connectivity + - k8s_delete_resource + - k8s_describe_resource + - k8s_get_events + - k8s_get_pod_logs + - k8s_get_resource_yaml + - k8s_get_resources + - k8s_patch_resource + - k8s_rollout + - k8s_scale + - helm_get_release + - helm_list_releases + - helm_repo_add + - helm_repo_update + - helm_uninstall + - helm_upgrade + - argo_pause_rollout + - argo_promote_rollout + - argo_rollouts_list + - argo_set_rollout_image + - argo_verify_argo_rollouts_controller_install + - argo_verify_gateway_plugin + - argo_verify_kubectl_plugin_install + - prometheus_label_names_tool + - prometheus_promql_tool + - prometheus_query_range_tool + - prometheus_query_tool + - prometheus_targets_tool + plugins: + - source: + oci: {{ $skillsImage | quote }} + path: opt/kagent-tools-skills + skills: + - k8s-troubleshooting + - helm-release-management + - argo-rollouts-progressive-delivery + - prometheus-observability +--- +apiVersion: api.kagent.dev/v1alpha3 +kind: Agent +metadata: + name: {{ printf "%s-networking" $prefix | quote }} + namespace: {{ include "kagent.namespace" . }} + labels: + {{- include "kagent.labels" . | nindent 4 }} +spec: + harnessRef: + name: {{ printf "%s-example" $prefix | quote }} + template: + inline: + description: "Istio service mesh and Cilium networking assistant." + modelConfig: + name: {{ include "kagent.defaultModelConfigName" . }} + promptTemplate: + dataSources: + - name: kagent-builtin-prompts + alias: builtin + systemPrompt: | + Answer questions about the Kubernetes cluster where kagent runs. When a + question matches one of your skills, load that skill and follow it. Each + skill names the kagent-tools MCP tools it uses. + + {{`{{include "builtin/skills-usage"}}`}} + + {{`{{include "builtin/kubernetes-context"}}`}} + + {{`{{include "builtin/tool-usage-best-practices"}}`}} + + {{`{{include "builtin/safety-guardrails"}}`}} + tools: + - mcp: + server: + kind: RemoteMCPServer + name: {{ $toolServer }} + tools: + - k8s_apply_manifest + - k8s_describe_resource + - k8s_get_events + - k8s_get_pod_logs + - k8s_get_resource_yaml + - k8s_get_resources + - istio_analyze_cluster_configuration + - istio_apply_waypoint + - istio_delete_waypoint + - istio_list_waypoints + - istio_proxy_config + - istio_proxy_status + - istio_version + - istio_waypoint_status + - istio_ztunnel_config + - cilium_display_policy_node_information + - cilium_display_selectors + - cilium_flush_ipsec_state + - cilium_get_daemon_status + - cilium_get_endpoint_details + - cilium_get_endpoints_list + - cilium_get_identity_details + - cilium_get_service_information + - cilium_list_bgp_peers + - cilium_list_bgp_routes + - cilium_list_cluster_nodes + - cilium_list_services + - cilium_show_cluster_mesh_status + - cilium_status_and_version + - cilium_uninstall_cilium + - cilium_validate_cilium_network_policies + plugins: + - source: + oci: {{ $skillsImage | quote }} + path: opt/kagent-tools-skills + skills: + - istio-traffic-debugging + - cilium-network-debugging +--- +apiVersion: api.kagent.dev/v1alpha3 +kind: Agent +metadata: + name: {{ printf "%s-security" $prefix | quote }} + namespace: {{ include "kagent.namespace" . }} + labels: + {{- include "kagent.labels" . | nindent 4 }} +spec: + harnessRef: + name: {{ printf "%s-example" $prefix | quote }} + template: + inline: + description: "Kubescape security posture assistant." + modelConfig: + name: {{ include "kagent.defaultModelConfigName" . }} + promptTemplate: + dataSources: + - name: kagent-builtin-prompts + alias: builtin + systemPrompt: | + Answer questions about the Kubernetes cluster where kagent runs. When a + question matches one of your skills, load that skill and follow it. Each + skill names the kagent-tools MCP tools it uses. + + {{`{{include "builtin/skills-usage"}}`}} + + {{`{{include "builtin/kubernetes-context"}}`}} + + {{`{{include "builtin/tool-usage-best-practices"}}`}} + + {{`{{include "builtin/safety-guardrails"}}`}} + tools: + - mcp: + server: + kind: RemoteMCPServer + name: {{ $toolServer }} + tools: + - kubescape_check_health + - kubescape_get_application_profile + - kubescape_get_configuration_scan + - kubescape_get_network_neighborhood + - kubescape_get_vulnerability_details + - kubescape_list_application_profiles + - kubescape_list_configuration_scans + - kubescape_list_network_neighborhoods + - kubescape_list_vulnerabilities + - kubescape_list_vulnerability_manifests + plugins: + - source: + oci: {{ $skillsImage | quote }} + path: opt/kagent-tools-skills + skills: + - kubescape-security-posture +{{- end }} diff --git a/helm/kagent/tests/example-agents_test.yaml b/helm/kagent/tests/example-agents_test.yaml new file mode 100644 index 0000000000..ca868bd91a --- /dev/null +++ b/helm/kagent/tests/example-agents_test.yaml @@ -0,0 +1,219 @@ +suite: test example agents +templates: + - example-agents.yaml +release: + name: kagent + namespace: kagent +set: + exampleAgents.enabled: true + exampleAgents.skillsImage: ghcr.io/kagent-dev/kagent/tools@sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd +tests: + - it: should render nothing by default + set: + exampleAgents.enabled: false + asserts: + - hasDocuments: + count: 0 + + - it: should render one Harness and three Agents + asserts: + - hasDocuments: + count: 4 + + - it: should render a Go ADK Harness that uses the release default image + documentSelector: + path: kind + value: Harness + asserts: + - equal: + path: apiVersion + value: api.kagent.dev/v1alpha3 + - equal: + path: metadata.name + value: kagent-tools-example + - equal: + path: metadata.namespace + value: kagent + - equal: + path: spec.kagent + value: {} + - notExists: + path: spec.workload + - equal: + path: spec.substrate.workerPoolRef.name + value: kagent-default + - equal: + path: spec.substrate.snapshotPolicy.location + value: gs://ate-snapshots/kagent/ + + - it: should use a custom snapshot location + documentSelector: + path: kind + value: Harness + set: + exampleAgents.snapshotLocation: gs://bucket/kagent/ + asserts: + - equal: + path: spec.substrate.snapshotPolicy.location + value: gs://bucket/kagent/ + + - it: should give every Agent the Harness, model, prompts, skills image, and one tool server binding + documentSelector: + path: kind + value: Agent + matchMany: true + asserts: + - equal: + path: apiVersion + value: api.kagent.dev/v1alpha3 + - equal: + path: spec.harnessRef.name + value: kagent-tools-example + - equal: + path: spec.template.inline.modelConfig.name + value: default-model-config + - equal: + path: spec.template.inline.promptTemplate.dataSources + value: + - name: kagent-builtin-prompts + alias: builtin + - matchRegex: + path: spec.template.inline.systemPrompt + pattern: '\{\{include "builtin/safety-guardrails"\}\}' + - matchRegex: + path: spec.template.inline.systemPrompt + pattern: '\{\{include "builtin/skills-usage"\}\}' + - lengthEqual: + path: spec.template.inline.tools + count: 1 + - equal: + path: spec.template.inline.tools[0].mcp.server + value: + kind: RemoteMCPServer + name: kagent-tool-server + - equal: + path: spec.template.inline.plugins[0].source + value: + oci: ghcr.io/kagent-dev/kagent/tools@sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd + path: opt/kagent-tools-skills + + - it: should render the cluster-ops Agent + documentSelector: + path: metadata.name + value: kagent-tools-cluster-ops + asserts: + - equal: + path: spec.template.inline.plugins[0].skills + value: + - k8s-troubleshooting + - helm-release-management + - argo-rollouts-progressive-delivery + - prometheus-observability + - lengthEqual: + path: spec.template.inline.tools[0].mcp.tools + count: 29 + - contains: + path: spec.template.inline.tools[0].mcp.tools + content: helm_list_releases + + - it: should render the networking Agent + documentSelector: + path: metadata.name + value: kagent-tools-networking + asserts: + - equal: + path: spec.template.inline.plugins[0].skills + value: + - istio-traffic-debugging + - cilium-network-debugging + - lengthEqual: + path: spec.template.inline.tools[0].mcp.tools + count: 31 + - contains: + path: spec.template.inline.tools[0].mcp.tools + content: istio_proxy_status + + - it: should render the security Agent + documentSelector: + path: metadata.name + value: kagent-tools-security + asserts: + - equal: + path: spec.template.inline.plugins[0].skills + value: + - kubescape-security-posture + - lengthEqual: + path: spec.template.inline.tools[0].mcp.tools + count: 10 + - contains: + path: spec.template.inline.tools[0].mcp.tools + content: kubescape_list_vulnerabilities + + - it: should follow the release name, namespace override, and name prefix + release: + name: custom + set: + namespaceOverride: agents + exampleAgents.namePrefix: tools-demo + asserts: + - equal: + path: metadata.namespace + value: agents + - equal: + path: metadata.name + value: tools-demo-example + documentSelector: + path: kind + value: Harness + - equal: + path: spec.harnessRef.name + value: tools-demo-example + documentSelector: + path: metadata.name + value: tools-demo-security + - equal: + path: spec.template.inline.tools[0].mcp.server.name + value: custom-tool-server + documentSelector: + path: metadata.name + value: tools-demo-networking + + - it: should mirror the skills image + set: + global.imageRegistry: mirror.example/ + asserts: + - equal: + path: spec.template.inline.plugins[0].source.oci + value: mirror.example/kagent-dev/kagent/tools@sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd + documentSelector: + path: kind + value: Agent + matchMany: true + + - it: should fail without a skills image digest + set: + exampleAgents.skillsImage: "" + asserts: + - failedTemplate: + errorMessage: "exampleAgents.enabled requires the kagent-tools image digest. Release charts package it when their kagent-tools version ships the skills. Otherwise, set exampleAgents.skillsImage to @sha256:." + + - it: should fail when the skills image is not pinned by digest + set: + exampleAgents.skillsImage: ghcr.io/kagent-dev/kagent/tools:0.3.0 + asserts: + - failedTemplate: + errorMessage: 'exampleAgents.skillsImage must be pinned by sha256 digest: "ghcr.io/kagent-dev/kagent/tools:0.3.0"' + + - it: should fail without the kagent-tools server + set: + kagent-tools.enabled: false + asserts: + - failedTemplate: + errorMessage: "exampleAgents.enabled requires kagent-tools.enabled: the example skills call the kagent-tools MCP server" + + - it: should fail without a snapshot location + set: + exampleAgents.snapshotLocation: "" + asserts: + - failedTemplate: + errorMessage: "exampleAgents.snapshotLocation is required: set the snapshot storage location for the example Harness" diff --git a/helm/kagent/values.yaml b/helm/kagent/values.yaml index 213877bcb6..dc6ad4059e 100644 --- a/helm/kagent/values.yaml +++ b/helm/kagent/values.yaml @@ -23,8 +23,9 @@ global: # air-gap mirror knob: one value redirects all images. Repository and tag stay # per-image. For per-image control, leave this unset and set the per-image # registry keys. Covers this chart's own images: controller, sandbox guest, ui, - # substrateWorkerPool.workerImage, and built-in Harness images - # (both packaged defaults and config.harnessImages overrides). The vendored subcharts + # substrateWorkerPool.workerImage, built-in Harness images + # (both packaged defaults and config.harnessImages overrides), and the example + # Agent skills image (packaged default and exampleAgents.skillsImage). The vendored subcharts # kagent-tools adopts it when its pinned version bumps. imageRegistry: "" # -- Pull secrets for every pod. The chart merges this list (union) into each @@ -770,6 +771,31 @@ kagent-tools: metrics: port: 8085 # Use a different port than the controller service port to avoid duplicate port definitions +# ============================================================================== +# EXAMPLE AGENTS +# ============================================================================== +# Three Agents (cluster-ops, networking, security) on one Go ADK Harness. They +# use the default ModelConfig, the kagent-tools MCP server, and the seven skills +# packaged in the kagent-tools image under opt/kagent-tools-skills. They need +# kagent-tools.enabled. +exampleAgents: + # -- Install the example Agents and their Harness. + enabled: false + # -- Name prefix: the Harness is -example and the Agents are + # -cluster-ops, -networking, and -security. + namePrefix: kagent-tools + # -- Digest-pinned kagent-tools image that holds the skills, for example + # ghcr.io/kagent-dev/kagent/tools@sha256:. Empty uses the digest that + # release packaging resolved for the kagent-tools version this chart pins. + # A source chart has no packaged digest, so set this value when you install + # from source. + # global.imageRegistry rewrites both the packaged default and this override. + skillsImage: "" + # -- Snapshot storage location for the example Harness. The default is the + # ate-snapshots bucket that the Substrate chart's built-in rustfs store + # creates. If you turned off rustfs, set your own bucket. + snapshotLocation: gs://ate-snapshots/kagent/ + # ============================================================================== # PROXY CONFIGURATION # ============================================================================== diff --git a/scripts/package-release-charts.sh b/scripts/package-release-charts.sh index d0478130a7..45efa07c1e 100644 --- a/scripts/package-release-charts.sh +++ b/scripts/package-release-charts.sh @@ -17,9 +17,28 @@ bash "$root/scripts/harness-image-catalog.sh" "$version" "$metadata_dir" \ "$repository/golang-adk" "$repository/claude-harness" "$repository/codex-harness" \ > "$staging/builtin-harness-images.json" +# make tools-image-metadata records the kagent-tools image digest for the +# example Agents. Without it, the chart can enable those Agents only with an +# explicit exampleAgents.skillsImage. +tools_metadata="$metadata_dir/kagent-tools.json" +if [[ -e "$tools_metadata" ]]; then + # The version must be the kagent-tools version the chart depends on, so a + # stale metadata file cannot pin another release's skills. + tools_version="$(yq '(.dependencies // [])[] | select(.name == "kagent-tools") | .version' "$source_dir/kagent/Chart-template.yaml")" + jq -e --arg tools_version "$tools_version" ' + (.image | type) == "string" and (.image | test("^[^[:space:]@]+@sha256:[a-f0-9]{64}$")) and + ($tools_version == "" or .version == $tools_version)' "$tools_metadata" >/dev/null || { + echo "Invalid kagent-tools image metadata, or it is not for kagent-tools $tools_version: $tools_metadata. Run make tools-image-metadata." >&2 + exit 1 + } +fi + cp -R "$source_dir" "$staging/helm" mkdir -p "$staging/helm/kagent/files" mv "$staging/builtin-harness-images.json" "$staging/helm/kagent/files/builtin-harness-images.json" +if [[ -e "$tools_metadata" ]]; then + cp "$tools_metadata" "$staging/helm/kagent/files/kagent-tools-image.json" +fi # Reuse local chart preparation, keeping generated files and intermediate archives isolated. make --no-print-directory -j1 -C "$root" helm-version VERSION="$version" \ diff --git a/scripts/package-release-charts_test.sh b/scripts/package-release-charts_test.sh index ab12e178f8..80f07a06fd 100644 --- a/scripts/package-release-charts_test.sh +++ b/scripts/package-release-charts_test.sh @@ -13,6 +13,7 @@ output_dir="$scratch/release charts" version=1.0.0-alpha9 digest="sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" config_digest="sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" +tools_digest="sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd" mkdir -p "$source_dir/kagent/templates" "$source_dir/kagent/files" "$metadata_dir" "$output_dir" # Use the real controller templates in dependency-free charts. The test invokes @@ -22,13 +23,16 @@ for chart in kagent kagent-crds; do printf 'apiVersion: v2\nname: %s\nversion: ${VERSION}\n' "${chart##*/}" > "$source_dir/$chart/Chart-template.yaml" done cp "$root/helm/kagent/values.yaml" "$source_dir/kagent/values.yaml" -cp "$root/helm/kagent/templates/"*.tpl "$root/helm/kagent/templates/controller-configmap.yaml" "$source_dir/kagent/templates/" +cp "$root/helm/kagent/templates/"*.tpl "$root/helm/kagent/templates/controller-configmap.yaml" \ + "$root/helm/kagent/templates/example-agents.yaml" "$source_dir/kagent/templates/" printf 'stale catalog\n' > "$source_dir/kagent/files/builtin-harness-images.json" cp -R "$source_dir" "$scratch/source-before" for image in golang-adk claude-harness codex-harness; do jq -n --arg digest "$digest" --arg config "$config_digest" \ '{"containerimage.digest": $digest, "containerimage.config.digest": $config}' > "$metadata_dir/$image.json" done +jq -n --arg digest "$tools_digest" \ + '{image: ("ghcr.io/kagent-dev/kagent/tools@" + $digest), version: "0.3.0"}' > "$metadata_dir/kagent-tools.json" release() { make --no-print-directory -C "$root" VERSION="$version" AWK=awk \ @@ -44,6 +48,15 @@ render_images() { "$scratch/rendered.yaml" > "$scratch/rendered.json" } +render_skills_source() { + local archive="$1" + shift + helm template release "$archive" --show-only templates/example-agents.yaml \ + --set exampleAgents.enabled=true --set exampleAgents.snapshotLocation=gs://bucket/kagent/ "$@" > "$scratch/rendered.yaml" + yq -o=json 'select(.kind == "Agent") | .spec.template.inline.plugins[0].source' \ + "$scratch/rendered.yaml" > "$scratch/rendered.json" +} + expect_failure() { local expected="$1" shift @@ -69,6 +82,20 @@ jq -e --arg digest "$digest" --arg version "$version" ' cp "$scratch/rendered.json" "$scratch/defaults.json" diff -r "$scratch/source-before" "$source_dir" +check="release packages the kagent-tools image digest" +render_skills_source "$archive" +jq -e --arg digest "$tools_digest" \ + '. == {oci: ("ghcr.io/kagent-dev/kagent/tools@" + $digest), path: "opt/kagent-tools-skills"}' \ + "$scratch/rendered.json" >/dev/null + +check="mirror and override the kagent-tools image" +render_skills_source "$archive" --set global.imageRegistry=mirror.example/ +jq -e --arg digest "$tools_digest" '.oci == "mirror.example/kagent-dev/kagent/tools@" + $digest' \ + "$scratch/rendered.json" >/dev/null +render_skills_source "$archive" --set-string "exampleAgents.skillsImage=custom.example/team/tools@$config_digest" +jq -e --arg config "$config_digest" '.oci == "custom.example/team/tools@" + $config' \ + "$scratch/rendered.json" >/dev/null + check="override one image" render_images "$archive" --set-string "config.harnessImages.codex=custom.example/team/codex@$config_digest" jq -e --slurpfile defaults "$scratch/defaults.json" --arg config "$config_digest" \ @@ -117,12 +144,96 @@ mv "$metadata_dir/codex-harness.json" "$scratch/codex-invalid.json" expect_failure codex-harness helm-release cp "$scratch/codex-valid.json" "$metadata_dir/codex-harness.json" +cp "$metadata_dir/kagent-tools.json" "$scratch/kagent-tools-valid.json" +for invalid in '{}' '{"image":"ghcr.io/kagent-dev/kagent/tools@sha256:bad","version":"0.3.0"}' \ + '{"image":"ghcr.io/kagent-dev/kagent/tools:0.3.0","version":"0.3.0"}' '{broken'; do + check="invalid kagent-tools metadata: $invalid" + printf '%s\n' "$invalid" > "$metadata_dir/kagent-tools.json" + expect_failure kagent-tools helm-release +done +cp "$scratch/kagent-tools-valid.json" "$metadata_dir/kagent-tools.json" +# The check runs before packaging, so the dependency is never fetched. +check="kagent-tools metadata for another kagent-tools version" +cp "$source_dir/kagent/Chart-template.yaml" "$scratch/kagent-chart-template.yaml" +printf 'dependencies:\n - name: kagent-tools\n version: 0.3.1\n' >> "$source_dir/kagent/Chart-template.yaml" +expect_failure 'it is not for kagent-tools 0.3.1' helm-release +cp "$scratch/kagent-chart-template.yaml" "$source_dir/kagent/Chart-template.yaml" + check="packaging failure preserves previous archives" mv "$source_dir/kagent/Chart-template.yaml" "$scratch/Chart-template.yaml" expect_failure kagent/Chart-template.yaml helm-release mv "$scratch/Chart-template.yaml" "$source_dir/kagent/Chart-template.yaml" diff -r "$scratch/source-before" "$source_dir" +check="release without kagent-tools metadata requires an explicit skills image" +mv "$metadata_dir/kagent-tools.json" "$scratch/kagent-tools.json" +release helm-release +if helm template release "$archive" --show-only templates/example-agents.yaml \ + --set exampleAgents.enabled=true --set exampleAgents.snapshotLocation=gs://bucket/kagent/ > "$scratch/rendered.yaml" 2> "$scratch/command.log"; then + exit 1 +fi +[[ "$(< "$scratch/command.log")" == *'requires the kagent-tools image digest'* ]] +mv "$scratch/kagent-tools.json" "$metadata_dir/kagent-tools.json" + +# Release packaging looks up one digest in a registry: the kagent-tools image, +# because kagent-tools releases from its own repository. A fake docker command +# stands in for the registry. +mkdir -p "$scratch/tools-bin" "$scratch/tools-source/kagent" +cat > "$scratch/tools-bin/docker" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +echo "docker $*" >> "$CONTAINER_LOG" +[[ "$1 $2 $3" == "buildx imagetools inspect" ]] +printf 'Name: %s\nMediaType: application/vnd.oci.image.index.v1+json\nDigest: %s\n' "$4" "$TOOLS_FIXTURE_DIGEST" +EOF +chmod +x "$scratch/tools-bin/docker" +resolve_tools() { + PATH="$scratch/tools-bin:$PATH" CONTAINER_LOG="$scratch/tools.log" release tools-image-metadata \ + HELM_SOURCE_DIR="$scratch/tools-source" RUNTIME_IMAGE_METADATA_DIR="$scratch/tools-metadata" "$@" +} +printf 'apiVersion: v2\nname: kagent\nversion: ${VERSION}\ndependencies:\n - name: kagent-tools\n version: %s\n' \ + 0.3.1 > "$scratch/tools-source/kagent/Chart-template.yaml" + +check="tools-image-metadata resolves the pinned kagent-tools version" +TOOLS_FIXTURE_DIGEST="$tools_digest" resolve_tools TOOLS_IMAGE_REPOSITORY=registry.example/team/tools +grep -qx 'docker buildx imagetools inspect registry.example/team/tools:0.3.1' "$scratch/tools.log" +jq -e --arg digest "$tools_digest" \ + '. == {image: ("registry.example/team/tools@" + $digest), version: "0.3.1"}' \ + "$scratch/tools-metadata/kagent-tools.json" >/dev/null + +check="tools-image-metadata records no digest for kagent-tools without skills" +printf 'apiVersion: v2\nname: kagent\nversion: ${VERSION}\ndependencies:\n - name: kagent-tools\n version: %s\n' \ + 0.3.0 > "$scratch/tools-source/kagent/Chart-template.yaml" +: > "$scratch/tools.log" +TOOLS_FIXTURE_DIGEST="$tools_digest" resolve_tools +test ! -e "$scratch/tools-metadata/kagent-tools.json" +test ! -s "$scratch/tools.log" +printf 'apiVersion: v2\nname: kagent\nversion: ${VERSION}\ndependencies:\n - name: kagent-tools\n version: %s\n' \ + 0.3.1 > "$scratch/tools-source/kagent/Chart-template.yaml" +TOOLS_FIXTURE_DIGEST="$tools_digest" resolve_tools + +check="tools-image-metadata requires a resolved digest and drops the old file" +test -e "$scratch/tools-metadata/kagent-tools.json" +if TOOLS_FIXTURE_DIGEST= resolve_tools; then + exit 1 +fi +[[ "$(< "$scratch/command.log")" == *'could not resolve the digest of ghcr.io/kagent-dev/kagent/tools:0.3.1'* ]] +test ! -e "$scratch/tools-metadata/kagent-tools.json" + +check="tools-image-metadata requires an exact kagent-tools version" +printf 'apiVersion: v2\nname: kagent\nversion: ${VERSION}\ndependencies:\n - name: kagent-tools\n version: "~0.3.0"\n' \ + > "$scratch/tools-source/kagent/Chart-template.yaml" +if TOOLS_FIXTURE_DIGEST="$tools_digest" resolve_tools; then + exit 1 +fi +[[ "$(< "$scratch/command.log")" == *'must pin an exact version'* ]] + +check="helm-publish resolves the kagent-tools digest before packaging" +release -n helm-publish +tools_line="$(grep -n 'tools-image-metadata.sh' "$scratch/command.log" | head -1 | cut -d: -f1)" +package_line="$(grep -n 'package-release-charts.sh' "$scratch/command.log" | head -1 | cut -d: -f1)" +test -n "$tools_line" && test -n "$package_line" && ((tools_line < package_line)) + # Run the real development Make targets with container-tool fixtures. Docker's # final Buildx digest and Podman's pushed digest must both reach the installed # source chart, including when the image names and tags are overridden. diff --git a/scripts/tools-image-metadata.sh b/scripts/tools-image-metadata.sh new file mode 100644 index 0000000000..a649d1d573 --- /dev/null +++ b/scripts/tools-image-metadata.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Called by make tools-image-metadata. kagent-tools releases from its own +# repository, so no build in this repository records the image digest. This +# script looks it up in the registry. +chart_template="${1:?kagent Chart-template.yaml is required}" +repository="${2:?kagent-tools image repository is required}" +output="${3:?output file is required}" + +command -v yq >/dev/null || { echo "tools-image-metadata.sh needs yq (mikefarah yq v4)" >&2; exit 1; } +mkdir -p "$(dirname "$output")" +rm -f "$output" +version="$(yq -e '.dependencies[] | select(.name == "kagent-tools") | .version' "$chart_template")" +if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then + echo "The kagent-tools dependency in $chart_template must pin an exact version, got: $version" >&2 + exit 1 +fi +# kagent-tools 0.3.0 and earlier ship no opt/kagent-tools-skills folder. Record +# no digest for them, so the example Agents need an explicit skillsImage. +last_without_skills="0.3.0" +if [[ "$(printf '%s\n%s\n' "$version" "$last_without_skills" | sort -V | tail -n1)" == "$last_without_skills" ]]; then + echo "kagent-tools $version has no skills folder, so no digest is recorded" >&2 + exit 0 +fi +# The kagent-tools chart tags its image with the chart version. +image="$repository:$version" + +# Read all output so Docker does not get a broken pipe under pipefail. +digest="$(docker buildx imagetools inspect "$image" | awk '$1 == "Digest:" { print $2 }')" +if [[ ! "$digest" =~ ^sha256:[a-f0-9]{64}$ ]]; then + echo "could not resolve the digest of $image" >&2 + exit 1 +fi +jq -n --arg image "$repository@$digest" --arg version "$version" '{image: $image, version: $version}' > "$output"