Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion (non-blocking). kagent-dev/tools#98 adds ghcr.io/kagent-dev/kagent/tools-skills, which holds only this skills root. The packaged default here still pins the full tools image, so every example Agent actor pulls the CLIs just to read the skill files. Once #98 ships, consider pointing TOOLS_IMAGE_REPOSITORY (and the docs and values examples) at tools-skills. If you do, last_without_skills in scripts/tools-image-metadata.sh has to move too. tools-skills is first published in v0.4.0, so a 0.3.x dependency would fail to resolve.

Evidence (both images built locally from tools#98 with make docker-build docker-build-skills)
$ docker images (content size / compressed)
ghcr.io/kagent-dev/kagent/tools:v0.3.0-4-gef15542         784MB / 205MB
ghcr.io/kagent-dev/kagent/tools-skills:v0.3.0-4-gef15542  84.9kB / 7.04kB   (1 layer)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 written by AI, reviewed by me 🤖

Agreed. I'll make this change in the PR that bumps kagent-tools to 0.4.0. It will point TOOLS_IMAGE_REPOSITORY, the docs and the values examples at tools-skills, and move last_without_skills with them. Doing it here would break packaging, since no 0.3.x release has that image.

# 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
Expand Down Expand Up @@ -596,9 +599,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

Expand Down
10 changes: 10 additions & 0 deletions docs/architecture/configuration-and-compilation.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,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
Expand Down
1 change: 1 addition & 0 deletions go/core/pkg/env/testing.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
9 changes: 9 additions & 0 deletions go/core/test/e2e/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
163 changes: 163 additions & 0 deletions go/core/test/e2e/example_agent_skills_test.go
Original file line number Diff line number Diff line change
@@ -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 == "" {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question. Nothing in CI sets KAGENT_E2E_TOOLS_SKILLS_IMAGE, so this test always skips there. When it does run, it also drops the chart's tool binding. That means no automated check confirms that the Agents as rendered, with their 29- and 31-tool bindings to kagent-tool-server, ever become Ready. Is there a plan to wire this up in the follow-up dependency bump, for example by setting the env var from the packaged digest? Or would you add a check now that applies the rendered manifests?

Evidence
$ grep -rln KAGENT_E2E_TOOLS_SKILLS_IMAGE .github Makefile go/Makefile scripts | wc -l
0

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 written by AI, reviewed by me 🤖

Yes, in the same bump PR. It will set KAGENT_E2E_TOOLS_SKILLS_IMAGE from the packaged digest. No released kagent-tools image has the skills yet, so CI has nothing to point at today.

The test still leaves out the tool binding, because the E2E install doesn't run kagent-tools. Until that changes, I checked the rendered Agents by hand on a Kind cluster with the tool server. All three became Ready with the split bindings, and the gated tools paused for approval.

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)
}
2 changes: 1 addition & 1 deletion go/core/test/e2e/interaction_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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}`
Expand Down
Loading
Loading