Skip to content
Open
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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ jobs:
--output=type=tar,dest=/dev/null
DOCKER_REPO: "${{ github.repository_owner }}/tools"
DOCKER_BUILDER: "docker buildx"
run: make docker-build
run: make docker-build docker-build-skills
working-directory: ./

go-unit-tests:
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/tag.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ jobs:
fi
make docker-build
make helm-publish
make docker-build-skills
release:
# Only run release after images and helm chart are pushed
# In the future we can take the chart from the helm action,
Expand Down
6 changes: 4 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -362,13 +362,15 @@ Types: `feat`, `fix`, `docs`, `refactor`, `test`, `chore`, `perf`, `ci`

### Plugin bundle (kagent 1.x skills)

The image doubles as a kagent Agent Plugin (see [docs/kagent-skills.md](docs/kagent-skills.md)).
The tools image doubles as a kagent Agent Plugin, and `make docker-build-skills` builds the
skills-only root as its own image, `tools-skills` (see [docs/kagent-skills.md](docs/kagent-skills.md)).
New tool providers should add or extend a skill in `plugin/skills/<name>/SKILL.md` and keep
`cmd/plugin_bundle_test.go` passing; it checks manifests, `mcp.json`, skill frontmatter, that
every tool a skill names is registered, and that every `` `argument: value` `` on a skill line is an
input of a tool named on that line. `cmd/plugin_docs_test.go` checks the `AgentTemplate` examples
in `docs/kagent-skills.md` against kagent's rules (`modelConfig`, at most 50 bound tools, and a
`tools:` list that covers the selected skills). Skills must only name tools that work with the
`tools:` list that covers the selected skills) and that each example mounts its root from the
right image. Skills must only name tools that work with the
chart defaults (no tool that needs an unconfigured LLM key). Run `make plugin-verify` after
changing the `Dockerfile` or `plugin/`.

Expand Down
10 changes: 10 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,16 @@ RUN set -eu; \
done; \
chown -R 65532:65532 /out/opt

# The tools-skills image holds only the skills root, so an Agent that mounts it
# does not pull the CLIs. The last stage is the default build target, so keep
# this stage above it.
FROM scratch AS skills
ARG VERSION
COPY --from=plugin /out/opt/kagent-tools-skills /opt/kagent-tools-skills
LABEL org.opencontainers.image.source=https://github.com/kagent-dev/tools
LABEL org.opencontainers.image.description="kagent Agent Plugin with the kagent-tools skills only"
LABEL org.opencontainers.image.version="$VERSION"

FROM gcr.io/distroless/static:nonroot

WORKDIR /
Expand Down
10 changes: 8 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,8 @@ retag: docker-build helm-version
TOOLS_IMAGE_NAME ?= tools
TOOLS_IMAGE_TAG ?= $(VERSION)
TOOLS_IMG ?= $(DOCKER_REGISTRY)/$(DOCKER_REPO)/$(TOOLS_IMAGE_NAME):$(TOOLS_IMAGE_TAG)
SKILLS_IMAGE_NAME ?= tools-skills
SKILLS_IMG ?= $(DOCKER_REGISTRY)/$(DOCKER_REPO)/$(SKILLS_IMAGE_NAME):$(TOOLS_IMAGE_TAG)

RETAGGED_DOCKER_REGISTRY = cr.kagent.dev
RETAGGED_TOOLS_IMG = $(RETAGGED_DOCKER_REGISTRY)/$(DOCKER_REPO)/$(TOOLS_IMAGE_NAME):$(TOOLS_IMAGE_TAG)
Expand Down Expand Up @@ -161,9 +163,13 @@ buildx-create:
docker-build: fmt buildx-create
$(DOCKER_BUILDER) build $(DOCKER_BUILD_ARGS) $(TOOLS_IMAGE_BUILD_ARGS) -t $(TOOLS_IMG) -f Dockerfile ./

.PHONY: docker-build-skills # build the skills-only image
docker-build-skills: buildx-create
$(DOCKER_BUILDER) build $(DOCKER_BUILD_ARGS) --build-arg VERSION=$(VERSION) --target skills -t $(SKILLS_IMG) -f Dockerfile ./

