diff --git a/go/api/adk/types.go b/go/api/adk/types.go index aee673f09..48d05c27f 100644 --- a/go/api/adk/types.go +++ b/go/api/adk/types.go @@ -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"` @@ -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 @@ -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 @@ -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 } diff --git a/go/api/config/crd/bases/kagent.dev_agents.yaml b/go/api/config/crd/bases/kagent.dev_agents.yaml index a34ced941..873a14d5e 100644 --- a/go/api/config/crd/bases/kagent.dev_agents.yaml +++ b/go/api/config/crd/bases/kagent.dev_agents.yaml @@ -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. diff --git a/go/api/v1alpha2/agent_types.go b/go/api/v1alpha2/agent_types.go index db57fe3cd..917234afe 100644 --- a/go/api/v1alpha2/agent_types.go +++ b/go/api/v1alpha2/agent_types.go @@ -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 diff --git a/go/api/v1alpha2/zz_generated.deepcopy.go b/go/api/v1alpha2/zz_generated.deepcopy.go index 06ed3954e..1e83b42e2 100644 --- a/go/api/v1alpha2/zz_generated.deepcopy.go +++ b/go/api/v1alpha2/zz_generated.deepcopy.go @@ -170,6 +170,11 @@ func (in *AgentSpec) DeepCopyInto(out *AgentSpec) { *out = new(AllowedNamespaces) (*in).DeepCopyInto(*out) } + if in.Hooks != nil { + in, out := &in.Hooks, &out.Hooks + *out = make([]HookSpec, len(*in)) + copy(*out, *in) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AgentSpec. @@ -564,6 +569,21 @@ func (in *GitRepo) DeepCopy() *GitRepo { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *HookSpec) DeepCopyInto(out *HookSpec) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HookSpec. +func (in *HookSpec) DeepCopy() *HookSpec { + if in == nil { + return nil + } + out := new(HookSpec) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *MCPTool) DeepCopyInto(out *MCPTool) { *out = *in diff --git a/go/core/internal/controller/translator/agent/adk_api_translator.go b/go/core/internal/controller/translator/agent/adk_api_translator.go index 3b96ab2ad..68fb1d318 100644 --- a/go/core/internal/controller/translator/agent/adk_api_translator.go +++ b/go/core/internal/controller/translator/agent/adk_api_translator.go @@ -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", @@ -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 } @@ -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/ + 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( diff --git a/go/core/internal/controller/translator/agent/hooks-init.sh.tmpl b/go/core/internal/controller/translator/agent/hooks-init.sh.tmpl new file mode 100644 index 000000000..a8cacb5e9 --- /dev/null +++ b/go/core/internal/controller/translator/agent/hooks-init.sh.tmpl @@ -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 }} diff --git a/go/core/internal/controller/translator/agent/testdata/inputs/agent_with_hooks.yaml b/go/core/internal/controller/translator/agent/testdata/inputs/agent_with_hooks.yaml new file mode 100644 index 000000000..beda158fb --- /dev/null +++ b/go/core/internal/controller/translator/agent/testdata/inputs/agent_with_hooks.yaml @@ -0,0 +1,49 @@ +operation: translateAgent +targetObject: hooks-agent +namespace: test +objects: + - apiVersion: v1 + kind: Secret + metadata: + name: openai-secret + namespace: test + data: + api-key: c2stdGVzdC1hcGkta2V5 # base64 encoded "sk-test-api-key" + - apiVersion: kagent.dev/v1alpha2 + kind: ModelConfig + metadata: + name: basic-model + namespace: test + spec: + provider: OpenAI + model: gpt-4o + apiKeySecret: openai-secret + apiKeySecretKey: api-key + - apiVersion: kagent.dev/v1alpha2 + kind: Agent + metadata: + name: hooks-agent + namespace: test + spec: + hooks: + # Two hooks sharing the same OCI ref — should produce one hooks-init container + - ref: ghcr.io/org/security-hooks:v1.0 + event: PreToolUse + type: claude-command + matcher: "bash|shell_exec" + command: check-tool-use.sh + - ref: ghcr.io/org/security-hooks:v1.0 + event: SessionStart + type: claude-command + command: inject-session-metadata.py + # Second distinct OCI ref + - ref: ghcr.io/org/audit-hooks:v2.0 + event: PostToolUse + type: claude-command + command: audit.sh + type: Declarative + declarative: + description: An agent with lifecycle hooks + systemMessage: You are a helpful assistant. + modelConfig: basic-model + tools: [] diff --git a/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_hooks.json b/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_hooks.json new file mode 100644 index 000000000..b4bf84d63 --- /dev/null +++ b/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_hooks.json @@ -0,0 +1,337 @@ +{ + "agentCard": { + "capabilities": { + "pushNotifications": false, + "stateTransitionHistory": true, + "streaming": true + }, + "defaultInputModes": [ + "text" + ], + "defaultOutputModes": [ + "text" + ], + "description": "", + "name": "hooks_agent", + "skills": null, + "url": "http://hooks-agent.test:8080", + "version": "" + }, + "config": { + "description": "", + "hooks": [ + { + "command": "check-tool-use.sh", + "dir": "/hooks/security-hooks", + "event": "PreToolUse", + "matcher": "bash|shell_exec", + "type": "claude-command" + }, + { + "command": "inject-session-metadata.py", + "dir": "/hooks/security-hooks", + "event": "SessionStart", + "type": "claude-command" + }, + { + "command": "audit.sh", + "dir": "/hooks/audit-hooks", + "event": "PostToolUse", + "type": "claude-command" + } + ], + "instruction": "You are a helpful assistant.", + "model": { + "base_url": "", + "model": "gpt-4o", + "type": "openai" + }, + "stream": false + }, + "manifest": [ + { + "apiVersion": "v1", + "kind": "Secret", + "metadata": { + "labels": { + "app": "kagent", + "app.kubernetes.io/managed-by": "kagent", + "app.kubernetes.io/name": "hooks-agent", + "app.kubernetes.io/part-of": "kagent", + "kagent": "hooks-agent" + }, + "name": "hooks-agent", + "namespace": "test", + "ownerReferences": [ + { + "apiVersion": "kagent.dev/v1alpha2", + "blockOwnerDeletion": true, + "controller": true, + "kind": "Agent", + "name": "hooks-agent", + "uid": "" + } + ] + }, + "stringData": { + "agent-card.json": "{\"name\":\"hooks_agent\",\"description\":\"\",\"url\":\"http://hooks-agent.test:8080\",\"version\":\"\",\"capabilities\":{\"streaming\":true,\"pushNotifications\":false,\"stateTransitionHistory\":true},\"defaultInputModes\":[\"text\"],\"defaultOutputModes\":[\"text\"],\"skills\":[]}", + "config.json": "{\"model\":{\"type\":\"openai\",\"model\":\"gpt-4o\",\"base_url\":\"\"},\"description\":\"\",\"instruction\":\"You are a helpful assistant.\",\"stream\":false,\"hooks\":[{\"event\":\"PreToolUse\",\"type\":\"claude-command\",\"matcher\":\"bash|shell_exec\",\"command\":\"check-tool-use.sh\",\"dir\":\"/hooks/security-hooks\"},{\"event\":\"SessionStart\",\"type\":\"claude-command\",\"command\":\"inject-session-metadata.py\",\"dir\":\"/hooks/security-hooks\"},{\"event\":\"PostToolUse\",\"type\":\"claude-command\",\"command\":\"audit.sh\",\"dir\":\"/hooks/audit-hooks\"}]}" + } + }, + { + "apiVersion": "v1", + "kind": "ServiceAccount", + "metadata": { + "labels": { + "app": "kagent", + "app.kubernetes.io/managed-by": "kagent", + "app.kubernetes.io/name": "hooks-agent", + "app.kubernetes.io/part-of": "kagent", + "kagent": "hooks-agent" + }, + "name": "hooks-agent", + "namespace": "test", + "ownerReferences": [ + { + "apiVersion": "kagent.dev/v1alpha2", + "blockOwnerDeletion": true, + "controller": true, + "kind": "Agent", + "name": "hooks-agent", + "uid": "" + } + ] + } + }, + { + "apiVersion": "apps/v1", + "kind": "Deployment", + "metadata": { + "labels": { + "app": "kagent", + "app.kubernetes.io/managed-by": "kagent", + "app.kubernetes.io/name": "hooks-agent", + "app.kubernetes.io/part-of": "kagent", + "kagent": "hooks-agent" + }, + "name": "hooks-agent", + "namespace": "test", + "ownerReferences": [ + { + "apiVersion": "kagent.dev/v1alpha2", + "blockOwnerDeletion": true, + "controller": true, + "kind": "Agent", + "name": "hooks-agent", + "uid": "" + } + ] + }, + "spec": { + "selector": { + "matchLabels": { + "app": "kagent", + "kagent": "hooks-agent" + } + }, + "strategy": { + "rollingUpdate": { + "maxSurge": 1, + "maxUnavailable": 0 + }, + "type": "RollingUpdate" + }, + "template": { + "metadata": { + "annotations": { + "kagent.dev/config-hash": "16807592396288039943" + }, + "labels": { + "app": "kagent", + "app.kubernetes.io/managed-by": "kagent", + "app.kubernetes.io/name": "hooks-agent", + "app.kubernetes.io/part-of": "kagent", + "kagent": "hooks-agent" + } + }, + "spec": { + "containers": [ + { + "args": [ + "--host", + "0.0.0.0", + "--port", + "8080", + "--filepath", + "/config" + ], + "env": [ + { + "name": "OPENAI_API_KEY", + "valueFrom": { + "secretKeyRef": { + "key": "api-key", + "name": "openai-secret" + } + } + }, + { + "name": "KAGENT_NAMESPACE", + "valueFrom": { + "fieldRef": { + "fieldPath": "metadata.namespace" + } + } + }, + { + "name": "KAGENT_NAME", + "value": "hooks-agent" + }, + { + "name": "KAGENT_URL", + "value": "http://kagent-controller.kagent:8083" + }, + { + "name": "KAGENT_HOOKS_FOLDER", + "value": "/hooks" + } + ], + "image": "cr.kagent.dev/kagent-dev/kagent/app:dev", + "imagePullPolicy": "IfNotPresent", + "name": "kagent", + "ports": [ + { + "containerPort": 8080, + "name": "http" + } + ], + "readinessProbe": { + "httpGet": { + "path": "/.well-known/agent-card.json", + "port": "http" + }, + "initialDelaySeconds": 15, + "periodSeconds": 15, + "timeoutSeconds": 15 + }, + "resources": { + "limits": { + "cpu": "2", + "memory": "1Gi" + }, + "requests": { + "cpu": "100m", + "memory": "384Mi" + } + }, + "volumeMounts": [ + { + "mountPath": "/config", + "name": "config" + }, + { + "mountPath": "/hooks", + "name": "kagent-hooks", + "readOnly": true + }, + { + "mountPath": "/var/run/secrets/tokens", + "name": "kagent-token" + } + ] + } + ], + "initContainers": [ + { + "command": [ + "/bin/sh", + "-c", + "set -e\n_image=\"$(cat \u003c\u003c'ENDVAL'\nghcr.io/org/security-hooks:v1.0\nENDVAL\n)\"\n_dest=\"$(cat \u003c\u003c'ENDVAL'\n/hooks/security-hooks\nENDVAL\n)\"\necho \"Exporting OCI hook image ${_image} into ${_dest}\"\n_uname=\"$(uname -m)\"\ncase \"$_uname\" in\n x86_64|amd64)\n _arch=\"amd64\"\n ;;\n aarch64|arm64)\n _arch=\"arm64\"\n ;;\n *)\n echo \"Unsupported architecture for OCI export: ${_uname}\" \u003e\u00262\n exit 1\n ;;\nesac\nkrane export --platform \"linux/${_arch}\" \"$_image\" '/tmp/oci-hook.tar'\nmkdir -p \"$_dest\"\ntar xf '/tmp/oci-hook.tar' -C \"$_dest\"\nrm -f '/tmp/oci-hook.tar'\n_image=\"$(cat \u003c\u003c'ENDVAL'\nghcr.io/org/audit-hooks:v2.0\nENDVAL\n)\"\n_dest=\"$(cat \u003c\u003c'ENDVAL'\n/hooks/audit-hooks\nENDVAL\n)\"\necho \"Exporting OCI hook image ${_image} into ${_dest}\"\n_uname=\"$(uname -m)\"\ncase \"$_uname\" in\n x86_64|amd64)\n _arch=\"amd64\"\n ;;\n aarch64|arm64)\n _arch=\"arm64\"\n ;;\n *)\n echo \"Unsupported architecture for OCI export: ${_uname}\" \u003e\u00262\n exit 1\n ;;\nesac\nkrane export --platform \"linux/${_arch}\" \"$_image\" '/tmp/oci-hook.tar'\nmkdir -p \"$_dest\"\ntar xf '/tmp/oci-hook.tar' -C \"$_dest\"\nrm -f '/tmp/oci-hook.tar'\n" + ], + "image": "cr.kagent.dev/kagent-dev/kagent/skills-init:dev", + "name": "hooks-init", + "resources": {}, + "volumeMounts": [ + { + "mountPath": "/hooks", + "name": "kagent-hooks" + } + ] + } + ], + "serviceAccountName": "hooks-agent", + "volumes": [ + { + "name": "config", + "secret": { + "secretName": "hooks-agent" + } + }, + { + "emptyDir": {}, + "name": "kagent-hooks" + }, + { + "name": "kagent-token", + "projected": { + "sources": [ + { + "serviceAccountToken": { + "audience": "kagent", + "expirationSeconds": 3600, + "path": "kagent-token" + } + } + ] + } + } + ] + } + } + }, + "status": {} + }, + { + "apiVersion": "v1", + "kind": "Service", + "metadata": { + "labels": { + "app": "kagent", + "app.kubernetes.io/managed-by": "kagent", + "app.kubernetes.io/name": "hooks-agent", + "app.kubernetes.io/part-of": "kagent", + "kagent": "hooks-agent" + }, + "name": "hooks-agent", + "namespace": "test", + "ownerReferences": [ + { + "apiVersion": "kagent.dev/v1alpha2", + "blockOwnerDeletion": true, + "controller": true, + "kind": "Agent", + "name": "hooks-agent", + "uid": "" + } + ] + }, + "spec": { + "ports": [ + { + "name": "http", + "port": 8080, + "targetPort": 8080 + } + ], + "selector": { + "app": "kagent", + "kagent": "hooks-agent" + }, + "type": "ClusterIP" + }, + "status": { + "loadBalancer": {} + } + } + ] +} \ No newline at end of file diff --git a/go/core/pkg/env/kagent.go b/go/core/pkg/env/kagent.go index 9d6786aed..a34afe16c 100644 --- a/go/core/pkg/env/kagent.go +++ b/go/core/pkg/env/kagent.go @@ -46,6 +46,13 @@ var ( ComponentAgentRuntime, ) + KagentHooksFolder = RegisterStringVar( + "KAGENT_HOOKS_FOLDER", + "/hooks", + "Directory path where agent hooks are mounted.", + ComponentAgentRuntime, + ) + KagentPropagateToken = RegisterStringVar( "KAGENT_PROPAGATE_TOKEN", "", diff --git a/helm/kagent-crds/templates/kagent.dev_agents.yaml b/helm/kagent-crds/templates/kagent.dev_agents.yaml index a34ced941..873a14d5e 100644 --- a/helm/kagent-crds/templates/kagent.dev_agents.yaml +++ b/helm/kagent-crds/templates/kagent.dev_agents.yaml @@ -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. diff --git a/python/packages/kagent-adk/src/kagent/adk/_agent_executor.py b/python/packages/kagent-adk/src/kagent/adk/_agent_executor.py index 726e5ad46..cbe9bc82e 100644 --- a/python/packages/kagent-adk/src/kagent/adk/_agent_executor.py +++ b/python/packages/kagent-adk/src/kagent/adk/_agent_executor.py @@ -528,9 +528,17 @@ async def _handle_request( runner: Runner, run_args: dict[str, Any], ): + from ._hooks import run_session_hooks + # ensure the session exists + is_new_session = context.current_task is None session = await self._prepare_session(context, run_args, runner) + # Fire SessionStart hooks when a brand-new session is created + session_hooks = getattr(runner.agent, "_kagent_session_hooks", []) + if is_new_session and session_hooks: + await run_session_hooks(session_hooks, "SessionStart", session.id) + # HITL resume: translate A2A approval/rejection to ADK FunctionResponse decision = extract_decision_from_message(context.message) if decision: @@ -627,6 +635,10 @@ async def _handle_request( if getattr(adk_event, "long_running_tool_ids", None): break + # Fire SessionEnd hooks after the agent run completes + if session_hooks: + await run_session_hooks(session_hooks, "SessionEnd", session.id) + # Attach the last LLM usage to run_metadata so the A2A task_manager # merges it into task.metadata on the completed Task object. if last_usage_metadata is not None: diff --git a/python/packages/kagent-adk/src/kagent/adk/_hooks.py b/python/packages/kagent-adk/src/kagent/adk/_hooks.py new file mode 100644 index 000000000..0aab694d6 --- /dev/null +++ b/python/packages/kagent-adk/src/kagent/adk/_hooks.py @@ -0,0 +1,239 @@ +""" +Hooks runtime for the kagent hooks system. + +Implements the "claude-command" protocol used by kagent hooks: + - The hook process reads a single JSON object from stdin. + - The hook process writes a single JSON object to stdout, OR writes a message + to stderr and exits with code 2 (signals a non-blocking error). + +Supported events: + - PreToolUse: fires before a tool is invoked; can approve or block. + - PostToolUse: fires after a tool completes; informational only. + - SessionStart: fires when a new agent session is created. + - SessionEnd: fires when an agent session completes. + +See also: + - HookSpec in go/api/v1alpha2/agent_types.go (CRD definition) + - HookConfig in go/api/adk/types.go (Go serialization) + - HookConfig in types.py (Python deserialization) +""" + +from __future__ import annotations + +import asyncio +import functools +import json +import logging +import os +import re +import subprocess +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from google.adk.tools.base_tool import BaseTool + from google.adk.tools.tool_context import ToolContext + + from kagent.adk.types import HookConfig + +logger = logging.getLogger(__name__) + +# Maximum seconds to wait for a hook subprocess before treating it as an error. +_HOOK_TIMEOUT_SECONDS = 30 + + +def _resolve_argv(hook: HookConfig) -> list[str]: + """Build the argv list for the hook command. + + If the first token of command is not an absolute path, prepend hook.dir so + the script can be found without the caller knowing the mount location. + """ + parts = hook.command.split() + if parts and not os.path.isabs(parts[0]): + parts[0] = os.path.join(hook.dir, parts[0]) + return parts + + +def execute_hook_subprocess(hook: HookConfig, input_data: dict[str, Any]) -> dict[str, Any] | None: + """Run a single hook command synchronously via subprocess. + + Protocol (claude-command): + - stdin: JSON-encoded input_data + - stdout: JSON-encoded output dict (may be empty ``{}``) + - exit 0: success; parse stdout as JSON + - exit 2: hook-signalled error; log stderr as warning, return None + - other: unexpected error; log as error, return None + + Returns the parsed JSON output dict, or None when the hook cannot be run + or explicitly signals an error (exit code 2). A None return is always + non-blocking — the agent continues normally. + """ + argv = _resolve_argv(hook) + input_json = json.dumps(input_data) + + try: + result = subprocess.run( + argv, + input=input_json, + capture_output=True, + text=True, + timeout=_HOOK_TIMEOUT_SECONDS, + ) + except FileNotFoundError: + logger.error("Hook command not found: %r (dir=%s)", hook.command, hook.dir) + return None + except subprocess.TimeoutExpired: + logger.error( + "Hook command timed out after %ds: %r", + _HOOK_TIMEOUT_SECONDS, + hook.command, + ) + return None + except Exception as exc: + logger.error("Unexpected error running hook %r: %s", hook.command, exc) + return None + + if result.returncode == 2: + stderr_msg = result.stderr.strip() + logger.warning("Hook %r exited with code 2: %s", hook.command, stderr_msg) + return None + + if result.returncode != 0: + logger.error( + "Hook %r exited with unexpected code %d: %s", + hook.command, + result.returncode, + result.stderr.strip(), + ) + return None + + if result.stderr: + logger.debug("Hook %r stderr: %s", hook.command, result.stderr.strip()) + + stdout = result.stdout.strip() + if not stdout: + return {} + + try: + return json.loads(stdout) + except json.JSONDecodeError as exc: + logger.error("Hook %r produced invalid JSON: %s", hook.command, exc) + return None + + +def _matches_tool(hook: HookConfig, tool_name: str) -> bool: + """Return True if the hook's matcher regex matches tool_name, or no matcher is set.""" + if not hook.matcher: + return True + return bool(re.search(hook.matcher, tool_name)) + + +def make_pre_tool_hook_callback(hooks: list[HookConfig]): + """Return an ADK before_tool_callback that runs all PreToolUse hooks. + + The callback is synchronous (ADK requirement). Hooks run in declaration + order. If any hook returns ``{"decision": "block"}`` the tool call is + rejected with the supplied reason. A hook error (None return) is + non-blocking by default. + + Returns None when there are no PreToolUse hooks (avoids wrapping overhead). + """ + pre_hooks = [h for h in hooks if h.event == "PreToolUse"] + if not pre_hooks: + return None + + def before_tool( + tool: BaseTool, + args: dict[str, Any], + tool_context: ToolContext, + ) -> str | dict | None: + tool_name = tool.name + input_data: dict[str, Any] = { + "hook_event_name": "PreToolUse", + "tool_name": tool_name, + "tool_input": args, + } + + for hook in pre_hooks: + if not _matches_tool(hook, tool_name): + continue + output = execute_hook_subprocess(hook, input_data) + if output is None: + continue # hook error — non-blocking + decision = output.get("decision", "approve") + if decision == "block": + reason = output.get("reason", "Tool execution blocked by hook.") + logger.info("Hook %r blocked tool %r: %s", hook.command, tool_name, reason) + return f"Tool execution blocked by hook: {reason}" + + return None # all hooks approved + + return before_tool + + +def make_post_tool_hook_callback(hooks: list[HookConfig]): + """Return an ADK after_tool_callback that runs all PostToolUse hooks. + + PostToolUse hooks are purely informational: their output is ignored and + they cannot modify the tool response. Hook errors are logged but + do not affect the agent. + + Returns None when there are no PostToolUse hooks. + """ + post_hooks = [h for h in hooks if h.event == "PostToolUse"] + if not post_hooks: + return None + + def after_tool( + tool: BaseTool, + args: dict[str, Any], + tool_context: ToolContext, + tool_response: dict[str, Any], + ) -> dict | None: + tool_name = tool.name + input_data: dict[str, Any] = { + "hook_event_name": "PostToolUse", + "tool_name": tool_name, + "tool_input": args, + "tool_response": tool_response, + } + + for hook in post_hooks: + if not _matches_tool(hook, tool_name): + continue + execute_hook_subprocess(hook, input_data) # output intentionally ignored + + return None # never modify the tool response + + return after_tool + + +async def run_session_hooks(hooks: list[HookConfig], event: str, session_id: str) -> None: + """Fire all hooks for a session lifecycle event asynchronously. + + Runs each matching hook in the default thread-pool executor so the async + event loop is not blocked. Errors from individual hooks are logged and + do not propagate — session hooks are informational. + + Args: + hooks: All hooks configured for the agent. + event: "SessionStart" or "SessionEnd". + session_id: The current session identifier. + """ + session_hooks = [h for h in hooks if h.event == event] + if not session_hooks: + return + + input_data: dict[str, Any] = { + "hook_event_name": event, + "session_id": session_id, + } + + loop = asyncio.get_event_loop() + for hook in session_hooks: + try: + await loop.run_in_executor( + None, + functools.partial(execute_hook_subprocess, hook, input_data), + ) + except Exception as exc: + logger.error("Session hook %r (%s) failed: %s", hook.command, event, exc) diff --git a/python/packages/kagent-adk/src/kagent/adk/cli.py b/python/packages/kagent-adk/src/kagent/adk/cli.py index 838d70134..a6998ce16 100644 --- a/python/packages/kagent-adk/src/kagent/adk/cli.py +++ b/python/packages/kagent-adk/src/kagent/adk/cli.py @@ -77,6 +77,9 @@ def root_agent_factory() -> BaseAgent: maybe_add_skills(root_agent) + if agent_config.hooks: + root_agent._kagent_session_hooks = agent_config.hooks + return root_agent kagent_app = KAgentApp( diff --git a/python/packages/kagent-adk/src/kagent/adk/types.py b/python/packages/kagent-adk/src/kagent/adk/types.py index 74ef7f46f..b5bbb9732 100644 --- a/python/packages/kagent-adk/src/kagent/adk/types.py +++ b/python/packages/kagent-adk/src/kagent/adk/types.py @@ -254,6 +254,21 @@ class MemoryConfig(BaseModel): embedding: EmbeddingConfig | None = None # Embedding model config for memory tools. +class HookConfig(BaseModel): + """Runtime configuration for a single agent lifecycle hook. + + Mirrors HookConfig in go/api/adk/types.go. + The hook command reads JSON from stdin and writes JSON to stdout following + the "claude-command" protocol. See _hooks.py for the runtime implementation. + """ + + event: str # "PreToolUse", "PostToolUse", "SessionStart", "SessionEnd" + type: str = "claude-command" + matcher: str | None = None # ECMAScript regex; None means match all tools + command: str # executable path, absolute or relative to dir + dir: str # absolute mount path, e.g. /hooks/my-image + + class AgentConfig(BaseModel): model: ModelUnion = Field(discriminator="type") description: str @@ -265,6 +280,7 @@ class AgentConfig(BaseModel): stream: bool | None = None # Refers to LLM response streaming, not A2A streaming memory: MemoryConfig | None = None # Memory configuration context_config: ContextConfig | None = None + hooks: list[HookConfig] | None = None # Lifecycle hooks def to_agent(self, name: str, sts_integration: Optional[ADKTokenPropagationPlugin] = None) -> Agent: if name is None or not str(name).strip(): @@ -386,6 +402,28 @@ async def rewrite_url_to_proxy(request: httpx.Request) -> None: # Build before_tool_callback if any tools require approval before_tool_callback = make_approval_callback(tools_requiring_approval) if tools_requiring_approval else None + # Build hook callbacks and compose with approval callback + from kagent.adk._hooks import make_post_tool_hook_callback, make_pre_tool_hook_callback + + active_hooks = self.hooks or [] + pre_hook_cb = make_pre_tool_hook_callback(active_hooks) + post_hook_cb = make_post_tool_hook_callback(active_hooks) + + if before_tool_callback and pre_hook_cb: + # Approval runs first; if it short-circuits (returns non-None), skip hook + _approval_cb = before_tool_callback + _hook_cb = pre_hook_cb + + def _combined_before_tool(tool, args, tool_context): + result = _approval_cb(tool, args, tool_context) + if result is not None: + return result + return _hook_cb(tool, args, tool_context) + + before_tool_callback = _combined_before_tool + elif pre_hook_cb: + before_tool_callback = pre_hook_cb + # static_instruction is sent directly to the model without any placeholder processing agent = Agent( name=name, @@ -395,6 +433,7 @@ async def rewrite_url_to_proxy(request: httpx.Request) -> None: tools=tools, code_executor=code_executor, before_tool_callback=before_tool_callback, + after_tool_callback=post_hook_cb, ) # Configure memory if enabled