From f6418eb71f86ea5ba277cd4b8f47d64cc100426c Mon Sep 17 00:00:00 2001 From: Eitan Yarmush Date: Tue, 1 Sep 2026 14:48:52 +0000 Subject: [PATCH 1/7] feat: add BYO A2A harness Signed-off-by: Eitan Yarmush --- .github/workflows/ci.yaml | 5 +- Makefile | 9 + docs/plans/api-v2-execution-plan.md | 32 +- .../crd/bases/kagent.dev_agenttemplates.yaml | 6 +- .../crd/bases/kagent.dev_harnesses.yaml | 31 +- go/api/v1alpha3/agenttemplate_types.go | 5 +- go/api/v1alpha3/configuration_crd_cel_test.go | 23 +- go/api/v1alpha3/harness_types.go | 26 +- go/api/v1alpha3/zz_generated.deepcopy.go | 41 +- go/core/internal/grpcserver/agenttemplate.go | 6 +- .../grpcserver/agenttemplate_harness_test.go | 2 +- go/core/test/byoa2a/main.go | 41 ++ go/core/test/e2e/README.md | 5 + go/core/test/e2e/claude_interaction_test.go | 8 +- go/core/test/e2e/interaction_test.go | 43 +- .../test/e2e/manifests/lifecycle.yaml.tmpl | 48 +++ go/core/v2/controller/collections_test.go | 8 +- go/core/v2/controller/reconciler.go | 2 + go/core/v2/substrate/actor_template.go | 8 +- go/core/v2/substrate/actor_template_test.go | 6 + go/core/v2/translator/adkconfig/compiler.go | 347 +++++++++++++++ .../translator/{kagent => adkconfig}/mcp.go | 2 +- .../translator/{kagent => adkconfig}/model.go | 5 +- go/core/v2/translator/byo/compiler.go | 67 +++ go/core/v2/translator/byo/compiler_test.go | 52 +++ go/core/v2/translator/claude/compiler_test.go | 6 +- go/core/v2/translator/compiler.go | 12 +- go/core/v2/translator/compiler_test.go | 28 +- .../v2/translator/kagent/agentcard_test.go | 12 + go/core/v2/translator/kagent/compiler.go | 406 ++---------------- go/core/v2/translator/revision.go | 6 +- go/core/v2/translator/revision_test.go | 16 + .../templates/kagent.dev_agenttemplates.yaml | 6 +- .../templates/kagent.dev_harnesses.yaml | 31 +- 34 files changed, 916 insertions(+), 435 deletions(-) create mode 100644 go/core/test/byoa2a/main.go create mode 100644 go/core/v2/translator/adkconfig/compiler.go rename go/core/v2/translator/{kagent => adkconfig}/mcp.go (99%) rename go/core/v2/translator/{kagent => adkconfig}/model.go (99%) create mode 100644 go/core/v2/translator/byo/compiler.go create mode 100644 go/core/v2/translator/byo/compiler_test.go diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 5f2d72965..1feff240c 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -120,7 +120,7 @@ jobs: --push run: | echo "Cache key: ${{ needs.setup.outputs.cache-key }}" - make build-controller build-golang-adk build-claude-harness + make build-controller build-golang-adk build-claude-harness build-byo-a2a make helm-install-provider kubectl rollout status deployment/kagent-controller -n kagent --timeout=120s kubectl wait --for=condition=Ready pod -l app.kubernetes.io/component=controller -n kagent --timeout=120s @@ -132,6 +132,9 @@ jobs: RUNTIME_DIGEST=$(docker buildx imagetools inspect "localhost:5001/kagent-dev/kagent/golang-adk:${VERSION}" | awk '$1 == "Digest:" { print $2; exit }') test -n "$RUNTIME_DIGEST" export KAGENT_E2E_RUNTIME_IMAGE="localhost:5001/kagent-dev/kagent/golang-adk@${RUNTIME_DIGEST}" + BYO_DIGEST=$(docker buildx imagetools inspect "localhost:5001/kagent-dev/kagent/byo-a2a:${VERSION}" | awk '$1 == "Digest:" { print $2; exit }') + test -n "$BYO_DIGEST" + export KAGENT_E2E_BYO_IMAGE="localhost:5001/kagent-dev/kagent/byo-a2a@${BYO_DIGEST}" CLAUDE_DIGEST=$(docker buildx imagetools inspect "localhost:5001/kagent-dev/kagent/claude-harness:${VERSION}" | awk '$1 == "Digest:" { print $2; exit }') test -n "$CLAUDE_DIGEST" export KAGENT_E2E_CLAUDE_IMAGE="localhost:5001/kagent-dev/kagent/claude-harness@${CLAUDE_DIGEST}" diff --git a/Makefile b/Makefile index 77e8a9255..ac354bb42 100644 --- a/Makefile +++ b/Makefile @@ -54,17 +54,20 @@ CONTROLLER_IMAGE_NAME ?= controller UI_IMAGE_NAME ?= ui KAGENT_ADK_IMAGE_NAME ?= kagent-adk GOLANG_ADK_IMAGE_NAME ?= golang-adk +BYO_A2A_E2E_IMAGE_NAME ?= byo-a2a CLAUDE_HARNESS_IMAGE_NAME ?= claude-harness CONTROLLER_IMAGE_TAG ?= $(VERSION) UI_IMAGE_TAG ?= $(VERSION) KAGENT_ADK_IMAGE_TAG ?= $(VERSION) GOLANG_ADK_IMAGE_TAG ?= $(VERSION) +BYO_A2A_E2E_IMAGE_TAG ?= $(VERSION) CLAUDE_HARNESS_IMAGE_TAG ?= $(VERSION) CONTROLLER_IMG ?= $(DOCKER_REGISTRY)/$(DOCKER_REPO)/$(CONTROLLER_IMAGE_NAME):$(CONTROLLER_IMAGE_TAG) UI_IMG ?= $(DOCKER_REGISTRY)/$(DOCKER_REPO)/$(UI_IMAGE_NAME):$(UI_IMAGE_TAG) KAGENT_ADK_IMG ?= $(DOCKER_REGISTRY)/$(DOCKER_REPO)/$(KAGENT_ADK_IMAGE_NAME):$(KAGENT_ADK_IMAGE_TAG) GOLANG_ADK_IMG ?= $(DOCKER_REGISTRY)/$(DOCKER_REPO)/$(GOLANG_ADK_IMAGE_NAME):$(GOLANG_ADK_IMAGE_TAG) +BYO_A2A_E2E_IMG ?= $(DOCKER_REGISTRY)/$(DOCKER_REPO)/$(BYO_A2A_E2E_IMAGE_NAME):$(BYO_A2A_E2E_IMAGE_TAG) CLAUDE_HARNESS_IMG ?= $(DOCKER_REGISTRY)/$(DOCKER_REPO)/$(CLAUDE_HARNESS_IMAGE_NAME):$(CLAUDE_HARNESS_IMAGE_TAG) #take from go/go.mod @@ -295,6 +298,12 @@ build-golang-adk: proto-generate buildx-create $(DOCKER_BUILDER) $(DOCKER_BUILD_ARGS) $(TOOLS_IMAGE_BUILD_ARGS) --build-arg BUILD_PACKAGE=adk/cmd/main.go -t $(GOLANG_ADK_IMG) -f go/Dockerfile ./go $(DOCKER_PUSH) $(GOLANG_ADK_IMG) +.PHONY: build-byo-a2a +build-byo-a2a: ## Build and push the opaque BYO A2A e2e image +build-byo-a2a: buildx-create + $(DOCKER_BUILDER) $(DOCKER_BUILD_ARGS) $(TOOLS_IMAGE_BUILD_ARGS) --build-arg BUILD_PACKAGE=core/test/byoa2a/main.go -t $(BYO_A2A_E2E_IMG) -f go/Dockerfile ./go + $(DOCKER_PUSH) $(BYO_A2A_E2E_IMG) + .PHONY: build-claude-harness build-claude-harness: ## Build and push the native Claude Harness image build-claude-harness: buildx-create diff --git a/docs/plans/api-v2-execution-plan.md b/docs/plans/api-v2-execution-plan.md index 0296e0f3e..7415d94bf 100644 --- a/docs/plans/api-v2-execution-plan.md +++ b/docs/plans/api-v2-execution-plan.md @@ -6,7 +6,7 @@ Land API v2 through four milestones: 1. Merge #2362 and freeze final CRD/gRPC contracts. 2. Deliver a usable single-agent vertical slice with the existing kagent runtime. -3. Add composition, Codex, Claude, UI/CLI/MCP cutover, and remove legacy APIs. +3. Add composition, Codex, Claude, BYO A2A images, UI/CLI/MCP cutover, and remove legacy APIs. 4. Add single-member checkpoint/fork using released Substrate snapshot support. API v2 is not complete until checkpoint/fork and their Substrate dependencies pass E2E coverage. @@ -22,8 +22,8 @@ Public invariants: final state is published; physical Actor suspension does not change a ready AgentInstance's logical state. - Substrate is the only compute backend. -- No public scheduling, service-account, Deployment, channel, profile, or BYO fields. -- V1 release-blocking adapters are kagent, Codex, and Claude. +- No public scheduling, service-account, Deployment, channel, or profile fields. Arbitrary images remain behind BYO Harness admission. +- V1 release-blocking adapters are kagent, Codex, Claude, and BYO A2A images. ## PR dependency graph @@ -42,11 +42,12 @@ K0 #2362 K3 + K4 + K8 ─┬─ K14 Codex adapter └─ K15 Claude adapter +K3 + K10 ───────── K15A BYO A2A adapter Substrate v0.0.20 snapshots + snapshot-sourced actors ─ K16 dependency adoption K6 + K10 + K16 ─ K17 checkpoints ─ K18 fork -K12 + K13 + K14 + K15 + K18 ─ K19 legacy removal ─ K20 release conformance +K12 + K13 + K14 + K15 + K15A + K18 ─ K19 legacy removal ─ K20 release conformance S0 ate-api ActorTemplate resources ───────────────────────┐ K3 + K5 ─────────────────────────────────────┴─ K5A backing-resource cutover @@ -324,7 +325,7 @@ CLI: - Apply Harness and AgentTemplate manifests. - Create/list/get/suspend/resume/delete AgentInstances through gRPC. - Invoke and follow Tasks through upstream A2A. -- Remove SandboxAgent, AgentHarness, Deployment, BYO, session, and ACP branches. +- Remove SandboxAgent, AgentHarness, Deployment, legacy Agent BYO, session, and ACP branches. MCP: @@ -370,6 +371,19 @@ Implement the third release-blocking adapter: - Map Claude output, tool calls, approvals, cancellation, and failures to the private upstream A2A service. - Publish only capabilities proven by the conformance suite. +### K15A — BYO A2A Harness adapter + +Allow users with Harness write access to supply a digest-pinned image that implements the private A2A runtime contract: + +- Add a typed `byo` Harness variant. Keep the image, optional command and args, environment, credentials, WorkerPool, snapshot policy, and admission selector on the Harness; do not put arbitrary images on AgentTemplate. +- Require A2A v1 gRPC through the standard Actor ingress, streaming, `/readyz` on port 8081, and durable private state under `/data`. Keep ports, routing, Actor identity, and Substrate mechanics fixed and private. +- Make AgentTemplate model, prompt, tools, skills, and plugins optional for BYO attachments. Compile every provided field into the existing ADK `AgentConfig` shape and inject it through `KAGENT_CONFIG_JSON` with the generated card in `KAGENT_AGENT_CARD_JSON`; a BYO image may consume that configuration or ignore it. +- Extract the shared ADK-config construction into a semantic helper used by the kagent and BYO compilers. Do not create a second configuration format or make either compiler depend on the other. +- Keep the public Agent Card derived from the pinned AgentTemplate revision and gateway capabilities. Do not wake the Actor or trust runtime-provided interfaces, security, or routing metadata to construct it. +- Infer egress destinations from configured models and MCP servers. Allow the Harness owner to declare an additional typed destination allowlist for image-owned dependencies that cannot be inferred; default to no additional egress. +- Preserve the existing AgentInstance lifecycle, automatic suspension, checkpoint, fork, authorization, task persistence, and public A2A gateway without BYO-specific branches outside compilation. +- Cover an opaque A2A image that ignores ADK configuration and an ADK-config-aware image that consumes optional model, prompt, MCP, skill, and plugin inputs. Exercise send/stream, cancellation, suspension, checkpoint, fork, credential redaction, and egress denial in Kind. + ### S1 — Upstream Substrate immutable ActorSnapshot API ✅ Released in Substrate v0.0.20: @@ -452,7 +466,7 @@ No automatic migration of legacy Sessions or live SandboxAgents is provided. Alp Enable blocking clean-install coverage: -- kagent, Codex, and Claude Harnesses. +- kagent, Codex, Claude, and BYO A2A Harnesses. - Prompt/model/MCP/skills/plugins. - Shared tools. - Create idempotency and controller restart at each provisioning step. @@ -492,15 +506,15 @@ K7 and K8 should branch from K3 and avoid editing each other’s source-specific ## Milestone gates - Preview 1: K0–K6 — single kagent AgentTemplate can prepare, instantiate, chat, suspend, resume, and delete through final APIs. -- Preview 2: K7–K8 and K10–K15 — full configuration, Shared composition, Codex, Claude, UI, CLI, and MCP behavior. +- Preview 2: K7–K8 and K10–K15A — full configuration, Shared composition, Codex, Claude, BYO A2A, UI, CLI, and MCP behavior. - Release candidate: S1–S2 and K16–K19 — checkpoint/fork complete and legacy surface deleted. -- API v2 complete: K20 passes with all three release-blocking adapters and Substrate E2E. +- API v2 complete: K20 passes with all four release-blocking adapters and Substrate E2E. ## Deliberate exclusions - No AgentHost, HostedAgent, shared Actors, managed native profiles, or channels. - No OpenClaw or Hermes release requirement. -- No BYO/fallback runtime. +- No externally hosted BYO agent or non-Substrate fallback runtime. - No cross-namespace references. - No multiple conversations or parallel Tasks per AgentInstance. - No template inheritance, BaseContext, shared-store, or filesystem CRDs. diff --git a/go/api/config/crd/bases/kagent.dev_agenttemplates.yaml b/go/api/config/crd/bases/kagent.dev_agenttemplates.yaml index deef45c59..6605e2ee6 100644 --- a/go/api/config/crd/bases/kagent.dev_agenttemplates.yaml +++ b/go/api/config/crd/bases/kagent.dev_agenttemplates.yaml @@ -48,8 +48,8 @@ spec: description: type: string modelConfig: - description: AgentTemplateLocalReference identifies a resource in - the AgentTemplate's namespace. + description: ModelConfig is required by managed harnesses and optional + for BYO harnesses. properties: name: minLength: 1 @@ -356,8 +356,6 @@ spec: rule: has(self.mcp) != has(self.agent) maxItems: 50 type: array - required: - - modelConfig type: object x-kubernetes-validations: - message: systemPrompt and systemPromptFrom are mutually exclusive diff --git a/go/api/config/crd/bases/kagent.dev_harnesses.yaml b/go/api/config/crd/bases/kagent.dev_harnesses.yaml index 0ef8b5f9b..4d6866b62 100644 --- a/go/api/config/crd/bases/kagent.dev_harnesses.yaml +++ b/go/api/config/crd/bases/kagent.dev_harnesses.yaml @@ -104,6 +104,33 @@ spec: required: - selector type: object + byo: + description: BYOHarness configures an image that implements kagent's + private A2A contract. + properties: + args: + description: Args overrides the image command arguments when set. + items: + type: string + maxItems: 64 + type: array + command: + description: Command overrides the image entrypoint when set. + items: + type: string + maxItems: 32 + type: array + egressDestinations: + description: |- + EgressDestinations permits image-owned dependencies that cannot be inferred + from AgentTemplate configuration. + items: + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ + type: string + maxItems: 32 + type: array + x-kubernetes-list-type: set + type: object claude: description: ClaudeHarness selects the Claude runtime adapter. type: object @@ -216,9 +243,9 @@ spec: - workload type: object x-kubernetes-validations: - - message: exactly one of kagent, codex, or claude must be specified + - message: exactly one of kagent, codex, claude, or byo must be specified rule: '(has(self.kagent) ? 1 : 0) + (has(self.codex) ? 1 : 0) + (has(self.claude) - ? 1 : 0) == 1' + ? 1 : 0) + (has(self.byo) ? 1 : 0) == 1' status: description: HarnessStatus reports controller-derived capabilities and current health. diff --git a/go/api/v1alpha3/agenttemplate_types.go b/go/api/v1alpha3/agenttemplate_types.go index cfddaf8e7..178a43b7b 100644 --- a/go/api/v1alpha3/agenttemplate_types.go +++ b/go/api/v1alpha3/agenttemplate_types.go @@ -197,8 +197,9 @@ type PluginBundle struct { // AgentTemplateSpec defines portable agent behavior. // +kubebuilder:validation:XValidation:rule="!(has(self.systemPrompt) && has(self.systemPromptFrom))",message="systemPrompt and systemPromptFrom are mutually exclusive" type AgentTemplateSpec struct { - // +required - ModelConfig AgentTemplateLocalReference `json:"modelConfig"` + // ModelConfig is required by managed harnesses and optional for BYO harnesses. + // +optional + ModelConfig *AgentTemplateLocalReference `json:"modelConfig,omitempty"` // +optional Description string `json:"description,omitempty"` // +optional diff --git a/go/api/v1alpha3/configuration_crd_cel_test.go b/go/api/v1alpha3/configuration_crd_cel_test.go index 952537660..ba995ea6e 100644 --- a/go/api/v1alpha3/configuration_crd_cel_test.go +++ b/go/api/v1alpha3/configuration_crd_cel_test.go @@ -57,7 +57,7 @@ func TestConfigurationCRDValidation(t *testing.T) { { name: "Harness requires one runtime", object: validHarness(namespace, "harness-no-runtime", HarnessSpec{}), - wantReject: "exactly one of kagent, codex, or claude must be specified", + wantReject: "exactly one of kagent, codex, claude, or byo must be specified", }, { name: "Harness rejects multiple runtimes", @@ -65,7 +65,7 @@ func TestConfigurationCRDValidation(t *testing.T) { Kagent: &KagentHarness{}, Codex: &CodexHarness{}, }), - wantReject: "exactly one of kagent, codex, or claude must be specified", + wantReject: "exactly one of kagent, codex, claude, or byo must be specified", }, { name: "Harness rejects tag-only image", @@ -102,6 +102,19 @@ func TestConfigurationCRDValidation(t *testing.T) { Env: []HarnessEnvVar{{Name: "EMPTY", Value: &empty}}, }), }, + { + name: "valid BYO Harness", + object: validHarness(namespace, "valid-byo-harness", HarnessSpec{ + BYO: &BYOHarness{}, + }), + }, + { + name: "BYO Harness rejects URL egress destination", + object: validHarness(namespace, "byo-url-egress", HarnessSpec{ + BYO: &BYOHarness{EgressDestinations: []string{"https://api.example.com"}}, + }), + wantReject: "spec.byo.egressDestinations", + }, { name: "AgentTemplate tool requires one source", object: validAgentTemplate(namespace, "template-empty-tool", []ToolBinding{{}}), @@ -124,6 +137,10 @@ func TestConfigurationCRDValidation(t *testing.T) { name: "valid AgentTemplate", object: validAgentTemplate(namespace, "valid-template", nil), }, + { + name: "AgentTemplate permits omitted ModelConfig", + object: &AgentTemplate{ObjectMeta: metav1.ObjectMeta{Name: "model-free-template", Namespace: namespace}}, + }, } for _, tc := range cases { @@ -155,7 +172,7 @@ func validAgentTemplate(namespace, name string, tools []ToolBinding) *AgentTempl return &AgentTemplate{ ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespace}, Spec: AgentTemplateSpec{ - ModelConfig: AgentTemplateLocalReference{Name: "default"}, + ModelConfig: &AgentTemplateLocalReference{Name: "default"}, Tools: tools, }, } diff --git a/go/api/v1alpha3/harness_types.go b/go/api/v1alpha3/harness_types.go index 9652ca4fa..70b50b31e 100644 --- a/go/api/v1alpha3/harness_types.go +++ b/go/api/v1alpha3/harness_types.go @@ -31,6 +31,27 @@ type CodexHarness struct{} // ClaudeHarness selects the Claude runtime adapter. type ClaudeHarness struct{} +// BYOHarness configures an image that implements kagent's private A2A contract. +type BYOHarness struct { + // Command overrides the image entrypoint when set. + // +kubebuilder:validation:MaxItems=32 + // +optional + Command []string `json:"command,omitempty"` + + // Args overrides the image command arguments when set. + // +kubebuilder:validation:MaxItems=64 + // +optional + Args []string `json:"args,omitempty"` + + // EgressDestinations permits image-owned dependencies that cannot be inferred + // from AgentTemplate configuration. + // +kubebuilder:validation:MaxItems=32 + // +kubebuilder:validation:items:Pattern=`^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$` + // +listType=set + // +optional + EgressDestinations []string `json:"egressDestinations,omitempty"` +} + // HarnessWorkload identifies the immutable runtime image used by a Harness. type HarnessWorkload struct { // Image is an OCI image reference pinned by sha256 digest. @@ -88,7 +109,7 @@ type HarnessAgentTemplateAdmission struct { // HarnessSpec defines a reusable runtime and its infrastructure policy. // -// +kubebuilder:validation:XValidation:rule="(has(self.kagent) ? 1 : 0) + (has(self.codex) ? 1 : 0) + (has(self.claude) ? 1 : 0) == 1",message="exactly one of kagent, codex, or claude must be specified" +// +kubebuilder:validation:XValidation:rule="(has(self.kagent) ? 1 : 0) + (has(self.codex) ? 1 : 0) + (has(self.claude) ? 1 : 0) + (has(self.byo) ? 1 : 0) == 1",message="exactly one of kagent, codex, claude, or byo must be specified" type HarnessSpec struct { // +optional Kagent *KagentHarness `json:"kagent,omitempty"` @@ -99,6 +120,9 @@ type HarnessSpec struct { // +optional Claude *ClaudeHarness `json:"claude,omitempty"` + // +optional + BYO *BYOHarness `json:"byo,omitempty"` + // +required Workload HarnessWorkload `json:"workload"` diff --git a/go/api/v1alpha3/zz_generated.deepcopy.go b/go/api/v1alpha3/zz_generated.deepcopy.go index a42d4f8f6..aee20b470 100644 --- a/go/api/v1alpha3/zz_generated.deepcopy.go +++ b/go/api/v1alpha3/zz_generated.deepcopy.go @@ -679,7 +679,11 @@ func (in *AgentTemplateSkill) DeepCopy() *AgentTemplateSkill { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *AgentTemplateSpec) DeepCopyInto(out *AgentTemplateSpec) { *out = *in - out.ModelConfig = in.ModelConfig + if in.ModelConfig != nil { + in, out := &in.ModelConfig, &out.ModelConfig + *out = new(AgentTemplateLocalReference) + **out = **in + } if in.SystemPromptFrom != nil { in, out := &in.SystemPromptFrom, &out.SystemPromptFrom *out = new(AgentTemplateConfigMapKeyReference) @@ -904,6 +908,36 @@ func (in *BYOAgentSpec) DeepCopy() *BYOAgentSpec { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *BYOHarness) DeepCopyInto(out *BYOHarness) { + *out = *in + if in.Command != nil { + in, out := &in.Command, &out.Command + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.Args != nil { + in, out := &in.Args, &out.Args + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.EgressDestinations != nil { + in, out := &in.EgressDestinations, &out.EgressDestinations + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BYOHarness. +func (in *BYOHarness) DeepCopy() *BYOHarness { + if in == nil { + return nil + } + out := new(BYOHarness) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *BaseVertexAIConfig) DeepCopyInto(out *BaseVertexAIConfig) { *out = *in @@ -1422,6 +1456,11 @@ func (in *HarnessSpec) DeepCopyInto(out *HarnessSpec) { *out = new(ClaudeHarness) **out = **in } + if in.BYO != nil { + in, out := &in.BYO, &out.BYO + *out = new(BYOHarness) + (*in).DeepCopyInto(*out) + } out.Workload = in.Workload if in.Env != nil { in, out := &in.Env, &out.Env diff --git a/go/core/internal/grpcserver/agenttemplate.go b/go/core/internal/grpcserver/agenttemplate.go index f936dbf37..ce7cb8e69 100644 --- a/go/core/internal/grpcserver/agenttemplate.go +++ b/go/core/internal/grpcserver/agenttemplate.go @@ -117,11 +117,15 @@ func (s *agentTemplateServer) agentTemplate(template *v1alpha3.AgentTemplate) (* for _, status := range template.Status.Harnesses { admitting = append(admitting, status.Harness) } + var modelConfigRef *apiv1alpha1.ResourceReference + if template.Spec.ModelConfig != nil { + modelConfigRef = &apiv1alpha1.ResourceReference{Namespace: template.Namespace, Name: template.Spec.ModelConfig.Name} + } return &apiv1alpha1.AgentTemplate{ Ref: &apiv1alpha1.ResourceReference{Namespace: template.Namespace, Name: template.Name}, // The model config lives in the template's own namespace: the CRD's // reference is name-only and same-namespace by construction. - ModelConfigRef: &apiv1alpha1.ResourceReference{Namespace: template.Namespace, Name: template.Spec.ModelConfig.Name}, + ModelConfigRef: modelConfigRef, Resource: resource, Description: template.Spec.Description, AdmittingHarnesses: admitting, diff --git a/go/core/internal/grpcserver/agenttemplate_harness_test.go b/go/core/internal/grpcserver/agenttemplate_harness_test.go index 81f47f64a..35eb61bb6 100644 --- a/go/core/internal/grpcserver/agenttemplate_harness_test.go +++ b/go/core/internal/grpcserver/agenttemplate_harness_test.go @@ -71,7 +71,7 @@ func testAgentTemplate(namespace, name, modelConfig string) *v1alpha3.AgentTempl return &v1alpha3.AgentTemplate{ ObjectMeta: metav1.ObjectMeta{Namespace: namespace, Name: name}, Spec: v1alpha3.AgentTemplateSpec{ - ModelConfig: v1alpha3.AgentTemplateLocalReference{Name: modelConfig}, + ModelConfig: &v1alpha3.AgentTemplateLocalReference{Name: modelConfig}, Description: "a template", }, } diff --git a/go/core/test/byoa2a/main.go b/go/core/test/byoa2a/main.go new file mode 100644 index 000000000..9bc86fc9b --- /dev/null +++ b/go/core/test/byoa2a/main.go @@ -0,0 +1,41 @@ +package main + +import ( + "context" + "iter" + "log" + + a2atype "github.com/a2aproject/a2a-go/v2/a2a" + "github.com/a2aproject/a2a-go/v2/a2asrv" + "github.com/kagent-dev/kagent/go/adk/pkg/app" +) + +type executor struct{} + +func (executor) Execute(_ context.Context, request *a2asrv.ExecutorContext) iter.Seq2[a2atype.Event, error] { + return func(yield func(a2atype.Event, error) bool) { + if !yield(a2atype.NewSubmittedTask(request, request.Message), nil) { + return + } + message := a2atype.NewMessage(a2atype.MessageRoleAgent, a2atype.NewTextPart("BYO agent response")) + message.ContextID, message.TaskID = request.ContextID, request.TaskID + yield(a2atype.NewStatusUpdateEvent(request, a2atype.TaskStateCompleted, message), nil) + } +} + +func (executor) Cancel(context.Context, *a2asrv.ExecutorContext) iter.Seq2[a2atype.Event, error] { + return func(func(a2atype.Event, error) bool) {} +} + +func main() { + application, err := app.New(app.AppConfig{ + AgentCard: a2atype.AgentCard{Name: "opaque-byo", Version: "v1", Capabilities: a2atype.AgentCapabilities{Streaming: true}}, + Port: "80", AppName: "opaque-byo", + }, executor{}) + if err != nil { + log.Fatal(err) + } + if err := application.Run(); err != nil { + log.Fatal(err) + } +} diff --git a/go/core/test/e2e/README.md b/go/core/test/e2e/README.md index 030f17d9b..208ed6976 100644 --- a/go/core/test/e2e/README.md +++ b/go/core/test/e2e/README.md @@ -19,6 +19,8 @@ atelet: ```bash KAGENT_E2E_RUNTIME_IMAGE=/kagent-dev/kagent/golang-adk@sha256: \ +KAGENT_E2E_BYO_IMAGE=/kagent-dev/kagent/byo-a2a@sha256: \ +KAGENT_E2E_CLAUDE_IMAGE=/kagent-dev/kagent/claude-harness@sha256: \ envsubst < go/core/test/e2e/manifests/lifecycle.yaml.tmpl | kubectl apply -f - KAGENT_E2E_GRPC_TARGET=:8084 make -C go e2e ``` @@ -31,6 +33,9 @@ cluster (`172.17.0.1` on Linux and `host.docker.internal` on macOS). Set `TestMCPInteraction` starts `mockmcp` on the same reachable host, registers it as a `RemoteMCPServer`, and verifies an actual `tools/call` request. +`TestOpaqueBYOAgentInteraction` uses the fixture built by `make build-byo-a2a`; +`TestConfiguredBYOMCPInteraction` runs the Go ADK image through the BYO adapter. + The `TestMCPAgentInstanceInteraction`, `TestMCPAskUserContinuation`, and `TestMCPCancelTask` cases exercise the controller's public `/mcp` endpoint on port 8083, including MCP Tasks polling, synchronous fallback, A2A task identity, diff --git a/go/core/test/e2e/claude_interaction_test.go b/go/core/test/e2e/claude_interaction_test.go index ecf526cfa..9bcdf81b8 100644 --- a/go/core/test/e2e/claude_interaction_test.go +++ b/go/core/test/e2e/claude_interaction_test.go @@ -444,7 +444,7 @@ func createClaudeTemplate(t *testing.T, kube ctrlclient.Client, modelConfig, des Labels: map[string]string{"kagent.dev/e2e-runtime": "claude"}, }, Spec: v1alpha3.AgentTemplateSpec{ - ModelConfig: v1alpha3.AgentTemplateLocalReference{Name: modelConfig}, + ModelConfig: &v1alpha3.AgentTemplateLocalReference{Name: modelConfig}, Description: description, SystemPrompt: "Reply concisely and follow the requested output format exactly.", }, } @@ -460,7 +460,7 @@ func createClaudeLocalAgentTemplates(t *testing.T, kube ctrlclient.Client, model Labels: map[string]string{"kagent.dev/e2e-runtime": "claude"}, }, Spec: v1alpha3.AgentTemplateSpec{ - ModelConfig: v1alpha3.AgentTemplateLocalReference{Name: model.Name}, + ModelConfig: &v1alpha3.AgentTemplateLocalReference{Name: model.Name}, Description: "Claude local specialist", SystemPrompt: childPrompt, }, @@ -472,7 +472,7 @@ func createClaudeLocalAgentTemplates(t *testing.T, kube ctrlclient.Client, model Labels: map[string]string{"kagent.dev/e2e-runtime": "claude"}, }, Spec: v1alpha3.AgentTemplateSpec{ - ModelConfig: v1alpha3.AgentTemplateLocalReference{Name: model.Name}, + ModelConfig: &v1alpha3.AgentTemplateLocalReference{Name: model.Name}, Description: "Claude local-subagent E2E fixture", SystemPrompt: "Always delegate the request to the specialist subagent, then return its answer.", Tools: []v1alpha3.ToolBinding{{Agent: &v1alpha3.AgentToolBinding{ @@ -556,7 +556,7 @@ func createClaudeMCPTemplate(t *testing.T, kube ctrlclient.Client, modelConfig, Labels: map[string]string{"kagent.dev/e2e-runtime": "claude"}, }, Spec: v1alpha3.AgentTemplateSpec{ - ModelConfig: v1alpha3.AgentTemplateLocalReference{Name: modelConfig}, + ModelConfig: &v1alpha3.AgentTemplateLocalReference{Name: modelConfig}, Description: "Claude direct whole-server MCP E2E fixture", SystemPrompt: "Use the configured MCP tool. Do not calculate the answer yourself.", Tools: []v1alpha3.ToolBinding{{MCP: &v1alpha3.MCPToolBinding{ diff --git a/go/core/test/e2e/interaction_test.go b/go/core/test/e2e/interaction_test.go index cd8e7b55e..e26113fa7 100644 --- a/go/core/test/e2e/interaction_test.go +++ b/go/core/test/e2e/interaction_test.go @@ -64,6 +64,16 @@ func TestAgentInstanceInteraction(t *testing.T) { } } +func TestOpaqueBYOAgentInteraction(t *testing.T) { + fixture := newInteractionFixtureForHarnessTemplate(t, interactionTarget(t), "byo-e2e", "byo-smoke") + for range 2 { + _, _, task := fixture.send(t, "hello") + if task.Status.State != a2atype.TaskStateCompleted || !strings.Contains(taskText(task), "BYO agent response") { + t.Fatalf("BYO A2A task = %+v", task) + } + } +} + func TestAgentInstanceAskUserSurvivesSuspension(t *testing.T) { fixture := newInteractionFixture(t, interactionTarget(t), startMockLLM(t, "mocks/invoke_golang_hitl_ask_user.json")) fixture.ctx = metadata.AppendToOutgoingContext(fixture.ctx, strings.ToLower(a2atype.SvcParamExtensions), adka2a.HITLExtensionURI) @@ -199,6 +209,23 @@ func TestMCPInteraction(t *testing.T) { t.Fatal("mock MCP server did not receive an add_numbers tool call") } +func TestConfiguredBYOMCPInteraction(t *testing.T) { + target := interactionTarget(t) + mcpURL, mcpServer := startMCPMock(t) + template := createMCPInteractionTemplateForHarness(t, startMockLLM(t, "mocks/invoke_mcp_agent.json"), mcpURL, "byo-adk-e2e", "byo-adk") + fixture := newInteractionFixtureForHarnessTemplate(t, target, "byo-adk-e2e", template) + _, _, task := fixture.send(t, "add 3 and 5") + if task.Status.State != a2atype.TaskStateCompleted || !strings.Contains(taskText(task), "result is 8") { + t.Fatalf("BYO A2A task state = %s, text = %q", task.Status.State, taskText(task)) + } + for _, request := range mcpServer.Requests() { + if bytes.Contains(request.Body, []byte(`"method":"tools/call"`)) { + return + } + } + t.Fatal("mock MCP server did not receive a tool call from configured BYO agent") +} + func TestSharedAgentInteraction(t *testing.T) { fixture := newSharedInteractionFixture(t, interactionTarget(t)) _, _, task := fixture.send(t, "Ask the specialist") @@ -682,7 +709,7 @@ func createInteractionTemplate(t *testing.T, modelURL string) string { Labels: map[string]string{"kagent.dev/e2e-runtime": "kagent", "kagent.dev/harness": "kagent"}, }, Spec: v1alpha3.AgentTemplateSpec{ - ModelConfig: v1alpha3.AgentTemplateLocalReference{Name: model.Name}, + ModelConfig: &v1alpha3.AgentTemplateLocalReference{Name: model.Name}, Description: "Agent interaction E2E fixture", SystemPrompt: "Reply briefly.", }, @@ -692,6 +719,10 @@ func createInteractionTemplate(t *testing.T, modelURL string) string { } func createMCPInteractionTemplate(t *testing.T, modelURL, mcpURL string) string { + return createMCPInteractionTemplateForHarness(t, modelURL, mcpURL, "kagent", "kagent") +} + +func createMCPInteractionTemplateForHarness(t *testing.T, modelURL, mcpURL, harnessName, runtimeLabel string) string { t.Helper() kube := interactionKubeClient(t) model := createInteractionModel(t, kube, modelURL, nil) @@ -714,10 +745,10 @@ func createMCPInteractionTemplate(t *testing.T, modelURL, mcpURL string) string template := &v1alpha3.AgentTemplate{ ObjectMeta: metav1.ObjectMeta{ GenerateName: "mcp-interaction-", Namespace: "kagent", - Labels: map[string]string{"kagent.dev/e2e-runtime": "kagent", "kagent.dev/harness": "kagent"}, + Labels: map[string]string{"kagent.dev/e2e-runtime": runtimeLabel, "kagent.dev/harness": harnessName}, }, Spec: v1alpha3.AgentTemplateSpec{ - ModelConfig: v1alpha3.AgentTemplateLocalReference{Name: model.Name}, + ModelConfig: &v1alpha3.AgentTemplateLocalReference{Name: model.Name}, Description: "MCP interaction E2E fixture", SystemPrompt: "Use add_numbers to answer arithmetic questions.", Tools: []v1alpha3.ToolBinding{{MCP: &v1alpha3.MCPToolBinding{ @@ -726,7 +757,7 @@ func createMCPInteractionTemplate(t *testing.T, modelURL, mcpURL string) string }}}, }, } - createAndWaitInteractionTemplate(t, kube, template) + createAndWaitInteractionTemplateForHarness(t, kube, template, harnessName) return template.Name } @@ -741,7 +772,7 @@ func createSharedInteractionTemplates(t *testing.T, modelURL string) (string, st Labels: map[string]string{"kagent.dev/e2e-runtime": "kagent", "kagent.dev/harness": "kagent"}, }, Spec: v1alpha3.AgentTemplateSpec{ - ModelConfig: v1alpha3.AgentTemplateLocalReference{Name: childModel.Name}, + ModelConfig: &v1alpha3.AgentTemplateLocalReference{Name: childModel.Name}, Description: "Shared specialist", SystemPrompt: "Answer as the shared specialist.", }, @@ -753,7 +784,7 @@ func createSharedInteractionTemplates(t *testing.T, modelURL string) (string, st Labels: map[string]string{"kagent.dev/e2e-runtime": "kagent", "kagent.dev/harness": "kagent"}, }, Spec: v1alpha3.AgentTemplateSpec{ - ModelConfig: v1alpha3.AgentTemplateLocalReference{Name: rootModel.Name}, + ModelConfig: &v1alpha3.AgentTemplateLocalReference{Name: rootModel.Name}, Description: "Shared agent interaction E2E fixture", SystemPrompt: "Delegate every request to the specialist.", Tools: []v1alpha3.ToolBinding{{Agent: &v1alpha3.AgentToolBinding{ diff --git a/go/core/test/e2e/manifests/lifecycle.yaml.tmpl b/go/core/test/e2e/manifests/lifecycle.yaml.tmpl index 7edb2bfe9..858cf61d8 100644 --- a/go/core/test/e2e/manifests/lifecycle.yaml.tmpl +++ b/go/core/test/e2e/manifests/lifecycle.yaml.tmpl @@ -19,6 +19,44 @@ spec: --- apiVersion: kagent.dev/v1alpha3 kind: Harness +metadata: + name: byo-e2e + namespace: kagent +spec: + byo: {} + workload: + image: ${KAGENT_E2E_BYO_IMAGE} + substrate: + workerPoolRef: + name: kagent-default + snapshotPolicy: + location: gs://ate-snapshots/kagent/ + allowedAgentTemplates: + selector: + matchLabels: + kagent.dev/e2e-runtime: byo +--- +apiVersion: kagent.dev/v1alpha3 +kind: Harness +metadata: + name: byo-adk-e2e + namespace: kagent +spec: + byo: {} + workload: + image: ${KAGENT_E2E_RUNTIME_IMAGE} + substrate: + workerPoolRef: + name: kagent-default + snapshotPolicy: + location: gs://ate-snapshots/kagent/ + allowedAgentTemplates: + selector: + matchLabels: + kagent.dev/e2e-runtime: byo-adk +--- +apiVersion: kagent.dev/v1alpha3 +kind: Harness metadata: name: claude-e2e namespace: kagent @@ -48,3 +86,13 @@ spec: modelConfig: name: default-model-config systemPrompt: Reply briefly. +--- +apiVersion: kagent.dev/v1alpha3 +kind: AgentTemplate +metadata: + name: byo-smoke + namespace: kagent + labels: + kagent.dev/e2e-runtime: byo +spec: + description: Opaque BYO A2A fixture without managed configuration diff --git a/go/core/v2/controller/collections_test.go b/go/core/v2/controller/collections_test.go index bddff506a..83cbe1bd9 100644 --- a/go/core/v2/controller/collections_test.go +++ b/go/core/v2/controller/collections_test.go @@ -51,7 +51,7 @@ func TestReconciliationCollectionsCompileAndObserveRevision(t *testing.T) { template := &kagentv1alpha3.AgentTemplate{ ObjectMeta: metav1.ObjectMeta{Namespace: "team-a", Name: "assistant", UID: "template-uid", Labels: map[string]string{"runtime": "python"}}, Spec: kagentv1alpha3.AgentTemplateSpec{ - ModelConfig: kagentv1alpha3.AgentTemplateLocalReference{Name: "model"}, + ModelConfig: &kagentv1alpha3.AgentTemplateLocalReference{Name: "model"}, SystemPrompt: "help", }, } @@ -127,7 +127,7 @@ func TestClaudeReconciliationCompilesActorTemplate(t *testing.T) { template := &kagentv1alpha3.AgentTemplate{ ObjectMeta: metav1.ObjectMeta{Namespace: "team-a", Name: "assistant", UID: "template-uid", Labels: map[string]string{"runtime": "claude"}}, - Spec: kagentv1alpha3.AgentTemplateSpec{ModelConfig: kagentv1alpha3.AgentTemplateLocalReference{Name: "model"}, SystemPrompt: "help"}, + Spec: kagentv1alpha3.AgentTemplateSpec{ModelConfig: &kagentv1alpha3.AgentTemplateLocalReference{Name: "model"}, SystemPrompt: "help"}, } claudeHarness := harness("team-a", "claude", map[string]string{"runtime": "claude"}) claudeHarness.UID = "harness-uid" @@ -170,12 +170,12 @@ func TestReconciliationTracksSharedAgentTemplate(t *testing.T) { opts := krt.NewOptionsBuilder(stop, "test", nil) child := &kagentv1alpha3.AgentTemplate{ ObjectMeta: metav1.ObjectMeta{Namespace: "team-a", Name: "child", Labels: map[string]string{"runtime": "python"}}, - Spec: kagentv1alpha3.AgentTemplateSpec{ModelConfig: kagentv1alpha3.AgentTemplateLocalReference{Name: "model"}, SystemPrompt: "before"}, + Spec: kagentv1alpha3.AgentTemplateSpec{ModelConfig: &kagentv1alpha3.AgentTemplateLocalReference{Name: "model"}, SystemPrompt: "before"}, } root := &kagentv1alpha3.AgentTemplate{ ObjectMeta: metav1.ObjectMeta{Namespace: "team-a", Name: "root", Labels: map[string]string{"runtime": "python"}}, Spec: kagentv1alpha3.AgentTemplateSpec{ - ModelConfig: kagentv1alpha3.AgentTemplateLocalReference{Name: "model"}, + ModelConfig: &kagentv1alpha3.AgentTemplateLocalReference{Name: "model"}, Tools: []kagentv1alpha3.ToolBinding{{Agent: &kagentv1alpha3.AgentToolBinding{ Name: "child", Description: "delegate", TemplateRef: kagentv1alpha3.AgentTemplateLocalReference{Name: child.Name}, }}}, diff --git a/go/core/v2/controller/reconciler.go b/go/core/v2/controller/reconciler.go index 866f13146..187d0fa3e 100644 --- a/go/core/v2/controller/reconciler.go +++ b/go/core/v2/controller/reconciler.go @@ -14,6 +14,7 @@ import ( kagentv1alpha3 "github.com/kagent-dev/kagent/go/api/v1alpha3" "github.com/kagent-dev/kagent/go/core/v2/substrate" v2translator "github.com/kagent-dev/kagent/go/core/v2/translator" + byotranslator "github.com/kagent-dev/kagent/go/core/v2/translator/byo" claudetranslator "github.com/kagent-dev/kagent/go/core/v2/translator/claude" kagenttranslator "github.com/kagent-dev/kagent/go/core/v2/translator/kagent" "google.golang.org/grpc/codes" @@ -70,6 +71,7 @@ func newPairReconciliations( revision, err := v2translator.NewCompiler(reader, map[v2translator.HarnessType]v2translator.HarnessCompiler{ v2translator.HarnessTypeKagent: kagenttranslator.NewCompiler(reader), v2translator.HarnessTypeClaude: claudetranslator.NewCompiler(reader), + v2translator.HarnessTypeBYO: byotranslator.NewCompiler(reader), }).CompileAgentTemplate(context.Background(), pair.Harness, pair.AgentTemplate) if err != nil { condition, reason := kagentv1alpha3.AgentTemplateConditionResolvedRefs, "ReferenceResolutionFailed" diff --git a/go/core/v2/substrate/actor_template.go b/go/core/v2/substrate/actor_template.go index 989a71319..1b4a72010 100644 --- a/go/core/v2/substrate/actor_template.go +++ b/go/core/v2/substrate/actor_template.go @@ -51,9 +51,11 @@ func ActorTemplateForRevision(spec *translator.Revision, revisionID translator.R ConfigName: "gvisor-default", }, Containers: []*ateapipb.Container{{ - Name: defaultContainerName, - Image: spec.Image, - Env: actorEnv, + Name: defaultContainerName, + Image: spec.Image, + Command: append([]string(nil), spec.Command...), + Args: append([]string(nil), spec.Args...), + Env: actorEnv, Readyz: &ateapipb.ContainerReadyz{HttpGet: &ateapipb.HTTPGetAction{ Path: "/readyz", Port: 8081, diff --git a/go/core/v2/substrate/actor_template_test.go b/go/core/v2/substrate/actor_template_test.go index 57eeefe9c..a9755bee9 100644 --- a/go/core/v2/substrate/actor_template_test.go +++ b/go/core/v2/substrate/actor_template_test.go @@ -1,6 +1,7 @@ package substrate import ( + "slices" "testing" "github.com/agent-substrate/substrate/pkg/proto/ateapipb" @@ -13,6 +14,8 @@ func TestActorTemplateForRevision(t *testing.T) { spec := &translator.Revision{ Namespace: "agents", AgentTemplateName: "helper", HarnessName: "kagent", Image: "agent.example/image@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + Command: []string{"/agent"}, + Args: []string{"serve"}, WorkerPoolName: "default", SnapshotLocation: "snapshots", ConfigJSON: []byte(`{"instruction":"help"}`), AgentCardJSON: []byte(`{"name":"helper"}`), Environment: []corev1.EnvVar{{Name: "API_KEY", Value: "secret"}}, @@ -29,6 +32,9 @@ func TestActorTemplateForRevision(t *testing.T) { t.Fatalf("ActorTemplate = %+v", template) } container := template.GetContainers()[0] + if !slices.Equal(container.Command, spec.Command) || !slices.Equal(container.Args, spec.Args) { + t.Fatalf("container command/args = %v %v", container.Command, container.Args) + } if template.GetSandboxConfig().GetSandboxClass() != ateapipb.SandboxClass_SANDBOX_CLASS_GVISOR || template.GetSandboxConfig().GetConfigName() != "gvisor-default" || container.GetReadyz().GetHttpGet().GetPath() != "/readyz" || container.GetReadyz().GetHttpGet().GetPort() != 8081 || container.GetReadyz().GetTimeoutSeconds() != 30 { t.Fatalf("unexpected runtime contract: %+v", template) } diff --git a/go/core/v2/translator/adkconfig/compiler.go b/go/core/v2/translator/adkconfig/compiler.go new file mode 100644 index 000000000..a2db71019 --- /dev/null +++ b/go/core/v2/translator/adkconfig/compiler.go @@ -0,0 +1,347 @@ +package adkconfig + +import ( + "context" + "crypto/sha256" + "encoding/json" + "fmt" + "net/url" + "slices" + "strings" + + "github.com/kagent-dev/kagent/go/api/adk" + "github.com/kagent-dev/kagent/go/api/v1alpha3" + v2translator "github.com/kagent-dev/kagent/go/core/v2/translator" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/types" +) + +// provenanceEntry records one Kubernetes input to a compiled revision. Secret +// entries identify a single key and hash its value; secret values are never stored. +type provenanceEntry struct { + APIVersion string `json:"apiVersion"` + Kind string `json:"kind"` + Name string `json:"name"` + Key string `json:"key,omitempty"` + UID types.UID `json:"uid"` + Generation int64 `json:"generation,omitempty"` + Hash string `json:"hash"` +} + +// Compiler translates resolved inputs into an ADK agent configuration. +type Compiler struct{ kube v2translator.Reader } + +// NewCompiler constructs an ADK configuration compiler. +func NewCompiler(kube v2translator.Reader) *Compiler { return &Compiler{kube: kube} } + +type Result struct { + Config *adk.AgentConfig + Models []*v1alpha3.ModelConfig + Templates []*v1alpha3.AgentTemplate + Environment []corev1.EnvVar + Egress []string +} + +// HarnessEnvironment converts portable Harness environment entries to Pod environment variables. +func HarnessEnvironment(harness *v1alpha3.Harness) []corev1.EnvVar { + environment := make([]corev1.EnvVar, 0, len(harness.Spec.Env)) + for _, value := range harness.Spec.Env { + variable := corev1.EnvVar{Name: value.Name} + if value.Value != nil { + variable.Value = *value.Value + } else { + variable.ValueFrom = &corev1.EnvVarSource{SecretKeyRef: value.CredentialRef.DeepCopy()} + } + environment = append(environment, variable) + } + return environment +} + +func (c *Compiler) Compile(ctx context.Context, input *v2translator.AgentInput) (*Result, error) { + return c.compileAgent(ctx, input) +} + +func (c *Compiler) compileAgent(ctx context.Context, input *v2translator.AgentInput) (*Result, error) { + modelRuntime := &modelRuntime{data: &modelDeploymentData{}} + if input.ModelConfig != nil { + var err error + modelRuntime, err = c.resolveModel(ctx, input.ModelConfig) + if err != nil { + return nil, fmt.Errorf("resolve ModelConfig %q: %w", input.ModelConfig.Name, err) + } + } + if modelRuntime.HasUnsupportedVolumes { + return nil, v2translator.NewValidationError("ModelConfig requires volume mounts unsupported by Substrate ActorTemplate") + } + stream := true + cfg := &adk.AgentConfig{Model: modelRuntime.Model, Description: input.Template.Spec.Description, Instruction: input.Instruction, Stream: &stream} + pluginConfig, pluginEgress, err := v2translator.CompileSkillResources(input.Template) + if err != nil { + return nil, err + } + if len(pluginConfig.Skills) > 0 || len(pluginConfig.Plugins) > 0 { + cfg.AgentPlugins = &pluginConfig + } + for _, tool := range input.MCPTools { + ref := &v1alpha3.McpServerTool{TypedReference: v1alpha3.TypedReference{ + ApiGroup: "kagent.dev", Kind: tool.Binding.Server.Kind, Name: tool.Binding.Server.Name, + }, ToolNames: append([]string(nil), tool.Binding.Tools...)} + headers, credentialEnv, err := c.resolveAgentTemplateHeaders(ctx, input.Template.Namespace, tool.Server.Spec.HeadersFrom) + if err != nil { + return nil, fmt.Errorf("resolve %s %q: %w", tool.Binding.Server.Kind, tool.Binding.Server.Name, err) + } + server := tool.Server.DeepCopy() + server.Spec.HeadersFrom = nil + if err := c.addRemoteMCPServer(cfg, modelRuntime, server, ref, headers); err != nil { + return nil, fmt.Errorf("compile %s %q: %w", tool.Binding.Server.Kind, tool.Binding.Server.Name, err) + } + modelRuntime.Environment = append(modelRuntime.Environment, credentialEnv...) + } + if modelRuntime.HasUnsupportedVolumes { + return nil, v2translator.NewValidationError("resolved model or MCP configuration requires volume mounts unsupported by Substrate ActorTemplate") + } + result := &Result{ + Config: cfg, Templates: []*v1alpha3.AgentTemplate{input.Template}, + Environment: modelRuntime.Environment, + Egress: append(agentConfigDestinations(cfg, input.ModelConfig, modelRuntime.Model), pluginEgress...), + } + if input.ModelConfig != nil { + result.Models = []*v1alpha3.ModelConfig{input.ModelConfig} + } + for _, binding := range input.Shared { + child, err := c.compileAgent(ctx, binding.Agent) + if err != nil { + return nil, err + } + child.Config.Name, child.Config.Description = binding.Name, binding.Description + cfg.SubAgents = append(cfg.SubAgents, child.Config) + result.Models = append(result.Models, child.Models...) + result.Templates = append(result.Templates, child.Templates...) + result.Environment = append(result.Environment, child.Environment...) + result.Egress = append(result.Egress, child.Egress...) + } + return result, nil +} + +// ResolveEnvironment replaces Kubernetes Secret references with literals +// because Substrate ActorTemplates accept only literal environment values. +func (c *Compiler) ResolveEnvironment(ctx context.Context, namespace string, environment []corev1.EnvVar) ([]corev1.EnvVar, error) { + resolved := append([]corev1.EnvVar(nil), environment...) + for i, variable := range resolved { + if variable.ValueFrom == nil { + continue + } + if variable.ValueFrom.SecretKeyRef == nil { + return nil, fmt.Errorf("environment variable %q uses an unsupported value source", variable.Name) + } + ref := variable.ValueFrom.SecretKeyRef + secret := &corev1.Secret{} + if err := c.kube.Get(ctx, types.NamespacedName{Namespace: namespace, Name: ref.Name}, secret); err != nil { + return nil, err + } + value, ok := secret.Data[ref.Key] + if !ok { + return nil, fmt.Errorf("secret %q does not contain key %q", ref.Name, ref.Key) + } + resolved[i].Value = string(value) + resolved[i].ValueFrom = nil + } + return resolved, nil +} + +// BuildProvenance records every Kubernetes input that can change the compiled +// runtime. Sorting makes the JSON stable across map iteration order. +func (c *Compiler) BuildProvenance(ctx context.Context, harness *v1alpha3.Harness, templates []*v1alpha3.AgentTemplate, models []*v1alpha3.ModelConfig, environment []corev1.EnvVar) ([]byte, error) { + entries := []provenanceEntry{objectProvenance(v1alpha3.GroupVersion.String(), "Harness", harness.Name, harness.UID, harness.Generation, harness.Spec)} + configMaps := map[string]struct{}{} + for _, template := range templates { + entries = append(entries, objectProvenance(v1alpha3.GroupVersion.String(), "AgentTemplate", template.Name, template.UID, template.Generation, template.Spec)) + if template.Spec.SystemPromptFrom != nil { + configMaps[template.Spec.SystemPromptFrom.Name] = struct{}{} + } + if template.Spec.PromptTemplate != nil { + for _, source := range template.Spec.PromptTemplate.DataSources { + configMaps[source.Name] = struct{}{} + } + } + } + for _, model := range models { + entries = append(entries, objectProvenance(v1alpha3.GroupVersion.String(), "ModelConfig", model.Name, model.UID, model.Generation, model.Spec)) + } + for name := range configMaps { + configMap := &corev1.ConfigMap{} + if err := c.kube.Get(ctx, types.NamespacedName{Namespace: harness.Namespace, Name: name}, configMap); err != nil { + return nil, err + } + entries = append(entries, objectProvenance("v1", "ConfigMap", name, configMap.UID, configMap.Generation, configMap.Data)) + } + for _, template := range templates { + for _, binding := range template.Spec.Tools { + if binding.MCP == nil { + continue + } + switch binding.MCP.Server.Kind { + case "RemoteMCPServer": + server := &v1alpha3.RemoteMCPServer{} + if err := c.kube.Get(ctx, types.NamespacedName{Namespace: template.Namespace, Name: binding.MCP.Server.Name}, server); err != nil { + return nil, err + } + entries = append(entries, objectProvenance(v1alpha3.GroupVersion.String(), "RemoteMCPServer", server.Name, server.UID, server.Generation, server.Spec)) + } + } + } + // Secret provenance contains only UID and value hash. Name+key deduplication + // keeps repeated references from changing the digest. + seenSecrets := map[string]struct{}{} + for _, variable := range environment { + if variable.ValueFrom == nil || variable.ValueFrom.SecretKeyRef == nil { + continue + } + ref := variable.ValueFrom.SecretKeyRef + identity := ref.Name + "\x00" + ref.Key + if _, ok := seenSecrets[identity]; ok { + continue + } + seenSecrets[identity] = struct{}{} + secret := &corev1.Secret{} + if err := c.kube.Get(ctx, types.NamespacedName{Namespace: harness.Namespace, Name: ref.Name}, secret); err != nil { + return nil, err + } + value, ok := secret.Data[ref.Key] + if !ok { + return nil, fmt.Errorf("secret %q does not contain key %q", ref.Name, ref.Key) + } + hash := sha256.Sum256(value) + entries = append(entries, provenanceEntry{APIVersion: "v1", Kind: "Secret", Name: ref.Name, Key: ref.Key, UID: secret.UID, Hash: fmt.Sprintf("%x", hash[:])}) + } + slices.SortFunc(entries, func(a, b provenanceEntry) int { + return strings.Compare(a.APIVersion+"\x00"+a.Kind+"\x00"+a.Name+"\x00"+a.Key, b.APIVersion+"\x00"+b.Kind+"\x00"+b.Name+"\x00"+b.Key) + }) + entries = slices.Compact(entries) + return json.Marshal(entries) +} + +// objectProvenance hashes the relevant object content rather than relying +// on generation alone, which is not available or meaningful for every input. +func objectProvenance(apiVersion, kind, name string, uid types.UID, generation int64, content any) provenanceEntry { + raw, _ := json.Marshal(content) + hash := sha256.Sum256(raw) + return provenanceEntry{APIVersion: apiVersion, Kind: kind, Name: name, UID: uid, Generation: generation, Hash: fmt.Sprintf("%x", hash[:])} +} + +// resolveAgentTemplateHeaders keeps Secret values out of serialized agent +// config. The runtime expands __KAGENT_ENV[...]__ from the corresponding +// Secret-backed environment variable when it constructs the MCP request. +func (c *Compiler) resolveAgentTemplateHeaders(ctx context.Context, namespace string, refs []v1alpha3.ValueRef) (map[string]string, []corev1.EnvVar, error) { + headers := make(map[string]string, len(refs)) + var environment []corev1.EnvVar + for _, ref := range refs { + if ref.ValueFrom == nil || ref.ValueFrom.Type != v1alpha3.SecretValueSource { + name, value, err := c.resolveValueRef(ctx, namespace, ref) + if err != nil { + return nil, nil, err + } + headers[name] = value + continue + } + selector := &corev1.SecretKeySelector{LocalObjectReference: corev1.LocalObjectReference{Name: ref.ValueFrom.Name}, Key: ref.ValueFrom.Key} + sum := sha256.Sum256([]byte(namespace + "\x00" + selector.Name + "\x00" + selector.Key)) + envName := "KAGENT_CREDENTIAL_" + strings.ToUpper(fmt.Sprintf("%x", sum[:8])) + headers[ref.Name] = "__KAGENT_ENV[" + envName + "]__" + environment = append(environment, corev1.EnvVar{Name: envName, ValueFrom: &corev1.EnvVarSource{SecretKeyRef: selector}}) + } + return headers, environment, nil +} + +func (c *Compiler) resolveValueRef(ctx context.Context, namespace string, ref v1alpha3.ValueRef) (string, string, error) { + if ref.ValueFrom == nil { + return ref.Name, ref.Value, nil + } + if ref.ValueFrom.Type != v1alpha3.ConfigMapValueSource { + return "", "", fmt.Errorf("unsupported value source type %q", ref.ValueFrom.Type) + } + configMap := &corev1.ConfigMap{} + if err := c.kube.Get(ctx, types.NamespacedName{Namespace: namespace, Name: ref.ValueFrom.Name}, configMap); err != nil { + return "", "", err + } + value, found := configMap.Data[ref.ValueFrom.Key] + if !found { + return "", "", fmt.Errorf("ConfigMap %q does not contain key %q", ref.ValueFrom.Name, ref.ValueFrom.Key) + } + return ref.Name, value, nil +} + +// agentTemplateCard describes the runtime-local A2A server. Substrate routes +// DedupeEnv preserves first-seen ordering but gives the last value for a name +// precedence, matching how compiler layers are applied. +func DedupeEnv(values []corev1.EnvVar) []corev1.EnvVar { + result := make([]corev1.EnvVar, 0, len(values)) + index := map[string]int{} + for _, value := range values { + if i, ok := index[value.Name]; ok { + result[i] = value + continue + } + index[value.Name] = len(result) + result = append(result, value) + } + return result +} + +// agentConfigDestinations extracts the network allowlist required by the +// resolved model and MCP configuration. Provider defaults are included when +// no explicit endpoint appears in the serialized model. +func agentConfigDestinations(cfg *adk.AgentConfig, modelConfig *v1alpha3.ModelConfig, model adk.Model) []string { + destinations := make([]string, 0, len(cfg.HttpTools)+len(cfg.SseTools)+1) + for _, tool := range cfg.HttpTools { + destinations = appendURLHost(destinations, tool.Params.Url) + } + for _, tool := range cfg.SseTools { + destinations = appendURLHost(destinations, tool.Params.Url) + } + modelJSON, _ := json.Marshal(model) + var values any + if json.Unmarshal(modelJSON, &values) == nil { + destinations = appendURLValues(destinations, values) + } + if modelConfig == nil { + slices.Sort(destinations) + return slices.Compact(destinations) + } + switch modelConfig.Spec.Provider { + case v1alpha3.ModelProviderOpenAI: + destinations = append(destinations, "api.openai.com") + case v1alpha3.ModelProviderAnthropic: + destinations = append(destinations, "api.anthropic.com") + case v1alpha3.ModelProviderGemini: + destinations = append(destinations, "generativelanguage.googleapis.com") + } + slices.Sort(destinations) + return slices.Compact(destinations) +} + +// appendURLValues walks serialized provider config because endpoint fields are +// provider-specific but all URLs reduce to the same hostname allowlist. +func appendURLValues(destinations []string, value any) []string { + switch value := value.(type) { + case string: + return appendURLHost(destinations, value) + case []any: + for _, item := range value { + destinations = appendURLValues(destinations, item) + } + case map[string]any: + for _, item := range value { + destinations = appendURLValues(destinations, item) + } + } + return destinations +} + +func appendURLHost(destinations []string, raw string) []string { + parsed, err := url.Parse(raw) + if err == nil && parsed.Hostname() != "" { + return append(destinations, parsed.Hostname()) + } + return destinations +} diff --git a/go/core/v2/translator/kagent/mcp.go b/go/core/v2/translator/adkconfig/mcp.go similarity index 99% rename from go/core/v2/translator/kagent/mcp.go rename to go/core/v2/translator/adkconfig/mcp.go index e24411b79..4fb639acb 100644 --- a/go/core/v2/translator/kagent/mcp.go +++ b/go/core/v2/translator/adkconfig/mcp.go @@ -1,4 +1,4 @@ -package kagent +package adkconfig import ( "github.com/kagent-dev/kagent/go/api/adk" diff --git a/go/core/v2/translator/kagent/model.go b/go/core/v2/translator/adkconfig/model.go similarity index 99% rename from go/core/v2/translator/kagent/model.go rename to go/core/v2/translator/adkconfig/model.go index d7f91aa64..f9d91e64f 100644 --- a/go/core/v2/translator/kagent/model.go +++ b/go/core/v2/translator/adkconfig/model.go @@ -1,4 +1,4 @@ -package kagent +package adkconfig import ( "context" @@ -14,7 +14,6 @@ import ( "github.com/kagent-dev/kagent/go/api/v1alpha3" "github.com/kagent-dev/kagent/go/core/internal/utils" "github.com/kagent-dev/kagent/go/core/pkg/env" - v2translator "github.com/kagent-dev/kagent/go/core/v2/translator" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/types" ) @@ -39,8 +38,6 @@ type modelRuntime struct { data *modelDeploymentData } -var _ v2translator.HarnessCompiler = (*Compiler)(nil) - // resolveModel collapses provider-specific translation output into the subset // needed to compile a runtime revision. func (c *Compiler) resolveModel(ctx context.Context, config *v1alpha3.ModelConfig) (*modelRuntime, error) { diff --git a/go/core/v2/translator/byo/compiler.go b/go/core/v2/translator/byo/compiler.go new file mode 100644 index 000000000..dc204a240 --- /dev/null +++ b/go/core/v2/translator/byo/compiler.go @@ -0,0 +1,67 @@ +package byo + +import ( + "context" + "encoding/json" + "fmt" + "slices" + "strings" + + a2atype "github.com/a2aproject/a2a-go/v2/a2a" + "github.com/kagent-dev/kagent/go/api/v1alpha3" + v2translator "github.com/kagent-dev/kagent/go/core/v2/translator" + "github.com/kagent-dev/kagent/go/core/v2/translator/adkconfig" +) + +// Compiler translates resolved inputs into a BYO A2A runtime revision. +type Compiler struct{ adk *adkconfig.Compiler } + +var _ v2translator.HarnessCompiler = (*Compiler)(nil) + +func NewCompiler(kube v2translator.Reader) *Compiler { + return &Compiler{adk: adkconfig.NewCompiler(kube)} +} + +func (c *Compiler) Compile(ctx context.Context, input *v2translator.HarnessInput) (*v2translator.Revision, error) { + compiled, err := c.adk.Compile(ctx, input.Root) + if err != nil { + return nil, err + } + template, harness := input.Root.Template, input.Harness + configJSON, err := json.Marshal(compiled.Config) + if err != nil { + return nil, fmt.Errorf("marshal agent config: %w", err) + } + cardJSON, err := json.Marshal(agentTemplateCard(template)) + if err != nil { + return nil, fmt.Errorf("marshal agent card: %w", err) + } + environment := adkconfig.DedupeEnv(append(compiled.Environment, adkconfig.HarnessEnvironment(harness)...)) + provenance, err := c.adk.BuildProvenance(ctx, harness, compiled.Templates, compiled.Models, environment) + if err != nil { + return nil, fmt.Errorf("build revision provenance: %w", err) + } + environment, err = c.adk.ResolveEnvironment(ctx, template.Namespace, environment) + if err != nil { + return nil, fmt.Errorf("resolve runtime environment: %w", err) + } + compiled.Egress = append(compiled.Egress, harness.Spec.BYO.EgressDestinations...) + slices.Sort(compiled.Egress) + + return &v2translator.Revision{ + Namespace: template.Namespace, AgentTemplateName: template.Name, HarnessName: harness.Name, + Image: harness.Spec.Workload.Image, Command: harness.Spec.BYO.Command, Args: harness.Spec.BYO.Args, + Environment: environment, ConfigJSON: configJSON, AgentCardJSON: cardJSON, + WorkerPoolName: harness.Spec.Substrate.WorkerPoolRef.Name, SnapshotLocation: harness.Spec.Substrate.SnapshotPolicy.Location, + Provenance: provenance, EgressDestinations: slices.Compact(compiled.Egress), + }, nil +} + +func agentTemplateCard(template *v1alpha3.AgentTemplate) *a2atype.AgentCard { + return &a2atype.AgentCard{ + Name: strings.ReplaceAll(template.Name, "-", "_"), Description: template.Spec.Description, Version: "v1", + SupportedInterfaces: []*a2atype.AgentInterface{{URL: "http://127.0.0.1:80", ProtocolBinding: a2atype.TransportProtocolGRPC, ProtocolVersion: a2atype.Version}}, + Capabilities: a2atype.AgentCapabilities{Streaming: true}, Skills: []a2atype.AgentSkill{}, + DefaultInputModes: []string{"text"}, DefaultOutputModes: []string{"text"}, + } +} diff --git a/go/core/v2/translator/byo/compiler_test.go b/go/core/v2/translator/byo/compiler_test.go new file mode 100644 index 000000000..75786959a --- /dev/null +++ b/go/core/v2/translator/byo/compiler_test.go @@ -0,0 +1,52 @@ +package byo + +import ( + "context" + "encoding/json" + "testing" + + "github.com/kagent-dev/kagent/go/api/adk" + "github.com/kagent-dev/kagent/go/api/v1alpha3" + v2translator "github.com/kagent-dev/kagent/go/core/v2/translator" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" +) + +type reader struct{} + +func (reader) Get(context.Context, types.NamespacedName, runtime.Object) error { return nil } + +func TestCompileOpaqueImage(t *testing.T) { + harness := &v1alpha3.Harness{ObjectMeta: metav1.ObjectMeta{Name: "byo", Namespace: "test"}, Spec: v1alpha3.HarnessSpec{ + BYO: &v1alpha3.BYOHarness{ + Command: []string{"/agent"}, Args: []string{"serve"}, + EgressDestinations: []string{"api.example.com"}, + }, + Workload: v1alpha3.HarnessWorkload{Image: "example.com/agent@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}, + Env: []v1alpha3.HarnessEnvVar{{Name: "MODE", Value: new("production")}}, + Substrate: v1alpha3.HarnessSubstratePolicy{ + WorkerPoolRef: corev1.LocalObjectReference{Name: "default"}, SnapshotPolicy: v1alpha3.HarnessSnapshotPolicy{Location: "snapshots"}, + }, + }} + template := &v1alpha3.AgentTemplate{ObjectMeta: metav1.ObjectMeta{Name: "custom-agent", Namespace: "test"}, Spec: v1alpha3.AgentTemplateSpec{ + Description: "custom A2A agent", SystemPrompt: "be helpful", + }} + + revision, err := NewCompiler(reader{}).Compile(context.Background(), &v2translator.HarnessInput{ + Harness: harness, Root: &v2translator.AgentInput{Template: template, Instruction: template.Spec.SystemPrompt}, + }) + require.NoError(t, err) + require.Equal(t, harness.Spec.BYO.Command, revision.Command) + require.Equal(t, harness.Spec.BYO.Args, revision.Args) + require.Equal(t, []string{"api.example.com"}, revision.EgressDestinations) + require.Equal(t, []corev1.EnvVar{{Name: "MODE", Value: "production"}}, revision.Environment) + + var config adk.AgentConfig + require.NoError(t, json.Unmarshal(revision.ConfigJSON, &config)) + require.Nil(t, config.Model) + require.Equal(t, "be helpful", config.Instruction) + require.Contains(t, string(revision.AgentCardJSON), `"streaming":true`) +} diff --git a/go/core/v2/translator/claude/compiler_test.go b/go/core/v2/translator/claude/compiler_test.go index b5e1ed7c7..472c7b0a6 100644 --- a/go/core/v2/translator/claude/compiler_test.go +++ b/go/core/v2/translator/claude/compiler_test.go @@ -326,7 +326,7 @@ func TestCompileLocalSharedAgent(t *testing.T) { Template: &v1alpha3.AgentTemplate{ ObjectMeta: metav1.ObjectMeta{Name: "specialist-template", Namespace: "test", UID: "child-template-uid"}, Spec: v1alpha3.AgentTemplateSpec{ - ModelConfig: v1alpha3.AgentTemplateLocalReference{Name: "child-model"}, + ModelConfig: &v1alpha3.AgentTemplateLocalReference{Name: "child-model"}, Description: "template description", SystemPrompt: "specialize", }, }, @@ -399,7 +399,7 @@ func TestCompileRejectsUnsupportedLocalAgentConfiguration(t *testing.T) { Agent: &v2translator.AgentInput{ Template: &v1alpha3.AgentTemplate{ ObjectMeta: metav1.ObjectMeta{Name: "child", Namespace: "test"}, - Spec: v1alpha3.AgentTemplateSpec{ModelConfig: v1alpha3.AgentTemplateLocalReference{Name: "child-model"}}, + Spec: v1alpha3.AgentTemplateSpec{ModelConfig: &v1alpha3.AgentTemplateLocalReference{Name: "child-model"}}, }, ModelConfig: &v1alpha3.ModelConfig{ObjectMeta: metav1.ObjectMeta{Name: "child-model", Namespace: "test"}, Spec: childSpec}, Instruction: "specialize", @@ -425,7 +425,7 @@ func testInput(t *testing.T, modelSpec v1alpha3.ModelConfigSpec, secretData map[ Substrate: v1alpha3.HarnessSubstratePolicy{WorkerPoolRef: corev1.LocalObjectReference{Name: "default"}, SnapshotPolicy: v1alpha3.HarnessSnapshotPolicy{Location: "snapshots"}}, }} template := &v1alpha3.AgentTemplate{ObjectMeta: metav1.ObjectMeta{Name: "assistant", Namespace: "test", UID: "template-uid"}, Spec: v1alpha3.AgentTemplateSpec{ - ModelConfig: v1alpha3.AgentTemplateLocalReference{Name: "model"}, Description: "assistant", SystemPrompt: "help carefully", + ModelConfig: &v1alpha3.AgentTemplateLocalReference{Name: "model"}, Description: "assistant", SystemPrompt: "help carefully", }} model := &v1alpha3.ModelConfig{ObjectMeta: metav1.ObjectMeta{Name: "model", Namespace: "test", UID: "model-uid"}, Spec: modelSpec} secret := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "model-auth", Namespace: "test", UID: "secret-uid"}, Data: secretData} diff --git a/go/core/v2/translator/compiler.go b/go/core/v2/translator/compiler.go index 1de28ef14..0c10c0a77 100644 --- a/go/core/v2/translator/compiler.go +++ b/go/core/v2/translator/compiler.go @@ -27,6 +27,7 @@ const ( HarnessTypeKagent HarnessType = "kagent" HarnessTypeCodex HarnessType = "codex" HarnessTypeClaude HarnessType = "claude" + HarnessTypeBYO HarnessType = "byo" ) // HarnessCompiler converts resolved, harness-neutral inputs into one runtime revision. @@ -112,6 +113,8 @@ func harnessType(harness *v1alpha3.Harness) HarnessType { return HarnessTypeCodex case harness.Spec.Claude != nil: return HarnessTypeClaude + case harness.Spec.BYO != nil: + return HarnessTypeBYO default: return "" } @@ -192,9 +195,12 @@ func (c *Compiler) buildInputs(ctx context.Context, tree *ResolvedTree) (*Harnes var build func(*ResolvedAgent) (*AgentInput, error) build = func(agent *ResolvedAgent) (*AgentInput, error) { template := agent.Template - model := &v1alpha3.ModelConfig{} - if err := c.kube.Get(ctx, types.NamespacedName{Namespace: template.Namespace, Name: template.Spec.ModelConfig.Name}, model); err != nil { - return nil, fmt.Errorf("resolve ModelConfig %q: %w", template.Spec.ModelConfig.Name, err) + var model *v1alpha3.ModelConfig + if template.Spec.ModelConfig != nil { + model = &v1alpha3.ModelConfig{} + if err := c.kube.Get(ctx, types.NamespacedName{Namespace: template.Namespace, Name: template.Spec.ModelConfig.Name}, model); err != nil { + return nil, fmt.Errorf("resolve ModelConfig %q: %w", template.Spec.ModelConfig.Name, err) + } } instruction, err := c.resolveAgentTemplatePrompt(ctx, template) if err != nil { diff --git a/go/core/v2/translator/compiler_test.go b/go/core/v2/translator/compiler_test.go index 771bff0ca..4da9ca7e1 100644 --- a/go/core/v2/translator/compiler_test.go +++ b/go/core/v2/translator/compiler_test.go @@ -43,7 +43,7 @@ func TestCompileAgentTemplatePinsAgentPluginSources(t *testing.T) { template := &v1alpha3.AgentTemplate{ ObjectMeta: metav1.ObjectMeta{Name: "helper", Namespace: "test"}, Spec: v1alpha3.AgentTemplateSpec{ - ModelConfig: v1alpha3.AgentTemplateLocalReference{Name: "default-model"}, + ModelConfig: &v1alpha3.AgentTemplateLocalReference{Name: "default-model"}, Skills: []v1alpha3.AgentTemplateSkill{ {Name: "review", Source: v1alpha3.ArtifactSource{ OCI: "ghcr.io/acme/review@sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", @@ -128,7 +128,7 @@ func TestCompilerAcceptsExternalHarnessCompiler(t *testing.T) { ObjectMeta: metav1.ObjectMeta{Name: "codex", Namespace: "test"}, Spec: v1alpha3.HarnessSpec{Codex: &v1alpha3.CodexHarness{}, AllowedAgentTemplates: &v1alpha3.HarnessAgentTemplateAdmission{Selector: metav1.LabelSelector{}}}, } - template := &v1alpha3.AgentTemplate{ObjectMeta: metav1.ObjectMeta{Name: "assistant", Namespace: "test"}, Spec: v1alpha3.AgentTemplateSpec{ModelConfig: v1alpha3.AgentTemplateLocalReference{Name: "default-model"}}} + template := &v1alpha3.AgentTemplate{ObjectMeta: metav1.ObjectMeta{Name: "assistant", Namespace: "test"}, Spec: v1alpha3.AgentTemplateSpec{ModelConfig: &v1alpha3.AgentTemplateLocalReference{Name: "default-model"}}} revision, err := v2translator.NewCompiler(testReader{kube}, map[v2translator.HarnessType]v2translator.HarnessCompiler{ v2translator.HarnessTypeCodex: adapter, @@ -138,6 +138,22 @@ func TestCompilerAcceptsExternalHarnessCompiler(t *testing.T) { require.Equal(t, template.Name, adapter.input.Root.Template.Name) } +func TestCompilerPermitsBYOWithoutModelConfig(t *testing.T) { + require.NoError(t, v1alpha3.AddToScheme(schemev1.Scheme)) + kube := fake.NewClientBuilder().WithScheme(schemev1.Scheme).Build() + adapter := &testHarnessCompiler{} + harness := &v1alpha3.Harness{ObjectMeta: metav1.ObjectMeta{Name: "byo", Namespace: "test"}, Spec: v1alpha3.HarnessSpec{ + BYO: &v1alpha3.BYOHarness{}, AllowedAgentTemplates: &v1alpha3.HarnessAgentTemplateAdmission{Selector: metav1.LabelSelector{}}, + }} + template := &v1alpha3.AgentTemplate{ObjectMeta: metav1.ObjectMeta{Name: "assistant", Namespace: "test"}} + + _, err := v2translator.NewCompiler(testReader{kube}, map[v2translator.HarnessType]v2translator.HarnessCompiler{ + v2translator.HarnessTypeBYO: adapter, + }).CompileAgentTemplate(context.Background(), harness, template) + require.NoError(t, err) + require.Nil(t, adapter.input.Root.ModelConfig) +} + func TestCompileAgentTemplateResolvesCredentialsForSubstrate(t *testing.T) { secret := &corev1.Secret{ ObjectMeta: metav1.ObjectMeta{Name: "mcp-auth", Namespace: "test"}, @@ -176,7 +192,7 @@ func TestCompileAgentTemplateResolvesCredentialsForSubstrate(t *testing.T) { template := &v1alpha3.AgentTemplate{ ObjectMeta: metav1.ObjectMeta{Name: "helper", Namespace: "test"}, Spec: v1alpha3.AgentTemplateSpec{ - ModelConfig: v1alpha3.AgentTemplateLocalReference{Name: "default-model"}, + ModelConfig: &v1alpha3.AgentTemplateLocalReference{Name: "default-model"}, SystemPrompt: "help", Tools: []v1alpha3.ToolBinding{{MCP: &v1alpha3.MCPToolBinding{ Server: v1alpha3.AgentTemplateTypedLocalReference{Kind: "RemoteMCPServer", Name: server.Name}, @@ -229,7 +245,7 @@ func TestCompileAgentTemplateSharedAgent(t *testing.T) { child := &v1alpha3.AgentTemplate{ ObjectMeta: metav1.ObjectMeta{Name: "researcher", Namespace: "test", Labels: map[string]string{"runtime": "kagent"}}, Spec: v1alpha3.AgentTemplateSpec{ - ModelConfig: v1alpha3.AgentTemplateLocalReference{Name: "default-model"}, Description: "template description", SystemPrompt: "research carefully", + ModelConfig: &v1alpha3.AgentTemplateLocalReference{Name: "default-model"}, Description: "template description", SystemPrompt: "research carefully", Tools: []v1alpha3.ToolBinding{{MCP: &v1alpha3.MCPToolBinding{ Server: v1alpha3.AgentTemplateTypedLocalReference{Kind: "RemoteMCPServer", Name: "search"}, Tools: []string{"lookup"}, }}}, @@ -241,7 +257,7 @@ func TestCompileAgentTemplateSharedAgent(t *testing.T) { root := &v1alpha3.AgentTemplate{ ObjectMeta: metav1.ObjectMeta{Name: "coordinator", Namespace: "test", Labels: map[string]string{"runtime": "kagent"}}, Spec: v1alpha3.AgentTemplateSpec{ - ModelConfig: v1alpha3.AgentTemplateLocalReference{Name: "default-model"}, SystemPrompt: "coordinate", + ModelConfig: &v1alpha3.AgentTemplateLocalReference{Name: "default-model"}, SystemPrompt: "coordinate", Tools: []v1alpha3.ToolBinding{{Agent: &v1alpha3.AgentToolBinding{ Name: "web_researcher", Description: "research the web", TemplateRef: v1alpha3.AgentTemplateLocalReference{Name: child.Name}, }}}, @@ -271,7 +287,7 @@ func TestCompileAgentTemplateRejectsInvalidSharedTrees(t *testing.T) { return v1alpha3.ToolBinding{Agent: &v1alpha3.AgentToolBinding{Name: name, Description: name, TemplateRef: v1alpha3.AgentTemplateLocalReference{Name: target}}} } template := func(name string, tools ...v1alpha3.ToolBinding) *v1alpha3.AgentTemplate { - return &v1alpha3.AgentTemplate{ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: "test", Labels: map[string]string{"runtime": "kagent"}}, Spec: v1alpha3.AgentTemplateSpec{ModelConfig: v1alpha3.AgentTemplateLocalReference{Name: "default-model"}, Tools: tools}} + return &v1alpha3.AgentTemplate{ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: "test", Labels: map[string]string{"runtime": "kagent"}}, Spec: v1alpha3.AgentTemplateSpec{ModelConfig: &v1alpha3.AgentTemplateLocalReference{Name: "default-model"}, Tools: tools}} } t.Run("shared DAG", func(t *testing.T) { diff --git a/go/core/v2/translator/kagent/agentcard_test.go b/go/core/v2/translator/kagent/agentcard_test.go index 69162a24b..f5e19e4cc 100644 --- a/go/core/v2/translator/kagent/agentcard_test.go +++ b/go/core/v2/translator/kagent/agentcard_test.go @@ -1,12 +1,24 @@ package kagent import ( + "context" + "strings" "testing" "github.com/kagent-dev/kagent/go/api/v1alpha3" + v2translator "github.com/kagent-dev/kagent/go/core/v2/translator" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) +func TestCompilerRequiresModelConfig(t *testing.T) { + _, err := NewCompiler(nil).Compile(context.Background(), &v2translator.HarnessInput{Root: &v2translator.AgentInput{ + Template: &v1alpha3.AgentTemplate{}, + }}) + if err == nil || !strings.Contains(err.Error(), "kagent ModelConfig is required") { + t.Fatalf("Compile() error = %v", err) + } +} + // TestAgentTemplateCardDeclaresHumanInTheLoop pins discoverability. The compiled // card is a snapshot stored with the revision, not the runtime's live card, so a // capability the runtime has is invisible unless this states it. Dropping it diff --git a/go/core/v2/translator/kagent/compiler.go b/go/core/v2/translator/kagent/compiler.go index 5b54b81f1..d2a5ffac9 100644 --- a/go/core/v2/translator/kagent/compiler.go +++ b/go/core/v2/translator/kagent/compiler.go @@ -2,66 +2,46 @@ package kagent import ( "context" - "crypto/sha256" "encoding/json" "fmt" - "net/url" "slices" "strings" a2atype "github.com/a2aproject/a2a-go/v2/a2a" - "github.com/kagent-dev/kagent/go/api/adk" "github.com/kagent-dev/kagent/go/api/v1alpha3" "github.com/kagent-dev/kagent/go/core/internal/utils" "github.com/kagent-dev/kagent/go/core/pkg/env" v2translator "github.com/kagent-dev/kagent/go/core/v2/translator" + "github.com/kagent-dev/kagent/go/core/v2/translator/adkconfig" corev1 "k8s.io/api/core/v1" - "k8s.io/apimachinery/pkg/types" ) -// provenanceEntry records one Kubernetes input to a compiled revision. Secret -// entries identify a single key and hash its value; secret values are never stored. -type provenanceEntry struct { - APIVersion string `json:"apiVersion"` - Kind string `json:"kind"` - Name string `json:"name"` - Key string `json:"key,omitempty"` - UID types.UID `json:"uid"` - Generation int64 `json:"generation,omitempty"` - Hash string `json:"hash"` -} +const hitlExtensionURI = "https://kagent.dev/extensions/hitl/v1" // Compiler translates resolved inputs into a kagent runtime revision. -type Compiler struct{ kube v2translator.Reader } +type Compiler struct { + adk *adkconfig.Compiler +} -// NewCompiler constructs a kagent harness compiler. -func NewCompiler(kube v2translator.Reader) *Compiler { return &Compiler{kube: kube} } +var _ v2translator.HarnessCompiler = (*Compiler)(nil) -type compiledAgent struct { - config *adk.AgentConfig - models []*v1alpha3.ModelConfig - templates []*v1alpha3.AgentTemplate - environment []corev1.EnvVar - egress []string +func NewCompiler(kube v2translator.Reader) *Compiler { + return &Compiler{adk: adkconfig.NewCompiler(kube)} } func (c *Compiler) Compile(ctx context.Context, input *v2translator.HarnessInput) (*v2translator.Revision, error) { - compiled, err := c.compileAgent(ctx, input.Root) + if err := requireModels(input.Root); err != nil { + return nil, err + } + compiled, err := c.adk.Compile(ctx, input.Root) if err != nil { return nil, err } template, harness := input.Root.Template, input.Harness - cfg := compiled.config - // The async driver is named, because the Python runtime cannot infer it and the Go - // one does not need it. `DatabaseSessionService` builds an asyncio engine and - // refuses a bare `sqlite:` URL with "the asyncio extension requires an async - // driver" — so a kagent-adk actor never opened its readiness port, and the harness - // sat in ResumeGoldenActor until the golden actor timed out. The Go ADK accepts - // `sqlite+` and strips the driver (see adk/pkg/session.sqlitePathFromURL), - // so one URL serves both. - cfg.SessionDBURL = "sqlite+aiosqlite:////data/sessions.db" - - configJSON, err := json.Marshal(cfg) + // The Python runtime needs an async SQLite driver; the Go runtime accepts + // this URL and strips the driver before opening the same durable database. + compiled.Config.SessionDBURL = "sqlite+aiosqlite:////data/sessions.db" + configJSON, err := json.Marshal(compiled.Config) if err != nil { return nil, fmt.Errorf("marshal agent config: %w", err) } @@ -70,18 +50,7 @@ func (c *Compiler) Compile(ctx context.Context, input *v2translator.HarnessInput return nil, fmt.Errorf("marshal agent card: %w", err) } - // Harness env is applied after provider env. dedupeEnv deliberately gives - // later entries precedence, allowing the Harness to override defaults. - environment := append([]corev1.EnvVar(nil), compiled.environment...) - for _, value := range harness.Spec.Env { - envVar := corev1.EnvVar{Name: value.Name} - if value.Value != nil { - envVar.Value = *value.Value - } else { - envVar.ValueFrom = &corev1.EnvVarSource{SecretKeyRef: value.CredentialRef.DeepCopy()} - } - environment = append(environment, envVar) - } + environment := append(compiled.Environment, adkconfig.HarnessEnvironment(harness)...) environment = append(environment, corev1.EnvVar{Name: env.KagentName.Name(), Value: template.Name + "-" + harness.Name}, corev1.EnvVar{Name: env.KagentNamespace.Name(), Value: template.Namespace}, @@ -91,345 +60,44 @@ func (c *Compiler) Compile(ctx context.Context, input *v2translator.HarnessInput corev1.EnvVar{Name: "KAGENT_A2A_GRPC_ADDRESS", Value: "[::]:80"}, corev1.EnvVar{Name: "KAGENT_PRE_RESPONSE_TRACE_FLUSH", Value: "true"}, ) - environment = dedupeEnv(environment) - - // One provenance list covers every Kubernetes input, including hashed Secret - // keys, so it both explains and participates in revision identity. - provenance, err := c.buildProvenance(ctx, harness, compiled.templates, compiled.models, environment) + environment = adkconfig.DedupeEnv(environment) + provenance, err := c.adk.BuildProvenance(ctx, harness, compiled.Templates, compiled.Models, environment) if err != nil { return nil, fmt.Errorf("build revision provenance: %w", err) } - environment, err = c.resolveEnvironment(ctx, template.Namespace, environment) + environment, err = c.adk.ResolveEnvironment(ctx, template.Namespace, environment) if err != nil { return nil, fmt.Errorf("resolve runtime environment: %w", err) } - - egressDestinations := compiled.egress - slices.Sort(egressDestinations) - egressDestinations = slices.Compact(egressDestinations) + slices.Sort(compiled.Egress) return &v2translator.Revision{ - Namespace: template.Namespace, - AgentTemplateName: template.Name, - HarnessName: harness.Name, - Image: harness.Spec.Workload.Image, - Environment: environment, - ConfigJSON: configJSON, - AgentCardJSON: cardJSON, - WorkerPoolName: harness.Spec.Substrate.WorkerPoolRef.Name, - SnapshotLocation: harness.Spec.Substrate.SnapshotPolicy.Location, - Provenance: provenance, - EgressDestinations: egressDestinations, + Namespace: template.Namespace, AgentTemplateName: template.Name, HarnessName: harness.Name, + Image: harness.Spec.Workload.Image, Environment: environment, ConfigJSON: configJSON, AgentCardJSON: cardJSON, + WorkerPoolName: harness.Spec.Substrate.WorkerPoolRef.Name, SnapshotLocation: harness.Spec.Substrate.SnapshotPolicy.Location, + Provenance: provenance, EgressDestinations: slices.Compact(compiled.Egress), }, nil } -func (c *Compiler) compileAgent(ctx context.Context, input *v2translator.AgentInput) (*compiledAgent, error) { - modelRuntime, err := c.resolveModel(ctx, input.ModelConfig) - if err != nil { - return nil, fmt.Errorf("resolve ModelConfig %q: %w", input.ModelConfig.Name, err) - } - if modelRuntime.HasUnsupportedVolumes { - return nil, v2translator.NewValidationError("ModelConfig requires volume mounts unsupported by Substrate ActorTemplate") - } - stream := true - cfg := &adk.AgentConfig{Model: modelRuntime.Model, Description: input.Template.Spec.Description, Instruction: input.Instruction, Stream: &stream} - pluginConfig, pluginEgress, err := v2translator.CompileSkillResources(input.Template) - if err != nil { - return nil, err - } - if len(pluginConfig.Skills) > 0 || len(pluginConfig.Plugins) > 0 { - cfg.AgentPlugins = &pluginConfig - } - for _, tool := range input.MCPTools { - ref := &v1alpha3.McpServerTool{TypedReference: v1alpha3.TypedReference{ - ApiGroup: "kagent.dev", Kind: tool.Binding.Server.Kind, Name: tool.Binding.Server.Name, - }, ToolNames: append([]string(nil), tool.Binding.Tools...)} - headers, credentialEnv, err := c.resolveAgentTemplateHeaders(ctx, input.Template.Namespace, tool.Server.Spec.HeadersFrom) - if err != nil { - return nil, fmt.Errorf("resolve %s %q: %w", tool.Binding.Server.Kind, tool.Binding.Server.Name, err) - } - server := tool.Server.DeepCopy() - server.Spec.HeadersFrom = nil - if err := c.addRemoteMCPServer(cfg, modelRuntime, server, ref, headers); err != nil { - return nil, fmt.Errorf("compile %s %q: %w", tool.Binding.Server.Kind, tool.Binding.Server.Name, err) - } - modelRuntime.Environment = append(modelRuntime.Environment, credentialEnv...) - } - if modelRuntime.HasUnsupportedVolumes { - return nil, v2translator.NewValidationError("resolved model or MCP configuration requires volume mounts unsupported by Substrate ActorTemplate") - } - result := &compiledAgent{ - config: cfg, models: []*v1alpha3.ModelConfig{input.ModelConfig}, templates: []*v1alpha3.AgentTemplate{input.Template}, - environment: modelRuntime.Environment, - egress: append(agentConfigDestinations(cfg, input.ModelConfig, modelRuntime.Model), pluginEgress...), +func requireModels(input *v2translator.AgentInput) error { + if input.ModelConfig == nil { + return v2translator.NewValidationError("kagent ModelConfig is required") } for _, binding := range input.Shared { - child, err := c.compileAgent(ctx, binding.Agent) - if err != nil { - return nil, err - } - child.config.Name, child.config.Description = binding.Name, binding.Description - cfg.SubAgents = append(cfg.SubAgents, child.config) - result.models = append(result.models, child.models...) - result.templates = append(result.templates, child.templates...) - result.environment = append(result.environment, child.environment...) - result.egress = append(result.egress, child.egress...) - } - return result, nil -} - -// resolveEnvironment replaces Kubernetes Secret references with literals -// because Substrate ActorTemplates accept only literal environment values. -func (c *Compiler) resolveEnvironment(ctx context.Context, namespace string, environment []corev1.EnvVar) ([]corev1.EnvVar, error) { - resolved := append([]corev1.EnvVar(nil), environment...) - for i, variable := range resolved { - if variable.ValueFrom == nil { - continue - } - if variable.ValueFrom.SecretKeyRef == nil { - return nil, fmt.Errorf("environment variable %q uses an unsupported value source", variable.Name) - } - ref := variable.ValueFrom.SecretKeyRef - secret := &corev1.Secret{} - if err := c.kube.Get(ctx, types.NamespacedName{Namespace: namespace, Name: ref.Name}, secret); err != nil { - return nil, err + if err := requireModels(binding.Agent); err != nil { + return err } - value, ok := secret.Data[ref.Key] - if !ok { - return nil, fmt.Errorf("secret %q does not contain key %q", ref.Name, ref.Key) - } - resolved[i].Value = string(value) - resolved[i].ValueFrom = nil } - return resolved, nil + return nil } -// buildProvenance records every Kubernetes input that can change the compiled -// runtime. Sorting makes the JSON stable across map iteration order. -func (c *Compiler) buildProvenance(ctx context.Context, harness *v1alpha3.Harness, templates []*v1alpha3.AgentTemplate, models []*v1alpha3.ModelConfig, environment []corev1.EnvVar) ([]byte, error) { - entries := []provenanceEntry{objectProvenance(v1alpha3.GroupVersion.String(), "Harness", harness.Name, harness.UID, harness.Generation, harness.Spec)} - configMaps := map[string]struct{}{} - for _, template := range templates { - entries = append(entries, objectProvenance(v1alpha3.GroupVersion.String(), "AgentTemplate", template.Name, template.UID, template.Generation, template.Spec)) - if template.Spec.SystemPromptFrom != nil { - configMaps[template.Spec.SystemPromptFrom.Name] = struct{}{} - } - if template.Spec.PromptTemplate != nil { - for _, source := range template.Spec.PromptTemplate.DataSources { - configMaps[source.Name] = struct{}{} - } - } - } - for _, model := range models { - entries = append(entries, objectProvenance(v1alpha3.GroupVersion.String(), "ModelConfig", model.Name, model.UID, model.Generation, model.Spec)) - } - for name := range configMaps { - configMap := &corev1.ConfigMap{} - if err := c.kube.Get(ctx, types.NamespacedName{Namespace: harness.Namespace, Name: name}, configMap); err != nil { - return nil, err - } - entries = append(entries, objectProvenance("v1", "ConfigMap", name, configMap.UID, configMap.Generation, configMap.Data)) - } - for _, template := range templates { - for _, binding := range template.Spec.Tools { - if binding.MCP == nil { - continue - } - switch binding.MCP.Server.Kind { - case "RemoteMCPServer": - server := &v1alpha3.RemoteMCPServer{} - if err := c.kube.Get(ctx, types.NamespacedName{Namespace: template.Namespace, Name: binding.MCP.Server.Name}, server); err != nil { - return nil, err - } - entries = append(entries, objectProvenance(v1alpha3.GroupVersion.String(), "RemoteMCPServer", server.Name, server.UID, server.Generation, server.Spec)) - } - } - } - // Secret provenance contains only UID and value hash. Name+key deduplication - // keeps repeated references from changing the digest. - seenSecrets := map[string]struct{}{} - for _, variable := range environment { - if variable.ValueFrom == nil || variable.ValueFrom.SecretKeyRef == nil { - continue - } - ref := variable.ValueFrom.SecretKeyRef - identity := ref.Name + "\x00" + ref.Key - if _, ok := seenSecrets[identity]; ok { - continue - } - seenSecrets[identity] = struct{}{} - secret := &corev1.Secret{} - if err := c.kube.Get(ctx, types.NamespacedName{Namespace: harness.Namespace, Name: ref.Name}, secret); err != nil { - return nil, err - } - value, ok := secret.Data[ref.Key] - if !ok { - return nil, fmt.Errorf("secret %q does not contain key %q", ref.Name, ref.Key) - } - hash := sha256.Sum256(value) - entries = append(entries, provenanceEntry{APIVersion: "v1", Kind: "Secret", Name: ref.Name, Key: ref.Key, UID: secret.UID, Hash: fmt.Sprintf("%x", hash[:])}) - } - slices.SortFunc(entries, func(a, b provenanceEntry) int { - return strings.Compare(a.APIVersion+"\x00"+a.Kind+"\x00"+a.Name+"\x00"+a.Key, b.APIVersion+"\x00"+b.Kind+"\x00"+b.Name+"\x00"+b.Key) - }) - entries = slices.Compact(entries) - return json.Marshal(entries) -} - -// objectProvenance hashes the relevant object content rather than relying -// on generation alone, which is not available or meaningful for every input. -func objectProvenance(apiVersion, kind, name string, uid types.UID, generation int64, content any) provenanceEntry { - raw, _ := json.Marshal(content) - hash := sha256.Sum256(raw) - return provenanceEntry{APIVersion: apiVersion, Kind: kind, Name: name, UID: uid, Generation: generation, Hash: fmt.Sprintf("%x", hash[:])} -} - -// resolveAgentTemplateHeaders keeps Secret values out of serialized agent -// config. The runtime expands __KAGENT_ENV[...]__ from the corresponding -// Secret-backed environment variable when it constructs the MCP request. -func (c *Compiler) resolveAgentTemplateHeaders(ctx context.Context, namespace string, refs []v1alpha3.ValueRef) (map[string]string, []corev1.EnvVar, error) { - headers := make(map[string]string, len(refs)) - var environment []corev1.EnvVar - for _, ref := range refs { - if ref.ValueFrom == nil || ref.ValueFrom.Type != v1alpha3.SecretValueSource { - name, value, err := c.resolveValueRef(ctx, namespace, ref) - if err != nil { - return nil, nil, err - } - headers[name] = value - continue - } - selector := &corev1.SecretKeySelector{LocalObjectReference: corev1.LocalObjectReference{Name: ref.ValueFrom.Name}, Key: ref.ValueFrom.Key} - sum := sha256.Sum256([]byte(namespace + "\x00" + selector.Name + "\x00" + selector.Key)) - envName := "KAGENT_CREDENTIAL_" + strings.ToUpper(fmt.Sprintf("%x", sum[:8])) - headers[ref.Name] = "__KAGENT_ENV[" + envName + "]__" - environment = append(environment, corev1.EnvVar{Name: envName, ValueFrom: &corev1.EnvVarSource{SecretKeyRef: selector}}) - } - return headers, environment, nil -} - -func (c *Compiler) resolveValueRef(ctx context.Context, namespace string, ref v1alpha3.ValueRef) (string, string, error) { - if ref.ValueFrom == nil { - return ref.Name, ref.Value, nil - } - if ref.ValueFrom.Type != v1alpha3.ConfigMapValueSource { - return "", "", fmt.Errorf("unsupported value source type %q", ref.ValueFrom.Type) - } - configMap := &corev1.ConfigMap{} - if err := c.kube.Get(ctx, types.NamespacedName{Namespace: namespace, Name: ref.ValueFrom.Name}, configMap); err != nil { - return "", "", err - } - value, found := configMap.Data[ref.ValueFrom.Key] - if !found { - return "", "", fmt.Errorf("ConfigMap %q does not contain key %q", ref.ValueFrom.Name, ref.ValueFrom.Key) - } - return ref.Name, value, nil -} - -// agentTemplateCard describes the runtime-local A2A server. Substrate routes -// hitlExtensionURI is the human-in-the-loop A2A extension the kagent runtime -// negotiates. Spelled here rather than imported from the ADK package so the -// controller does not depend on the runtime's module for one constant; the two -// must agree, and `agentcard.go` is the definition. -const hitlExtensionURI = "https://kagent.dev/extensions/hitl/v1" - -// public traffic to this loopback interface; the card must not advertise a -// cluster-specific external address. func agentTemplateCard(template *v1alpha3.AgentTemplate) *a2atype.AgentCard { return &a2atype.AgentCard{ - Name: strings.ReplaceAll(template.Name, "-", "_"), - Description: template.Spec.Description, - Version: "v1", - SupportedInterfaces: []*a2atype.AgentInterface{{ - URL: "http://127.0.0.1:80", - ProtocolBinding: a2atype.TransportProtocolGRPC, - ProtocolVersion: a2atype.Version, - }}, - // This compiler builds cards for the kagent runtime specifically, whose A2A - // layer always negotiates human-in-the-loop (see adk/pkg/a2a/agentcard.go, - // which appends this extension unconditionally). Declaring it here is what - // makes an agent's question discoverably answerable: a client reads the card - // to learn it may request the extension and render the choices. Other - // harnesses compile their own cards and make no such claim. - Capabilities: a2atype.AgentCapabilities{ - Streaming: true, - Extensions: []a2atype.AgentExtension{{ - URI: hitlExtensionURI, - Description: "Human in the loop for tool approval, ask user, and nested subagents", - }}, - }, - Skills: []a2atype.AgentSkill{}, - DefaultInputModes: []string{"text"}, - DefaultOutputModes: []string{"text"}, - } -} - -// dedupeEnv preserves first-seen ordering but gives the last value for a name -// precedence, matching how compiler layers are applied. -func dedupeEnv(values []corev1.EnvVar) []corev1.EnvVar { - result := make([]corev1.EnvVar, 0, len(values)) - index := map[string]int{} - for _, value := range values { - if i, ok := index[value.Name]; ok { - result[i] = value - continue - } - index[value.Name] = len(result) - result = append(result, value) - } - return result -} - -// agentConfigDestinations extracts the network allowlist required by the -// resolved model and MCP configuration. Provider defaults are included when -// no explicit endpoint appears in the serialized model. -func agentConfigDestinations(cfg *adk.AgentConfig, modelConfig *v1alpha3.ModelConfig, model adk.Model) []string { - destinations := make([]string, 0, len(cfg.HttpTools)+len(cfg.SseTools)+1) - for _, tool := range cfg.HttpTools { - destinations = appendURLHost(destinations, tool.Params.Url) - } - for _, tool := range cfg.SseTools { - destinations = appendURLHost(destinations, tool.Params.Url) - } - modelJSON, _ := json.Marshal(model) - var values any - if json.Unmarshal(modelJSON, &values) == nil { - destinations = appendURLValues(destinations, values) - } - switch modelConfig.Spec.Provider { - case v1alpha3.ModelProviderOpenAI: - destinations = append(destinations, "api.openai.com") - case v1alpha3.ModelProviderAnthropic: - destinations = append(destinations, "api.anthropic.com") - case v1alpha3.ModelProviderGemini: - destinations = append(destinations, "generativelanguage.googleapis.com") - } - slices.Sort(destinations) - return slices.Compact(destinations) -} - -// appendURLValues walks serialized provider config because endpoint fields are -// provider-specific but all URLs reduce to the same hostname allowlist. -func appendURLValues(destinations []string, value any) []string { - switch value := value.(type) { - case string: - return appendURLHost(destinations, value) - case []any: - for _, item := range value { - destinations = appendURLValues(destinations, item) - } - case map[string]any: - for _, item := range value { - destinations = appendURLValues(destinations, item) - } - } - return destinations -} - -func appendURLHost(destinations []string, raw string) []string { - parsed, err := url.Parse(raw) - if err == nil && parsed.Hostname() != "" { - return append(destinations, parsed.Hostname()) + Name: strings.ReplaceAll(template.Name, "-", "_"), Description: template.Spec.Description, Version: "v1", + SupportedInterfaces: []*a2atype.AgentInterface{{URL: "http://127.0.0.1:80", ProtocolBinding: a2atype.TransportProtocolGRPC, ProtocolVersion: a2atype.Version}}, + Capabilities: a2atype.AgentCapabilities{Streaming: true, Extensions: []a2atype.AgentExtension{{ + URI: hitlExtensionURI, Description: "Human in the loop for tool approval, ask user, and nested subagents", + }}}, + Skills: []a2atype.AgentSkill{}, DefaultInputModes: []string{"text"}, DefaultOutputModes: []string{"text"}, } - return destinations } diff --git a/go/core/v2/translator/revision.go b/go/core/v2/translator/revision.go index a900bb65b..30f9d0e91 100644 --- a/go/core/v2/translator/revision.go +++ b/go/core/v2/translator/revision.go @@ -33,6 +33,8 @@ type Revision struct { // Image and Environment describe the runtime container. Image string + Command []string + Args []string Environment []corev1.EnvVar // ConfigJSON and AgentCardJSON are injected into that container verbatim. ConfigJSON []byte @@ -61,6 +63,8 @@ func (r *Revision) Digest() (RevisionID, error) { AgentTemplateName string `json:"agentTemplateName"` HarnessName string `json:"harnessName"` Image string `json:"image"` + Command []string `json:"command,omitempty"` + Args []string `json:"args,omitempty"` Environment []corev1.EnvVar `json:"environment"` ConfigJSON json.RawMessage `json:"config"` AgentCardJSON json.RawMessage `json:"agentCard"` @@ -70,7 +74,7 @@ func (r *Revision) Digest() (RevisionID, error) { EgressDestinations []string `json:"egressDestinations"` }{ Namespace: r.Namespace, AgentTemplateName: r.AgentTemplateName, HarnessName: r.HarnessName, - Image: r.Image, Environment: r.Environment, ConfigJSON: r.ConfigJSON, AgentCardJSON: r.AgentCardJSON, + Image: r.Image, Command: r.Command, Args: r.Args, Environment: r.Environment, ConfigJSON: r.ConfigJSON, AgentCardJSON: r.AgentCardJSON, WorkerPoolName: r.WorkerPoolName, SnapshotLocation: r.SnapshotLocation, Provenance: r.Provenance, EgressDestinations: r.EgressDestinations, }) diff --git a/go/core/v2/translator/revision_test.go b/go/core/v2/translator/revision_test.go index 583912a06..e602e1f10 100644 --- a/go/core/v2/translator/revision_test.go +++ b/go/core/v2/translator/revision_test.go @@ -39,3 +39,19 @@ func TestRevisionDigestExcludesWarnings(t *testing.T) { t.Fatal("non-behavioral warning changed runtime revision") } } + +func TestRevisionDigestIncludesCommand(t *testing.T) { + revision := &Revision{Namespace: "agents", AgentTemplateName: "helper", HarnessName: "byo", Command: []string{"/agent"}} + first, err := revision.Digest() + if err != nil { + t.Fatal(err) + } + revision.Command = []string{"/other-agent"} + second, err := revision.Digest() + if err != nil { + t.Fatal(err) + } + if first == second { + t.Fatal("command change did not change runtime revision") + } +} diff --git a/helm/kagent-crds/templates/kagent.dev_agenttemplates.yaml b/helm/kagent-crds/templates/kagent.dev_agenttemplates.yaml index deef45c59..6605e2ee6 100644 --- a/helm/kagent-crds/templates/kagent.dev_agenttemplates.yaml +++ b/helm/kagent-crds/templates/kagent.dev_agenttemplates.yaml @@ -48,8 +48,8 @@ spec: description: type: string modelConfig: - description: AgentTemplateLocalReference identifies a resource in - the AgentTemplate's namespace. + description: ModelConfig is required by managed harnesses and optional + for BYO harnesses. properties: name: minLength: 1 @@ -356,8 +356,6 @@ spec: rule: has(self.mcp) != has(self.agent) maxItems: 50 type: array - required: - - modelConfig type: object x-kubernetes-validations: - message: systemPrompt and systemPromptFrom are mutually exclusive diff --git a/helm/kagent-crds/templates/kagent.dev_harnesses.yaml b/helm/kagent-crds/templates/kagent.dev_harnesses.yaml index 0ef8b5f9b..4d6866b62 100644 --- a/helm/kagent-crds/templates/kagent.dev_harnesses.yaml +++ b/helm/kagent-crds/templates/kagent.dev_harnesses.yaml @@ -104,6 +104,33 @@ spec: required: - selector type: object + byo: + description: BYOHarness configures an image that implements kagent's + private A2A contract. + properties: + args: + description: Args overrides the image command arguments when set. + items: + type: string + maxItems: 64 + type: array + command: + description: Command overrides the image entrypoint when set. + items: + type: string + maxItems: 32 + type: array + egressDestinations: + description: |- + EgressDestinations permits image-owned dependencies that cannot be inferred + from AgentTemplate configuration. + items: + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ + type: string + maxItems: 32 + type: array + x-kubernetes-list-type: set + type: object claude: description: ClaudeHarness selects the Claude runtime adapter. type: object @@ -216,9 +243,9 @@ spec: - workload type: object x-kubernetes-validations: - - message: exactly one of kagent, codex, or claude must be specified + - message: exactly one of kagent, codex, claude, or byo must be specified rule: '(has(self.kagent) ? 1 : 0) + (has(self.codex) ? 1 : 0) + (has(self.claude) - ? 1 : 0) == 1' + ? 1 : 0) + (has(self.byo) ? 1 : 0) == 1' status: description: HarnessStatus reports controller-derived capabilities and current health. From 73280bb85863fd70a49f1ab94caa2368240c558a Mon Sep 17 00:00:00 2001 From: Eitan Yarmush Date: Tue, 1 Sep 2026 16:05:55 +0000 Subject: [PATCH 2/7] refactor: simplify BYO harness API Signed-off-by: Eitan Yarmush --- docs/plans/api-v2-execution-plan.md | 4 +- .../crd/bases/kagent.dev_agenttemplates.yaml | 56 ++++++++++++----- .../crd/bases/kagent.dev_harnesses.yaml | 39 +++++------- go/api/v1alpha3/agenttemplate_types.go | 29 +++------ go/api/v1alpha3/configuration_crd_cel_test.go | 32 +++++++--- go/api/v1alpha3/harness_types.go | 29 ++++----- go/api/v1alpha3/zz_generated.deepcopy.go | 63 +++++-------------- .../grpcserver/agenttemplate_harness_test.go | 2 +- go/core/test/e2e/claude_interaction_test.go | 12 ++-- go/core/test/e2e/interaction_test.go | 12 ++-- .../test/e2e/manifests/lifecycle.yaml.tmpl | 2 + go/core/v2/controller/collections_test.go | 10 +-- go/core/v2/translator/byo/compiler.go | 3 +- go/core/v2/translator/byo/compiler_test.go | 13 ++-- go/core/v2/translator/claude/compiler_test.go | 12 ++-- go/core/v2/translator/compiler_test.go | 22 +++---- .../templates/kagent.dev_agenttemplates.yaml | 56 ++++++++++++----- .../templates/kagent.dev_harnesses.yaml | 39 +++++------- 18 files changed, 216 insertions(+), 219 deletions(-) diff --git a/docs/plans/api-v2-execution-plan.md b/docs/plans/api-v2-execution-plan.md index 7415d94bf..8688d1cb6 100644 --- a/docs/plans/api-v2-execution-plan.md +++ b/docs/plans/api-v2-execution-plan.md @@ -375,12 +375,12 @@ Implement the third release-blocking adapter: Allow users with Harness write access to supply a digest-pinned image that implements the private A2A runtime contract: -- Add a typed `byo` Harness variant. Keep the image, optional command and args, environment, credentials, WorkerPool, snapshot policy, and admission selector on the Harness; do not put arbitrary images on AgentTemplate. +- Add a typed `byo` Harness variant. Keep the image, command, args, environment, credentials, WorkerPool, snapshot policy, and admission selector on the Harness; do not put arbitrary images on AgentTemplate. Command and args are generic workload fields; BYO requires an explicit command because Substrate does not use the image entrypoint. - Require A2A v1 gRPC through the standard Actor ingress, streaming, `/readyz` on port 8081, and durable private state under `/data`. Keep ports, routing, Actor identity, and Substrate mechanics fixed and private. - Make AgentTemplate model, prompt, tools, skills, and plugins optional for BYO attachments. Compile every provided field into the existing ADK `AgentConfig` shape and inject it through `KAGENT_CONFIG_JSON` with the generated card in `KAGENT_AGENT_CARD_JSON`; a BYO image may consume that configuration or ignore it. - Extract the shared ADK-config construction into a semantic helper used by the kagent and BYO compilers. Do not create a second configuration format or make either compiler depend on the other. - Keep the public Agent Card derived from the pinned AgentTemplate revision and gateway capabilities. Do not wake the Actor or trust runtime-provided interfaces, security, or routing metadata to construct it. -- Infer egress destinations from configured models and MCP servers. Allow the Harness owner to declare an additional typed destination allowlist for image-owned dependencies that cannot be inferred; default to no additional egress. +- Infer egress destinations from configured models and MCP servers. Do not expose image-owned egress configuration until its policy model is designed. - Preserve the existing AgentInstance lifecycle, automatic suspension, checkpoint, fork, authorization, task persistence, and public A2A gateway without BYO-specific branches outside compilation. - Cover an opaque A2A image that ignores ADK configuration and an ADK-config-aware image that consumes optional model, prompt, MCP, skill, and plugin inputs. Exercise send/stream, cancellation, suspension, checkpoint, fork, credential redaction, and egress denial in Kind. diff --git a/go/api/config/crd/bases/kagent.dev_agenttemplates.yaml b/go/api/config/crd/bases/kagent.dev_agenttemplates.yaml index 6605e2ee6..7cdb61678 100644 --- a/go/api/config/crd/bases/kagent.dev_agenttemplates.yaml +++ b/go/api/config/crd/bases/kagent.dev_agenttemplates.yaml @@ -52,11 +52,19 @@ spec: for BYO harnesses. properties: name: - minLength: 1 + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names type: string - required: - - name type: object + x-kubernetes-map-type: atomic + x-kubernetes-validations: + - message: name must not be empty + rule: has(self.name) && self.name != '' plugins: items: description: PluginBundle selects Agent Skills from one immutable @@ -302,15 +310,24 @@ spec: minLength: 1 type: string templateRef: - description: AgentTemplateLocalReference identifies a resource - in the AgentTemplate's namespace. + description: |- + LocalObjectReference contains enough information to let you locate the + referenced object inside the same namespace. properties: name: - minLength: 1 + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names type: string - required: - - name type: object + x-kubernetes-map-type: atomic + x-kubernetes-validations: + - message: name must not be empty + rule: has(self.name) && self.name != '' required: - description - name @@ -321,21 +338,32 @@ spec: MCP server. properties: server: - description: AgentTemplateTypedLocalReference identifies - a typed resource in the AgentTemplate's namespace. + description: |- + TypedLocalObjectReference contains enough information to let you locate the + typed referenced object inside the same namespace. properties: + apiGroup: + description: |- + APIGroup is the group for the resource being referenced. + If APIGroup is not specified, the specified Kind must be in the core API group. + For any other third-party types, APIGroup is required. + type: string kind: - enum: - - RemoteMCPServer - minLength: 1 + description: Kind is the type of resource being referenced type: string name: - minLength: 1 + description: Name is the name of resource being referenced type: string required: - kind - name type: object + x-kubernetes-map-type: atomic + x-kubernetes-validations: + - message: kind must be RemoteMCPServer + rule: self.kind == 'RemoteMCPServer' + - message: apiGroup must be omitted + rule: '!has(self.apiGroup)' tools: description: |- Tools optionally limits which server tools are exposed. An omitted or empty diff --git a/go/api/config/crd/bases/kagent.dev_harnesses.yaml b/go/api/config/crd/bases/kagent.dev_harnesses.yaml index 4d6866b62..d23f647c1 100644 --- a/go/api/config/crd/bases/kagent.dev_harnesses.yaml +++ b/go/api/config/crd/bases/kagent.dev_harnesses.yaml @@ -105,31 +105,8 @@ spec: - selector type: object byo: - description: BYOHarness configures an image that implements kagent's + description: BYOHarness selects an image that implements kagent's private A2A contract. - properties: - args: - description: Args overrides the image command arguments when set. - items: - type: string - maxItems: 64 - type: array - command: - description: Command overrides the image entrypoint when set. - items: - type: string - maxItems: 32 - type: array - egressDestinations: - description: |- - EgressDestinations permits image-owned dependencies that cannot be inferred - from AgentTemplate configuration. - items: - pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ - type: string - maxItems: 32 - type: array - x-kubernetes-list-type: set type: object claude: description: ClaudeHarness selects the Claude runtime adapter. @@ -230,6 +207,18 @@ spec: description: HarnessWorkload identifies the immutable runtime image used by a Harness. properties: + args: + description: Args overrides the image command arguments when set. + items: + type: string + maxItems: 64 + type: array + command: + description: Command overrides the image entrypoint when set. + items: + type: string + maxItems: 32 + type: array image: description: Image is an OCI image reference pinned by sha256 digest. @@ -246,6 +235,8 @@ spec: - message: exactly one of kagent, codex, claude, or byo must be specified rule: '(has(self.kagent) ? 1 : 0) + (has(self.codex) ? 1 : 0) + (has(self.claude) ? 1 : 0) + (has(self.byo) ? 1 : 0) == 1' + - message: BYO harnesses must specify workload.command + rule: '!has(self.byo) || size(self.workload.command) > 0' status: description: HarnessStatus reports controller-derived capabilities and current health. diff --git a/go/api/v1alpha3/agenttemplate_types.go b/go/api/v1alpha3/agenttemplate_types.go index 178a43b7b..06bcac016 100644 --- a/go/api/v1alpha3/agenttemplate_types.go +++ b/go/api/v1alpha3/agenttemplate_types.go @@ -17,28 +17,11 @@ limitations under the License. package v1alpha3 import ( + corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" ) -// AgentTemplateLocalReference identifies a resource in the AgentTemplate's namespace. -type AgentTemplateLocalReference struct { - // +kubebuilder:validation:MinLength=1 - // +required - Name string `json:"name"` -} - -// AgentTemplateTypedLocalReference identifies a typed resource in the AgentTemplate's namespace. -type AgentTemplateTypedLocalReference struct { - // +kubebuilder:validation:Enum=RemoteMCPServer - // +kubebuilder:validation:MinLength=1 - // +required - Kind string `json:"kind"` - // +kubebuilder:validation:MinLength=1 - // +required - Name string `json:"name"` -} - // AgentTemplateConfigMapKeyReference identifies a key in a same-namespace ConfigMap. type AgentTemplateConfigMapKeyReference struct { // +kubebuilder:validation:MinLength=1 @@ -72,8 +55,10 @@ type AgentTemplatePromptSource struct { // MCPToolBinding binds tools from a same-namespace MCP server. type MCPToolBinding struct { + // +kubebuilder:validation:XValidation:rule="self.kind == 'RemoteMCPServer'",message="kind must be RemoteMCPServer" + // +kubebuilder:validation:XValidation:rule="!has(self.apiGroup)",message="apiGroup must be omitted" // +required - Server AgentTemplateTypedLocalReference `json:"server"` + Server corev1.TypedLocalObjectReference `json:"server"` // Tools optionally limits which server tools are exposed. An omitted or empty // list exposes every tool. Harnesses that cannot enforce a partial selection // may expose the whole server and report a warning. @@ -102,8 +87,9 @@ type AgentToolBinding struct { // +kubebuilder:validation:MinLength=1 // +required Description string `json:"description"` + // +kubebuilder:validation:XValidation:rule="has(self.name) && self.name != ''",message="name must not be empty" // +required - TemplateRef AgentTemplateLocalReference `json:"templateRef"` + TemplateRef corev1.LocalObjectReference `json:"templateRef"` // +kubebuilder:default=Shared // +optional Isolation AgentToolIsolation `json:"isolation,omitempty"` @@ -198,8 +184,9 @@ type PluginBundle struct { // +kubebuilder:validation:XValidation:rule="!(has(self.systemPrompt) && has(self.systemPromptFrom))",message="systemPrompt and systemPromptFrom are mutually exclusive" type AgentTemplateSpec struct { // ModelConfig is required by managed harnesses and optional for BYO harnesses. + // +kubebuilder:validation:XValidation:rule="has(self.name) && self.name != ''",message="name must not be empty" // +optional - ModelConfig *AgentTemplateLocalReference `json:"modelConfig,omitempty"` + ModelConfig *corev1.LocalObjectReference `json:"modelConfig,omitempty"` // +optional Description string `json:"description,omitempty"` // +optional diff --git a/go/api/v1alpha3/configuration_crd_cel_test.go b/go/api/v1alpha3/configuration_crd_cel_test.go index ba995ea6e..18ed956b0 100644 --- a/go/api/v1alpha3/configuration_crd_cel_test.go +++ b/go/api/v1alpha3/configuration_crd_cel_test.go @@ -105,15 +105,16 @@ func TestConfigurationCRDValidation(t *testing.T) { { name: "valid BYO Harness", object: validHarness(namespace, "valid-byo-harness", HarnessSpec{ - BYO: &BYOHarness{}, + BYO: &BYOHarness{}, + Workload: HarnessWorkload{Command: []string{"/agent"}}, }), }, { - name: "BYO Harness rejects URL egress destination", - object: validHarness(namespace, "byo-url-egress", HarnessSpec{ - BYO: &BYOHarness{EgressDestinations: []string{"https://api.example.com"}}, + name: "BYO Harness requires workload command", + object: validHarness(namespace, "byo-missing-command", HarnessSpec{ + BYO: &BYOHarness{}, }), - wantReject: "spec.byo.egressDestinations", + wantReject: "BYO harnesses must specify workload.command", }, { name: "AgentTemplate tool requires one source", @@ -124,11 +125,11 @@ func TestConfigurationCRDValidation(t *testing.T) { name: "AgentTemplate tool rejects two sources", object: validAgentTemplate(namespace, "template-two-tools", []ToolBinding{{ MCP: &MCPToolBinding{ - Server: AgentTemplateTypedLocalReference{Kind: "RemoteMCPServer", Name: "tools"}, + Server: corev1.TypedLocalObjectReference{Kind: "RemoteMCPServer", Name: "tools"}, Tools: []string{"search"}, }, Agent: &AgentToolBinding{ - Name: "helper", Description: "delegate work", TemplateRef: AgentTemplateLocalReference{Name: "helper"}, + Name: "helper", Description: "delegate work", TemplateRef: corev1.LocalObjectReference{Name: "helper"}, }, }}), wantReject: "exactly one of mcp or agent must be specified", @@ -141,6 +142,21 @@ func TestConfigurationCRDValidation(t *testing.T) { name: "AgentTemplate permits omitted ModelConfig", object: &AgentTemplate{ObjectMeta: metav1.ObjectMeta{Name: "model-free-template", Namespace: namespace}}, }, + { + name: "AgentTemplate rejects empty ModelConfig reference", + object: &AgentTemplate{ + ObjectMeta: metav1.ObjectMeta{Name: "empty-model-reference", Namespace: namespace}, + Spec: AgentTemplateSpec{ModelConfig: &corev1.LocalObjectReference{}}, + }, + wantReject: "name must not be empty", + }, + { + name: "AgentTemplate rejects unsupported MCP server kind", + object: validAgentTemplate(namespace, "unsupported-mcp-kind", []ToolBinding{{ + MCP: &MCPToolBinding{Server: corev1.TypedLocalObjectReference{Kind: "Service", Name: "tools"}}, + }}), + wantReject: "kind must be RemoteMCPServer", + }, } for _, tc := range cases { @@ -172,7 +188,7 @@ func validAgentTemplate(namespace, name string, tools []ToolBinding) *AgentTempl return &AgentTemplate{ ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespace}, Spec: AgentTemplateSpec{ - ModelConfig: &AgentTemplateLocalReference{Name: "default"}, + ModelConfig: &corev1.LocalObjectReference{Name: "default"}, Tools: tools, }, } diff --git a/go/api/v1alpha3/harness_types.go b/go/api/v1alpha3/harness_types.go index 70b50b31e..930055911 100644 --- a/go/api/v1alpha3/harness_types.go +++ b/go/api/v1alpha3/harness_types.go @@ -31,8 +31,16 @@ type CodexHarness struct{} // ClaudeHarness selects the Claude runtime adapter. type ClaudeHarness struct{} -// BYOHarness configures an image that implements kagent's private A2A contract. -type BYOHarness struct { +// BYOHarness selects an image that implements kagent's private A2A contract. +type BYOHarness struct{} + +// HarnessWorkload identifies the immutable runtime image used by a Harness. +type HarnessWorkload struct { + // Image is an OCI image reference pinned by sha256 digest. + // +kubebuilder:validation:Pattern=`^[^[:space:]@]+@sha256:[a-f0-9]{64}$` + // +required + Image string `json:"image"` + // Command overrides the image entrypoint when set. // +kubebuilder:validation:MaxItems=32 // +optional @@ -42,22 +50,6 @@ type BYOHarness struct { // +kubebuilder:validation:MaxItems=64 // +optional Args []string `json:"args,omitempty"` - - // EgressDestinations permits image-owned dependencies that cannot be inferred - // from AgentTemplate configuration. - // +kubebuilder:validation:MaxItems=32 - // +kubebuilder:validation:items:Pattern=`^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$` - // +listType=set - // +optional - EgressDestinations []string `json:"egressDestinations,omitempty"` -} - -// HarnessWorkload identifies the immutable runtime image used by a Harness. -type HarnessWorkload struct { - // Image is an OCI image reference pinned by sha256 digest. - // +kubebuilder:validation:Pattern=`^[^[:space:]@]+@sha256:[a-f0-9]{64}$` - // +required - Image string `json:"image"` } // HarnessEnvVar configures one runtime environment variable. @@ -110,6 +102,7 @@ type HarnessAgentTemplateAdmission struct { // HarnessSpec defines a reusable runtime and its infrastructure policy. // // +kubebuilder:validation:XValidation:rule="(has(self.kagent) ? 1 : 0) + (has(self.codex) ? 1 : 0) + (has(self.claude) ? 1 : 0) + (has(self.byo) ? 1 : 0) == 1",message="exactly one of kagent, codex, claude, or byo must be specified" +// +kubebuilder:validation:XValidation:rule="!has(self.byo) || size(self.workload.command) > 0",message="BYO harnesses must specify workload.command" type HarnessSpec struct { // +optional Kagent *KagentHarness `json:"kagent,omitempty"` diff --git a/go/api/v1alpha3/zz_generated.deepcopy.go b/go/api/v1alpha3/zz_generated.deepcopy.go index aee20b470..fb14b550d 100644 --- a/go/api/v1alpha3/zz_generated.deepcopy.go +++ b/go/api/v1alpha3/zz_generated.deepcopy.go @@ -610,21 +610,6 @@ func (in *AgentTemplateList) DeepCopyObject() runtime.Object { return nil } -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *AgentTemplateLocalReference) DeepCopyInto(out *AgentTemplateLocalReference) { - *out = *in -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AgentTemplateLocalReference. -func (in *AgentTemplateLocalReference) DeepCopy() *AgentTemplateLocalReference { - if in == nil { - return nil - } - out := new(AgentTemplateLocalReference) - in.DeepCopyInto(out) - return out -} - // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *AgentTemplatePromptSource) DeepCopyInto(out *AgentTemplatePromptSource) { *out = *in @@ -681,7 +666,7 @@ func (in *AgentTemplateSpec) DeepCopyInto(out *AgentTemplateSpec) { *out = *in if in.ModelConfig != nil { in, out := &in.ModelConfig, &out.ModelConfig - *out = new(AgentTemplateLocalReference) + *out = new(v1.LocalObjectReference) **out = **in } if in.SystemPromptFrom != nil { @@ -749,21 +734,6 @@ func (in *AgentTemplateStatus) DeepCopy() *AgentTemplateStatus { return out } -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *AgentTemplateTypedLocalReference) DeepCopyInto(out *AgentTemplateTypedLocalReference) { - *out = *in -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AgentTemplateTypedLocalReference. -func (in *AgentTemplateTypedLocalReference) DeepCopy() *AgentTemplateTypedLocalReference { - if in == nil { - return nil - } - out := new(AgentTemplateTypedLocalReference) - in.DeepCopyInto(out) - return out -} - // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *AgentToolBinding) DeepCopyInto(out *AgentToolBinding) { *out = *in @@ -911,21 +881,6 @@ func (in *BYOAgentSpec) DeepCopy() *BYOAgentSpec { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *BYOHarness) DeepCopyInto(out *BYOHarness) { *out = *in - if in.Command != nil { - in, out := &in.Command, &out.Command - *out = make([]string, len(*in)) - copy(*out, *in) - } - if in.Args != nil { - in, out := &in.Args, &out.Args - *out = make([]string, len(*in)) - copy(*out, *in) - } - if in.EgressDestinations != nil { - in, out := &in.EgressDestinations, &out.EgressDestinations - *out = make([]string, len(*in)) - copy(*out, *in) - } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BYOHarness. @@ -1459,9 +1414,9 @@ func (in *HarnessSpec) DeepCopyInto(out *HarnessSpec) { if in.BYO != nil { in, out := &in.BYO, &out.BYO *out = new(BYOHarness) - (*in).DeepCopyInto(*out) + **out = **in } - out.Workload = in.Workload + in.Workload.DeepCopyInto(&out.Workload) if in.Env != nil { in, out := &in.Env, &out.Env *out = make([]HarnessEnvVar, len(*in)) @@ -1534,6 +1489,16 @@ func (in *HarnessSubstratePolicy) DeepCopy() *HarnessSubstratePolicy { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *HarnessWorkload) DeepCopyInto(out *HarnessWorkload) { *out = *in + if in.Command != nil { + in, out := &in.Command, &out.Command + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.Args != nil { + in, out := &in.Args, &out.Args + *out = make([]string, len(*in)) + copy(*out, *in) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HarnessWorkload. @@ -1579,7 +1544,7 @@ func (in *MCPTool) DeepCopy() *MCPTool { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *MCPToolBinding) DeepCopyInto(out *MCPToolBinding) { *out = *in - out.Server = in.Server + in.Server.DeepCopyInto(&out.Server) if in.Tools != nil { in, out := &in.Tools, &out.Tools *out = make([]string, len(*in)) diff --git a/go/core/internal/grpcserver/agenttemplate_harness_test.go b/go/core/internal/grpcserver/agenttemplate_harness_test.go index 35eb61bb6..8c9c13f3f 100644 --- a/go/core/internal/grpcserver/agenttemplate_harness_test.go +++ b/go/core/internal/grpcserver/agenttemplate_harness_test.go @@ -71,7 +71,7 @@ func testAgentTemplate(namespace, name, modelConfig string) *v1alpha3.AgentTempl return &v1alpha3.AgentTemplate{ ObjectMeta: metav1.ObjectMeta{Namespace: namespace, Name: name}, Spec: v1alpha3.AgentTemplateSpec{ - ModelConfig: &v1alpha3.AgentTemplateLocalReference{Name: modelConfig}, + ModelConfig: &corev1.LocalObjectReference{Name: modelConfig}, Description: "a template", }, } diff --git a/go/core/test/e2e/claude_interaction_test.go b/go/core/test/e2e/claude_interaction_test.go index 9bcdf81b8..40eb3a9e1 100644 --- a/go/core/test/e2e/claude_interaction_test.go +++ b/go/core/test/e2e/claude_interaction_test.go @@ -444,7 +444,7 @@ func createClaudeTemplate(t *testing.T, kube ctrlclient.Client, modelConfig, des Labels: map[string]string{"kagent.dev/e2e-runtime": "claude"}, }, Spec: v1alpha3.AgentTemplateSpec{ - ModelConfig: &v1alpha3.AgentTemplateLocalReference{Name: modelConfig}, + ModelConfig: &corev1.LocalObjectReference{Name: modelConfig}, Description: description, SystemPrompt: "Reply concisely and follow the requested output format exactly.", }, } @@ -460,7 +460,7 @@ func createClaudeLocalAgentTemplates(t *testing.T, kube ctrlclient.Client, model Labels: map[string]string{"kagent.dev/e2e-runtime": "claude"}, }, Spec: v1alpha3.AgentTemplateSpec{ - ModelConfig: &v1alpha3.AgentTemplateLocalReference{Name: model.Name}, + ModelConfig: &corev1.LocalObjectReference{Name: model.Name}, Description: "Claude local specialist", SystemPrompt: childPrompt, }, @@ -472,12 +472,12 @@ func createClaudeLocalAgentTemplates(t *testing.T, kube ctrlclient.Client, model Labels: map[string]string{"kagent.dev/e2e-runtime": "claude"}, }, Spec: v1alpha3.AgentTemplateSpec{ - ModelConfig: &v1alpha3.AgentTemplateLocalReference{Name: model.Name}, + ModelConfig: &corev1.LocalObjectReference{Name: model.Name}, Description: "Claude local-subagent E2E fixture", SystemPrompt: "Always delegate the request to the specialist subagent, then return its answer.", Tools: []v1alpha3.ToolBinding{{Agent: &v1alpha3.AgentToolBinding{ Name: "specialist", Description: "Handles every delegated specialist request", - TemplateRef: v1alpha3.AgentTemplateLocalReference{Name: child.Name}, + TemplateRef: corev1.LocalObjectReference{Name: child.Name}, Isolation: v1alpha3.AgentToolIsolationShared, }}}, }, @@ -556,11 +556,11 @@ func createClaudeMCPTemplate(t *testing.T, kube ctrlclient.Client, modelConfig, Labels: map[string]string{"kagent.dev/e2e-runtime": "claude"}, }, Spec: v1alpha3.AgentTemplateSpec{ - ModelConfig: &v1alpha3.AgentTemplateLocalReference{Name: modelConfig}, + ModelConfig: &corev1.LocalObjectReference{Name: modelConfig}, Description: "Claude direct whole-server MCP E2E fixture", SystemPrompt: "Use the configured MCP tool. Do not calculate the answer yourself.", Tools: []v1alpha3.ToolBinding{{MCP: &v1alpha3.MCPToolBinding{ - Server: v1alpha3.AgentTemplateTypedLocalReference{Kind: "RemoteMCPServer", Name: mcpServer}, + Server: corev1.TypedLocalObjectReference{Kind: "RemoteMCPServer", Name: mcpServer}, }}}, }, } diff --git a/go/core/test/e2e/interaction_test.go b/go/core/test/e2e/interaction_test.go index e26113fa7..f60370304 100644 --- a/go/core/test/e2e/interaction_test.go +++ b/go/core/test/e2e/interaction_test.go @@ -709,7 +709,7 @@ func createInteractionTemplate(t *testing.T, modelURL string) string { Labels: map[string]string{"kagent.dev/e2e-runtime": "kagent", "kagent.dev/harness": "kagent"}, }, Spec: v1alpha3.AgentTemplateSpec{ - ModelConfig: &v1alpha3.AgentTemplateLocalReference{Name: model.Name}, + ModelConfig: &corev1.LocalObjectReference{Name: model.Name}, Description: "Agent interaction E2E fixture", SystemPrompt: "Reply briefly.", }, @@ -748,11 +748,11 @@ func createMCPInteractionTemplateForHarness(t *testing.T, modelURL, mcpURL, harn Labels: map[string]string{"kagent.dev/e2e-runtime": runtimeLabel, "kagent.dev/harness": harnessName}, }, Spec: v1alpha3.AgentTemplateSpec{ - ModelConfig: &v1alpha3.AgentTemplateLocalReference{Name: model.Name}, + ModelConfig: &corev1.LocalObjectReference{Name: model.Name}, Description: "MCP interaction E2E fixture", SystemPrompt: "Use add_numbers to answer arithmetic questions.", Tools: []v1alpha3.ToolBinding{{MCP: &v1alpha3.MCPToolBinding{ - Server: v1alpha3.AgentTemplateTypedLocalReference{Kind: "RemoteMCPServer", Name: server.Name}, + Server: corev1.TypedLocalObjectReference{Kind: "RemoteMCPServer", Name: server.Name}, Tools: []string{"add_numbers"}, }}}, }, @@ -772,7 +772,7 @@ func createSharedInteractionTemplates(t *testing.T, modelURL string) (string, st Labels: map[string]string{"kagent.dev/e2e-runtime": "kagent", "kagent.dev/harness": "kagent"}, }, Spec: v1alpha3.AgentTemplateSpec{ - ModelConfig: &v1alpha3.AgentTemplateLocalReference{Name: childModel.Name}, + ModelConfig: &corev1.LocalObjectReference{Name: childModel.Name}, Description: "Shared specialist", SystemPrompt: "Answer as the shared specialist.", }, @@ -784,12 +784,12 @@ func createSharedInteractionTemplates(t *testing.T, modelURL string) (string, st Labels: map[string]string{"kagent.dev/e2e-runtime": "kagent", "kagent.dev/harness": "kagent"}, }, Spec: v1alpha3.AgentTemplateSpec{ - ModelConfig: &v1alpha3.AgentTemplateLocalReference{Name: rootModel.Name}, + ModelConfig: &corev1.LocalObjectReference{Name: rootModel.Name}, Description: "Shared agent interaction E2E fixture", SystemPrompt: "Delegate every request to the specialist.", Tools: []v1alpha3.ToolBinding{{Agent: &v1alpha3.AgentToolBinding{ Name: "specialist", Description: "Handles specialist requests", - TemplateRef: v1alpha3.AgentTemplateLocalReference{Name: child.Name}, + TemplateRef: corev1.LocalObjectReference{Name: child.Name}, Isolation: v1alpha3.AgentToolIsolationShared, }}}, }, diff --git a/go/core/test/e2e/manifests/lifecycle.yaml.tmpl b/go/core/test/e2e/manifests/lifecycle.yaml.tmpl index 858cf61d8..284d30be4 100644 --- a/go/core/test/e2e/manifests/lifecycle.yaml.tmpl +++ b/go/core/test/e2e/manifests/lifecycle.yaml.tmpl @@ -26,6 +26,7 @@ spec: byo: {} workload: image: ${KAGENT_E2E_BYO_IMAGE} + command: ["/app"] substrate: workerPoolRef: name: kagent-default @@ -45,6 +46,7 @@ spec: byo: {} workload: image: ${KAGENT_E2E_RUNTIME_IMAGE} + command: ["/app"] substrate: workerPoolRef: name: kagent-default diff --git a/go/core/v2/controller/collections_test.go b/go/core/v2/controller/collections_test.go index 83cbe1bd9..aace00d17 100644 --- a/go/core/v2/controller/collections_test.go +++ b/go/core/v2/controller/collections_test.go @@ -51,7 +51,7 @@ func TestReconciliationCollectionsCompileAndObserveRevision(t *testing.T) { template := &kagentv1alpha3.AgentTemplate{ ObjectMeta: metav1.ObjectMeta{Namespace: "team-a", Name: "assistant", UID: "template-uid", Labels: map[string]string{"runtime": "python"}}, Spec: kagentv1alpha3.AgentTemplateSpec{ - ModelConfig: &kagentv1alpha3.AgentTemplateLocalReference{Name: "model"}, + ModelConfig: &corev1.LocalObjectReference{Name: "model"}, SystemPrompt: "help", }, } @@ -127,7 +127,7 @@ func TestClaudeReconciliationCompilesActorTemplate(t *testing.T) { template := &kagentv1alpha3.AgentTemplate{ ObjectMeta: metav1.ObjectMeta{Namespace: "team-a", Name: "assistant", UID: "template-uid", Labels: map[string]string{"runtime": "claude"}}, - Spec: kagentv1alpha3.AgentTemplateSpec{ModelConfig: &kagentv1alpha3.AgentTemplateLocalReference{Name: "model"}, SystemPrompt: "help"}, + Spec: kagentv1alpha3.AgentTemplateSpec{ModelConfig: &corev1.LocalObjectReference{Name: "model"}, SystemPrompt: "help"}, } claudeHarness := harness("team-a", "claude", map[string]string{"runtime": "claude"}) claudeHarness.UID = "harness-uid" @@ -170,14 +170,14 @@ func TestReconciliationTracksSharedAgentTemplate(t *testing.T) { opts := krt.NewOptionsBuilder(stop, "test", nil) child := &kagentv1alpha3.AgentTemplate{ ObjectMeta: metav1.ObjectMeta{Namespace: "team-a", Name: "child", Labels: map[string]string{"runtime": "python"}}, - Spec: kagentv1alpha3.AgentTemplateSpec{ModelConfig: &kagentv1alpha3.AgentTemplateLocalReference{Name: "model"}, SystemPrompt: "before"}, + Spec: kagentv1alpha3.AgentTemplateSpec{ModelConfig: &corev1.LocalObjectReference{Name: "model"}, SystemPrompt: "before"}, } root := &kagentv1alpha3.AgentTemplate{ ObjectMeta: metav1.ObjectMeta{Namespace: "team-a", Name: "root", Labels: map[string]string{"runtime": "python"}}, Spec: kagentv1alpha3.AgentTemplateSpec{ - ModelConfig: &kagentv1alpha3.AgentTemplateLocalReference{Name: "model"}, + ModelConfig: &corev1.LocalObjectReference{Name: "model"}, Tools: []kagentv1alpha3.ToolBinding{{Agent: &kagentv1alpha3.AgentToolBinding{ - Name: "child", Description: "delegate", TemplateRef: kagentv1alpha3.AgentTemplateLocalReference{Name: child.Name}, + Name: "child", Description: "delegate", TemplateRef: corev1.LocalObjectReference{Name: child.Name}, }}}, }, } diff --git a/go/core/v2/translator/byo/compiler.go b/go/core/v2/translator/byo/compiler.go index dc204a240..67af9656c 100644 --- a/go/core/v2/translator/byo/compiler.go +++ b/go/core/v2/translator/byo/compiler.go @@ -45,12 +45,11 @@ func (c *Compiler) Compile(ctx context.Context, input *v2translator.HarnessInput if err != nil { return nil, fmt.Errorf("resolve runtime environment: %w", err) } - compiled.Egress = append(compiled.Egress, harness.Spec.BYO.EgressDestinations...) slices.Sort(compiled.Egress) return &v2translator.Revision{ Namespace: template.Namespace, AgentTemplateName: template.Name, HarnessName: harness.Name, - Image: harness.Spec.Workload.Image, Command: harness.Spec.BYO.Command, Args: harness.Spec.BYO.Args, + Image: harness.Spec.Workload.Image, Command: harness.Spec.Workload.Command, Args: harness.Spec.Workload.Args, Environment: environment, ConfigJSON: configJSON, AgentCardJSON: cardJSON, WorkerPoolName: harness.Spec.Substrate.WorkerPoolRef.Name, SnapshotLocation: harness.Spec.Substrate.SnapshotPolicy.Location, Provenance: provenance, EgressDestinations: slices.Compact(compiled.Egress), diff --git a/go/core/v2/translator/byo/compiler_test.go b/go/core/v2/translator/byo/compiler_test.go index 75786959a..e12f276c6 100644 --- a/go/core/v2/translator/byo/compiler_test.go +++ b/go/core/v2/translator/byo/compiler_test.go @@ -21,11 +21,8 @@ func (reader) Get(context.Context, types.NamespacedName, runtime.Object) error { func TestCompileOpaqueImage(t *testing.T) { harness := &v1alpha3.Harness{ObjectMeta: metav1.ObjectMeta{Name: "byo", Namespace: "test"}, Spec: v1alpha3.HarnessSpec{ - BYO: &v1alpha3.BYOHarness{ - Command: []string{"/agent"}, Args: []string{"serve"}, - EgressDestinations: []string{"api.example.com"}, - }, - Workload: v1alpha3.HarnessWorkload{Image: "example.com/agent@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}, + BYO: &v1alpha3.BYOHarness{}, + Workload: v1alpha3.HarnessWorkload{Image: "example.com/agent@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Command: []string{"/agent"}, Args: []string{"serve"}}, Env: []v1alpha3.HarnessEnvVar{{Name: "MODE", Value: new("production")}}, Substrate: v1alpha3.HarnessSubstratePolicy{ WorkerPoolRef: corev1.LocalObjectReference{Name: "default"}, SnapshotPolicy: v1alpha3.HarnessSnapshotPolicy{Location: "snapshots"}, @@ -39,9 +36,9 @@ func TestCompileOpaqueImage(t *testing.T) { Harness: harness, Root: &v2translator.AgentInput{Template: template, Instruction: template.Spec.SystemPrompt}, }) require.NoError(t, err) - require.Equal(t, harness.Spec.BYO.Command, revision.Command) - require.Equal(t, harness.Spec.BYO.Args, revision.Args) - require.Equal(t, []string{"api.example.com"}, revision.EgressDestinations) + require.Equal(t, harness.Spec.Workload.Command, revision.Command) + require.Equal(t, harness.Spec.Workload.Args, revision.Args) + require.Empty(t, revision.EgressDestinations) require.Equal(t, []corev1.EnvVar{{Name: "MODE", Value: "production"}}, revision.Environment) var config adk.AgentConfig diff --git a/go/core/v2/translator/claude/compiler_test.go b/go/core/v2/translator/claude/compiler_test.go index 472c7b0a6..8409d64d8 100644 --- a/go/core/v2/translator/claude/compiler_test.go +++ b/go/core/v2/translator/claude/compiler_test.go @@ -230,7 +230,7 @@ func TestCompileDirectWholeServerMCP(t *testing.T) { }}, } input.Root.Template.Spec.Tools = []v1alpha3.ToolBinding{{MCP: &v1alpha3.MCPToolBinding{ - Server: v1alpha3.AgentTemplateTypedLocalReference{Kind: "RemoteMCPServer", Name: server.Name}, + Server: corev1.TypedLocalObjectReference{Kind: "RemoteMCPServer", Name: server.Name}, Tools: []string{"get_time", "echo", "add_numbers"}, }}} input.Root.MCPTools = []v2translator.ResolvedMCPTool{{Binding: *input.Root.Template.Spec.Tools[0].MCP.DeepCopy(), Server: server}} @@ -284,7 +284,7 @@ func TestCompileWholeServerMCPSelectionWarnings(t *testing.T) { {Name: "one"}, {Name: "two"}, }}, } - binding := v1alpha3.MCPToolBinding{Server: v1alpha3.AgentTemplateTypedLocalReference{Kind: "RemoteMCPServer", Name: server.Name}} + binding := v1alpha3.MCPToolBinding{Server: corev1.TypedLocalObjectReference{Kind: "RemoteMCPServer", Name: server.Name}} input.Root.MCPTools = []v2translator.ResolvedMCPTool{{Binding: binding, Server: server}} revision, err := NewCompiler(reader).Compile(context.Background(), input) if err != nil { @@ -326,7 +326,7 @@ func TestCompileLocalSharedAgent(t *testing.T) { Template: &v1alpha3.AgentTemplate{ ObjectMeta: metav1.ObjectMeta{Name: "specialist-template", Namespace: "test", UID: "child-template-uid"}, Spec: v1alpha3.AgentTemplateSpec{ - ModelConfig: &v1alpha3.AgentTemplateLocalReference{Name: "child-model"}, + ModelConfig: &corev1.LocalObjectReference{Name: "child-model"}, Description: "template description", SystemPrompt: "specialize", }, }, @@ -338,7 +338,7 @@ func TestCompileLocalSharedAgent(t *testing.T) { } input.Root.Template.Spec.Tools = []v1alpha3.ToolBinding{{Agent: &v1alpha3.AgentToolBinding{ Name: "specialist", Description: "Handles specialist requests", - TemplateRef: v1alpha3.AgentTemplateLocalReference{Name: child.Template.Name}, + TemplateRef: corev1.LocalObjectReference{Name: child.Template.Name}, Isolation: v1alpha3.AgentToolIsolationShared, }}} input.Root.Shared = []v2translator.AgentInputBinding{{ @@ -399,7 +399,7 @@ func TestCompileRejectsUnsupportedLocalAgentConfiguration(t *testing.T) { Agent: &v2translator.AgentInput{ Template: &v1alpha3.AgentTemplate{ ObjectMeta: metav1.ObjectMeta{Name: "child", Namespace: "test"}, - Spec: v1alpha3.AgentTemplateSpec{ModelConfig: &v1alpha3.AgentTemplateLocalReference{Name: "child-model"}}, + Spec: v1alpha3.AgentTemplateSpec{ModelConfig: &corev1.LocalObjectReference{Name: "child-model"}}, }, ModelConfig: &v1alpha3.ModelConfig{ObjectMeta: metav1.ObjectMeta{Name: "child-model", Namespace: "test"}, Spec: childSpec}, Instruction: "specialize", @@ -425,7 +425,7 @@ func testInput(t *testing.T, modelSpec v1alpha3.ModelConfigSpec, secretData map[ Substrate: v1alpha3.HarnessSubstratePolicy{WorkerPoolRef: corev1.LocalObjectReference{Name: "default"}, SnapshotPolicy: v1alpha3.HarnessSnapshotPolicy{Location: "snapshots"}}, }} template := &v1alpha3.AgentTemplate{ObjectMeta: metav1.ObjectMeta{Name: "assistant", Namespace: "test", UID: "template-uid"}, Spec: v1alpha3.AgentTemplateSpec{ - ModelConfig: &v1alpha3.AgentTemplateLocalReference{Name: "model"}, Description: "assistant", SystemPrompt: "help carefully", + ModelConfig: &corev1.LocalObjectReference{Name: "model"}, Description: "assistant", SystemPrompt: "help carefully", }} model := &v1alpha3.ModelConfig{ObjectMeta: metav1.ObjectMeta{Name: "model", Namespace: "test", UID: "model-uid"}, Spec: modelSpec} secret := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "model-auth", Namespace: "test", UID: "secret-uid"}, Data: secretData} diff --git a/go/core/v2/translator/compiler_test.go b/go/core/v2/translator/compiler_test.go index 4da9ca7e1..5ea0eebdb 100644 --- a/go/core/v2/translator/compiler_test.go +++ b/go/core/v2/translator/compiler_test.go @@ -43,7 +43,7 @@ func TestCompileAgentTemplatePinsAgentPluginSources(t *testing.T) { template := &v1alpha3.AgentTemplate{ ObjectMeta: metav1.ObjectMeta{Name: "helper", Namespace: "test"}, Spec: v1alpha3.AgentTemplateSpec{ - ModelConfig: &v1alpha3.AgentTemplateLocalReference{Name: "default-model"}, + ModelConfig: &corev1.LocalObjectReference{Name: "default-model"}, Skills: []v1alpha3.AgentTemplateSkill{ {Name: "review", Source: v1alpha3.ArtifactSource{ OCI: "ghcr.io/acme/review@sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", @@ -128,7 +128,7 @@ func TestCompilerAcceptsExternalHarnessCompiler(t *testing.T) { ObjectMeta: metav1.ObjectMeta{Name: "codex", Namespace: "test"}, Spec: v1alpha3.HarnessSpec{Codex: &v1alpha3.CodexHarness{}, AllowedAgentTemplates: &v1alpha3.HarnessAgentTemplateAdmission{Selector: metav1.LabelSelector{}}}, } - template := &v1alpha3.AgentTemplate{ObjectMeta: metav1.ObjectMeta{Name: "assistant", Namespace: "test"}, Spec: v1alpha3.AgentTemplateSpec{ModelConfig: &v1alpha3.AgentTemplateLocalReference{Name: "default-model"}}} + template := &v1alpha3.AgentTemplate{ObjectMeta: metav1.ObjectMeta{Name: "assistant", Namespace: "test"}, Spec: v1alpha3.AgentTemplateSpec{ModelConfig: &corev1.LocalObjectReference{Name: "default-model"}}} revision, err := v2translator.NewCompiler(testReader{kube}, map[v2translator.HarnessType]v2translator.HarnessCompiler{ v2translator.HarnessTypeCodex: adapter, @@ -192,13 +192,13 @@ func TestCompileAgentTemplateResolvesCredentialsForSubstrate(t *testing.T) { template := &v1alpha3.AgentTemplate{ ObjectMeta: metav1.ObjectMeta{Name: "helper", Namespace: "test"}, Spec: v1alpha3.AgentTemplateSpec{ - ModelConfig: &v1alpha3.AgentTemplateLocalReference{Name: "default-model"}, + ModelConfig: &corev1.LocalObjectReference{Name: "default-model"}, SystemPrompt: "help", Tools: []v1alpha3.ToolBinding{{MCP: &v1alpha3.MCPToolBinding{ - Server: v1alpha3.AgentTemplateTypedLocalReference{Kind: "RemoteMCPServer", Name: server.Name}, + Server: corev1.TypedLocalObjectReference{Kind: "RemoteMCPServer", Name: server.Name}, Tools: []string{"lookup"}, }}, {MCP: &v1alpha3.MCPToolBinding{ - Server: v1alpha3.AgentTemplateTypedLocalReference{Kind: "RemoteMCPServer", Name: secondServer.Name}, + Server: corev1.TypedLocalObjectReference{Kind: "RemoteMCPServer", Name: secondServer.Name}, Tools: []string{"search"}, }}}, }, @@ -245,9 +245,9 @@ func TestCompileAgentTemplateSharedAgent(t *testing.T) { child := &v1alpha3.AgentTemplate{ ObjectMeta: metav1.ObjectMeta{Name: "researcher", Namespace: "test", Labels: map[string]string{"runtime": "kagent"}}, Spec: v1alpha3.AgentTemplateSpec{ - ModelConfig: &v1alpha3.AgentTemplateLocalReference{Name: "default-model"}, Description: "template description", SystemPrompt: "research carefully", + ModelConfig: &corev1.LocalObjectReference{Name: "default-model"}, Description: "template description", SystemPrompt: "research carefully", Tools: []v1alpha3.ToolBinding{{MCP: &v1alpha3.MCPToolBinding{ - Server: v1alpha3.AgentTemplateTypedLocalReference{Kind: "RemoteMCPServer", Name: "search"}, Tools: []string{"lookup"}, + Server: corev1.TypedLocalObjectReference{Kind: "RemoteMCPServer", Name: "search"}, Tools: []string{"lookup"}, }}}, Skills: []v1alpha3.AgentTemplateSkill{{Name: "review", Source: v1alpha3.ArtifactSource{ OCI: "ghcr.io/acme/review@sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", @@ -257,9 +257,9 @@ func TestCompileAgentTemplateSharedAgent(t *testing.T) { root := &v1alpha3.AgentTemplate{ ObjectMeta: metav1.ObjectMeta{Name: "coordinator", Namespace: "test", Labels: map[string]string{"runtime": "kagent"}}, Spec: v1alpha3.AgentTemplateSpec{ - ModelConfig: &v1alpha3.AgentTemplateLocalReference{Name: "default-model"}, SystemPrompt: "coordinate", + ModelConfig: &corev1.LocalObjectReference{Name: "default-model"}, SystemPrompt: "coordinate", Tools: []v1alpha3.ToolBinding{{Agent: &v1alpha3.AgentToolBinding{ - Name: "web_researcher", Description: "research the web", TemplateRef: v1alpha3.AgentTemplateLocalReference{Name: child.Name}, + Name: "web_researcher", Description: "research the web", TemplateRef: corev1.LocalObjectReference{Name: child.Name}, }}}, }, } @@ -284,10 +284,10 @@ func TestCompileAgentTemplateRejectsInvalidSharedTrees(t *testing.T) { Kagent: &v1alpha3.KagentHarness{}, AllowedAgentTemplates: selector, }} binding := func(name, target string) v1alpha3.ToolBinding { - return v1alpha3.ToolBinding{Agent: &v1alpha3.AgentToolBinding{Name: name, Description: name, TemplateRef: v1alpha3.AgentTemplateLocalReference{Name: target}}} + return v1alpha3.ToolBinding{Agent: &v1alpha3.AgentToolBinding{Name: name, Description: name, TemplateRef: corev1.LocalObjectReference{Name: target}}} } template := func(name string, tools ...v1alpha3.ToolBinding) *v1alpha3.AgentTemplate { - return &v1alpha3.AgentTemplate{ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: "test", Labels: map[string]string{"runtime": "kagent"}}, Spec: v1alpha3.AgentTemplateSpec{ModelConfig: &v1alpha3.AgentTemplateLocalReference{Name: "default-model"}, Tools: tools}} + return &v1alpha3.AgentTemplate{ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: "test", Labels: map[string]string{"runtime": "kagent"}}, Spec: v1alpha3.AgentTemplateSpec{ModelConfig: &corev1.LocalObjectReference{Name: "default-model"}, Tools: tools}} } t.Run("shared DAG", func(t *testing.T) { diff --git a/helm/kagent-crds/templates/kagent.dev_agenttemplates.yaml b/helm/kagent-crds/templates/kagent.dev_agenttemplates.yaml index 6605e2ee6..7cdb61678 100644 --- a/helm/kagent-crds/templates/kagent.dev_agenttemplates.yaml +++ b/helm/kagent-crds/templates/kagent.dev_agenttemplates.yaml @@ -52,11 +52,19 @@ spec: for BYO harnesses. properties: name: - minLength: 1 + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names type: string - required: - - name type: object + x-kubernetes-map-type: atomic + x-kubernetes-validations: + - message: name must not be empty + rule: has(self.name) && self.name != '' plugins: items: description: PluginBundle selects Agent Skills from one immutable @@ -302,15 +310,24 @@ spec: minLength: 1 type: string templateRef: - description: AgentTemplateLocalReference identifies a resource - in the AgentTemplate's namespace. + description: |- + LocalObjectReference contains enough information to let you locate the + referenced object inside the same namespace. properties: name: - minLength: 1 + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names type: string - required: - - name type: object + x-kubernetes-map-type: atomic + x-kubernetes-validations: + - message: name must not be empty + rule: has(self.name) && self.name != '' required: - description - name @@ -321,21 +338,32 @@ spec: MCP server. properties: server: - description: AgentTemplateTypedLocalReference identifies - a typed resource in the AgentTemplate's namespace. + description: |- + TypedLocalObjectReference contains enough information to let you locate the + typed referenced object inside the same namespace. properties: + apiGroup: + description: |- + APIGroup is the group for the resource being referenced. + If APIGroup is not specified, the specified Kind must be in the core API group. + For any other third-party types, APIGroup is required. + type: string kind: - enum: - - RemoteMCPServer - minLength: 1 + description: Kind is the type of resource being referenced type: string name: - minLength: 1 + description: Name is the name of resource being referenced type: string required: - kind - name type: object + x-kubernetes-map-type: atomic + x-kubernetes-validations: + - message: kind must be RemoteMCPServer + rule: self.kind == 'RemoteMCPServer' + - message: apiGroup must be omitted + rule: '!has(self.apiGroup)' tools: description: |- Tools optionally limits which server tools are exposed. An omitted or empty diff --git a/helm/kagent-crds/templates/kagent.dev_harnesses.yaml b/helm/kagent-crds/templates/kagent.dev_harnesses.yaml index 4d6866b62..d23f647c1 100644 --- a/helm/kagent-crds/templates/kagent.dev_harnesses.yaml +++ b/helm/kagent-crds/templates/kagent.dev_harnesses.yaml @@ -105,31 +105,8 @@ spec: - selector type: object byo: - description: BYOHarness configures an image that implements kagent's + description: BYOHarness selects an image that implements kagent's private A2A contract. - properties: - args: - description: Args overrides the image command arguments when set. - items: - type: string - maxItems: 64 - type: array - command: - description: Command overrides the image entrypoint when set. - items: - type: string - maxItems: 32 - type: array - egressDestinations: - description: |- - EgressDestinations permits image-owned dependencies that cannot be inferred - from AgentTemplate configuration. - items: - pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ - type: string - maxItems: 32 - type: array - x-kubernetes-list-type: set type: object claude: description: ClaudeHarness selects the Claude runtime adapter. @@ -230,6 +207,18 @@ spec: description: HarnessWorkload identifies the immutable runtime image used by a Harness. properties: + args: + description: Args overrides the image command arguments when set. + items: + type: string + maxItems: 64 + type: array + command: + description: Command overrides the image entrypoint when set. + items: + type: string + maxItems: 32 + type: array image: description: Image is an OCI image reference pinned by sha256 digest. @@ -246,6 +235,8 @@ spec: - message: exactly one of kagent, codex, claude, or byo must be specified rule: '(has(self.kagent) ? 1 : 0) + (has(self.codex) ? 1 : 0) + (has(self.claude) ? 1 : 0) + (has(self.byo) ? 1 : 0) == 1' + - message: BYO harnesses must specify workload.command + rule: '!has(self.byo) || size(self.workload.command) > 0' status: description: HarnessStatus reports controller-derived capabilities and current health. From baa8b7627afbc1f9427baec9072f870766906a23 Mon Sep 17 00:00:00 2001 From: Eitan Yarmush Date: Tue, 1 Sep 2026 16:34:25 +0000 Subject: [PATCH 3/7] ci: parallelize e2e image builds Signed-off-by: Eitan Yarmush --- .github/workflows/ci.yaml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 1feff240c..17c1ddb7b 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -120,7 +120,8 @@ jobs: --push run: | echo "Cache key: ${{ needs.setup.outputs.cache-key }}" - make build-controller build-golang-adk build-claude-harness build-byo-a2a + jobs=$(nproc) + make -j"$(( jobs < 4 ? jobs : 4 ))" build-controller build-golang-adk build-claude-harness build-byo-a2a make helm-install-provider kubectl rollout status deployment/kagent-controller -n kagent --timeout=120s kubectl wait --for=condition=Ready pod -l app.kubernetes.io/component=controller -n kagent --timeout=120s From cf780f90d4e0505104accc601f77135abf7a3654 Mon Sep 17 00:00:00 2001 From: Eitan Yarmush Date: Tue, 1 Sep 2026 16:42:59 +0000 Subject: [PATCH 4/7] build: inline BYO test image tag Signed-off-by: Eitan Yarmush --- Makefile | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/Makefile b/Makefile index ac354bb42..1b0ace037 100644 --- a/Makefile +++ b/Makefile @@ -54,20 +54,17 @@ CONTROLLER_IMAGE_NAME ?= controller UI_IMAGE_NAME ?= ui KAGENT_ADK_IMAGE_NAME ?= kagent-adk GOLANG_ADK_IMAGE_NAME ?= golang-adk -BYO_A2A_E2E_IMAGE_NAME ?= byo-a2a CLAUDE_HARNESS_IMAGE_NAME ?= claude-harness CONTROLLER_IMAGE_TAG ?= $(VERSION) UI_IMAGE_TAG ?= $(VERSION) KAGENT_ADK_IMAGE_TAG ?= $(VERSION) GOLANG_ADK_IMAGE_TAG ?= $(VERSION) -BYO_A2A_E2E_IMAGE_TAG ?= $(VERSION) CLAUDE_HARNESS_IMAGE_TAG ?= $(VERSION) CONTROLLER_IMG ?= $(DOCKER_REGISTRY)/$(DOCKER_REPO)/$(CONTROLLER_IMAGE_NAME):$(CONTROLLER_IMAGE_TAG) UI_IMG ?= $(DOCKER_REGISTRY)/$(DOCKER_REPO)/$(UI_IMAGE_NAME):$(UI_IMAGE_TAG) KAGENT_ADK_IMG ?= $(DOCKER_REGISTRY)/$(DOCKER_REPO)/$(KAGENT_ADK_IMAGE_NAME):$(KAGENT_ADK_IMAGE_TAG) GOLANG_ADK_IMG ?= $(DOCKER_REGISTRY)/$(DOCKER_REPO)/$(GOLANG_ADK_IMAGE_NAME):$(GOLANG_ADK_IMAGE_TAG) -BYO_A2A_E2E_IMG ?= $(DOCKER_REGISTRY)/$(DOCKER_REPO)/$(BYO_A2A_E2E_IMAGE_NAME):$(BYO_A2A_E2E_IMAGE_TAG) CLAUDE_HARNESS_IMG ?= $(DOCKER_REGISTRY)/$(DOCKER_REPO)/$(CLAUDE_HARNESS_IMAGE_NAME):$(CLAUDE_HARNESS_IMAGE_TAG) #take from go/go.mod @@ -301,8 +298,8 @@ build-golang-adk: proto-generate buildx-create .PHONY: build-byo-a2a build-byo-a2a: ## Build and push the opaque BYO A2A e2e image build-byo-a2a: buildx-create - $(DOCKER_BUILDER) $(DOCKER_BUILD_ARGS) $(TOOLS_IMAGE_BUILD_ARGS) --build-arg BUILD_PACKAGE=core/test/byoa2a/main.go -t $(BYO_A2A_E2E_IMG) -f go/Dockerfile ./go - $(DOCKER_PUSH) $(BYO_A2A_E2E_IMG) + $(DOCKER_BUILDER) $(DOCKER_BUILD_ARGS) $(TOOLS_IMAGE_BUILD_ARGS) --build-arg BUILD_PACKAGE=core/test/byoa2a/main.go -t $(DOCKER_REGISTRY)/$(DOCKER_REPO)/byo-a2a:$(VERSION) -f go/Dockerfile ./go + $(DOCKER_PUSH) $(DOCKER_REGISTRY)/$(DOCKER_REPO)/byo-a2a:$(VERSION) .PHONY: build-claude-harness build-claude-harness: ## Build and push the native Claude Harness image From 28f3259412fade9111246b7c9a9ed21817f8106b Mon Sep 17 00:00:00 2001 From: Eitan Yarmush Date: Tue, 1 Sep 2026 17:48:42 +0000 Subject: [PATCH 5/7] refactor: rename ADK config compiler Signed-off-by: Eitan Yarmush --- .../adkconfig/{compiler.go => builder.go} | 20 +++++++++---------- go/core/v2/translator/adkconfig/mcp.go | 2 +- go/core/v2/translator/adkconfig/model.go | 6 +++--- go/core/v2/translator/byo/compiler.go | 10 +++++----- go/core/v2/translator/kagent/compiler.go | 10 +++++----- 5 files changed, 24 insertions(+), 24 deletions(-) rename go/core/v2/translator/adkconfig/{compiler.go => builder.go} (92%) diff --git a/go/core/v2/translator/adkconfig/compiler.go b/go/core/v2/translator/adkconfig/builder.go similarity index 92% rename from go/core/v2/translator/adkconfig/compiler.go rename to go/core/v2/translator/adkconfig/builder.go index a2db71019..caff8ebb7 100644 --- a/go/core/v2/translator/adkconfig/compiler.go +++ b/go/core/v2/translator/adkconfig/builder.go @@ -28,11 +28,11 @@ type provenanceEntry struct { Hash string `json:"hash"` } -// Compiler translates resolved inputs into an ADK agent configuration. -type Compiler struct{ kube v2translator.Reader } +// Builder assembles resolved inputs into an ADK agent configuration. +type Builder struct{ kube v2translator.Reader } -// NewCompiler constructs an ADK configuration compiler. -func NewCompiler(kube v2translator.Reader) *Compiler { return &Compiler{kube: kube} } +// NewBuilder constructs an ADK configuration builder. +func NewBuilder(kube v2translator.Reader) *Builder { return &Builder{kube: kube} } type Result struct { Config *adk.AgentConfig @@ -57,11 +57,11 @@ func HarnessEnvironment(harness *v1alpha3.Harness) []corev1.EnvVar { return environment } -func (c *Compiler) Compile(ctx context.Context, input *v2translator.AgentInput) (*Result, error) { +func (c *Builder) Build(ctx context.Context, input *v2translator.AgentInput) (*Result, error) { return c.compileAgent(ctx, input) } -func (c *Compiler) compileAgent(ctx context.Context, input *v2translator.AgentInput) (*Result, error) { +func (c *Builder) compileAgent(ctx context.Context, input *v2translator.AgentInput) (*Result, error) { modelRuntime := &modelRuntime{data: &modelDeploymentData{}} if input.ModelConfig != nil { var err error @@ -125,7 +125,7 @@ func (c *Compiler) compileAgent(ctx context.Context, input *v2translator.AgentIn // ResolveEnvironment replaces Kubernetes Secret references with literals // because Substrate ActorTemplates accept only literal environment values. -func (c *Compiler) ResolveEnvironment(ctx context.Context, namespace string, environment []corev1.EnvVar) ([]corev1.EnvVar, error) { +func (c *Builder) ResolveEnvironment(ctx context.Context, namespace string, environment []corev1.EnvVar) ([]corev1.EnvVar, error) { resolved := append([]corev1.EnvVar(nil), environment...) for i, variable := range resolved { if variable.ValueFrom == nil { @@ -151,7 +151,7 @@ func (c *Compiler) ResolveEnvironment(ctx context.Context, namespace string, env // BuildProvenance records every Kubernetes input that can change the compiled // runtime. Sorting makes the JSON stable across map iteration order. -func (c *Compiler) BuildProvenance(ctx context.Context, harness *v1alpha3.Harness, templates []*v1alpha3.AgentTemplate, models []*v1alpha3.ModelConfig, environment []corev1.EnvVar) ([]byte, error) { +func (c *Builder) BuildProvenance(ctx context.Context, harness *v1alpha3.Harness, templates []*v1alpha3.AgentTemplate, models []*v1alpha3.ModelConfig, environment []corev1.EnvVar) ([]byte, error) { entries := []provenanceEntry{objectProvenance(v1alpha3.GroupVersion.String(), "Harness", harness.Name, harness.UID, harness.Generation, harness.Spec)} configMaps := map[string]struct{}{} for _, template := range templates { @@ -232,7 +232,7 @@ func objectProvenance(apiVersion, kind, name string, uid types.UID, generation i // resolveAgentTemplateHeaders keeps Secret values out of serialized agent // config. The runtime expands __KAGENT_ENV[...]__ from the corresponding // Secret-backed environment variable when it constructs the MCP request. -func (c *Compiler) resolveAgentTemplateHeaders(ctx context.Context, namespace string, refs []v1alpha3.ValueRef) (map[string]string, []corev1.EnvVar, error) { +func (c *Builder) resolveAgentTemplateHeaders(ctx context.Context, namespace string, refs []v1alpha3.ValueRef) (map[string]string, []corev1.EnvVar, error) { headers := make(map[string]string, len(refs)) var environment []corev1.EnvVar for _, ref := range refs { @@ -253,7 +253,7 @@ func (c *Compiler) resolveAgentTemplateHeaders(ctx context.Context, namespace st return headers, environment, nil } -func (c *Compiler) resolveValueRef(ctx context.Context, namespace string, ref v1alpha3.ValueRef) (string, string, error) { +func (c *Builder) resolveValueRef(ctx context.Context, namespace string, ref v1alpha3.ValueRef) (string, string, error) { if ref.ValueFrom == nil { return ref.Name, ref.Value, nil } diff --git a/go/core/v2/translator/adkconfig/mcp.go b/go/core/v2/translator/adkconfig/mcp.go index 4fb639acb..897e8416f 100644 --- a/go/core/v2/translator/adkconfig/mcp.go +++ b/go/core/v2/translator/adkconfig/mcp.go @@ -7,7 +7,7 @@ import ( // addRemoteMCPServer translates the two remote protocols supported by the ADK. // This path intentionally has no proxy URL or egress-gateway indirection. -func (c *Compiler) addRemoteMCPServer(config *adk.AgentConfig, runtime *modelRuntime, server *v1alpha3.RemoteMCPServer, tool *v1alpha3.McpServerTool, headers map[string]string) error { +func (c *Builder) addRemoteMCPServer(config *adk.AgentConfig, runtime *modelRuntime, server *v1alpha3.RemoteMCPServer, tool *v1alpha3.McpServerTool, headers map[string]string) error { targetURL := server.Spec.URL switch server.Spec.Protocol { diff --git a/go/core/v2/translator/adkconfig/model.go b/go/core/v2/translator/adkconfig/model.go index f9d91e64f..fd82298a1 100644 --- a/go/core/v2/translator/adkconfig/model.go +++ b/go/core/v2/translator/adkconfig/model.go @@ -40,7 +40,7 @@ type modelRuntime struct { // resolveModel collapses provider-specific translation output into the subset // needed to compile a runtime revision. -func (c *Compiler) resolveModel(ctx context.Context, config *v1alpha3.ModelConfig) (*modelRuntime, error) { +func (c *Builder) resolveModel(ctx context.Context, config *v1alpha3.ModelConfig) (*modelRuntime, error) { model, data, err := c.translateModel(ctx, config) if err != nil { return nil, err @@ -207,7 +207,7 @@ func addTokenExchangeConfiguration(openai *adk.OpenAI, mdd *modelDeploymentData, // resolveFoundryEndpoint returns the Foundry endpoint, preferring the inline // value and otherwise resolving it from the referenced ConfigMap (endpointFrom), // which lets Azure Service Operator own the account endpoint. -func (c *Compiler) resolveFoundryEndpoint(ctx context.Context, namespace string, cfg *v1alpha3.FoundryConfig) (string, error) { +func (c *Builder) resolveFoundryEndpoint(ctx context.Context, namespace string, cfg *v1alpha3.FoundryConfig) (string, error) { if cfg.Endpoint != "" { return cfg.Endpoint, nil } @@ -233,7 +233,7 @@ func (c *Compiler) resolveFoundryEndpoint(ctx context.Context, namespace string, // are intentionally local rather than calling the legacy translator: v2 can // now evolve and eventually replace that code without a compatibility layer. // It returns the ADK wire model and its Kubernetes runtime requirements. -func (c *Compiler) translateModel(ctx context.Context, model *v1alpha3.ModelConfig) (adk.Model, *modelDeploymentData, error) { +func (c *Builder) translateModel(ctx context.Context, model *v1alpha3.ModelConfig) (adk.Model, *modelDeploymentData, error) { modelDeploymentData := &modelDeploymentData{} // Add TLS configuration if present diff --git a/go/core/v2/translator/byo/compiler.go b/go/core/v2/translator/byo/compiler.go index 67af9656c..81c07e075 100644 --- a/go/core/v2/translator/byo/compiler.go +++ b/go/core/v2/translator/byo/compiler.go @@ -14,16 +14,16 @@ import ( ) // Compiler translates resolved inputs into a BYO A2A runtime revision. -type Compiler struct{ adk *adkconfig.Compiler } +type Compiler struct{ config *adkconfig.Builder } var _ v2translator.HarnessCompiler = (*Compiler)(nil) func NewCompiler(kube v2translator.Reader) *Compiler { - return &Compiler{adk: adkconfig.NewCompiler(kube)} + return &Compiler{config: adkconfig.NewBuilder(kube)} } func (c *Compiler) Compile(ctx context.Context, input *v2translator.HarnessInput) (*v2translator.Revision, error) { - compiled, err := c.adk.Compile(ctx, input.Root) + compiled, err := c.config.Build(ctx, input.Root) if err != nil { return nil, err } @@ -37,11 +37,11 @@ func (c *Compiler) Compile(ctx context.Context, input *v2translator.HarnessInput return nil, fmt.Errorf("marshal agent card: %w", err) } environment := adkconfig.DedupeEnv(append(compiled.Environment, adkconfig.HarnessEnvironment(harness)...)) - provenance, err := c.adk.BuildProvenance(ctx, harness, compiled.Templates, compiled.Models, environment) + provenance, err := c.config.BuildProvenance(ctx, harness, compiled.Templates, compiled.Models, environment) if err != nil { return nil, fmt.Errorf("build revision provenance: %w", err) } - environment, err = c.adk.ResolveEnvironment(ctx, template.Namespace, environment) + environment, err = c.config.ResolveEnvironment(ctx, template.Namespace, environment) if err != nil { return nil, fmt.Errorf("resolve runtime environment: %w", err) } diff --git a/go/core/v2/translator/kagent/compiler.go b/go/core/v2/translator/kagent/compiler.go index d2a5ffac9..0f18de676 100644 --- a/go/core/v2/translator/kagent/compiler.go +++ b/go/core/v2/translator/kagent/compiler.go @@ -20,20 +20,20 @@ const hitlExtensionURI = "https://kagent.dev/extensions/hitl/v1" // Compiler translates resolved inputs into a kagent runtime revision. type Compiler struct { - adk *adkconfig.Compiler + config *adkconfig.Builder } var _ v2translator.HarnessCompiler = (*Compiler)(nil) func NewCompiler(kube v2translator.Reader) *Compiler { - return &Compiler{adk: adkconfig.NewCompiler(kube)} + return &Compiler{config: adkconfig.NewBuilder(kube)} } func (c *Compiler) Compile(ctx context.Context, input *v2translator.HarnessInput) (*v2translator.Revision, error) { if err := requireModels(input.Root); err != nil { return nil, err } - compiled, err := c.adk.Compile(ctx, input.Root) + compiled, err := c.config.Build(ctx, input.Root) if err != nil { return nil, err } @@ -61,11 +61,11 @@ func (c *Compiler) Compile(ctx context.Context, input *v2translator.HarnessInput corev1.EnvVar{Name: "KAGENT_PRE_RESPONSE_TRACE_FLUSH", Value: "true"}, ) environment = adkconfig.DedupeEnv(environment) - provenance, err := c.adk.BuildProvenance(ctx, harness, compiled.Templates, compiled.Models, environment) + provenance, err := c.config.BuildProvenance(ctx, harness, compiled.Templates, compiled.Models, environment) if err != nil { return nil, fmt.Errorf("build revision provenance: %w", err) } - environment, err = c.adk.ResolveEnvironment(ctx, template.Namespace, environment) + environment, err = c.config.ResolveEnvironment(ctx, template.Namespace, environment) if err != nil { return nil, fmt.Errorf("resolve runtime environment: %w", err) } From b3a75257cdf28251df87e13b0f6bc6acd5104ec0 Mon Sep 17 00:00:00 2001 From: Eitan Yarmush Date: Tue, 1 Sep 2026 20:57:26 +0000 Subject: [PATCH 6/7] fix: bind configured BYO fixture to ingress port Signed-off-by: Eitan Yarmush --- go/core/test/e2e/manifests/lifecycle.yaml.tmpl | 3 +++ 1 file changed, 3 insertions(+) diff --git a/go/core/test/e2e/manifests/lifecycle.yaml.tmpl b/go/core/test/e2e/manifests/lifecycle.yaml.tmpl index 284d30be4..9d69480d4 100644 --- a/go/core/test/e2e/manifests/lifecycle.yaml.tmpl +++ b/go/core/test/e2e/manifests/lifecycle.yaml.tmpl @@ -47,6 +47,9 @@ spec: workload: image: ${KAGENT_E2E_RUNTIME_IMAGE} command: ["/app"] + env: + - name: PORT + value: "80" substrate: workerPoolRef: name: kagent-default From 096ba348056bfcb0aa8fe2f06ac80eca72b902b0 Mon Sep 17 00:00:00 2001 From: Eitan Yarmush Date: Tue, 1 Sep 2026 21:43:08 +0000 Subject: [PATCH 7/7] fix: remove duplicate model config guard Signed-off-by: Eitan Yarmush --- go/core/v2/translator/adkconfig/builder.go | 3 --- 1 file changed, 3 deletions(-) diff --git a/go/core/v2/translator/adkconfig/builder.go b/go/core/v2/translator/adkconfig/builder.go index fb64107fb..6500d6238 100644 --- a/go/core/v2/translator/adkconfig/builder.go +++ b/go/core/v2/translator/adkconfig/builder.go @@ -343,9 +343,6 @@ func agentConfigDestinations(cfg *adk.AgentConfig, modelConfig *v1alpha3.ModelCo slices.Sort(destinations) return slices.Compact(destinations) } - if modelConfig == nil { - return slices.Compact(destinations) - } switch modelConfig.Spec.Provider { case v1alpha3.ModelProviderOpenAI: destinations = append(destinations, "api.openai.com")