.PHONY: plugin-verify
plugin-verify: docker-build ## Verify the image is a valid kagent Agent Plugin (layout, limits, stdio)
./scripts/verify-plugin-image.sh $(TOOLS_IMG)
plugin-verify: docker-build docker-build-skills ## Verify the images are valid kagent Agent Plugins (layout, limits, stdio)
./scripts/verify-plugin-image.sh $(TOOLS_IMG) $(SKILLS_IMG)

.PHONY: docker-build # build tools image for amd64 and arm64
docker-build-all: fmt buildx-create
Expand Down
9 changes: 5 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,10 +47,11 @@ helm ls -A

- **kagent 1.x skills bundle (OCI image)**

The same image is a kagent Agent Plugin: mount it in an `AgentTemplate` with `source.oci`
(digest-pinned) and `path: opt/kagent-tools-skills` to get the operational skills, and bind the
tools through a `RemoteMCPServer`. The full root, `opt/kagent-tools` (MCP tools, all CLIs and
skills in one bundle), is over kagent's 100 MiB package limit and needs a future kagent release.
The operational skills ship as a kagent Agent Plugin image, `ghcr.io/kagent-dev/kagent/tools-skills`.
To use them, mount the image in an `AgentTemplate` with a digest-pinned `source.oci` and
`path: opt/kagent-tools-skills`, and bind the tools through a `RemoteMCPServer`. The full root,
`opt/kagent-tools` in `ghcr.io/kagent-dev/kagent/tools`, bundles the MCP tools, all CLIs, and the
skills. It is over kagent's 100 MiB package limit and needs a future kagent release.
See [Using kagent-tools as a kagent 1.x skills bundle](docs/kagent-skills.md).

## Quickstart Guide
Expand Down
7 changes: 6 additions & 1 deletion cmd/plugin_docs_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,12 @@ func TestSkillsGuideAgentTemplatesAreValid(t *testing.T) {
}

for _, plugin := range template.Spec.Plugins {
require.Contains(t, plugin.Source.OCI, "@sha256:", "example %d: pin the image by digest", index)
image := map[string]string{
skillsOnlyRoot: "ghcr.io/kagent-dev/kagent/tools-skills@sha256:",
"opt/kagent-tools": "ghcr.io/kagent-dev/kagent/tools@sha256:",
}[plugin.Source.Path]
require.NotEmptyf(t, image, "example %d: unknown plugin root %q", index, plugin.Source.Path)
require.Truef(t, strings.HasPrefix(plugin.Source.OCI, image), "example %d: %s must come from %s<digest>", index, plugin.Source.Path, image)
if plugin.Source.Path != skillsOnlyRoot || len(boundTools) == 0 {
continue
}
Expand Down
59 changes: 59 additions & 0 deletions cmd/plugin_image_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@ package main

import (
"io/fs"
"maps"
"os"
"path/filepath"
"slices"
"strings"
"testing"

Expand Down Expand Up @@ -95,3 +97,60 @@ func TestPluginImageLayout(t *testing.T) {
require.Equal(t, expectedTarget, target, link)
}
}

// TestSkillsImageMatchesToolsImage checks that the skills-only image ships
// the tools image's opt/kagent-tools-skills and nothing else.
func TestSkillsImageMatchesToolsImage(t *testing.T) {
toolsRoot := os.Getenv("KAGENT_TOOLS_IMAGE_ROOT")
skillsImageRoot := os.Getenv("KAGENT_TOOLS_SKILLS_IMAGE_ROOT")
if toolsRoot == "" || skillsImageRoot == "" {
t.Skip("set KAGENT_TOOLS_IMAGE_ROOT and KAGENT_TOOLS_SKILLS_IMAGE_ROOT to exported image filesystems (scripts/verify-plugin-image.sh)")
}

// docker export adds .dockerenv, dev, etc, proc, and sys to every container.
top, err := os.ReadDir(skillsImageRoot)
require.NoError(t, err)
for _, entry := range top {
require.Contains(t, []string{".dockerenv", "dev", "etc", "proc", "sys", "opt"}, entry.Name(), "unexpected top-level entry in the skills image")
}
opt, err := os.ReadDir(filepath.Join(skillsImageRoot, "opt"))
require.NoError(t, err)
require.Len(t, opt, 1)
require.Equal(t, "kagent-tools-skills", opt[0].Name())

want := snapshotTree(t, filepath.Join(toolsRoot, "opt", "kagent-tools-skills"))
got := snapshotTree(t, filepath.Join(skillsImageRoot, "opt", "kagent-tools-skills"))
require.NotEmpty(t, want)
require.Equal(t, slices.Sorted(maps.Keys(want)), slices.Sorted(maps.Keys(got)))
for path, entry := range want {
require.Equal(t, entry, got[path], path)
}
}

// snapshotTree maps each path under root to its mode, plus the content of a
// file or the target of a symlink.
func snapshotTree(t *testing.T, root string) map[string]string {
t.Helper()
tree := map[string]string{}
err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error {
require.NoError(t, err)
rel, err := filepath.Rel(root, path)
require.NoError(t, err)
info, err := entry.Info()
require.NoError(t, err)
tree[rel] = info.Mode().String()
switch {
case info.Mode().IsRegular():
content, err := os.ReadFile(path)
require.NoError(t, err)
tree[rel] += "\n" + string(content)
case info.Mode()&os.ModeSymlink != 0:
target, err := os.Readlink(path)
require.NoError(t, err)
tree[rel] += " -> " + target
}
return nil
})
require.NoError(t, err)
return tree
}
36 changes: 20 additions & 16 deletions docs/kagent-skills.md
Original file line number Diff line number Diff line change
@@ -1,20 +1,22 @@
# Using kagent-tools as a kagent 1.x skills bundle

