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
15 changes: 15 additions & 0 deletions go/api/adk/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -427,6 +427,18 @@ func (c *AgentCompressionConfig) UnmarshalJSON(data []byte) error {
return nil
}

// HookConfig is the serialized representation of a single hook sent in config.json
// to the Python runtime. Dir is the absolute path to the hook's mounted directory.
// See HookSpec in go/api/v1alpha2/agent_types.go for the CRD definition and
// _hooks.py in the Python ADK for the runtime implementation.
type HookConfig struct {
Event string `json:"event"` // "PreToolUse", "PostToolUse", "SessionStart", "SessionEnd"
Type string `json:"type"` // "claude-command"
Matcher string `json:"matcher,omitempty"` // ECMAScript regex; empty means match all tools
Command string `json:"command"` // executable path relative to Dir
Dir string `json:"dir"` // absolute mount path, e.g. /hooks/my-image
}

// See `python/packages/kagent-adk/src/kagent/adk/types.py` for the python version of this
type AgentConfig struct {
Model Model `json:"model"`
Expand All @@ -439,6 +451,7 @@ type AgentConfig struct {
Stream *bool `json:"stream,omitempty"`
Memory *MemoryConfig `json:"memory,omitempty"`
ContextConfig *AgentContextConfig `json:"context_config,omitempty"`
Hooks []HookConfig `json:"hooks,omitempty"`
}

// GetStream returns the stream value or default if not set
Expand Down Expand Up @@ -469,6 +482,7 @@ func (a *AgentConfig) UnmarshalJSON(data []byte) error {
Stream *bool `json:"stream,omitempty"`
Memory json.RawMessage `json:"memory"`
ContextConfig *AgentContextConfig `json:"context_config,omitempty"`
Hooks []HookConfig `json:"hooks,omitempty"`
}
if err := json.Unmarshal(data, &tmp); err != nil {
return err
Expand Down Expand Up @@ -497,6 +511,7 @@ func (a *AgentConfig) UnmarshalJSON(data []byte) error {
a.Stream = tmp.Stream
a.Memory = memory
a.ContextConfig = tmp.ContextConfig
a.Hooks = tmp.Hooks
return nil
}

Expand Down
53 changes: 53 additions & 0 deletions go/api/config/crd/bases/kagent.dev_agents.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -10166,6 +10166,59 @@ spec:
rule: '!has(self.systemMessage) || !has(self.systemMessageFrom)'
description:
type: string
hooks:
description: |-
Hooks defines lifecycle hooks for this agent.
Hook commands are loaded from OCI images into the /hooks directory,
similar to how skills are loaded. If the same container ref is used
by multiple hooks it is mounted only once.
items:
description: |-
HookSpec defines a single hook to fire on an agent lifecycle event.
Hook commands are loaded from OCI container images, similar to Skills.
If the same ref is used by multiple hooks it is only mounted once.
properties:
command:
description: |-
Command is the executable (and optional arguments) to run inside the hook
container directory, e.g. "check-tool-use.sh" or "python3 audit.py".
minLength: 1
type: string
event:
description: Event is the lifecycle event that triggers this
hook.
enum:
- PreToolUse
- PostToolUse
- SessionStart
- SessionEnd
type: string
matcher:
description: |-
Matcher is an optional ECMAScript-compatible regex matched against the tool name.
Only applicable for PreToolUse and PostToolUse events.
When absent, the hook fires for all tool invocations.
type: string
ref:
description: Ref is the OCI container image that contains the
hook command.
minLength: 1
type: string
type:
default: claude-command
description: |-
Type is the hook protocol used for stdin/stdout communication.
Currently only "claude-command" is supported.
enum:
- claude-command
type: string
required:
- command
- event
- ref
type: object
maxItems: 20
type: array
skills:
description: |-
Skills to load into the agent. They will be pulled from the specified container images.
Expand Down
62 changes: 62 additions & 0 deletions go/api/v1alpha2/agent_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,68 @@ type AgentSpec struct {
// See: https://gateway-api.sigs.k8s.io/guides/multiple-ns/#cross-namespace-routing
// +optional
AllowedNamespaces *AllowedNamespaces `json:"allowedNamespaces,omitempty"`

// Hooks defines lifecycle hooks for this agent.
// Hook commands are loaded from OCI images into the /hooks directory,
// similar to how skills are loaded. If the same container ref is used
// by multiple hooks it is mounted only once.
// +optional
// +kubebuilder:validation:MaxItems=20
Hooks []HookSpec `json:"hooks,omitempty"`
}

// HookEventType identifies the agent lifecycle event that triggers a hook.
// +kubebuilder:validation:Enum=PreToolUse;PostToolUse;SessionStart;SessionEnd
type HookEventType string

const (
HookEvent_PreToolUse HookEventType = "PreToolUse"
HookEvent_PostToolUse HookEventType = "PostToolUse"
HookEvent_SessionStart HookEventType = "SessionStart"
HookEvent_SessionEnd HookEventType = "SessionEnd"
)

// HookProtocolType specifies the stdin/stdout protocol used by a hook command.
// +kubebuilder:validation:Enum=claude-command
type HookProtocolType string

const (
// HookProtocol_ClaudeCommand uses the Claude Code hook protocol:
// the hook reads a JSON object from stdin and writes a JSON object to stdout,
// or writes an error message to stderr and exits with code 2.
HookProtocol_ClaudeCommand HookProtocolType = "claude-command"
)

// HookSpec defines a single hook to fire on an agent lifecycle event.
// Hook commands are loaded from OCI container images, similar to Skills.
// If the same ref is used by multiple hooks it is only mounted once.
type HookSpec struct {
// Ref is the OCI container image that contains the hook command.
// +kubebuilder:validation:Required
// +kubebuilder:validation:MinLength=1
Ref string `json:"ref"`

// Event is the lifecycle event that triggers this hook.
// +kubebuilder:validation:Required
Event HookEventType `json:"event"`

// Type is the hook protocol used for stdin/stdout communication.
// Currently only "claude-command" is supported.
// +optional
// +kubebuilder:default=claude-command
Type HookProtocolType `json:"type,omitempty"`

// Matcher is an optional ECMAScript-compatible regex matched against the tool name.
// Only applicable for PreToolUse and PostToolUse events.
// When absent, the hook fires for all tool invocations.
// +optional
Matcher string `json:"matcher,omitempty"`

// Command is the executable (and optional arguments) to run inside the hook
// container directory, e.g. "check-tool-use.sh" or "python3 audit.py".
// +kubebuilder:validation:Required
// +kubebuilder:validation:MinLength=1
Command string `json:"command"`
}

// +kubebuilder:validation:AtLeastOneOf=refs,gitRefs
Expand Down
20 changes: 20 additions & 0 deletions go/api/v1alpha2/zz_generated.deepcopy.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

116 changes: 116 additions & 0 deletions go/core/internal/controller/translator/agent/adk_api_translator.go
Original file line number Diff line number Diff line change
Expand Up @@ -475,6 +475,41 @@ func (a *adkApiTranslator) buildManifest(
volumes = append(volumes, skillsVolumes...)
}

// Hooks: deduplicate OCI refs and mount hook container images into /hooks
var hookRefs []string
seenHookRefs := map[string]bool{}
for _, hook := range agent.Spec.Hooks {
if !seenHookRefs[hook.Ref] {
seenHookRefs[hook.Ref] = true
hookRefs = append(hookRefs, hook.Ref)
}
}
hasHooks := len(hookRefs) > 0

if hasHooks {
volumes = append(volumes, corev1.Volume{
Name: "kagent-hooks",
VolumeSource: corev1.VolumeSource{
EmptyDir: &corev1.EmptyDirVolumeSource{},
},
})
volumeMounts = append(volumeMounts, corev1.VolumeMount{
Name: "kagent-hooks",
MountPath: "/hooks",
ReadOnly: true,
})
sharedEnv = append(sharedEnv, corev1.EnvVar{
Name: env.KagentHooksFolder.Name(),
Value: "/hooks",
})

hooksContainer, err := buildHooksInitContainer(hookRefs, dep.SecurityContext)
if err != nil {
return nil, fmt.Errorf("failed to build hooks init container: %w", err)
}
initContainers = append(initContainers, hooksContainer)
}

// Token volume
volumes = append(volumes, corev1.Volume{
Name: "kagent-token",
Expand Down Expand Up @@ -769,6 +804,17 @@ func (a *adkApiTranslator) translateInlineAgent(ctx context.Context, agent *v1al
cfg.Instruction = resolved
}

// Translate hooks into AgentConfig
for _, hook := range agent.Spec.Hooks {
cfg.Hooks = append(cfg.Hooks, adk.HookConfig{
Event: string(hook.Event),
Type: string(hook.Type),
Matcher: hook.Matcher,
Command: hook.Command,
Dir: "/hooks/" + ociHookName(hook.Ref),
})
}

return cfg, mdd, secretHashBytes, nil
}

Expand Down Expand Up @@ -1648,6 +1694,76 @@ func ociSkillName(imageRef string) string {
return path.Base(ref)
}

// hooksInitData holds the template data for the hooks-init shell script.
type hooksInitData struct {
OCIRefs []ociRefData // OCI images to pull; reuses ociRefData with Dest=/hooks/<name>
InsecureOCI bool // --insecure flag for krane
}

//go:embed hooks-init.sh.tmpl
var hooksInitScriptTmpl string

// hooksScriptTemplate is the shell script template for fetching hook OCI images.
var hooksScriptTemplate = template.Must(template.New("hooks-init").Parse(hooksInitScriptTmpl))

// buildHooksScript renders the hooks-init shell script.
func buildHooksScript(data hooksInitData) (string, error) {
var buf bytes.Buffer
if err := hooksScriptTemplate.Execute(&buf, data); err != nil {
return "", fmt.Errorf("failed to render hooks init script: %w", err)
}
return buf.String(), nil
}

// ociHookName extracts a hook directory name from an OCI image reference.
// Uses the same logic as ociSkillName: takes the last path component stripped of tag/digest.
func ociHookName(imageRef string) string {
return ociSkillName(imageRef)
}

// buildHooksInitContainer creates the init container that fetches hook OCI images
// into the /hooks EmptyDir volume. Deduplication of refs is handled by the caller.
func buildHooksInitContainer(
hookRefs []string,
securityContext *corev1.SecurityContext,
) (corev1.Container, error) {
data := hooksInitData{}
seenNames := map[string]bool{}
for _, imageRef := range hookRefs {
name := ociHookName(imageRef)
if seenNames[name] {
return corev1.Container{}, NewValidationError(
"hook OCI refs produce duplicate directory name %q; use distinct image names", name,
)
}
seenNames[name] = true
data.OCIRefs = append(data.OCIRefs, ociRefData{
Image: imageRef,
Dest: "/hooks/" + name,
})
}

script, err := buildHooksScript(data)
if err != nil {
return corev1.Container{}, err
}

initSecCtx := securityContext
if initSecCtx != nil {
initSecCtx = initSecCtx.DeepCopy()
}

return corev1.Container{
Name: "hooks-init",
Image: DefaultSkillsInitImageConfig.Image(), // reuses skills-init image (has krane)
Command: []string{"/bin/sh", "-c", script},
VolumeMounts: []corev1.VolumeMount{
{Name: "kagent-hooks", MountPath: "/hooks"},
},
SecurityContext: initSecCtx,
}, nil
}

// prepareSkillsInitData converts CRD values to the template-ready data struct.
// It validates subPaths and detects duplicate skill directory names.
func prepareSkillsInitData(
Expand Down
29 changes: 29 additions & 0 deletions go/core/internal/controller/translator/agent/hooks-init.sh.tmpl
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
set -e
{{- range .OCIRefs }}
_image="$(cat <<'ENDVAL'
{{ .Image }}
ENDVAL
)"
_dest="$(cat <<'ENDVAL'
{{ .Dest }}
ENDVAL
)"
echo "Exporting OCI hook image ${_image} into ${_dest}"
_uname="$(uname -m)"
case "$_uname" in
x86_64|amd64)
_arch="amd64"
;;
aarch64|arm64)
_arch="arm64"
;;
*)
echo "Unsupported architecture for OCI export: ${_uname}" >&2
exit 1
;;
esac
krane export{{ if $.InsecureOCI }} --insecure{{ end }} --platform "linux/${_arch}" "$_image" '/tmp/oci-hook.tar'
mkdir -p "$_dest"
tar xf '/tmp/oci-hook.tar' -C "$_dest"
rm -f '/tmp/oci-hook.tar'
{{- end }}
Loading
Loading