The kagent-tools image is also a kagent 1.x **Agent Plugin**: an agent can mount it read-only
as an OCI image volume to get a set of operational skills. Today, mount the skills-only root and
bind the tools through a `RemoteMCPServer`. The full root, which also ships the MCP server and all
CLIs in one bundle, needs a future kagent release (see [Requirements](#requirements)).
kagent-tools ships its operational skills as a kagent 1.x **Agent Plugin**. An agent mounts the
plugin image read-only, as an OCI image volume. Today, mount the skills-only image,
`ghcr.io/kagent-dev/kagent/tools-skills`, and bind the tools through a `RemoteMCPServer`. The
full root also ships the MCP server and all CLIs in one bundle. It needs a future kagent release
(see [Requirements](#requirements)).

## What you get

The image contains two plugin roots. Select **one** of them per agent.
Two images carry plugin roots, starting with v0.4.0. Select **one** root per agent.

| Root (`source.path`) | Contents |
|---|---|
| `opt/kagent-tools-skills` | `plugin.json` and `skills/` only (use with a `RemoteMCPServer`). Works on current kagent 1.x. |
| `opt/kagent-tools` | `plugin.json`, `mcp.json` (stdio MCP server), `bin/` (`kagent-tools`, `kubectl`, `helm`, `istioctl`, `kubectl-argo-rollouts`, `cilium`), `config/kubeconfig`, `skills/`. Needs a future kagent release. |
| Image | Root (`source.path`) | Contents |
|---|---|---|
| `ghcr.io/kagent-dev/kagent/tools-skills` | `opt/kagent-tools-skills` | `plugin.json` and `skills/` only (use with a `RemoteMCPServer`). A few KiB. Works on current kagent 1.x. |
| `ghcr.io/kagent-dev/kagent/tools` | `opt/kagent-tools-skills` | The same skills-only root, inside the full tools image. Mounting this root pulls the whole tools image, so prefer `tools-skills`. |
| `ghcr.io/kagent-dev/kagent/tools` | `opt/kagent-tools` | `plugin.json`, `mcp.json` (stdio MCP server), `bin/` (`kagent-tools`, `kubectl`, `helm`, `istioctl`, `kubectl-argo-rollouts`, `cilium`), `config/kubeconfig`, `skills/`. Needs a future kagent release. |

Skills (identical in both roots):
Skills (identical in every root):

| Skill | Use it for |
|---|---|
Expand All @@ -39,16 +41,17 @@ Skills are playbooks that call the MCP tools by name (`k8s_get_resources`, `helm
- For the full root (`opt/kagent-tools`): a kagent release that does not apply the 100 MiB
package-size limit to platform-mounted OCI packages. The full root is about 550 MiB because it
contains the CLIs. Older kagent releases must use [skills-only mode](#skills-only-mode).
- The image pinned by digest. Print it with:
- Each image pinned by digest. Print the digest with:

```bash
docker buildx imagetools inspect ghcr.io/kagent-dev/kagent/tools-skills:<tag>
docker buildx imagetools inspect ghcr.io/kagent-dev/kagent/tools:<tag>
```

## Skills-only mode

This works on current kagent 1.x releases and with every harness. Deploy the server with the Helm
chart as usual, reference it as a `RemoteMCPServer`, and mount the skills-only root:
chart as usual, reference it as a `RemoteMCPServer`, and mount the skills-only image:

```yaml
apiVersion: api.kagent.dev/v1alpha3
Expand Down Expand Up @@ -90,7 +93,7 @@ spec:
- k8s_scale
plugins:
- source:
oci: ghcr.io/kagent-dev/kagent/tools@sha256:<digest>
oci: ghcr.io/kagent-dev/kagent/tools-skills@sha256:<digest>
path: opt/kagent-tools-skills
skills:
- k8s-troubleshooting
Expand Down Expand Up @@ -246,6 +249,7 @@ by `mcp.json` to the bundled kubeconfig) > in-cluster config. The resolved path
make plugin-verify
```

This builds the image, exports its filesystem, checks both roots against kagent's package rules
(file types, symlinks, entry count, skills-only size), and runs an MCP `initialize` +
`tools/list` over stdio to confirm stdout carries only JSON-RPC.
This builds the tools and tools-skills images and exports their filesystems. It checks both roots
against kagent's package rules (file types, symlinks, entry count, skills-only size). It checks
that the tools-skills image holds the same skills root as the tools image. Last, it runs an MCP
`initialize` and `tools/list` over stdio to confirm that stdout carries only JSON-RPC.
28 changes: 22 additions & 6 deletions scripts/verify-plugin-image.sh
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
#!/usr/bin/env bash
# Verifies that a built kagent-tools image is a valid kagent Agent Plugin.
# Verifies that a built kagent-tools image is a valid kagent Agent Plugin. Given a
# second image, it also checks that the skills-only image holds the same skills root.
set -euo pipefail

image="${1:?usage: $0 <image>}"
image="${1:?usage: $0 <image> [<skills-image>]}"
skills_image="${2:-}"
workdir="$(mktemp -d)"
container=""
cleanup() {
Expand All @@ -11,10 +13,24 @@ cleanup() {
}
trap cleanup EXIT

container="$(docker create "$image")"
docker export "$container" | tar -x -C "$workdir"
export_image() {
# The skills image has no command. docker create requires one but never runs it.
container="$(docker create "$1" /unused)"
mkdir -p "$2"
docker export "$container" | tar -x -C "$2"
docker rm "$container" >/dev/null
container=""
}

KAGENT_TOOLS_IMAGE_ROOT="$workdir" go test ./cmd -run TestPluginImageLayout -count=1 -v
export_image "$image" "$workdir/tools"
export KAGENT_TOOLS_IMAGE_ROOT="$workdir/tools"
tests=TestPluginImageLayout
if [[ -n "$skills_image" ]]; then
export_image "$skills_image" "$workdir/skills"
export KAGENT_TOOLS_SKILLS_IMAGE_ROOT="$workdir/skills"
tests="$tests|TestSkillsImageMatchesToolsImage"
fi
go test ./cmd -run "^($tests)\$" -count=1 -v

echo "--- legacy entrypoints"
docker run --rm --entrypoint /tool-server "$image" --version
Expand All @@ -35,4 +51,4 @@ done
if grep -qv '^{"jsonrpc"' <<<"$response"; then
echo "non JSON-RPC output on stdout:"; grep -v '^{"jsonrpc"' <<<"$response" | head -5; exit 1
fi
echo "plugin image OK: $image"
echo "plugin image OK: $image${skills_image:+, skills image OK: $skills_image}"
Loading