From c651efde7b1d62c96fd11754760a0e61c96c40f8 Mon Sep 17 00:00:00 2001 From: Jet Chiang Date: Wed, 26 Aug 2026 17:38:17 -0400 Subject: [PATCH 01/11] claude initial commit Signed-off-by: Jet Chiang --- .github/workflows/ci.yaml | 6 +- .github/workflows/image-scan.yaml | 3 + .github/workflows/tag.yaml | 1 + Makefile | 14 +- go/Dockerfile | 1 + go/core/test/e2e/claude_interaction_test.go | 425 ++++++++++++++++ go/core/test/e2e/interaction_test.go | 79 ++- .../test/e2e/manifests/lifecycle.yaml.tmpl | 19 + .../test/e2e/mocks/invoke_claude_agent.json | 68 +++ .../mocks/invoke_claude_builtin_tools.json | 90 ++++ .../mocks/invoke_claude_local_subagent.json | 89 ++++ go/core/v2/controller/collections_test.go | 44 ++ go/core/v2/controller/reconciler.go | 2 + go/core/v2/translator/claude/compiler.go | 452 ++++++++++++++++++ go/core/v2/translator/claude/compiler_test.go | 294 ++++++++++++ go/harness/claude/Dockerfile | 44 ++ go/harness/claude/cmd/kagent-claude/main.go | 91 ++++ .../claude/cmd/kagent-claude/main_test.go | 18 + go/harness/claude/config/config.go | 107 +++++ go/harness/claude/config/config_test.go | 89 ++++ go/harness/claude/internal/adapter/adapter.go | 140 ++++++ .../claude/internal/adapter/adapter_test.go | 74 +++ go/harness/claude/internal/driver/event.go | 29 ++ go/harness/claude/internal/driver/parser.go | 255 ++++++++++ .../claude/internal/driver/parser_test.go | 194 ++++++++ go/harness/claude/internal/driver/process.go | 220 +++++++++ .../claude/internal/driver/process_test.go | 93 ++++ go/harness/claude/internal/session/store.go | 116 +++++ .../claude/internal/session/store_test.go | 90 ++++ go/harness/claude/testdata/agent-card.json | 8 + go/harness/claude/testdata/poc-config.json | 11 + go/harness/claude/testdata/stream-error.jsonl | 2 + .../claude/testdata/stream-resume.jsonl | 3 + .../claude/testdata/stream-success.jsonl | 7 + go/harness/runtime/a2a/executor.go | 285 +++++++++++ go/harness/runtime/a2a/executor_test.go | 246 ++++++++++ go/harness/runtime/runtime.go | 53 ++ 37 files changed, 3750 insertions(+), 12 deletions(-) create mode 100644 go/core/test/e2e/claude_interaction_test.go create mode 100644 go/core/test/e2e/mocks/invoke_claude_agent.json create mode 100644 go/core/test/e2e/mocks/invoke_claude_builtin_tools.json create mode 100644 go/core/test/e2e/mocks/invoke_claude_local_subagent.json create mode 100644 go/core/v2/translator/claude/compiler.go create mode 100644 go/core/v2/translator/claude/compiler_test.go create mode 100644 go/harness/claude/Dockerfile create mode 100644 go/harness/claude/cmd/kagent-claude/main.go create mode 100644 go/harness/claude/cmd/kagent-claude/main_test.go create mode 100644 go/harness/claude/config/config.go create mode 100644 go/harness/claude/config/config_test.go create mode 100644 go/harness/claude/internal/adapter/adapter.go create mode 100644 go/harness/claude/internal/adapter/adapter_test.go create mode 100644 go/harness/claude/internal/driver/event.go create mode 100644 go/harness/claude/internal/driver/parser.go create mode 100644 go/harness/claude/internal/driver/parser_test.go create mode 100644 go/harness/claude/internal/driver/process.go create mode 100644 go/harness/claude/internal/driver/process_test.go create mode 100644 go/harness/claude/internal/session/store.go create mode 100644 go/harness/claude/internal/session/store_test.go create mode 100644 go/harness/claude/testdata/agent-card.json create mode 100644 go/harness/claude/testdata/poc-config.json create mode 100644 go/harness/claude/testdata/stream-error.jsonl create mode 100644 go/harness/claude/testdata/stream-resume.jsonl create mode 100644 go/harness/claude/testdata/stream-success.jsonl create mode 100644 go/harness/runtime/a2a/executor.go create mode 100644 go/harness/runtime/a2a/executor_test.go create mode 100644 go/harness/runtime/runtime.go diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 075b5eff1..94dc704c9 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 + make build-controller build-golang-adk build-claude-harness 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}" + 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}" envsubst < core/test/e2e/manifests/lifecycle.yaml.tmpl | kubectl apply -f - for _ in $(seq 1 60); do READY=$(kubectl get agenttemplate smoke -n kagent -o jsonpath='{.status.harnesses[?(@.harness=="kagent")].conditions[?(@.type=="Ready")].status}') @@ -411,6 +414,7 @@ jobs: - kagent-adk - cli - golang-adk + - claude-harness runs-on: ubuntu-latest services: registry: diff --git a/.github/workflows/image-scan.yaml b/.github/workflows/image-scan.yaml index 573496526..0e40e4e8e 100644 --- a/.github/workflows/image-scan.yaml +++ b/.github/workflows/image-scan.yaml @@ -42,6 +42,9 @@ jobs: - build_target: golang-adk image_name: golang-adk tag_suffix: "" + - build_target: claude-harness + image_name: claude-harness + tag_suffix: "" runs-on: ubuntu-latest services: registry: diff --git a/.github/workflows/tag.yaml b/.github/workflows/tag.yaml index 15b02f42d..e8625478d 100644 --- a/.github/workflows/tag.yaml +++ b/.github/workflows/tag.yaml @@ -21,6 +21,7 @@ jobs: - ui - kagent-adk - golang-adk + - claude-harness runs-on: ubuntu-latest permissions: contents: read diff --git a/Makefile b/Makefile index c237b30e9..77e8a9255 100644 --- a/Makefile +++ b/Makefile @@ -55,14 +55,17 @@ UI_IMAGE_NAME ?= ui KAGENT_ADK_IMAGE_NAME ?= kagent-adk GOLANG_ADK_IMAGE_NAME ?= golang-adk +CLAUDE_HARNESS_IMAGE_NAME ?= claude-harness CONTROLLER_IMAGE_TAG ?= $(VERSION) UI_IMAGE_TAG ?= $(VERSION) KAGENT_ADK_IMAGE_TAG ?= $(VERSION) GOLANG_ADK_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) +CLAUDE_HARNESS_IMG ?= $(DOCKER_REGISTRY)/$(DOCKER_REPO)/$(CLAUDE_HARNESS_IMAGE_NAME):$(CLAUDE_HARNESS_IMAGE_TAG) #take from go/go.mod AWK ?= $(shell command -v gawk || command -v awk) @@ -219,17 +222,19 @@ build-all: ## Build all images for amd64+arm64 without pushing (outputs to /dev/ build-all: BUILD_ARGS ?= --progress=plain --builder $(BUILDX_BUILDER_NAME) --platform linux/amd64,linux/arm64 --output type=tar,dest=/dev/null build-all: proto-generate buildx-create $(DOCKER_BUILDER) $(BUILD_ARGS) $(TOOLS_IMAGE_BUILD_ARGS) -f go/Dockerfile ./go + $(DOCKER_BUILDER) $(BUILD_ARGS) $(TOOLS_IMAGE_BUILD_ARGS) -f go/harness/claude/Dockerfile ./go $(DOCKER_BUILDER) $(BUILD_ARGS) $(TOOLS_IMAGE_BUILD_ARGS) -f ui/Dockerfile ./ui $(DOCKER_BUILDER) $(BUILD_ARGS) $(TOOLS_IMAGE_BUILD_ARGS) -f python/Dockerfile ./python .PHONY: build build: ## Build and push all component images -build: buildx-create build-ui build-kagent-adk build-golang-adk build-controller +build: buildx-create build-ui build-kagent-adk build-golang-adk build-claude-harness build-controller @echo "Build completed successfully." @echo "Controller Image: $(CONTROLLER_IMG)" @echo "UI Image: $(UI_IMG)" @echo "Kagent ADK Image: $(KAGENT_ADK_IMG)" @echo "Golang ADK Image: $(GOLANG_ADK_IMG)" + @echo "Claude Harness Image: $(CLAUDE_HARNESS_IMG)" .PHONY: build-monitor build-monitor: ## Watch BuildKit process list inside the buildx container @@ -257,6 +262,7 @@ build-img-versions: ## Print the fully-qualified image tags for all components @echo ui=$(UI_IMG) @echo kagent-adk=$(KAGENT_ADK_IMG) @echo golang-adk=$(GOLANG_ADK_IMG) + @echo claude-harness=$(CLAUDE_HARNESS_IMG) .PHONY: controller-manifests controller-manifests: ## Regenerate CRD manifests and copy them into the Helm chart @@ -289,6 +295,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-claude-harness +build-claude-harness: ## Build and push the native Claude Harness image +build-claude-harness: buildx-create + $(DOCKER_BUILDER) $(DOCKER_BUILD_ARGS) $(TOOLS_IMAGE_BUILD_ARGS) -t $(CLAUDE_HARNESS_IMG) -f go/harness/claude/Dockerfile ./go + $(DOCKER_PUSH) $(CLAUDE_HARNESS_IMG) + .PHONY: push push: ## Push all component images (controller, ui, ADKs) push: push-controller push-ui push-kagent-adk push-golang-adk diff --git a/go/Dockerfile b/go/Dockerfile index 8ff53f083..e77c5b931 100644 --- a/go/Dockerfile +++ b/go/Dockerfile @@ -21,6 +21,7 @@ RUN --mount=type=cache,target=/root/go/pkg/mod,rw \ COPY api/ api/ COPY core/ core/ COPY adk/ adk/ +COPY harness/ harness/ # Build ARG LDFLAGS diff --git a/go/core/test/e2e/claude_interaction_test.go b/go/core/test/e2e/claude_interaction_test.go new file mode 100644 index 000000000..7be5940fd --- /dev/null +++ b/go/core/test/e2e/claude_interaction_test.go @@ -0,0 +1,425 @@ +package e2e_test + +import ( + "context" + "embed" + "errors" + "io" + "strings" + "testing" + + a2atype "github.com/a2aproject/a2a-go/v2/a2a" + "github.com/a2aproject/a2a-go/v2/a2apb/v1/pbconv" + apiv1alpha1 "github.com/kagent-dev/kagent/go/api/gen/kagent/api/v1alpha1" + "github.com/kagent-dev/kagent/go/api/v1alpha3" + "github.com/kagent-dev/mockllm" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + ctrlclient "sigs.k8s.io/controller-runtime/pkg/client" +) + +const claudeE2EHarness = "claude-e2e" + +//go:embed mocks/invoke_claude_agent.json mocks/invoke_claude_builtin_tools.json mocks/invoke_claude_local_subagent.json +var claudeInteractionMocks embed.FS + +func TestClaudeMockInteractionResumeAndPersistence(t *testing.T) { + target := interactionTarget(t) + modelURL := reachableServerURL(t, startMockLLMServer(t, claudeInteractionMocks, "mocks/invoke_claude_agent.json"), "") + template := createClaudeMockTemplate(t, modelURL) + fixture := newInteractionFixtureForHarnessTemplate(t, target, claudeE2EHarness, template) + + streamed := sendClaudeStreaming(t, fixture, "Return exactly CLAUDE_MOCK_FIRST.") + if streamed.state != a2atype.TaskStateCompleted { + t.Fatalf("streamed mock Claude task state = %s, failure = %q, want COMPLETED", streamed.state, streamed.failureText) + } + if !streamed.sawWorking || !streamed.sawArtifact { + t.Fatalf("streamed mock Claude events: working=%t artifact=%t, want both", streamed.sawWorking, streamed.sawArtifact) + } + if !strings.Contains(streamed.text, "CLAUDE_MOCK_FIRST") { + t.Fatalf("streamed mock Claude response = %q, want CLAUDE_MOCK_FIRST", streamed.text) + } + first := getClaudeTask(t, fixture, streamed.taskID) + if first.Status.State != a2atype.TaskStateCompleted || !strings.Contains(taskText(first), "CLAUDE_MOCK_FIRST") { + t.Fatalf("persisted first Claude task state = %s, text = %q", first.Status.State, taskText(first)) + } + + _, _, resumed := fixture.send(t, "Return exactly CLAUDE_MOCK_SECOND.") + if resumed.Status.State != a2atype.TaskStateCompleted { + t.Fatalf("resumed mock Claude task state = %s, text = %q, want COMPLETED", resumed.Status.State, taskText(resumed)) + } + if text := taskText(resumed); !strings.Contains(text, "CLAUDE_MOCK_SECOND") { + t.Fatalf("resumed mock Claude response = %q, want CLAUDE_MOCK_SECOND", text) + } + assertClaudeTaskHistory(t, fixture, first.ID, resumed.ID) +} + +func TestClaudeMockActiveTaskCancellation(t *testing.T) { + target := interactionTarget(t) + modelURL, started := startBlockingClaudeMock(t) + template := createClaudeMockTemplate(t, modelURL) + fixture := newInteractionFixtureForHarnessTemplate(t, target, claudeE2EHarness, template) + testActiveTaskCancellation(t, fixture, started) +} + +func TestClaudeMockBuiltinToolEvents(t *testing.T) { + target := interactionTarget(t) + modelURL := reachableServerURL(t, startMockLLMServer(t, claudeInteractionMocks, "mocks/invoke_claude_builtin_tools.json"), "") + template := createClaudeMockTemplate(t, modelURL) + fixture := newInteractionFixtureForHarnessTemplate(t, target, claudeE2EHarness, template) + + streamed := sendClaudeStreaming(t, fixture, "Create the requested file and read it back.") + if streamed.state != a2atype.TaskStateCompleted || !strings.Contains(streamed.text, "CLAUDE_BUILTIN_TOOLS_DONE") { + t.Fatalf("built-in tool task state = %s, text = %q", streamed.state, streamed.text) + } + assertClaudeToolEvents(t, streamed.toolEvents, "Bash", "Read") + persisted := getClaudeTask(t, fixture, streamed.taskID) + assertClaudeToolEvents(t, claudeTaskToolEvents(persisted), "Bash", "Read") +} + +func TestClaudeMockLocalSubagentRouting(t *testing.T) { + target := interactionTarget(t) + modelURL := reachableServerURL(t, startMockLLMServer(t, claudeInteractionMocks, "mocks/invoke_claude_local_subagent.json"), "") + kube := interactionKubeClient(t) + model := createClaudeMockModel(t, kube, modelURL) + rootTemplate, childTemplate := createClaudeLocalAgentTemplates(t, kube, model, "CLAUDE_LOCAL_SPECIALIST_INSTRUCTION") + fixture := newInteractionFixtureForHarnessTemplate(t, target, claudeE2EHarness, rootTemplate) + + streamed := sendClaudeStreaming(t, fixture, "Delegate this request to the specialist.") + if streamed.state != a2atype.TaskStateCompleted || !strings.Contains(streamed.text, "CLAUDE_SUBAGENT_FINAL") { + t.Fatalf("local subagent task state = %s, text = %q, failure = %q", streamed.state, streamed.text, streamed.failureText) + } + const toolName = "Agent" + assertClaudeToolEvents(t, streamed.toolEvents, toolName) + persisted := getClaudeTask(t, fixture, streamed.taskID) + assertClaudeToolEvents(t, claudeTaskToolEvents(persisted), toolName) + assertNoClaudeChildInstance(t, fixture, childTemplate) + assertClaudeTaskHistory(t, fixture, streamed.taskID) +} + +type claudeStreamResult struct { + taskID a2atype.TaskID + state a2atype.TaskState + text string + sawWorking bool + sawArtifact bool + toolEvents []claudeToolEvent + failureText string +} + +type claudeToolEvent struct { + partType string + id string + name string +} + +func sendClaudeStreaming(t *testing.T, fixture *interactionFixture, text string) claudeStreamResult { + t.Helper() + _, request := newMessageRequest(t, text) + stream, err := fixture.client.SendStreamingMessage(fixture.ctx, request) + if err != nil { + t.Fatalf("start streaming Claude A2A message: %v", err) + } + var result claudeStreamResult + var output strings.Builder + terminalEvents := 0 + for { + response, err := stream.Recv() + if errors.Is(err, io.EOF) { + if terminalEvents != 1 { + t.Fatalf("Claude stream terminal event count = %d, want 1", terminalEvents) + } + if result.taskID == "" { + t.Fatal("Claude stream completed without a task ID") + } + result.text = output.String() + return result + } + if err != nil { + t.Fatalf("receive Claude task stream: %v", err) + } + if terminalEvents != 0 { + t.Fatalf("Claude stream emitted an event after terminal state %s", result.state) + } + event, err := pbconv.FromProtoStreamResponse(response) + if err != nil { + t.Fatalf("decode Claude task stream: %v", err) + } + if info := event.TaskInfo(); info.TaskID != "" { + result.taskID = info.TaskID + } + switch event := event.(type) { + case *a2atype.Task: + result.state = event.Status.State + if event.Status.State == a2atype.TaskStateWorking { + result.sawWorking = true + } + case *a2atype.TaskArtifactUpdateEvent: + result.sawArtifact = true + if event.Artifact != nil { + for _, part := range event.Artifact.Parts { + output.WriteString(part.Text()) + } + } + case *a2atype.TaskStatusUpdateEvent: + result.state = event.Status.State + if event.Status.State == a2atype.TaskStateWorking { + result.sawWorking = true + } + if event.Status.State == a2atype.TaskStateFailed && event.Status.Message != nil { + var parts []string + for _, part := range event.Status.Message.Parts { + parts = append(parts, part.Text()) + } + result.failureText = strings.Join(parts, "\n") + } + result.toolEvents = append(result.toolEvents, claudeToolEvents(event.Status.Message)...) + } + if result.state.Terminal() { + terminalEvents++ + } + } +} + +func claudeToolEvents(message *a2atype.Message) []claudeToolEvent { + if message == nil { + return nil + } + var events []claudeToolEvent + for _, part := range message.Parts { + partType, _ := part.Metadata["kagent_type"].(string) + if partType != "function_call" && partType != "function_response" { + continue + } + data, ok := part.Data().(map[string]any) + if !ok { + continue + } + id, _ := data["id"].(string) + name, _ := data["name"].(string) + events = append(events, claudeToolEvent{partType: partType, id: id, name: name}) + } + return events +} + +func claudeTaskToolEvents(task *a2atype.Task) []claudeToolEvent { + var events []claudeToolEvent + for _, message := range task.History { + events = append(events, claudeToolEvents(message)...) + } + events = append(events, claudeToolEvents(task.Status.Message)...) + return events +} + +func assertClaudeToolEvents(t *testing.T, events []claudeToolEvent, toolNames ...string) { + t.Helper() + for _, toolName := range toolNames { + calls, responses := 0, 0 + ids := map[string]struct{}{} + for _, event := range events { + if event.name != toolName { + continue + } + if event.id == "" { + t.Fatalf("%s event for %s has no tool-use ID", event.partType, toolName) + } + switch event.partType { + case "function_call": + calls++ + ids[event.id] = struct{}{} + case "function_response": + responses++ + if _, ok := ids[event.id]; !ok { + t.Fatalf("response for %s tool-use ID %q has no preceding call", toolName, event.id) + } + } + } + if calls != 1 || responses != 1 { + t.Fatalf("A2A events for %s: calls=%d responses=%d, want one of each; all events=%#v", toolName, calls, responses, events) + } + } +} + +func firstClaudeToolPairName(events []claudeToolEvent) string { + calls := map[string]map[string]struct{}{} + for _, event := range events { + if event.partType == "function_call" { + if calls[event.name] == nil { + calls[event.name] = map[string]struct{}{} + } + calls[event.name][event.id] = struct{}{} + } + if event.partType == "function_response" { + if _, ok := calls[event.name][event.id]; ok { + return event.name + } + } + } + return "" +} + +func getClaudeTask(t *testing.T, fixture *interactionFixture, taskID a2atype.TaskID) *a2atype.Task { + t.Helper() + request, err := pbconv.ToProtoGetTaskRequest(&a2atype.GetTaskRequest{ID: taskID}) + if err != nil { + t.Fatalf("build GetTask request: %v", err) + } + response, err := fixture.client.GetTask(fixture.ctx, request) + if err != nil { + t.Fatalf("get Claude task %s: %v", taskID, err) + } + task, err := pbconv.FromProtoTask(response) + if err != nil { + t.Fatalf("decode Claude task %s: %v", taskID, err) + } + return task +} + +func assertClaudeTaskHistory(t *testing.T, fixture *interactionFixture, taskIDs ...a2atype.TaskID) { + t.Helper() + request, err := pbconv.ToProtoListTasksRequest(&a2atype.ListTasksRequest{ContextID: fixture.instanceID}) + if err != nil { + t.Fatalf("build ListTasks request: %v", err) + } + response, err := fixture.client.ListTasks(fixture.ctx, request) + if err != nil { + t.Fatalf("list Claude tasks: %v", err) + } + listed, err := pbconv.FromProtoListTasksResponse(response) + if err != nil { + t.Fatalf("decode Claude task list: %v", err) + } + if len(listed.Tasks) != len(taskIDs) { + t.Fatalf("Claude task count = %d, want %d", len(listed.Tasks), len(taskIDs)) + } + want := make(map[a2atype.TaskID]struct{}, len(taskIDs)) + for _, taskID := range taskIDs { + want[taskID] = struct{}{} + } + for _, task := range listed.Tasks { + if _, ok := want[task.ID]; !ok { + t.Fatalf("listed unexpected Claude task %s", task.ID) + } + if task.Status.State != a2atype.TaskStateCompleted { + t.Fatalf("listed Claude task %s state = %s, want COMPLETED", task.ID, task.Status.State) + } + } +} + +func startBlockingClaudeMock(t *testing.T) (string, <-chan struct{}) { + t.Helper() + cfg, err := mockllm.LoadConfigFromFile("mocks/invoke_claude_agent.json", claudeInteractionMocks) + if err != nil { + t.Fatalf("load Claude mock LLM response: %v", err) + } + if len(cfg.Anthropic) == 0 { + t.Fatal("Claude mock LLM fixture has no Anthropic response") + } + baseURL, started := startBlockingMockServer(t, cfg.Anthropic[0].Response) + return reachableServerURL(t, baseURL, ""), started +} + +func createClaudeMockTemplate(t *testing.T, baseURL string) string { + t.Helper() + kube := interactionKubeClient(t) + model := createClaudeMockModel(t, kube, baseURL) + return createClaudeTemplate(t, kube, model.Name, "Claude mockLLM interaction fixture") +} + +func createClaudeMockModel(t *testing.T, kube ctrlclient.Client, baseURL string) *v1alpha3.ModelConfig { + t.Helper() + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{GenerateName: "claude-mock-", Namespace: "kagent"}, + Data: map[string][]byte{"ANTHROPIC_API_KEY": []byte("mock-key")}, + } + if err := kube.Create(t.Context(), secret); err != nil { + t.Fatalf("create Claude mock Secret: %v", err) + } + t.Cleanup(func() { + if err := kube.Delete(context.Background(), secret); err != nil && !apierrors.IsNotFound(err) { + t.Errorf("delete Claude mock Secret: %v", err) + } + }) + model := &v1alpha3.ModelConfig{ + ObjectMeta: metav1.ObjectMeta{GenerateName: "claude-mock-", Namespace: "kagent"}, + Spec: v1alpha3.ModelConfigSpec{ + Provider: v1alpha3.ModelProviderAnthropic, + Model: "claude-sonnet-4-5", + APIKeySecret: secret.Name, APIKeySecretKey: "ANTHROPIC_API_KEY", + Anthropic: &v1alpha3.AnthropicConfig{BaseURL: baseURL}, + }, + } + if err := kube.Create(t.Context(), model); err != nil { + t.Fatalf("create Claude mock ModelConfig: %v", err) + } + t.Cleanup(func() { + if err := kube.Delete(context.Background(), model); err != nil && !apierrors.IsNotFound(err) { + t.Errorf("delete Claude mock ModelConfig: %v", err) + } + }) + return model +} + +func createClaudeTemplate(t *testing.T, kube ctrlclient.Client, modelConfig, description string) string { + t.Helper() + template := &v1alpha3.AgentTemplate{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "claude-interaction-", Namespace: "kagent", + Labels: map[string]string{"kagent.dev/e2e-runtime": "claude"}, + }, + Spec: v1alpha3.AgentTemplateSpec{ + ModelConfig: v1alpha3.AgentTemplateLocalReference{Name: modelConfig}, + Description: description, SystemPrompt: "Reply concisely and follow the requested output format exactly.", + }, + } + createAndWaitInteractionTemplateForHarness(t, kube, template, claudeE2EHarness) + return template.Name +} + +func createClaudeLocalAgentTemplates(t *testing.T, kube ctrlclient.Client, model *v1alpha3.ModelConfig, childPrompt string) (string, string) { + t.Helper() + child := &v1alpha3.AgentTemplate{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "claude-local-child-", Namespace: "kagent", + Labels: map[string]string{"kagent.dev/e2e-runtime": "claude"}, + }, + Spec: v1alpha3.AgentTemplateSpec{ + ModelConfig: v1alpha3.AgentTemplateLocalReference{Name: model.Name}, + Description: "Claude local specialist", + SystemPrompt: childPrompt, + }, + } + createAndWaitInteractionTemplateForHarness(t, kube, child, claudeE2EHarness) + root := &v1alpha3.AgentTemplate{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "claude-local-root-", Namespace: "kagent", + Labels: map[string]string{"kagent.dev/e2e-runtime": "claude"}, + }, + Spec: v1alpha3.AgentTemplateSpec{ + 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{ + Name: "specialist", Description: "Handles every delegated specialist request", + TemplateRef: v1alpha3.AgentTemplateLocalReference{Name: child.Name}, + Isolation: v1alpha3.AgentToolIsolationShared, + }}}, + }, + } + createAndWaitInteractionTemplateForHarness(t, kube, root, claudeE2EHarness) + return root.Name, child.Name +} + +func assertNoClaudeChildInstance(t *testing.T, fixture *interactionFixture, childTemplate string) { + t.Helper() + instances, err := fixture.instances.ListAgentInstances(fixture.ctx, &apiv1alpha1.ListAgentInstancesRequest{Namespace: "kagent"}) + if err != nil { + t.Fatalf("list Claude AgentInstances: %v", err) + } + for _, instance := range instances.GetAgentInstances() { + if instance.GetAgentTemplate().GetName() == childTemplate { + t.Fatalf("Claude local child created AgentInstance %q", instance.GetId()) + } + } +} diff --git a/go/core/test/e2e/interaction_test.go b/go/core/test/e2e/interaction_test.go index 4c739bb18..499afd0e6 100644 --- a/go/core/test/e2e/interaction_test.go +++ b/go/core/test/e2e/interaction_test.go @@ -5,6 +5,10 @@ import ( "context" "embed" "encoding/json" + "errors" + "fmt" + "io" + "io/fs" "net" "net/http" "net/http/httptest" @@ -31,6 +35,7 @@ import ( "google.golang.org/grpc/credentials/insecure" "google.golang.org/grpc/metadata" "google.golang.org/grpc/status" + corev1 "k8s.io/api/core/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" k8sruntime "k8s.io/apimachinery/pkg/runtime" @@ -287,6 +292,11 @@ func TestAgentInstanceActiveTask(t *testing.T) { target := interactionTarget(t) modelURL, started := startBlockingInteractionMock(t) fixture := newInteractionFixture(t, target, modelURL) + testActiveTaskCancellation(t, fixture, started) +} + +func testActiveTaskCancellation(t *testing.T, fixture *interactionFixture, started <-chan struct{}) { + t.Helper() _, request := newMessageRequest(t, "Wait for cancellation") stream, err := fixture.client.SendStreamingMessage(fixture.ctx, request) if err != nil { @@ -357,6 +367,8 @@ func TestAgentInstanceActiveTask(t *testing.T) { } waitForTaskState(t, subscription, a2atype.TaskStateCanceled) waitForTaskState(t, stream, a2atype.TaskStateCanceled) + assertTaskStreamClosed(t, subscription) + assertTaskStreamClosed(t, stream) getRequest, err := pbconv.ToProtoGetTaskRequest(&a2atype.GetTaskRequest{ID: task.ID}) if err != nil { t.Fatalf("build GetTask request: %v", err) @@ -405,6 +417,11 @@ func newInteractionFixture(t *testing.T, target, modelURL string) *interactionFi } func newInteractionFixtureForTemplate(t *testing.T, target, templateName string) *interactionFixture { + t.Helper() + return newInteractionFixtureForHarnessTemplate(t, target, "kagent", templateName) +} + +func newInteractionFixtureForHarnessTemplate(t *testing.T, target, harnessName, templateName string) *interactionFixture { t.Helper() conn, err := grpc.NewClient(target, grpc.WithTransportCredentials(insecure.NewCredentials())) if err != nil { @@ -415,7 +432,7 @@ func newInteractionFixtureForTemplate(t *testing.T, target, templateName string) t.Cleanup(cancel) instances := apiv1alpha1.NewAgentInstanceServiceClient(conn) request := &apiv1alpha1.CreateAgentInstanceRequest{ - Namespace: "kagent", AgentTemplate: templateName, Harness: "kagent", RequestId: uuid.NewString(), + Namespace: "kagent", AgentTemplate: templateName, Harness: harnessName, RequestId: uuid.NewString(), } var created *apiv1alpha1.CreateAgentInstanceResponse err = wait.PollUntilContextTimeout(ctx, time.Second, time.Minute, true, func(ctx context.Context) (bool, error) { @@ -519,13 +536,26 @@ func waitForTaskState(t *testing.T, stream streamReceiver, want a2atype.TaskStat } } +func assertTaskStreamClosed(t *testing.T, stream streamReceiver) { + t.Helper() + response, err := stream.Recv() + if !errors.Is(err, io.EOF) { + t.Fatalf("task stream emitted after its terminal boundary: response=%#v error=%v", response, err) + } +} + func startInteractionMock(t *testing.T) string { return startMockLLM(t, "mocks/invoke_golang_adk_agent.json") } func startMockLLM(t *testing.T, fixture string) string { t.Helper() - cfg, err := mockllm.LoadConfigFromFile(fixture, interactionMocks) + return reachableModelURL(t, startMockLLMServer(t, interactionMocks, fixture)) +} + +func startMockLLMServer(t *testing.T, fixtures fs.ReadFileFS, fixture string) string { + t.Helper() + cfg, err := mockllm.LoadConfigFromFile(fixture, fixtures) if err != nil { t.Fatalf("load mock LLM response: %v", err) } @@ -539,7 +569,7 @@ func startMockLLM(t *testing.T, fixture string) string { t.Errorf("stop mock LLM: %v", err) } }) - return reachableModelURL(t, baseURL) + return baseURL } func startBlockingInteractionMock(t *testing.T) (string, <-chan struct{}) { @@ -548,6 +578,12 @@ func startBlockingInteractionMock(t *testing.T) (string, <-chan struct{}) { if err != nil { t.Fatalf("load mock LLM response: %v", err) } + baseURL, started := startBlockingMockServer(t, cfg.OpenAI[0].Response) + return reachableModelURL(t, baseURL), started +} + +func startBlockingMockServer(t *testing.T, response any) (string, <-chan struct{}) { + t.Helper() started := make(chan struct{}) release := make(chan struct{}) var startedOnce, releaseOnce sync.Once @@ -559,21 +595,22 @@ func startBlockingInteractionMock(t *testing.T) (string, <-chan struct{}) { case <-release: } w.Header().Set("Content-Type", "application/json") - if err := json.NewEncoder(w).Encode(cfg.OpenAI[0].Response); err != nil { + if err := json.NewEncoder(w).Encode(response); err != nil { t.Errorf("write mock LLM response: %v", err) } })) _ = server.Listener.Close() - server.Listener, err = net.Listen("tcp", "0.0.0.0:0") + listener, err := net.Listen("tcp", "0.0.0.0:0") if err != nil { t.Fatalf("listen for blocking mock LLM: %v", err) } + server.Listener = listener server.Start() t.Cleanup(func() { releaseOnce.Do(func() { close(release) }) server.Close() }) - return reachableModelURL(t, server.URL), started + return server.URL, started } func startSharedInteractionMock(t *testing.T) string { @@ -730,6 +767,9 @@ func interactionKubeClient(t *testing.T) ctrlclient.Client { t.Fatalf("load Kubernetes config: %v", err) } clientScheme := k8sruntime.NewScheme() + if err := corev1.AddToScheme(clientScheme); err != nil { + t.Fatalf("register Kubernetes core API: %v", err) + } if err := v1alpha3.AddToScheme(clientScheme); err != nil { t.Fatalf("register kagent API: %v", err) } @@ -762,6 +802,11 @@ func createInteractionModel(t *testing.T, kube ctrlclient.Client, modelURL strin } func createAndWaitInteractionTemplate(t *testing.T, kube ctrlclient.Client, template *v1alpha3.AgentTemplate) { + t.Helper() + createAndWaitInteractionTemplateForHarness(t, kube, template, "kagent") +} + +func createAndWaitInteractionTemplateForHarness(t *testing.T, kube ctrlclient.Client, template *v1alpha3.AgentTemplate, harnessName string) { t.Helper() if err := kube.Create(t.Context(), template); err != nil { t.Fatalf("create interaction AgentTemplate: %v", err) @@ -772,23 +817,37 @@ func createAndWaitInteractionTemplate(t *testing.T, kube ctrlclient.Client, temp } }) + var lastReady *metav1.Condition err := wait.PollUntilContextTimeout(t.Context(), time.Second, 2*time.Minute, true, func(ctx context.Context) (bool, error) { if err := kube.Get(ctx, ctrlclient.ObjectKeyFromObject(template), template); err != nil { return false, err } for _, harness := range template.Status.Harnesses { - if harness.Harness != "kagent" { + if harness.Harness != harnessName { continue } - for _, condition := range harness.Conditions { - if condition.Type == v1alpha3.AgentTemplateConditionReady && condition.Status == metav1.ConditionTrue { - return true, nil + for index := range harness.Conditions { + condition := &harness.Conditions[index] + if condition.Status == metav1.ConditionFalse && + (condition.Type != v1alpha3.AgentTemplateConditionReady || condition.Reason != "ActorTemplatePending") { + return false, fmt.Errorf("AgentTemplate %s/%s harness %q condition %s failed: %s: %s", + template.Namespace, template.Name, harnessName, condition.Type, condition.Reason, condition.Message) + } + if condition.Type == v1alpha3.AgentTemplateConditionReady { + lastReady = condition.DeepCopy() + if condition.Status == metav1.ConditionTrue { + return true, nil + } } } } return false, nil }) if err != nil { + if lastReady != nil { + t.Fatalf("wait for interaction AgentTemplate %s/%s on harness %q: %v; last Ready condition: status=%s reason=%s message=%q", + template.Namespace, template.Name, harnessName, err, lastReady.Status, lastReady.Reason, lastReady.Message) + } t.Fatalf("wait for interaction AgentTemplate: %v", err) } } diff --git a/go/core/test/e2e/manifests/lifecycle.yaml.tmpl b/go/core/test/e2e/manifests/lifecycle.yaml.tmpl index 3f37ccf8f..7edb2bfe9 100644 --- a/go/core/test/e2e/manifests/lifecycle.yaml.tmpl +++ b/go/core/test/e2e/manifests/lifecycle.yaml.tmpl @@ -18,6 +18,25 @@ spec: kagent.dev/e2e-runtime: kagent --- apiVersion: kagent.dev/v1alpha3 +kind: Harness +metadata: + name: claude-e2e + namespace: kagent +spec: + claude: {} + workload: + image: ${KAGENT_E2E_CLAUDE_IMAGE} + substrate: + workerPoolRef: + name: kagent-default + snapshotPolicy: + location: gs://ate-snapshots/kagent/ + allowedAgentTemplates: + selector: + matchLabels: + kagent.dev/e2e-runtime: claude +--- +apiVersion: kagent.dev/v1alpha3 kind: AgentTemplate metadata: name: smoke diff --git a/go/core/test/e2e/mocks/invoke_claude_agent.json b/go/core/test/e2e/mocks/invoke_claude_agent.json new file mode 100644 index 000000000..1442bc05d --- /dev/null +++ b/go/core/test/e2e/mocks/invoke_claude_agent.json @@ -0,0 +1,68 @@ +{ + "anthropic": [ + { + "name": "first_claude_request", + "match": { + "match_type": "contains", + "message": { + "role": "user", + "content": [ + { + "type": "text", + "text": "Return exactly CLAUDE_MOCK_FIRST." + } + ] + } + }, + "response": { + "id": "msg_claude_mock_first", + "type": "message", + "role": "assistant", + "content": [ + { + "type": "text", + "text": "CLAUDE_MOCK_FIRST" + } + ], + "model": "claude-sonnet-4-5", + "stop_reason": "end_turn", + "usage": { + "input_tokens": 10, + "output_tokens": 4 + } + } + }, + { + "name": "resumed_claude_request", + "match": { + "match_type": "contains", + "message": { + "role": "user", + "content": [ + { + "type": "text", + "text": "Return exactly CLAUDE_MOCK_SECOND." + } + ] + } + }, + "response": { + "id": "msg_claude_mock_second", + "type": "message", + "role": "assistant", + "content": [ + { + "type": "text", + "text": "CLAUDE_MOCK_SECOND" + } + ], + "model": "claude-sonnet-4-5", + "stop_reason": "end_turn", + "usage": { + "input_tokens": 12, + "output_tokens": 4 + } + } + } + ] +} diff --git a/go/core/test/e2e/mocks/invoke_claude_builtin_tools.json b/go/core/test/e2e/mocks/invoke_claude_builtin_tools.json new file mode 100644 index 000000000..9592ce784 --- /dev/null +++ b/go/core/test/e2e/mocks/invoke_claude_builtin_tools.json @@ -0,0 +1,90 @@ +{ + "anthropic": [ + { + "name": "claude_builtin_start", + "match": { + "match_type": "contains", + "message": { + "role": "user", + "content": [{"type": "text", "text": "Create the requested file and read it back."}] + } + }, + "response": { + "id": "msg_builtin_bash", + "type": "message", + "role": "assistant", + "content": [{ + "type": "tool_use", + "id": "toolu_builtin_bash", + "name": "Bash", + "input": {"command": "printf CLAUDE_TOOL_FILE > /data/workspace/claude-tool.txt"} + }], + "model": "claude-sonnet-4-5", + "stop_reason": "tool_use", + "usage": {"input_tokens": 10, "output_tokens": 4} + } + }, + { + "name": "claude_builtin_bash_result", + "match": { + "match_type": "contains", + "message": { + "role": "user", + "content": [{"type": "tool_result", "tool_use_id": "toolu_builtin_bash"}] + } + }, + "response": { + "id": "msg_builtin_read", + "type": "message", + "role": "assistant", + "content": [{ + "type": "tool_use", + "id": "toolu_builtin_read", + "name": "Read", + "input": {"file_path": "/data/workspace/claude-tool.txt"} + }], + "model": "claude-sonnet-4-5", + "stop_reason": "tool_use", + "usage": {"input_tokens": 12, "output_tokens": 4} + } + }, + { + "name": "claude_builtin_read_result", + "match": { + "match_type": "contains", + "message": { + "role": "user", + "content": [{"type": "tool_result", "tool_use_id": "toolu_builtin_read"}] + } + }, + "response": { + "id": "msg_builtin_done", + "type": "message", + "role": "assistant", + "content": [{"type": "text", "text": "CLAUDE_BUILTIN_TOOLS_DONE"}], + "model": "claude-sonnet-4-5", + "stop_reason": "end_turn", + "usage": {"input_tokens": 14, "output_tokens": 4} + } + }, + { + "name": "claude_builtin_auxiliary", + "match": { + "match_type": "contains", + "message": { + "role": "user", + "content": [{"type": "text", "text": ""}] + } + }, + "response": { + "id": "msg_builtin_auxiliary", + "type": "message", + "role": "assistant", + "content": [{"type": "text", "text": "OK"}], + "model": "claude-sonnet-4-5", + "stop_reason": "end_turn", + "usage": {"input_tokens": 2, "output_tokens": 1} + } + } + ] +} diff --git a/go/core/test/e2e/mocks/invoke_claude_local_subagent.json b/go/core/test/e2e/mocks/invoke_claude_local_subagent.json new file mode 100644 index 000000000..747873b34 --- /dev/null +++ b/go/core/test/e2e/mocks/invoke_claude_local_subagent.json @@ -0,0 +1,89 @@ +{ + "anthropic": [ + { + "name": "claude_subagent_start", + "match": { + "match_type": "contains", + "message": { + "role": "user", + "content": [{"type": "text", "text": "Delegate this request to the specialist."}] + } + }, + "response": { + "id": "msg_subagent_route", + "type": "message", + "role": "assistant", + "content": [{ + "type": "tool_use", + "id": "toolu_local_specialist", + "name": "Agent", + "input": { + "description": "Run the local specialist", + "prompt": "Return the specialist marker to the parent.", + "subagent_type": "specialist" + } + }], + "model": "claude-sonnet-4-5", + "stop_reason": "tool_use", + "usage": {"input_tokens": 10, "output_tokens": 4} + } + }, + { + "name": "claude_subagent_child", + "match": { + "match_type": "contains", + "message": { + "role": "user", + "content": [{"type": "text", "text": "Return the specialist marker to the parent."}] + } + }, + "response": { + "id": "msg_subagent_child", + "type": "message", + "role": "assistant", + "content": [{"type": "text", "text": "CLAUDE_SUBAGENT_CHILD"}], + "model": "claude-sonnet-4-5", + "stop_reason": "end_turn", + "usage": {"input_tokens": 8, "output_tokens": 3} + } + }, + { + "name": "claude_subagent_result", + "match": { + "match_type": "contains", + "message": { + "role": "user", + "content": [{"type": "tool_result", "tool_use_id": "toolu_local_specialist"}] + } + }, + "response": { + "id": "msg_subagent_final", + "type": "message", + "role": "assistant", + "content": [{"type": "text", "text": "CLAUDE_SUBAGENT_FINAL"}], + "model": "claude-sonnet-4-5", + "stop_reason": "end_turn", + "usage": {"input_tokens": 12, "output_tokens": 3} + } + }, + { + "name": "claude_subagent_auxiliary", + "match": { + "match_type": "contains", + "message": { + "role": "user", + "content": [{"type": "text", "text": ""}] + } + }, + "response": { + "id": "msg_subagent_auxiliary", + "type": "message", + "role": "assistant", + "content": [{"type": "text", "text": "OK"}], + "model": "claude-sonnet-4-5", + "stop_reason": "end_turn", + "usage": {"input_tokens": 2, "output_tokens": 1} + } + } + ] +} diff --git a/go/core/v2/controller/collections_test.go b/go/core/v2/controller/collections_test.go index c2657718b..a4d92d53c 100644 --- a/go/core/v2/controller/collections_test.go +++ b/go/core/v2/controller/collections_test.go @@ -119,6 +119,50 @@ func TestReconciliationCollectionsCompileAndObserveRevision(t *testing.T) { }) } +func TestClaudeReconciliationCompilesActorTemplate(t *testing.T) { + stop := make(chan struct{}) + t.Cleanup(func() { close(stop) }) + opts := krt.NewOptionsBuilder(stop, "test-claude", nil) + + 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"}, + } + claudeHarness := harness("team-a", "claude", map[string]string{"runtime": "claude"}) + claudeHarness.UID = "harness-uid" + claudeHarness.Spec.Claude = &kagentv1alpha3.ClaudeHarness{} + claudeHarness.Spec.Workload.Image = "example.com/claude@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + claudeHarness.Spec.Substrate = kagentv1alpha3.HarnessSubstratePolicy{ + WorkerPoolRef: corev1.LocalObjectReference{Name: "default"}, SnapshotPolicy: kagentv1alpha3.HarnessSnapshotPolicy{Location: "snapshots"}, + } + model := &kagentv1alpha3.ModelConfig{ObjectMeta: metav1.ObjectMeta{Namespace: "team-a", Name: "model", UID: "model-uid"}, Spec: kagentv1alpha3.ModelConfigSpec{ + Provider: kagentv1alpha3.ModelProviderAnthropic, Model: "claude-sonnet-4-5", APIKeySecret: "model-auth", APIKeySecretKey: "api-key", + }} + secret := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Namespace: "team-a", Name: "model-auth", UID: "secret-uid"}, Data: map[string][]byte{"api-key": []byte("secret")}} + templates := krt.NewStaticCollection(nil, []*kagentv1alpha3.AgentTemplate{template}, opts.WithName("AgentTemplates")...) + pairs := newPairCollection(templates, krt.NewStaticCollection(nil, []*kagentv1alpha3.Harness{claudeHarness}, opts.WithName("Harnesses")...), opts) + reconciliations := newPairReconciliations( + pairs, templates, + krt.NewStaticCollection(nil, []*kagentv1alpha3.ModelConfig{model}, opts.WithName("ModelConfigs")...), + krt.NewStaticCollection[*kagentv1alpha3.RemoteMCPServer](nil, nil, opts.WithName("RemoteMCPServers")...), + krt.NewStaticCollection[*corev1.ConfigMap](nil, nil, opts.WithName("ConfigMaps")...), + krt.NewStaticCollection(nil, []*corev1.Secret{secret}, opts.WithName("Secrets")...), + krt.NewStaticCollection(nil, []*atev1alpha1.WorkerPool{{ObjectMeta: metav1.ObjectMeta{Namespace: "team-a", Name: "default"}}}, opts.WithName("WorkerPools")...), + krt.NewStaticCollection[*atev1alpha1.ActorTemplate](nil, nil, opts.WithName("ActorTemplates")...), opts, + ) + waitFor(t, func() bool { + states := reconciliations.List() + return len(states) == 1 && states[0].Failure == nil && states[0].DesiredActorTemplate != nil + }) + state := reconciliations.List()[0] + if state.Revision == nil || state.Revision.Environment[0].Name != "ANTHROPIC_API_KEY" || state.Revision.Environment[0].Value != "secret" { + t.Fatalf("Claude revision environment = %#v", state.Revision) + } + if state.DesiredActorTemplate.Spec.Containers[0].Readyz.HTTPGet.Port != 8081 { + t.Fatalf("Claude ActorTemplate readiness = %#v", state.DesiredActorTemplate.Spec.Containers[0].Readyz) + } +} + func TestReconciliationTracksSharedAgentTemplate(t *testing.T) { stop := make(chan struct{}) t.Cleanup(func() { close(stop) }) diff --git a/go/core/v2/controller/reconciler.go b/go/core/v2/controller/reconciler.go index b3637b205..b19550a83 100644 --- a/go/core/v2/controller/reconciler.go +++ b/go/core/v2/controller/reconciler.go @@ -13,6 +13,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" + claudetranslator "github.com/kagent-dev/kagent/go/core/v2/translator/claude" kagenttranslator "github.com/kagent-dev/kagent/go/core/v2/translator/kagent" "istio.io/istio/pkg/kube/controllers" "istio.io/istio/pkg/kube/krt" @@ -65,6 +66,7 @@ func newPairReconciliations( } revision, err := v2translator.NewCompiler(reader, map[v2translator.HarnessType]v2translator.HarnessCompiler{ v2translator.HarnessTypeKagent: kagenttranslator.NewCompiler(reader), + v2translator.HarnessTypeClaude: claudetranslator.NewCompiler(reader), }).CompileAgentTemplate(context.Background(), pair.Harness, pair.AgentTemplate) if err != nil { condition, reason := kagentv1alpha3.AgentTemplateConditionResolvedRefs, "ReferenceResolutionFailed" diff --git a/go/core/v2/translator/claude/compiler.go b/go/core/v2/translator/claude/compiler.go new file mode 100644 index 000000000..193f6375d --- /dev/null +++ b/go/core/v2/translator/claude/compiler.go @@ -0,0 +1,452 @@ +// Package claude compiles resolved v1alpha3 inputs for the native Claude +// Harness adapter. +package claude + +import ( + "context" + "crypto/sha256" + "encoding/json" + "fmt" + "net/url" + "reflect" + "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" + claudeconfig "github.com/kagent-dev/kagent/go/harness/claude/config" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/types" +) + +const ( + useBedrockEnv = "CLAUDE_CODE_USE_BEDROCK" + useVertexEnv = "CLAUDE_CODE_USE_VERTEX" + awsRegionEnv = "AWS_REGION" + awsAccessKeyEnv = "AWS_ACCESS_KEY_ID" + awsSecretKeyEnv = "AWS_SECRET_ACCESS_KEY" + awsSessionTokenEnv = "AWS_SESSION_TOKEN" + awsBedrockTokenEnv = "AWS_BEARER_TOKEN_BEDROCK" + anthropicAPIKeyEnv = "ANTHROPIC_API_KEY" + anthropicBaseURLEnv = "ANTHROPIC_BASE_URL" + vertexProjectEnv = "ANTHROPIC_VERTEX_PROJECT_ID" + vertexRegionEnv = "CLOUD_ML_REGION" + sandboxEnv = "IS_SANDBOX" + preResponseFlushEnv = "KAGENT_PRE_RESPONSE_TRACE_FLUSH" +) + +var ownedEnvironment = map[string]struct{}{ + useBedrockEnv: {}, useVertexEnv: {}, awsRegionEnv: {}, awsAccessKeyEnv: {}, + awsSecretKeyEnv: {}, awsSessionTokenEnv: {}, awsBedrockTokenEnv: {}, + anthropicAPIKeyEnv: {}, anthropicBaseURLEnv: {}, vertexProjectEnv: {}, vertexRegionEnv: {}, + sandboxEnv: {}, claudeconfig.GoogleCredentialsJSONEnvName: {}, +} + +type Compiler struct{ kube v2translator.Reader } + +func NewCompiler(kube v2translator.Reader) *Compiler { return &Compiler{kube: kube} } + +func (c *Compiler) Compile(ctx context.Context, input *v2translator.HarnessInput) (*v2translator.Revision, error) { + if input == nil || input.Harness == nil || input.Root == nil || input.Root.Template == nil || input.Root.ModelConfig == nil { + return nil, fmt.Errorf("Claude compiler requires a resolved Harness, AgentTemplate, and ModelConfig") + } + if len(input.Root.MCPTools) != 0 { + return nil, v2translator.NewValidationError("Claude external MCP tools are not supported yet") + } + if len(input.Root.Template.Spec.Skills) != 0 || len(input.Root.Template.Spec.Plugins) != 0 { + return nil, v2translator.NewValidationError("Claude skills and plugins are not supported yet") + } + model := input.Root.ModelConfig + if strings.TrimSpace(model.Spec.Model) == "" { + return nil, v2translator.NewValidationError("Claude ModelConfig model is required") + } + if len(model.Spec.DefaultHeaders) != 0 || !model.Spec.TLS.IsEmpty() || model.Spec.APIKeyPassthrough { + return nil, v2translator.NewValidationError("Claude does not support ModelConfig defaultHeaders, TLS, or apiKeyPassthrough yet") + } + + providerEnvironment, egress, err := c.provider(ctx, model) + if err != nil { + return nil, err + } + environment := append([]corev1.EnvVar(nil), providerEnvironment...) + for _, variable := range input.Harness.Spec.Env { + if _, reserved := ownedEnvironment[variable.Name]; reserved { + return nil, v2translator.NewValidationError("Harness env %q conflicts with Claude's compiled provider configuration", variable.Name) + } + envVar := corev1.EnvVar{Name: variable.Name} + if variable.Value != nil { + envVar.Value = *variable.Value + } else { + envVar.ValueFrom = &corev1.EnvVarSource{SecretKeyRef: variable.CredentialRef.DeepCopy()} + } + environment = append(environment, envVar) + } + // Substrate v0.0.20 runs Actor processes as root even when the image declares + // a non-root USER. Claude otherwise rejects --dangerously-skip-permissions. + environment = append(environment, + corev1.EnvVar{Name: sandboxEnv, Value: "1"}, + corev1.EnvVar{Name: preResponseFlushEnv, Value: "true"}, + ) + + localAgents, err := c.compileLocalAgents(input.Root) + if err != nil { + return nil, err + } + config := claudeconfig.Production(model.Spec.Model, input.Root.Instruction) + config.Agents = localAgents + if err := config.Validate(); err != nil { + return nil, v2translator.NewValidationError("invalid compiled Claude configuration: %v", err) + } + configJSON, err := json.Marshal(config) + if err != nil { + return nil, fmt.Errorf("marshal Claude config: %w", err) + } + cardJSON, err := json.Marshal(agentTemplateCard(input.Root.Template)) + if err != nil { + return nil, fmt.Errorf("marshal Claude agent card: %w", err) + } + provenance, err := c.buildProvenance(ctx, input, environment) + if err != nil { + return nil, fmt.Errorf("build Claude revision provenance: %w", err) + } + environment, err = c.resolveEnvironment(ctx, input.Harness.Namespace, environment) + if err != nil { + return nil, fmt.Errorf("resolve Claude runtime environment: %w", err) + } + + template, harness := input.Root.Template, input.Harness + 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: egress, + }, nil +} + +func (c *Compiler) compileLocalAgents(root *v2translator.AgentInput) (map[string]claudeconfig.Agent, error) { + if len(root.Shared) == 0 { + return nil, nil + } + agents := make(map[string]claudeconfig.Agent, len(root.Shared)) + for _, binding := range root.Shared { + child := binding.Agent + if child == nil || child.Template == nil || child.ModelConfig == nil { + return nil, fmt.Errorf("Claude local agent %q is not fully resolved", binding.Name) + } + if len(child.MCPTools) != 0 || len(child.Shared) != 0 || len(child.Template.Spec.Tools) != 0 { + return nil, v2translator.NewValidationError("Claude local agent %q cannot contain MCP or nested agent tools yet", binding.Name) + } + if len(child.Template.Spec.Skills) != 0 || len(child.Template.Spec.Plugins) != 0 { + return nil, v2translator.NewValidationError("Claude local agent %q cannot contain skills or plugins yet", binding.Name) + } + if strings.TrimSpace(child.ModelConfig.Spec.Model) == "" { + return nil, v2translator.NewValidationError("Claude local agent %q ModelConfig model is required", binding.Name) + } + if !sameProviderConfiguration(root.ModelConfig.Spec, child.ModelConfig.Spec) { + return nil, v2translator.NewValidationError("Claude local agent %q must use the root agent's provider and authentication configuration", binding.Name) + } + if _, exists := agents[binding.Name]; exists { + return nil, v2translator.NewValidationError("duplicate Claude local agent name %q", binding.Name) + } + agents[binding.Name] = claudeconfig.Agent{ + Description: binding.Description, + Prompt: child.Instruction, + Model: child.ModelConfig.Spec.Model, + } + } + return agents, nil +} + +func sameProviderConfiguration(root, child v1alpha3.ModelConfigSpec) bool { + root.Model, child.Model = "", "" + return reflect.DeepEqual(root, child) +} + +func (c *Compiler) provider(ctx context.Context, model *v1alpha3.ModelConfig) ([]corev1.EnvVar, []string, error) { + switch model.Spec.Provider { + case v1alpha3.ModelProviderAnthropic: + var baseURL string + if model.Spec.Anthropic != nil { + options := *model.Spec.Anthropic + baseURL = strings.TrimSpace(options.BaseURL) + options.BaseURL = "" + if !reflect.DeepEqual(options, v1alpha3.AnthropicConfig{}) { + return nil, nil, v2translator.NewValidationError("Claude does not support Anthropic provider options beyond baseUrl yet") + } + } + if err := c.requireSecretKey(ctx, model, model.Spec.APIKeySecret, model.Spec.APIKeySecretKey, false); err != nil { + return nil, nil, err + } + environment := []corev1.EnvVar{secretEnvironment(anthropicAPIKeyEnv, model.Spec.APIKeySecret, model.Spec.APIKeySecretKey)} + egress := []string{"api.anthropic.com"} + if baseURL != "" { + hostname, err := anthropicBaseURLHostname(baseURL) + if err != nil { + return nil, nil, err + } + environment = append(environment, corev1.EnvVar{Name: anthropicBaseURLEnv, Value: baseURL}) + egress = []string{hostname} + } + return environment, egress, nil + + case v1alpha3.ModelProviderBedrock: + if model.Spec.Bedrock == nil || strings.TrimSpace(model.Spec.Bedrock.Region) == "" { + return nil, nil, v2translator.NewValidationError("Claude Bedrock requires bedrock.region") + } + options := *model.Spec.Bedrock + options.Region = "" + // "5m" is the CRD default and matches Claude Code's native cache TTL. + if options.CacheTTL == "5m" { + options.CacheTTL = "" + } + if !reflect.DeepEqual(options, v1alpha3.BedrockConfig{}) { + return nil, nil, v2translator.NewValidationError("Claude does not support Bedrock provider options beyond region yet") + } + if model.Spec.APIKeySecret == "" { + return nil, nil, v2translator.NewValidationError("Claude Bedrock requires apiKeySecret with AWS credentials") + } + if model.Spec.APIKeySecretKey != "" { + return nil, nil, v2translator.NewValidationError("Claude Bedrock reads standard AWS keys from apiKeySecret; apiKeySecretKey must be empty") + } + secret, err := c.secret(ctx, model.Namespace, model.Spec.APIKeySecret) + if err != nil { + return nil, nil, err + } + environment := []corev1.EnvVar{{Name: useBedrockEnv, Value: "1"}, {Name: awsRegionEnv, Value: model.Spec.Bedrock.Region}} + if value := secret.Data[awsBedrockTokenEnv]; len(value) != 0 { + environment = append(environment, secretEnvironment(awsBedrockTokenEnv, secret.Name, awsBedrockTokenEnv)) + } else { + for _, key := range []string{awsAccessKeyEnv, awsSecretKeyEnv} { + if len(secret.Data[key]) == 0 { + return nil, nil, v2translator.NewValidationError("Claude Bedrock Secret %q requires %s and %s, or %s", secret.Name, awsAccessKeyEnv, awsSecretKeyEnv, awsBedrockTokenEnv) + } + environment = append(environment, secretEnvironment(key, secret.Name, key)) + } + if len(secret.Data[awsSessionTokenEnv]) != 0 { + environment = append(environment, secretEnvironment(awsSessionTokenEnv, secret.Name, awsSessionTokenEnv)) + } + } + return environment, []string{"bedrock-runtime." + model.Spec.Bedrock.Region + ".amazonaws.com"}, nil + + case v1alpha3.ModelProviderAnthropicVertexAI: + if model.Spec.AnthropicVertexAI == nil || strings.TrimSpace(model.Spec.AnthropicVertexAI.ProjectID) == "" || strings.TrimSpace(model.Spec.AnthropicVertexAI.Location) == "" { + return nil, nil, v2translator.NewValidationError("Claude Vertex requires anthropicVertexAI.projectID and location") + } + options := *model.Spec.AnthropicVertexAI + options.ProjectID, options.Location = "", "" + if !reflect.DeepEqual(options, v1alpha3.AnthropicVertexAIConfig{}) { + return nil, nil, v2translator.NewValidationError("Claude does not support AnthropicVertexAI provider options beyond projectID and location yet") + } + if err := c.requireGoogleCredentials(ctx, model); err != nil { + return nil, nil, err + } + cfg := model.Spec.AnthropicVertexAI + return []corev1.EnvVar{ + {Name: useVertexEnv, Value: "1"}, {Name: vertexProjectEnv, Value: cfg.ProjectID}, {Name: vertexRegionEnv, Value: cfg.Location}, + secretEnvironment(claudeconfig.GoogleCredentialsJSONEnvName, model.Spec.APIKeySecret, model.Spec.APIKeySecretKey), + }, []string{vertexHostname(cfg.Location), "oauth2.googleapis.com"}, nil + default: + return nil, nil, v2translator.NewValidationError("Claude does not support ModelConfig provider %q", model.Spec.Provider) + } +} + +func anthropicBaseURLHostname(raw string) (string, error) { + parsed, err := url.Parse(raw) + if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Hostname() == "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" { + return "", v2translator.NewValidationError("Claude Anthropic baseUrl must be an absolute HTTP(S) URL without credentials, query, or fragment") + } + return parsed.Hostname(), nil +} + +func (c *Compiler) requireSecretKey(ctx context.Context, model *v1alpha3.ModelConfig, name, key string, requireJSON bool) error { + if name == "" || key == "" { + return v2translator.NewValidationError("Claude %s requires apiKeySecret and apiKeySecretKey", model.Spec.Provider) + } + secret, err := c.secret(ctx, model.Namespace, name) + if err != nil { + return err + } + value, ok := secret.Data[key] + if !ok || len(value) == 0 { + return v2translator.NewValidationError("Claude credential Secret %q does not contain a non-empty key %q", name, key) + } + if requireJSON && !json.Valid(value) { + return v2translator.NewValidationError("Claude Vertex credential Secret %q key %q must contain valid JSON", name, key) + } + return nil +} + +func (c *Compiler) requireGoogleCredentials(ctx context.Context, model *v1alpha3.ModelConfig) error { + if err := c.requireSecretKey(ctx, model, model.Spec.APIKeySecret, model.Spec.APIKeySecretKey, true); err != nil { + return err + } + secret, err := c.secret(ctx, model.Namespace, model.Spec.APIKeySecret) + if err != nil { + return err + } + var credentials struct { + Type string `json:"type"` + ProjectID string `json:"project_id"` + TokenURI string `json:"token_uri"` + } + if err := json.Unmarshal(secret.Data[model.Spec.APIKeySecretKey], &credentials); err != nil { + return v2translator.NewValidationError("decode Claude Vertex credentials: %v", err) + } + if credentials.Type != "service_account" { + return v2translator.NewValidationError("Claude Vertex credentials must be a service_account key in the first release") + } + if credentials.ProjectID != model.Spec.AnthropicVertexAI.ProjectID { + return v2translator.NewValidationError("Claude Vertex credential project_id must match anthropicVertexAI.projectID") + } + if credentials.TokenURI != "" { + parsed, err := url.Parse(credentials.TokenURI) + if err != nil || parsed.Scheme != "https" || parsed.Hostname() != "oauth2.googleapis.com" { + return v2translator.NewValidationError("Claude Vertex credential token_uri must use https://oauth2.googleapis.com") + } + } + return nil +} + +func (c *Compiler) secret(ctx context.Context, namespace, name string) (*corev1.Secret, error) { + secret := &corev1.Secret{} + if err := c.kube.Get(ctx, types.NamespacedName{Namespace: namespace, Name: name}, secret); err != nil { + return nil, fmt.Errorf("read Claude credential Secret %q: %w", name, err) + } + return secret, nil +} + +func secretEnvironment(environmentName, secretName, key string) corev1.EnvVar { + return corev1.EnvVar{Name: environmentName, ValueFrom: &corev1.EnvVarSource{SecretKeyRef: &corev1.SecretKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{Name: secretName}, Key: key, + }}} +} + +func vertexHostname(location string) string { + switch location { + case "global": + return "aiplatform.googleapis.com" + case "us", "eu": + return "aiplatform." + location + ".rep.googleapis.com" + default: + return location + "-aiplatform.googleapis.com" + } +} + +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"` +} + +func (c *Compiler) buildProvenance(ctx context.Context, input *v2translator.HarnessInput, environment []corev1.EnvVar) ([]byte, error) { + harness := input.Harness + entries := []provenanceEntry{objectProvenance(v1alpha3.GroupVersion.String(), "Harness", harness.Name, harness.UID, harness.Generation, harness.Spec)} + configMaps := map[string]struct{}{} + objects := map[string]struct{}{} + addObject := func(kind, name string, uid types.UID, generation int64, content any) { + identity := kind + "\x00" + name + if _, exists := objects[identity]; exists { + return + } + objects[identity] = struct{}{} + entries = append(entries, objectProvenance(v1alpha3.GroupVersion.String(), kind, name, uid, generation, content)) + } + var addAgent func(*v2translator.AgentInput) + addAgent = func(agent *v2translator.AgentInput) { + template, model := agent.Template, agent.ModelConfig + addObject("AgentTemplate", template.Name, template.UID, template.Generation, template.Spec) + addObject("ModelConfig", model.Name, model.UID, model.Generation, model.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 _, child := range agent.Shared { + addAgent(child.Agent) + } + } + addAgent(input.Root) + 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)) + } + seen := 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 := seen[identity]; ok { + continue + } + seen[identity] = struct{}{} + secret, err := c.secret(ctx, harness.Namespace, ref.Name) + if 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) + }) + return json.Marshal(entries) +} + +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[:])} +} + +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, err := c.secret(ctx, namespace, ref.Name) + if 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, resolved[i].ValueFrom = string(value), nil + } + return resolved, 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"}, + } +} + +var _ v2translator.HarnessCompiler = (*Compiler)(nil) diff --git a/go/core/v2/translator/claude/compiler_test.go b/go/core/v2/translator/claude/compiler_test.go new file mode 100644 index 000000000..85183364e --- /dev/null +++ b/go/core/v2/translator/claude/compiler_test.go @@ -0,0 +1,294 @@ +package claude + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "reflect" + "strings" + "testing" + + "github.com/kagent-dev/kagent/go/api/v1alpha3" + v2translator "github.com/kagent-dev/kagent/go/core/v2/translator" + claudeconfig "github.com/kagent-dev/kagent/go/harness/claude/config" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + schemev1 "k8s.io/client-go/kubernetes/scheme" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +const credentialValue = "credential-must-not-be-serialized" + +func TestCompileSupportedProviders(t *testing.T) { + tests := []struct { + name string + model v1alpha3.ModelConfigSpec + secretData map[string][]byte + wantEnv map[string]string + wantEgress []string + }{ + { + name: "Anthropic", + model: v1alpha3.ModelConfigSpec{Provider: v1alpha3.ModelProviderAnthropic, Model: "claude-sonnet-4-5", + APIKeySecret: "model-auth", APIKeySecretKey: "api-key"}, + secretData: map[string][]byte{"api-key": []byte(credentialValue)}, + wantEnv: map[string]string{anthropicAPIKeyEnv: credentialValue}, + wantEgress: []string{"api.anthropic.com"}, + }, + { + name: "Anthropic gateway", + model: v1alpha3.ModelConfigSpec{Provider: v1alpha3.ModelProviderAnthropic, Model: "claude-sonnet-4-5", + APIKeySecret: "model-auth", APIKeySecretKey: "api-key", + Anthropic: &v1alpha3.AnthropicConfig{BaseURL: "http://host.docker.internal:8090/anthropic"}}, + secretData: map[string][]byte{"api-key": []byte(credentialValue)}, + wantEnv: map[string]string{anthropicAPIKeyEnv: credentialValue, + anthropicBaseURLEnv: "http://host.docker.internal:8090/anthropic"}, + wantEgress: []string{"host.docker.internal"}, + }, + { + name: "Bedrock IAM", + model: v1alpha3.ModelConfigSpec{Provider: v1alpha3.ModelProviderBedrock, Model: "us.anthropic.claude-sonnet-4-5-20250929-v1:0", + APIKeySecret: "model-auth", Bedrock: &v1alpha3.BedrockConfig{Region: "us-east-1", CacheTTL: "5m"}}, + secretData: map[string][]byte{awsAccessKeyEnv: []byte("access"), awsSecretKeyEnv: []byte(credentialValue), awsSessionTokenEnv: []byte("session")}, + wantEnv: map[string]string{useBedrockEnv: "1", awsRegionEnv: "us-east-1", awsAccessKeyEnv: "access", + awsSecretKeyEnv: credentialValue, awsSessionTokenEnv: "session"}, + wantEgress: []string{"bedrock-runtime.us-east-1.amazonaws.com"}, + }, + { + name: "Bedrock API key", + model: v1alpha3.ModelConfigSpec{Provider: v1alpha3.ModelProviderBedrock, Model: "us.anthropic.claude-sonnet-4-5-20250929-v1:0", + APIKeySecret: "model-auth", Bedrock: &v1alpha3.BedrockConfig{Region: "us-west-2"}}, + secretData: map[string][]byte{awsBedrockTokenEnv: []byte(credentialValue)}, + wantEnv: map[string]string{useBedrockEnv: "1", awsRegionEnv: "us-west-2", awsBedrockTokenEnv: credentialValue}, + wantEgress: []string{"bedrock-runtime.us-west-2.amazonaws.com"}, + }, + { + name: "Anthropic Vertex AI", + model: v1alpha3.ModelConfigSpec{Provider: v1alpha3.ModelProviderAnthropicVertexAI, Model: "claude-sonnet-4-5@20250929", + APIKeySecret: "model-auth", APIKeySecretKey: "credentials.json", + AnthropicVertexAI: &v1alpha3.AnthropicVertexAIConfig{BaseVertexAIConfig: v1alpha3.BaseVertexAIConfig{ProjectID: "project", Location: "us-east5"}}}, + secretData: map[string][]byte{"credentials.json": []byte(`{"type":"service_account","project_id":"project","token_uri":"https://oauth2.googleapis.com/token","private_key":"` + credentialValue + `"}`)}, + wantEnv: map[string]string{useVertexEnv: "1", vertexProjectEnv: "project", vertexRegionEnv: "us-east5", + claudeconfig.GoogleCredentialsJSONEnvName: `{"type":"service_account","project_id":"project","token_uri":"https://oauth2.googleapis.com/token","private_key":"` + credentialValue + `"}`}, + wantEgress: []string{"us-east5-aiplatform.googleapis.com", "oauth2.googleapis.com"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + input, reader := testInput(t, tt.model, tt.secretData) + revision, err := NewCompiler(reader).Compile(context.Background(), input) + if err != nil { + t.Fatal(err) + } + var config claudeconfig.Config + if err := json.Unmarshal(revision.ConfigJSON, &config); err != nil { + t.Fatal(err) + } + if config.Model != tt.model.Model || config.AppendSystemPrompt != "help carefully" || config.ExpectedClaudeVersion != claudeconfig.PinnedClaudeVersion { + t.Fatalf("compiled config = %#v", config) + } + gotEnvironment := map[string]string{} + for _, variable := range revision.Environment { + if variable.ValueFrom != nil { + t.Fatalf("unresolved environment variable = %#v", variable) + } + gotEnvironment[variable.Name] = variable.Value + } + for name, value := range tt.wantEnv { + if gotEnvironment[name] != value { + t.Errorf("environment[%s] = %q, want %q", name, gotEnvironment[name], value) + } + } + if gotEnvironment[sandboxEnv] != "1" { + t.Errorf("environment[%s] = %q, want %q", sandboxEnv, gotEnvironment[sandboxEnv], "1") + } + if !reflect.DeepEqual(revision.EgressDestinations, tt.wantEgress) { + t.Errorf("egress = %v", revision.EgressDestinations) + } + if bytes.Contains(revision.ConfigJSON, []byte(credentialValue)) || bytes.Contains(revision.Provenance, []byte(credentialValue)) { + t.Fatal("compiled config or provenance contains credential material") + } + if !bytes.Contains(revision.Provenance, []byte(`"kind":"Secret"`)) { + t.Fatalf("provenance omits credential Secret: %s", revision.Provenance) + } + + again, err := NewCompiler(reader).Compile(context.Background(), input) + if err != nil || !reflect.DeepEqual(revision, again) { + t.Fatalf("compilation is not deterministic: %v", err) + } + }) + } +} + +func TestCompileRejectsUnsupportedConfiguration(t *testing.T) { + tests := []struct { + name string + model v1alpha3.ModelConfigSpec + }{ + {name: "provider", model: v1alpha3.ModelConfigSpec{Provider: v1alpha3.ModelProviderOpenAI, Model: "gpt"}}, + {name: "passthrough", model: v1alpha3.ModelConfigSpec{Provider: v1alpha3.ModelProviderAnthropic, Model: "claude", APIKeyPassthrough: true}}, + {name: "headers", model: v1alpha3.ModelConfigSpec{Provider: v1alpha3.ModelProviderAnthropic, Model: "claude", DefaultHeaders: map[string]string{"x": "y"}}}, + {name: "Anthropic options", model: v1alpha3.ModelConfigSpec{Provider: v1alpha3.ModelProviderAnthropic, Model: "claude", Anthropic: &v1alpha3.AnthropicConfig{Temperature: "0.5"}}}, + {name: "Anthropic relative base URL", model: v1alpha3.ModelConfigSpec{Provider: v1alpha3.ModelProviderAnthropic, Model: "claude", APIKeySecret: "model-auth", APIKeySecretKey: "api-key", Anthropic: &v1alpha3.AnthropicConfig{BaseURL: "/v1"}}}, + {name: "Anthropic base URL credentials", model: v1alpha3.ModelConfigSpec{Provider: v1alpha3.ModelProviderAnthropic, Model: "claude", APIKeySecret: "model-auth", APIKeySecretKey: "api-key", Anthropic: &v1alpha3.AnthropicConfig{BaseURL: "https://user:password@example.com"}}}, + {name: "Bedrock options", model: v1alpha3.ModelConfigSpec{Provider: v1alpha3.ModelProviderBedrock, Model: "claude", APIKeySecret: "model-auth", Bedrock: &v1alpha3.BedrockConfig{Region: "us-east-1", PromptCaching: true}}}, + {name: "Vertex options", model: v1alpha3.ModelConfigSpec{Provider: v1alpha3.ModelProviderAnthropicVertexAI, Model: "claude", APIKeySecret: "model-auth", APIKeySecretKey: "credentials.json", AnthropicVertexAI: &v1alpha3.AnthropicVertexAIConfig{BaseVertexAIConfig: v1alpha3.BaseVertexAIConfig{ProjectID: "project", Location: "global", Temperature: "0.5"}}}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + input, reader := testInput(t, tt.model, map[string][]byte{"api-key": []byte("secret"), awsAccessKeyEnv: []byte("access"), awsSecretKeyEnv: []byte("secret"), "credentials.json": []byte(`{"type":"service_account"}`)}) + _, err := NewCompiler(reader).Compile(context.Background(), input) + var validation *v2translator.ValidationError + if !errors.As(err, &validation) { + t.Fatalf("Compile() error = %v, want validation error", err) + } + }) + } +} + +func TestCompileRejectsProviderOwnedHarnessEnvironment(t *testing.T) { + model := v1alpha3.ModelConfigSpec{ + Provider: v1alpha3.ModelProviderAnthropic, Model: "claude-sonnet-4-5", + APIKeySecret: "model-auth", APIKeySecretKey: "api-key", + } + input, reader := testInput(t, model, map[string][]byte{"api-key": []byte("secret")}) + value := "http://mock.example.com" + input.Harness.Spec.Env = []v1alpha3.HarnessEnvVar{{Name: anthropicBaseURLEnv, Value: &value}} + _, err := NewCompiler(reader).Compile(context.Background(), input) + var validation *v2translator.ValidationError + if !errors.As(err, &validation) { + t.Fatalf("Compile() error = %v, want validation error", err) + } +} + +func TestCompileLocalSharedAgent(t *testing.T) { + modelSpec := v1alpha3.ModelConfigSpec{ + Provider: v1alpha3.ModelProviderAnthropic, Model: "claude-root", + APIKeySecret: "model-auth", APIKeySecretKey: "api-key", + Anthropic: &v1alpha3.AnthropicConfig{BaseURL: "https://gateway.example.com/anthropic"}, + } + input, reader := testInput(t, modelSpec, map[string][]byte{"api-key": []byte("secret")}) + childModelSpec := modelSpec + childModelSpec.Model = "claude-specialist" + child := &v2translator.AgentInput{ + Template: &v1alpha3.AgentTemplate{ + ObjectMeta: metav1.ObjectMeta{Name: "specialist-template", Namespace: "test", UID: "child-template-uid"}, + Spec: v1alpha3.AgentTemplateSpec{ + ModelConfig: v1alpha3.AgentTemplateLocalReference{Name: "child-model"}, + Description: "template description", SystemPrompt: "specialize", + }, + }, + ModelConfig: &v1alpha3.ModelConfig{ + ObjectMeta: metav1.ObjectMeta{Name: "child-model", Namespace: "test", UID: "child-model-uid"}, + Spec: childModelSpec, + }, + Instruction: "Return the specialist marker.", + } + input.Root.Template.Spec.Tools = []v1alpha3.ToolBinding{{Agent: &v1alpha3.AgentToolBinding{ + Name: "specialist", Description: "Handles specialist requests", + TemplateRef: v1alpha3.AgentTemplateLocalReference{Name: child.Template.Name}, + Isolation: v1alpha3.AgentToolIsolationShared, + }}} + input.Root.Shared = []v2translator.AgentInputBinding{{ + Name: "specialist", Description: "Handles specialist requests", Agent: child, + }} + + revision, err := NewCompiler(reader).Compile(context.Background(), input) + if err != nil { + t.Fatal(err) + } + var cfg claudeconfig.Config + if err := json.Unmarshal(revision.ConfigJSON, &cfg); err != nil { + t.Fatal(err) + } + want := claudeconfig.Agent{ + Description: "Handles specialist requests", Prompt: "Return the specialist marker.", Model: "claude-specialist", + } + if !reflect.DeepEqual(cfg.Agents, map[string]claudeconfig.Agent{"specialist": want}) { + t.Fatalf("compiled agents = %#v, want specialist %#v", cfg.Agents, want) + } + for _, identity := range []string{`"name":"specialist-template"`, `"name":"child-model"`} { + if !bytes.Contains(revision.Provenance, []byte(identity)) { + t.Fatalf("provenance %s does not contain %s", revision.Provenance, identity) + } + } +} + +func TestCompileRejectsUnsupportedLocalAgentConfiguration(t *testing.T) { + modelSpec := v1alpha3.ModelConfigSpec{ + Provider: v1alpha3.ModelProviderAnthropic, Model: "claude-root", + APIKeySecret: "model-auth", APIKeySecretKey: "api-key", + } + tests := []struct { + name string + mutate func(*v2translator.AgentInputBinding) + want string + }{ + {name: "provider configuration", mutate: func(binding *v2translator.AgentInputBinding) { + binding.Agent.ModelConfig.Spec.APIKeySecret = "different-auth" + }, want: "root agent's provider"}, + {name: "nested tools", mutate: func(binding *v2translator.AgentInputBinding) { + binding.Agent.Template.Spec.Tools = []v1alpha3.ToolBinding{{MCP: &v1alpha3.MCPToolBinding{}}} + }, want: "cannot contain MCP or nested agent tools"}, + {name: "skills", mutate: func(binding *v2translator.AgentInputBinding) { + binding.Agent.Template.Spec.Skills = []v1alpha3.AgentTemplateSkill{{Name: "review"}} + }, want: "cannot contain skills or plugins"}, + {name: "invalid binding name", mutate: func(binding *v2translator.AgentInputBinding) { + binding.Name = "not valid" + }, want: "invalid compiled Claude configuration"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + input, reader := testInput(t, modelSpec, map[string][]byte{"api-key": []byte("secret")}) + childSpec := modelSpec + childSpec.Model = "claude-child" + binding := v2translator.AgentInputBinding{ + Name: "specialist", Description: "Handles specialist requests", + Agent: &v2translator.AgentInput{ + Template: &v1alpha3.AgentTemplate{ + ObjectMeta: metav1.ObjectMeta{Name: "child", Namespace: "test"}, + Spec: v1alpha3.AgentTemplateSpec{ModelConfig: v1alpha3.AgentTemplateLocalReference{Name: "child-model"}}, + }, + ModelConfig: &v1alpha3.ModelConfig{ObjectMeta: metav1.ObjectMeta{Name: "child-model", Namespace: "test"}, Spec: childSpec}, + Instruction: "specialize", + }, + } + tt.mutate(&binding) + input.Root.Shared = []v2translator.AgentInputBinding{binding} + _, err := NewCompiler(reader).Compile(context.Background(), input) + if err == nil || !strings.Contains(err.Error(), tt.want) { + t.Fatalf("Compile() error = %v, want containing %q", err, tt.want) + } + }) + } +} + +func testInput(t *testing.T, modelSpec v1alpha3.ModelConfigSpec, secretData map[string][]byte) (*v2translator.HarnessInput, v2translator.Reader) { + t.Helper() + if err := v1alpha3.AddToScheme(schemev1.Scheme); err != nil { + t.Fatal(err) + } + harness := &v1alpha3.Harness{ObjectMeta: metav1.ObjectMeta{Name: "claude", Namespace: "test", UID: "harness-uid"}, Spec: v1alpha3.HarnessSpec{ + Claude: &v1alpha3.ClaudeHarness{}, Workload: v1alpha3.HarnessWorkload{Image: "example.com/claude@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}, + 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", + }} + 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} + kube := fake.NewClientBuilder().WithScheme(schemev1.Scheme).WithObjects(secret).Build() + reader := testReader{kube} + return &v2translator.HarnessInput{Harness: harness, Root: &v2translator.AgentInput{Template: template, ModelConfig: model, Instruction: "help carefully"}}, reader +} + +type testReader struct{ client.Client } + +func (r testReader) Get(ctx context.Context, key types.NamespacedName, object runtime.Object) error { + return r.Client.Get(ctx, key, object.(client.Object)) +} diff --git a/go/harness/claude/Dockerfile b/go/harness/claude/Dockerfile new file mode 100644 index 000000000..048d80316 --- /dev/null +++ b/go/harness/claude/Dockerfile @@ -0,0 +1,44 @@ +ARG BASE_IMAGE_REGISTRY=cgr.dev +ARG BUILDPLATFORM +FROM --platform=$BUILDPLATFORM ${BASE_IMAGE_REGISTRY}/chainguard/go:latest AS builder +ARG TARGETARCH +ARG TARGETOS + +WORKDIR /workspace +COPY go.mod go.sum ./ +RUN --mount=type=cache,target=/root/go/pkg/mod,rw \ + --mount=type=cache,target=/root/.cache/go-build,rw \ + go mod download +COPY api/ api/ +COPY adk/ adk/ +COPY harness/ harness/ +RUN --mount=type=cache,target=/root/go/pkg/mod,rw \ + --mount=type=cache,target=/root/.cache/go-build,rw \ + CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH} \ + go build -trimpath -o /kagent-claude ./harness/claude/cmd/kagent-claude + +FROM node:22-trixie-slim +ARG CLAUDE_CODE_VERSION=2.1.217 +ARG VERSION + +ENV DISABLE_AUTOUPDATER=1 +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates git \ + && rm -rf /var/lib/apt/lists/* \ + && npm install -g "@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}" \ + && groupadd --system --gid 65532 kagent \ + && useradd --system --uid 65532 --gid 65532 --home-dir /data --shell /usr/sbin/nologin kagent \ + && mkdir -p /data/adapter /data/claude /data/generated /data/workspace \ + && chown -R 65532:65532 /data + +COPY --from=builder /kagent-claude /usr/local/bin/kagent-claude + +USER 65532:65532 +WORKDIR /data/workspace +EXPOSE 80 8081 + +LABEL org.opencontainers.image.source="https://github.com/kagent-dev/kagent" +LABEL org.opencontainers.image.description="Kagent Claude native Harness adapter" +LABEL org.opencontainers.image.version="$VERSION" + +ENTRYPOINT ["/usr/local/bin/kagent-claude"] diff --git a/go/harness/claude/cmd/kagent-claude/main.go b/go/harness/claude/cmd/kagent-claude/main.go new file mode 100644 index 000000000..2764b2c73 --- /dev/null +++ b/go/harness/claude/cmd/kagent-claude/main.go @@ -0,0 +1,91 @@ +// Command kagent-claude runs the Claude Harness runtime adapter. +package main + +import ( + "context" + "encoding/json" + "flag" + "fmt" + "log" + "os" + "strings" + "time" + + a2atype "github.com/a2aproject/a2a-go/v2/a2a" + "github.com/kagent-dev/kagent/go/adk/pkg/app" + "github.com/kagent-dev/kagent/go/harness/claude/internal/adapter" + "github.com/kagent-dev/kagent/go/harness/claude/internal/session" + runtimea2a "github.com/kagent-dev/kagent/go/harness/runtime/a2a" +) + +const ( + configEnv = "KAGENT_CONFIG_JSON" + agentCardEnv = "KAGENT_AGENT_CARD_JSON" + dataDir = "/data" + privatePort = "80" +) + +func main() { + check := flag.Bool("check", false, "validate configuration and Claude version, then exit") + flag.Parse() + if err := run(*check, os.Getenv, os.Environ()); err != nil { + log.Fatal(err) + } +} + +func run(check bool, getenv func(string) string, environment []string) error { + configJSON, err := requiredEnvironment(getenv, configEnv) + if err != nil { + return err + } + agentCardJSON, err := requiredEnvironment(getenv, agentCardEnv) + if err != nil { + return err + } + var card a2atype.AgentCard + if err := json.Unmarshal(agentCardJSON, &card); err != nil { + return fmt.Errorf("decode agent card: %w", err) + } + if strings.TrimSpace(card.Name) == "" { + return fmt.Errorf("agent card name is required") + } + + runner, err := adapter.New(adapter.Input{ + ConfigJSON: configJSON, + Workspace: dataDir + "/workspace", DurableDir: dataDir, + EphemeralDir: "/tmp/kagent-claude", + Environment: environment, + }) + if err != nil { + return fmt.Errorf("configure Claude Harness: %w", err) + } + validateCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := runner.Validate(validateCtx); err != nil { + return err + } + if check { + return nil + } + store, err := session.New(dataDir + "/adapter") + if err != nil { + return err + } + executor, err := runtimea2a.New(runner, store) + if err != nil { + return err + } + application, err := app.New(app.AppConfig{AgentCard: card, Port: privatePort, AppName: card.Name}, executor) + if err != nil { + return fmt.Errorf("construct private A2A app: %w", err) + } + return application.Run() +} + +func requiredEnvironment(getenv func(string) string, name string) ([]byte, error) { + value := strings.TrimSpace(getenv(name)) + if value == "" { + return nil, fmt.Errorf("%s is required", name) + } + return []byte(value), nil +} diff --git a/go/harness/claude/cmd/kagent-claude/main_test.go b/go/harness/claude/cmd/kagent-claude/main_test.go new file mode 100644 index 000000000..f4b552f5f --- /dev/null +++ b/go/harness/claude/cmd/kagent-claude/main_test.go @@ -0,0 +1,18 @@ +package main + +import ( + "strings" + "testing" +) + +func TestRequiredEnvironment(t *testing.T) { + values := map[string]string{configEnv: " {\"version\":2} "} + got, err := requiredEnvironment(func(name string) string { return values[name] }, configEnv) + if err != nil || string(got) != `{"version":2}` { + t.Fatalf("requiredEnvironment() = %q, %v", got, err) + } + _, err = requiredEnvironment(func(string) string { return " " }, agentCardEnv) + if err == nil || !strings.Contains(err.Error(), agentCardEnv) { + t.Fatalf("requiredEnvironment() error = %v", err) + } +} diff --git a/go/harness/claude/config/config.go b/go/harness/claude/config/config.go new file mode 100644 index 000000000..43f33975f --- /dev/null +++ b/go/harness/claude/config/config.go @@ -0,0 +1,107 @@ +// Package config defines the versioned, non-secret Claude Harness runtime +// configuration shared by its compiler and Actor entrypoint. +package config + +import ( + "encoding/json" + "fmt" + "io" + "regexp" + "strings" + "time" +) + +var agentNamePattern = regexp.MustCompile(`^[A-Za-z0-9_-]+$`) + +const ( + Version = 2 + PinnedClaudeVersion = "2.1.217" + GoogleCredentialsJSONEnvName = "KAGENT_CLAUDE_GOOGLE_CREDENTIALS_JSON" +) + +type Config struct { + Version int `json:"version"` + ClaudeExecutable string `json:"claude_executable"` + ExpectedClaudeVersion string `json:"expected_claude_version"` + StrictVersion bool `json:"strict_version"` + Model string `json:"model,omitempty"` + AppendSystemPrompt string `json:"append_system_prompt,omitempty"` + Agents map[string]Agent `json:"agents,omitempty"` + MaxEventBytes int `json:"max_event_bytes"` + MaxStderrBytes int `json:"max_stderr_bytes"` + InterruptGraceMillis int `json:"interrupt_grace_millis"` +} + +// Agent is one compiler-owned local Claude subagent passed through --agents. +// Permission mode, hooks, memory, isolation, and inline MCP are deliberately +// omitted from the supported contract. +type Agent struct { + Description string `json:"description"` + Prompt string `json:"prompt"` + Model string `json:"model,omitempty"` +} + +func Production(model, instruction string) Config { + return Config{ + Version: Version, ClaudeExecutable: "claude", + ExpectedClaudeVersion: PinnedClaudeVersion, StrictVersion: true, + Model: model, AppendSystemPrompt: instruction, + MaxEventBytes: 1 << 20, MaxStderrBytes: 64 << 10, + InterruptGraceMillis: 2000, + } +} + +func Parse(b []byte) (Config, error) { + var cfg Config + dec := json.NewDecoder(strings.NewReader(string(b))) + dec.DisallowUnknownFields() + if err := dec.Decode(&cfg); err != nil { + return Config{}, fmt.Errorf("decode config: %w", err) + } + if err := dec.Decode(&struct{}{}); err != io.EOF { + return Config{}, fmt.Errorf("decode config: trailing JSON value") + } + if err := cfg.Validate(); err != nil { + return Config{}, err + } + return cfg, nil +} + +func (c Config) Validate() error { + if c.Version != Version { + return fmt.Errorf("unsupported config version %d (want %d)", c.Version, Version) + } + if strings.TrimSpace(c.ClaudeExecutable) == "" { + return fmt.Errorf("claude_executable is required") + } + if c.StrictVersion && strings.TrimSpace(c.ExpectedClaudeVersion) == "" { + return fmt.Errorf("expected_claude_version is required when strict_version is enabled") + } + if c.MaxEventBytes <= 0 || c.MaxStderrBytes <= 0 || c.InterruptGraceMillis <= 0 { + return fmt.Errorf("event, stderr, and interrupt grace limits must be positive") + } + for name, agent := range c.Agents { + if !agentNamePattern.MatchString(name) { + return fmt.Errorf("Claude agent name %q must contain only letters, numbers, underscores, or hyphens", name) + } + if strings.TrimSpace(agent.Description) == "" || strings.TrimSpace(agent.Prompt) == "" { + return fmt.Errorf("Claude agent %q requires a non-empty description and prompt", name) + } + } + return nil +} + +func (c Config) AgentsJSON() (string, error) { + if len(c.Agents) == 0 { + return "", nil + } + raw, err := json.Marshal(c.Agents) + if err != nil { + return "", fmt.Errorf("encode Claude agents: %w", err) + } + return string(raw), nil +} + +func (c Config) InterruptGrace() time.Duration { + return time.Duration(c.InterruptGraceMillis) * time.Millisecond +} diff --git a/go/harness/claude/config/config_test.go b/go/harness/claude/config/config_test.go new file mode 100644 index 000000000..d16e1e8ff --- /dev/null +++ b/go/harness/claude/config/config_test.go @@ -0,0 +1,89 @@ +package config + +import ( + "reflect" + "strings" + "testing" +) + +func TestProductionRoundTrip(t *testing.T) { + cfg := Production("claude-test", "help") + if err := cfg.Validate(); err != nil { + t.Fatal(err) + } + if cfg.ExpectedClaudeVersion != PinnedClaudeVersion || cfg.Model != "claude-test" || cfg.AppendSystemPrompt != "help" { + t.Errorf("production config = %#v", cfg) + } +} + +func TestAgentsJSON(t *testing.T) { + cfg := Production("claude-test", "help") + cfg.Agents = map[string]Agent{ + "reviewer": { + Description: "Reviews changes", Prompt: "Review carefully", Model: "claude-child", + }, + } + if err := cfg.Validate(); err != nil { + t.Fatal(err) + } + raw, err := cfg.AgentsJSON() + if err != nil { + t.Fatal(err) + } + want := `{"reviewer":{"description":"Reviews changes","prompt":"Review carefully","model":"claude-child"}}` + if raw != want { + t.Fatalf("AgentsJSON() = %s, want %s", raw, want) + } + parsed, err := Parse([]byte(`{"version":2,"claude_executable":"claude","expected_claude_version":"2.1.217","strict_version":true,"agents":` + raw + `,"max_event_bytes":100,"max_stderr_bytes":100,"interrupt_grace_millis":100}`)) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(parsed.Agents, cfg.Agents) { + t.Fatalf("parsed agents = %#v, want %#v", parsed.Agents, cfg.Agents) + } +} + +func TestConfigRejectsInvalidAgents(t *testing.T) { + tests := []map[string]Agent{ + {"": {Description: "description", Prompt: "prompt"}}, + {"not valid": {Description: "description", Prompt: "prompt"}}, + {"reviewer": {Prompt: "prompt"}}, + } + for _, agents := range tests { + cfg := Production("claude-test", "help") + cfg.Agents = agents + if err := cfg.Validate(); err == nil { + t.Fatalf("Validate() accepted agents %#v", agents) + } + } +} + +func TestParseValidates(t *testing.T) { + contents := `{"version":2,"claude_executable":"claude","expected_claude_version":"2.1.217","strict_version":true,"model":"claude-test","append_system_prompt":"help","max_event_bytes":100,"max_stderr_bytes":100,"interrupt_grace_millis":100}` + cfg, err := Parse([]byte(contents)) + if err != nil { + t.Fatal(err) + } + if cfg.Model != "claude-test" || cfg.AppendSystemPrompt != "help" { + t.Errorf("parsed config = %#v", cfg) + } +} + +func TestConfigRejectsUnknownFields(t *testing.T) { + if _, err := Parse([]byte(`{"version":2,"surprise":true}`)); err == nil { + t.Fatal("Parse() accepted an unknown field") + } +} + +func TestConfigRejectsTrailingValue(t *testing.T) { + if _, err := Parse([]byte(`{} {}`)); err == nil { + t.Fatal("Parse() accepted a trailing JSON value") + } +} + +func TestConfigRejectsMissingLimits(t *testing.T) { + _, err := Parse([]byte(`{"version":2,"claude_executable":"claude"}`)) + if err == nil || !strings.Contains(err.Error(), "limits must be positive") { + t.Fatalf("Parse() error = %v", err) + } +} diff --git a/go/harness/claude/internal/adapter/adapter.go b/go/harness/claude/internal/adapter/adapter.go new file mode 100644 index 000000000..58e223355 --- /dev/null +++ b/go/harness/claude/internal/adapter/adapter.go @@ -0,0 +1,140 @@ +// Package adapter constructs the Claude runtime from compiler-owned +// configuration and Actor-owned paths. +package adapter + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/kagent-dev/kagent/go/harness/claude/config" + "github.com/kagent-dev/kagent/go/harness/claude/internal/driver" +) + +const ( + claudeConfigDirEnv = "CLAUDE_CONFIG_DIR" + disableUpdaterEnv = "DISABLE_AUTOUPDATER" + googleCredsEnv = "GOOGLE_APPLICATION_CREDENTIALS" +) + +// Input contains compiler output and Actor-owned locations used to construct +// the Claude driver. +type Input struct { + ConfigJSON []byte + Workspace string + DurableDir string + EphemeralDir string + Environment []string +} + +// New validates and materializes Claude-owned state, then constructs its driver. +func New(input Input) (*driver.ProcessDriver, error) { + cfg, err := config.Parse(input.ConfigJSON) + if err != nil { + return nil, err + } + agentsJSON, err := cfg.AgentsJSON() + if err != nil { + return nil, err + } + if !filepath.IsAbs(input.Workspace) || !filepath.IsAbs(input.DurableDir) || !filepath.IsAbs(input.EphemeralDir) { + return nil, fmt.Errorf("workspace, durable, and ephemeral directories must be absolute paths") + } + claudeDir := filepath.Join(input.DurableDir, "claude") + for _, directory := range []struct{ name, path string }{ + {name: "workspace", path: input.Workspace}, + {name: "Claude state", path: claudeDir}, + {name: "generated Claude skills", path: filepath.Join(claudeDir, "skills")}, + } { + if err := ensurePrivateDir(directory.path); err != nil { + return nil, fmt.Errorf("prepare %s directory: %w", directory.name, err) + } + } + environment := setEnvironment(input.Environment, claudeConfigDirEnv, claudeDir) + environment = setEnvironment(environment, disableUpdaterEnv, "1") + environment, err = materializeGoogleCredentials(environment, input.EphemeralDir) + if err != nil { + return nil, err + } + return driver.NewProcessDriver(driver.ProcessConfig{ + Executable: cfg.ClaudeExecutable, ExpectedVersion: cfg.ExpectedClaudeVersion, + StrictVersion: cfg.StrictVersion, Workspace: input.Workspace, Model: cfg.Model, + AppendSystemPrompt: cfg.AppendSystemPrompt, AgentsJSON: agentsJSON, Environment: environment, + MaxEventBytes: cfg.MaxEventBytes, MaxStderrBytes: cfg.MaxStderrBytes, + InterruptGrace: cfg.InterruptGrace(), + }), nil +} + +func materializeGoogleCredentials(environment []string, directory string) ([]string, error) { + prefix := config.GoogleCredentialsJSONEnvName + "=" + var credentials string + filtered := make([]string, 0, len(environment)) + for _, item := range environment { + if strings.HasPrefix(item, prefix) { + if credentials != "" { + return nil, fmt.Errorf("%s is configured more than once", config.GoogleCredentialsJSONEnvName) + } + credentials = strings.TrimPrefix(item, prefix) + continue + } + filtered = append(filtered, item) + } + if credentials == "" { + return filtered, nil + } + if !json.Valid([]byte(credentials)) { + return nil, fmt.Errorf("%s must contain valid JSON", config.GoogleCredentialsJSONEnvName) + } + if err := ensurePrivateDir(directory); err != nil { + return nil, fmt.Errorf("prepare ephemeral credentials directory: %w", err) + } + path := filepath.Join(directory, "google-credentials.json") + temporary, err := os.CreateTemp(directory, ".google-credentials-*.tmp") + if err != nil { + return nil, fmt.Errorf("create temporary Google credentials: %w", err) + } + temporaryPath := temporary.Name() + defer os.Remove(temporaryPath) + if err := temporary.Chmod(0o600); err != nil { + _ = temporary.Close() + return nil, fmt.Errorf("secure temporary Google credentials: %w", err) + } + if _, err := temporary.WriteString(credentials); err != nil { + _ = temporary.Close() + return nil, fmt.Errorf("materialize Google credentials: %w", err) + } + if err := temporary.Close(); err != nil { + return nil, fmt.Errorf("close Google credentials: %w", err) + } + if err := os.Rename(temporaryPath, path); err != nil { + return nil, fmt.Errorf("replace Google credentials: %w", err) + } + return setEnvironment(filtered, googleCredsEnv, path), nil +} + +func ensurePrivateDir(path string) error { + if err := os.MkdirAll(path, 0o700); err != nil { + return err + } + info, err := os.Lstat(path) + if err != nil { + return err + } + if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() { + return fmt.Errorf("%q is not a directory", path) + } + return os.Chmod(path, 0o700) +} + +func setEnvironment(environment []string, name, value string) []string { + prefix := name + "=" + result := make([]string, 0, len(environment)+1) + for _, item := range environment { + if !strings.HasPrefix(item, prefix) { + result = append(result, item) + } + } + return append(result, prefix+value) +} diff --git a/go/harness/claude/internal/adapter/adapter_test.go b/go/harness/claude/internal/adapter/adapter_test.go new file mode 100644 index 000000000..780011353 --- /dev/null +++ b/go/harness/claude/internal/adapter/adapter_test.go @@ -0,0 +1,74 @@ +package adapter + +import ( + "os" + "path/filepath" + "testing" + + "github.com/kagent-dev/kagent/go/harness/claude/config" +) + +func TestNewMaterializesDurableDirectories(t *testing.T) { + durableDir := filepath.Join(t.TempDir(), "data") + ephemeralDir := filepath.Join(t.TempDir(), "credentials") + workspace := filepath.Join(durableDir, "workspace") + runner, err := New(Input{ + ConfigJSON: []byte(`{"version":2,"claude_executable":"claude","expected_claude_version":"2.1.217","strict_version":true,"max_event_bytes":100,"max_stderr_bytes":100,"interrupt_grace_millis":100}`), + Workspace: workspace, + DurableDir: durableDir, EphemeralDir: ephemeralDir, + Environment: []string{"PATH=/bin", "CLAUDE_CONFIG_DIR=/wrong", "DISABLE_AUTOUPDATER=0"}, + }) + if err != nil { + t.Fatal(err) + } + if runner == nil { + t.Fatal("New() returned a nil runner") + } + for _, path := range []string{workspace, filepath.Join(durableDir, "claude"), filepath.Join(durableDir, "claude", "skills")} { + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != 0o700 { + t.Errorf("%s permissions = %o, want 700", path, info.Mode().Perm()) + } + } +} + +func TestNewRejectsInvalidInput(t *testing.T) { + input := Input{ConfigJSON: []byte(`{}`), Workspace: "relative", DurableDir: "relative", EphemeralDir: "relative"} + if _, err := New(input); err == nil { + t.Fatal("New() accepted invalid input") + } +} + +func TestMaterializeGoogleCredentials(t *testing.T) { + dir := t.TempDir() + raw := `{"type":"service_account","project_id":"test"}` + environment, err := materializeGoogleCredentials([]string{"A=1", config.GoogleCredentialsJSONEnvName + "=" + raw}, dir) + if err != nil { + t.Fatal(err) + } + path := filepath.Join(dir, "google-credentials.json") + contents, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(contents) != raw { + t.Fatalf("credentials = %q", contents) + } + if len(environment) != 2 || environment[0] != "A=1" || environment[1] != googleCredsEnv+"="+path { + t.Fatalf("environment = %v", environment) + } + if info, err := os.Stat(path); err != nil || info.Mode().Perm() != 0o600 { + t.Fatalf("credential permissions = %v, %v", info, err) + } +} + +func TestSetEnvironmentOverridesExistingValue(t *testing.T) { + got := setEnvironment([]string{"A=1", "A=2", "B=3"}, "A", "4") + want := []string{"B=3", "A=4"} + if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] { + t.Fatalf("setEnvironment() = %v, want %v", got, want) + } +} diff --git a/go/harness/claude/internal/driver/event.go b/go/harness/claude/internal/driver/event.go new file mode 100644 index 000000000..e06941389 --- /dev/null +++ b/go/harness/claude/internal/driver/event.go @@ -0,0 +1,29 @@ +package driver + +type EventKind string + +const ( + EventSessionStarted EventKind = "session_started" + EventTextDelta EventKind = "text_delta" + EventToolActivity EventKind = "tool_activity" + EventCompleted EventKind = "completed" + EventFailed EventKind = "failed" +) + +// Event is the Claude stream vocabulary consumed by ProcessDriver. Vendor +// parsing details stay here and are normalized before reaching shared runtime +// code. +type Event struct { + Kind EventKind + SessionID string + Text string + ToolID string + ToolName string + ToolPhase string + ToolResult any + ToolError bool + Metadata map[string]any + Category string + SafeMessage string + Result string +} diff --git a/go/harness/claude/internal/driver/parser.go b/go/harness/claude/internal/driver/parser.go new file mode 100644 index 000000000..f0c3ec38b --- /dev/null +++ b/go/harness/claude/internal/driver/parser.go @@ -0,0 +1,255 @@ +package driver + +import ( + "bufio" + "bytes" + "encoding/json" + "fmt" + "io" + "strconv" +) + +type parser struct { + emitted map[string]string + currentMessageID string + tools map[string]string + emittedToolCalls map[string]struct{} + emittedResults map[string]struct{} + terminal bool +} + +func ParseJSONL(r io.Reader, maxEventBytes int, emit func(Event) error) error { + if maxEventBytes <= 0 { + return fmt.Errorf("max event bytes must be positive") + } + p := parser{ + emitted: map[string]string{}, tools: map[string]string{}, + emittedToolCalls: map[string]struct{}{}, emittedResults: map[string]struct{}{}, + } + reader := bufio.NewReaderSize(r, min(maxEventBytes+1, 64*1024)) + for { + line, err := readBoundedLine(reader, maxEventBytes) + if len(bytes.TrimSpace(line)) > 0 { + if parseErr := p.parseLine(line, emit); parseErr != nil { + return parseErr + } + } + if err == io.EOF { + break + } + if err != nil { + return fmt.Errorf("read Claude event: %w", err) + } + } + if !p.terminal { + return fmt.Errorf("Claude process exited without a terminal result event") + } + return nil +} + +func readBoundedLine(r *bufio.Reader, max int) ([]byte, error) { + var line []byte + for { + fragment, err := r.ReadSlice('\n') + if len(line)+len(fragment) > max { + return nil, fmt.Errorf("Claude event exceeds %d bytes", max) + } + line = append(line, fragment...) + if err != bufio.ErrBufferFull { + return line, err + } + } +} + +func (p *parser) parseLine(line []byte, emit func(Event) error) error { + var envelope struct { + Type string `json:"type"` + Subtype string `json:"subtype"` + SessionID string `json:"session_id"` + IsError bool `json:"is_error"` + Result string `json:"result"` + Event json.RawMessage `json:"event"` + Message json.RawMessage `json:"message"` + Origin struct { + Kind string `json:"kind"` + } `json:"origin"` + } + if err := json.Unmarshal(line, &envelope); err != nil { + return fmt.Errorf("decode Claude event: %w", err) + } + switch envelope.Type { + case "system": + if envelope.Subtype == "init" && envelope.SessionID != "" { + return emit(Event{Kind: EventSessionStarted, SessionID: envelope.SessionID}) + } + case "stream_event": + return p.parseStreamEvent(envelope.Event, emit) + case "assistant": + return p.parseAssistant(envelope.Message, emit) + case "user": + return p.parseUser(envelope.Message, emit) + case "result": + if envelope.Origin.Kind == "task-notification" { + return nil + } + p.terminal = true + if envelope.IsError || envelope.Subtype != "success" { + message := envelope.Result + if message == "" { + message = "Claude execution failed" + } + return emit(Event{Kind: EventFailed, Category: envelope.Subtype, SafeMessage: message}) + } + return emit(Event{Kind: EventCompleted, SessionID: envelope.SessionID, Result: envelope.Result}) + } + return nil +} + +func (p *parser) parseStreamEvent(raw json.RawMessage, emit func(Event) error) error { + var event struct { + Type string `json:"type"` + Index int `json:"index"` + Message struct { + ID string `json:"id"` + } `json:"message"` + Delta struct { + Type string `json:"type"` + Text string `json:"text"` + } `json:"delta"` + ContentBlock struct { + Type string `json:"type"` + ID string `json:"id"` + Name string `json:"name"` + Input map[string]any `json:"input"` + } `json:"content_block"` + } + if len(raw) == 0 { + return nil + } + if err := json.Unmarshal(raw, &event); err != nil { + return fmt.Errorf("decode Claude stream event: %w", err) + } + switch event.Type { + case "message_start": + p.currentMessageID = event.Message.ID + case "content_block_delta": + if event.Delta.Type == "text_delta" && event.Delta.Text != "" { + key := p.blockKey(event.Index) + p.emitted[key] += event.Delta.Text + return emit(Event{Kind: EventTextDelta, Text: event.Delta.Text}) + } + case "content_block_start": + if event.ContentBlock.Type == "tool_use" { + if event.ContentBlock.ID == "" || event.ContentBlock.Name == "" { + return fmt.Errorf("Claude tool_use start requires an id and name") + } + if previous := p.tools[event.ContentBlock.ID]; previous != "" && previous != event.ContentBlock.Name { + return fmt.Errorf("Claude tool_use %q changed name from %q to %q", event.ContentBlock.ID, previous, event.ContentBlock.Name) + } + p.tools[event.ContentBlock.ID] = event.ContentBlock.Name + } + } + return nil +} + +func (p *parser) parseAssistant(raw json.RawMessage, emit func(Event) error) error { + var message struct { + ID string `json:"id"` + Content []struct { + Type string `json:"type"` + Text string `json:"text"` + ID string `json:"id"` + Name string `json:"name"` + Input map[string]any `json:"input"` + } `json:"content"` + } + if err := json.Unmarshal(raw, &message); err != nil { + return fmt.Errorf("decode Claude assistant message: %w", err) + } + if message.ID != "" { + p.currentMessageID = message.ID + } + for i, content := range message.Content { + key := p.blockKey(i) + switch content.Type { + case "text": + previous := p.emitted[key] + if previous == "" { + p.emitted[key] = content.Text + if content.Text != "" { + if err := emit(Event{Kind: EventTextDelta, Text: content.Text}); err != nil { + return err + } + } + } else if len(content.Text) > len(previous) && content.Text[:len(previous)] == previous { + suffix := content.Text[len(previous):] + p.emitted[key] = content.Text + if suffix != "" { + if err := emit(Event{Kind: EventTextDelta, Text: suffix}); err != nil { + return err + } + } + } + case "tool_use": + if content.ID == "" || content.Name == "" { + return fmt.Errorf("Claude assistant tool_use requires an id and name") + } + if previous := p.tools[content.ID]; previous != "" && previous != content.Name { + return fmt.Errorf("Claude tool_use %q changed name from %q to %q", content.ID, previous, content.Name) + } + p.tools[content.ID] = content.Name + if _, emitted := p.emittedToolCalls[content.ID]; emitted { + continue + } + p.emittedToolCalls[content.ID] = struct{}{} + if err := emit(Event{Kind: EventToolActivity, ToolID: content.ID, ToolName: content.Name, ToolPhase: "started", Metadata: content.Input}); err != nil { + return err + } + } + } + return nil +} + +func (p *parser) parseUser(raw json.RawMessage, emit func(Event) error) error { + var message struct { + Content []struct { + Type string `json:"type"` + ToolUseID string `json:"tool_use_id"` + Content json.RawMessage `json:"content"` + IsError bool `json:"is_error"` + } `json:"content"` + } + if err := json.Unmarshal(raw, &message); err != nil { + return fmt.Errorf("decode Claude user message: %w", err) + } + for _, content := range message.Content { + if content.Type != "tool_result" { + continue + } + name := p.tools[content.ToolUseID] + if content.ToolUseID == "" || name == "" { + return fmt.Errorf("Claude tool_result references unknown tool_use id %q", content.ToolUseID) + } + if _, emitted := p.emittedResults[content.ToolUseID]; emitted { + return fmt.Errorf("Claude tool_result for %q was emitted more than once", content.ToolUseID) + } + var result any + if len(content.Content) != 0 && string(content.Content) != "null" { + if err := json.Unmarshal(content.Content, &result); err != nil { + return fmt.Errorf("decode Claude tool_result %q content: %w", content.ToolUseID, err) + } + } + p.emittedResults[content.ToolUseID] = struct{}{} + if err := emit(Event{ + Kind: EventToolActivity, ToolID: content.ToolUseID, ToolName: name, + ToolPhase: "completed", ToolResult: result, ToolError: content.IsError, + }); err != nil { + return err + } + } + return nil +} + +func (p *parser) blockKey(index int) string { + return p.currentMessageID + ":" + strconv.Itoa(index) +} diff --git a/go/harness/claude/internal/driver/parser_test.go b/go/harness/claude/internal/driver/parser_test.go new file mode 100644 index 000000000..719bad37f --- /dev/null +++ b/go/harness/claude/internal/driver/parser_test.go @@ -0,0 +1,194 @@ +package driver + +import ( + "bytes" + "errors" + "io" + "os" + "strings" + "testing" +) + +const pinnedClaudeVersion = "2.1.217" + +func TestParseJSONLStreamingAndDeduplication(t *testing.T) { + b, err := os.ReadFile("../../testdata/stream-success.jsonl") + if err != nil { + t.Fatal(err) + } + for _, reader := range []io.Reader{bytes.NewReader(b), &fragmentReader{data: b, size: 3}} { + var events []Event + if err := ParseJSONL(reader, 4096, func(event Event) error { + events = append(events, event) + return nil + }); err != nil { + t.Fatalf("ParseJSONL() error = %v", err) + } + var text strings.Builder + for _, event := range events { + if event.Kind == EventTextDelta { + text.WriteString(event.Text) + } + } + if text.String() != "hello" { + t.Errorf("streamed text = %q, want hello", text.String()) + } + if events[0].Kind != EventSessionStarted || events[len(events)-1].Kind != EventCompleted { + t.Errorf("event boundaries = %q..%q", events[0].Kind, events[len(events)-1].Kind) + } + } +} + +func TestParseJSONLTerminalFailure(t *testing.T) { + b, err := os.ReadFile("../../testdata/stream-error.jsonl") + if err != nil { + t.Fatal(err) + } + var last Event + if err := ParseJSONL(bytes.NewReader(b), 4096, func(event Event) error { last = event; return nil }); err != nil { + t.Fatal(err) + } + if last.Kind != EventFailed || last.Category != "error_max_budget_usd" { + t.Fatalf("last event = %#v", last) + } +} + +func TestParseJSONLBuiltInToolLifecycle(t *testing.T) { + input := strings.Join([]string{ + `{"type":"system","subtype":"init","session_id":"11111111-1111-4111-8111-111111111111"}`, + `{"type":"stream_event","event":{"type":"message_start","message":{"id":"msg_tool"}}}`, + `{"type":"stream_event","event":{"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"tool-1","name":"Read","input":{}}}}`, + `{"type":"assistant","message":{"id":"msg_tool","content":[{"type":"tool_use","id":"tool-1","name":"Read","input":{"file_path":"/data/workspace/README.md"}}]}}`, + `{"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":"tool-1","content":"file contents","is_error":false}]}}`, + `{"type":"assistant","message":{"id":"msg_edit","content":[{"type":"tool_use","id":"tool-2","name":"Edit","input":{"file_path":"/data/workspace/missing.md"}}]}}`, + `{"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":"tool-2","content":[{"type":"text","text":"file not found"}],"is_error":true}]}}`, + `{"type":"assistant","message":{"id":"msg_done","content":[{"type":"text","text":"done"}]}}`, + `{"type":"result","subtype":"success","is_error":false,"result":"done"}`, + }, "\n") + "\n" + var events []Event + if err := ParseJSONL(strings.NewReader(input), 4096, func(event Event) error { + events = append(events, event) + return nil + }); err != nil { + t.Fatal(err) + } + var tools []Event + for _, event := range events { + if event.Kind == EventToolActivity { + tools = append(tools, event) + } + } + if len(tools) != 4 { + t.Fatalf("tool events = %#v, want two calls and results", tools) + } + if tools[0].ToolPhase != "started" || tools[0].ToolID != "tool-1" || tools[0].ToolName != "Read" || tools[0].Metadata["file_path"] != "/data/workspace/README.md" { + t.Fatalf("tool call = %#v", tools[0]) + } + if tools[1].ToolPhase != "completed" || tools[1].ToolID != "tool-1" || tools[1].ToolName != "Read" || tools[1].ToolResult != "file contents" || tools[1].ToolError { + t.Fatalf("tool result = %#v", tools[1]) + } + if tools[2].ToolPhase != "started" || tools[2].ToolID != "tool-2" || tools[2].ToolName != "Edit" { + t.Fatalf("second tool call = %#v", tools[2]) + } + if tools[3].ToolPhase != "completed" || tools[3].ToolID != "tool-2" || tools[3].ToolName != "Edit" || !tools[3].ToolError { + t.Fatalf("failed tool result = %#v", tools[3]) + } +} + +func TestParseJSONLIgnoresSubagentTaskNotificationResult(t *testing.T) { + input := strings.Join([]string{ + `{"type":"system","subtype":"init","session_id":"11111111-1111-4111-8111-111111111111"}`, + `{"type":"result","subtype":"success","is_error":false,"result":"parent done"}`, + `{"type":"result","subtype":"success","is_error":false,"result":"child done","origin":{"kind":"task-notification"}}`, + }, "\n") + "\n" + var terminal []Event + if err := ParseJSONL(strings.NewReader(input), 4096, func(event Event) error { + if event.Kind == EventCompleted || event.Kind == EventFailed { + terminal = append(terminal, event) + } + return nil + }); err != nil { + t.Fatal(err) + } + if len(terminal) != 1 || terminal[0].Result != "parent done" { + t.Fatalf("terminal events = %#v, want only the parent result", terminal) + } +} + +func TestParseJSONLRejectsInvalidToolLifecycle(t *testing.T) { + tests := []struct { + name string + lines []string + want string + }{ + { + name: "unknown result", + lines: []string{ + `{"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":"missing","content":"result"}]}}`, + }, + want: "unknown tool_use id", + }, + { + name: "duplicate result", + lines: []string{ + `{"type":"assistant","message":{"id":"msg","content":[{"type":"tool_use","id":"tool-1","name":"Edit","input":{}}]}}`, + `{"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":"tool-1","content":"ok"}]}}`, + `{"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":"tool-1","content":"again"}]}}`, + }, + want: "more than once", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + input := strings.Join(tt.lines, "\n") + "\n" + err := ParseJSONL(strings.NewReader(input), 4096, func(Event) error { return nil }) + if err == nil || !strings.Contains(err.Error(), tt.want) { + t.Fatalf("ParseJSONL() error = %v, want containing %q", err, tt.want) + } + }) + } +} + +func TestParseJSONLErrors(t *testing.T) { + tests := []struct { + name string + input string + max int + want string + }{ + {name: "malformed", input: "{nope}\n", max: 100, want: "decode Claude event"}, + {name: "oversized", input: strings.Repeat("x", 101) + "\n", max: 100, want: "exceeds 100 bytes"}, + {name: "missing terminal", input: `{"type":"system","subtype":"init","session_id":"11111111-1111-4111-8111-111111111111"}` + "\n", max: 1024, want: "without a terminal"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ParseJSONL(strings.NewReader(tt.input), tt.max, func(Event) error { return nil }) + if err == nil || !strings.Contains(err.Error(), tt.want) { + t.Fatalf("ParseJSONL() error = %v, want containing %q", err, tt.want) + } + }) + } +} + +func TestParseJSONLPropagatesEmitterError(t *testing.T) { + want := errors.New("stop") + input := `{"type":"result","subtype":"success"}` + "\n" + if err := ParseJSONL(strings.NewReader(input), 1024, func(Event) error { return want }); !errors.Is(err, want) { + t.Fatalf("ParseJSONL() error = %v, want %v", err, want) + } +} + +type fragmentReader struct { + data []byte + size int +} + +func (r *fragmentReader) Read(p []byte) (int, error) { + if len(r.data) == 0 { + return 0, io.EOF + } + n := min(r.size, len(r.data), len(p)) + copy(p, r.data[:n]) + r.data = r.data[n:] + return n, nil +} diff --git a/go/harness/claude/internal/driver/process.go b/go/harness/claude/internal/driver/process.go new file mode 100644 index 000000000..8026f6d2d --- /dev/null +++ b/go/harness/claude/internal/driver/process.go @@ -0,0 +1,220 @@ +package driver + +import ( + "bytes" + "context" + "fmt" + "os" + "os/exec" + "strings" + "time" + + "github.com/kagent-dev/kagent/go/harness/runtime" +) + +type ProcessConfig struct { + Executable string + ExpectedVersion string + StrictVersion bool + Workspace string + Model string + AppendSystemPrompt string + AgentsJSON string + Environment []string + MaxEventBytes int + MaxStderrBytes int + InterruptGrace time.Duration +} + +type ProcessDriver struct { + config ProcessConfig +} + +func NewProcessDriver(config ProcessConfig) *ProcessDriver { + return &ProcessDriver{config: config} +} + +func (d *ProcessDriver) Validate(ctx context.Context) error { + path, err := exec.LookPath(d.config.Executable) + if err != nil { + return fmt.Errorf("find Claude executable %q: %w", d.config.Executable, err) + } + cmd := exec.CommandContext(ctx, path, "--version") + cmd.Dir = d.config.Workspace + cmd.Env = append([]string(nil), d.config.Environment...) + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("read Claude version: %w", err) + } + version := strings.TrimSpace(string(output)) + if d.config.StrictVersion && !strings.Contains(version, d.config.ExpectedVersion) { + return fmt.Errorf("Claude version mismatch: got %q, expected %q", version, d.config.ExpectedVersion) + } + return nil +} + +func (d *ProcessDriver) Args(turn runtime.Turn) []string { + args := []string{ + "-p", turn.Prompt, + "--output-format", "stream-json", + "--verbose", + "--include-partial-messages", + "--dangerously-skip-permissions", + "--strict-mcp-config", + } + if d.config.Model != "" { + args = append(args, "--model", d.config.Model) + } + if d.config.AppendSystemPrompt != "" { + args = append(args, "--append-system-prompt", d.config.AppendSystemPrompt) + } + if d.config.AgentsJSON != "" { + args = append(args, "--agents", d.config.AgentsJSON) + } + if turn.ContinuationID != "" { + // Resume the Actor's exact root conversation. --continue selects Claude's + // latest session and can be redirected by subagents or interrupted attempts. + args = append(args, "--resume", turn.ContinuationID) + } + return args +} + +func (d *ProcessDriver) Run(ctx context.Context, turn runtime.Turn, sink runtime.EventSink) (runtime.Outcome, error) { + cmd := exec.Command(d.config.Executable, d.Args(turn)...) + cmd.Dir = d.config.Workspace + cmd.Env = append([]string(nil), d.config.Environment...) + stdout, err := cmd.StdoutPipe() + if err != nil { + return runtime.Outcome{}, fmt.Errorf("open Claude stdout: %w", err) + } + stderr := &boundedBuffer{max: d.config.MaxStderrBytes} + cmd.Stderr = stderr + if err := cmd.Start(); err != nil { + return runtime.Outcome{}, fmt.Errorf("start Claude: %w", err) + } + type parseItem struct { + event *Event + err error + } + items := make(chan parseItem) + stopEmit := make(chan struct{}) + go func() { + defer close(items) + parseErr := ParseJSONL(stdout, d.config.MaxEventBytes, func(event Event) error { + select { + case items <- parseItem{event: &event}: + return nil + case <-stopEmit: + return context.Canceled + } + }) + select { + case items <- parseItem{err: parseErr}: + case <-stopEmit: + } + }() + waitDone := make(chan error, 1) + go func() { waitDone <- cmd.Wait() }() + var terminal *runtime.Outcome + + for { + select { + case item, ok := <-items: + if !ok { + return runtime.Outcome{}, fmt.Errorf("Claude parser stopped without a result") + } + if item.event != nil { + outcome, err := emitEvent(*item.event, sink, terminal != nil) + if err == nil { + if outcome != nil { + terminal = outcome + } + continue + } + close(stopEmit) + d.terminate(cmd, waitDone) + for range items { + } + return runtime.Outcome{}, err + } + if item.err != nil { + close(stopEmit) + d.terminate(cmd, waitDone) + return runtime.Outcome{}, item.err + } + if waitErr := <-waitDone; waitErr != nil { + return runtime.Outcome{}, fmt.Errorf("Claude exited with an error: %w: %s", waitErr, stderr.String()) + } + if terminal == nil { + return runtime.Outcome{}, fmt.Errorf("Claude process exited without a terminal result") + } + return *terminal, nil + case <-ctx.Done(): + close(stopEmit) + d.terminate(cmd, waitDone) + for range items { + } + return runtime.Outcome{}, ctx.Err() + } + } +} + +func emitEvent(event Event, sink runtime.EventSink, terminal bool) (*runtime.Outcome, error) { + if terminal { + return nil, fmt.Errorf("Claude emitted activity after its terminal result") + } + switch event.Kind { + case EventSessionStarted: + return nil, sink.SessionStarted(runtime.SessionStarted{ContinuationID: event.SessionID}) + case EventTextDelta: + return nil, sink.TextDelta(runtime.TextDelta{Text: event.Text}) + case EventToolActivity: + switch event.ToolPhase { + case "started": + return nil, sink.ToolCall(runtime.ToolCall{ + ID: event.ToolID, Name: event.ToolName, Arguments: event.Metadata, + }) + case "completed": + return nil, sink.ToolResult(runtime.ToolResult{ + ID: event.ToolID, Name: event.ToolName, Result: event.ToolResult, IsError: event.ToolError, + }) + default: + return nil, fmt.Errorf("Claude tool activity has unsupported phase %q", event.ToolPhase) + } + case EventCompleted: + return &runtime.Outcome{}, nil + case EventFailed: + return &runtime.Outcome{Failure: &runtime.Failure{Message: event.SafeMessage}}, nil + default: + return nil, fmt.Errorf("unsupported Claude event kind %q", event.Kind) + } +} + +func (d *ProcessDriver) terminate(cmd *exec.Cmd, waitDone <-chan error) { + _ = cmd.Process.Signal(os.Interrupt) + timer := time.NewTimer(d.config.InterruptGrace) + defer timer.Stop() + select { + case <-waitDone: + case <-timer.C: + _ = cmd.Process.Kill() + <-waitDone + } +} + +type boundedBuffer struct { + bytes.Buffer + max int +} + +func (b *boundedBuffer) Write(p []byte) (int, error) { + original := len(p) + remaining := b.max - b.Len() + if remaining > 0 { + if len(p) > remaining { + p = p[:remaining] + } + _, _ = b.Buffer.Write(p) + } + return original, nil +} diff --git a/go/harness/claude/internal/driver/process_test.go b/go/harness/claude/internal/driver/process_test.go new file mode 100644 index 000000000..eff80a172 --- /dev/null +++ b/go/harness/claude/internal/driver/process_test.go @@ -0,0 +1,93 @@ +package driver + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/kagent-dev/kagent/go/harness/runtime" +) + +type recordingSink struct { + sessions []runtime.SessionStarted +} + +func (s *recordingSink) SessionStarted(event runtime.SessionStarted) error { + s.sessions = append(s.sessions, event) + return nil +} +func (*recordingSink) TextDelta(runtime.TextDelta) error { return nil } +func (*recordingSink) ToolCall(runtime.ToolCall) error { return nil } +func (*recordingSink) ToolResult(runtime.ToolResult) error { return nil } + +func TestProcessDriverArgumentsAndStream(t *testing.T) { + dir := t.TempDir() + capture := filepath.Join(dir, "args") + executable := filepath.Join(dir, "claude") + script := "#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then echo '2.1.217 (Claude Code)'; exit 0; fi\nprintf '%s\\n' \"$@\" > \"$CAPTURE\"\nprintf '%s\\n' '{\"type\":\"system\",\"subtype\":\"init\",\"session_id\":\"11111111-1111-4111-8111-111111111111\"}' '{\"type\":\"result\",\"subtype\":\"success\",\"session_id\":\"11111111-1111-4111-8111-111111111111\"}'\n" + if err := os.WriteFile(executable, []byte(script), 0o700); err != nil { + t.Fatal(err) + } + agentsJSON := `{"reviewer":{"description":"Reviews changes","prompt":"Review carefully","tools":["Read"]}}` + d := NewProcessDriver(ProcessConfig{Executable: executable, ExpectedVersion: pinnedClaudeVersion, StrictVersion: true, Workspace: dir, Model: "claude-test", AppendSystemPrompt: "extra", AgentsJSON: agentsJSON, Environment: []string{"CAPTURE=" + capture}, MaxEventBytes: 4096, MaxStderrBytes: 1024, InterruptGrace: time.Second}) + if err := d.Validate(t.Context()); err != nil { + t.Fatalf("Validate() error = %v", err) + } + sink := &recordingSink{} + turn := runtime.Turn{Prompt: "hello", ContinuationID: "11111111-1111-4111-8111-111111111111"} + outcome, err := d.Run(t.Context(), turn, sink) + if err != nil { + t.Fatalf("Run() error = %v", err) + } + if outcome.Failure != nil { + t.Fatalf("Run() outcome = %#v", outcome) + } + args, err := os.ReadFile(capture) + if err != nil { + t.Fatal(err) + } + want := strings.Join(d.Args(turn), "\n") + "\n" + if string(args) != want { + t.Errorf("arguments = %q, want %q", args, want) + } + for _, required := range []string{"--dangerously-skip-permissions\n", "--strict-mcp-config\n"} { + if !strings.Contains(string(args), required) { + t.Errorf("arguments do not contain required fixed policy flag %q", strings.TrimSpace(required)) + } + } + if !strings.Contains(string(args), "--agents\n"+agentsJSON+"\n") { + t.Error("arguments do not contain compiler-owned local agents JSON") + } + if strings.Contains(string(args), "--permission-prompt-tool\n") { + t.Error("arguments unexpectedly configure Claude's native permission bridge") + } + if strings.Contains(string(args), "--bare\n") { + t.Error("arguments unexpectedly disable normal Claude Code project/auth behavior with --bare") + } + if len(sink.sessions) != 1 || sink.sessions[0].ContinuationID != turn.ContinuationID { + t.Errorf("session events = %#v", sink.sessions) + } +} + +func TestProcessDriverCancellation(t *testing.T) { + dir := t.TempDir() + executable := filepath.Join(dir, "claude") + script := "#!/bin/sh\nprintf '%s\\n' '{\"type\":\"system\",\"subtype\":\"init\",\"session_id\":\"11111111-1111-4111-8111-111111111111\"}'\nwhile :; do :; done\n" + if err := os.WriteFile(executable, []byte(script), 0o700); err != nil { + t.Fatal(err) + } + d := NewProcessDriver(ProcessConfig{Executable: executable, Workspace: dir, MaxEventBytes: 4096, MaxStderrBytes: 1024, InterruptGrace: 50 * time.Millisecond}) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + started := time.Now() + _, err := d.Run(ctx, runtime.Turn{Prompt: "hello"}, &recordingSink{}) + if err != context.Canceled { + t.Fatalf("Run() error = %v, want context canceled", err) + } + if time.Since(started) > time.Second { + t.Fatalf("cancellation took too long") + } +} diff --git a/go/harness/claude/internal/session/store.go b/go/harness/claude/internal/session/store.go new file mode 100644 index 000000000..2e8bb494d --- /dev/null +++ b/go/harness/claude/internal/session/store.go @@ -0,0 +1,116 @@ +package session + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sync" + + "github.com/google/uuid" +) + +const stateVersion = 2 + +type state struct { + Version int `json:"version"` + Runtime string `json:"runtime"` + SessionID string `json:"session_id,omitempty"` +} + +type Store struct { + mu sync.RWMutex + path string + data state +} + +func New(durableDir string) (*Store, error) { + if durableDir == "" { + return nil, fmt.Errorf("durable directory is required") + } + if err := os.MkdirAll(durableDir, 0o700); err != nil { + return nil, fmt.Errorf("create session state directory: %w", err) + } + if err := os.Chmod(durableDir, 0o700); err != nil { + return nil, fmt.Errorf("secure session state directory: %w", err) + } + s := &Store{path: filepath.Join(durableDir, "state.json")} + s.data = state{Version: stateVersion, Runtime: "claude"} + b, err := os.ReadFile(s.path) + if os.IsNotExist(err) { + return s, nil + } + if err != nil { + return nil, fmt.Errorf("read session state: %w", err) + } + if err := json.Unmarshal(b, &s.data); err != nil { + return nil, fmt.Errorf("decode session state: %w", err) + } + if s.data.Version != stateVersion || s.data.Runtime != "claude" { + return nil, fmt.Errorf("unsupported or corrupt Claude session state") + } + if s.data.SessionID != "" { + if err := validateSessionID(s.data.SessionID); err != nil { + return nil, fmt.Errorf("invalid persisted session state: %w", err) + } + } + return s, nil +} + +func (s *Store) Load() (string, bool, error) { + s.mu.RLock() + defer s.mu.RUnlock() + return s.data.SessionID, s.data.SessionID != "", nil +} + +func (s *Store) Bind(nativeSessionID string) error { + if err := validateSessionID(nativeSessionID); err != nil { + return err + } + s.mu.Lock() + defer s.mu.Unlock() + if s.data.SessionID != "" && s.data.SessionID != nativeSessionID { + return fmt.Errorf("actor is already bound to another Claude session") + } + if s.data.SessionID == nativeSessionID { + return nil + } + next := state{Version: stateVersion, Runtime: "claude", SessionID: nativeSessionID} + b, err := json.MarshalIndent(next, "", " ") + if err != nil { + return fmt.Errorf("encode session state: %w", err) + } + tmp, err := os.CreateTemp(filepath.Dir(s.path), ".sessions-*.tmp") + if err != nil { + return fmt.Errorf("create temporary session state: %w", err) + } + tmpName := tmp.Name() + defer os.Remove(tmpName) + if err := tmp.Chmod(0o600); err != nil { + _ = tmp.Close() + return fmt.Errorf("secure temporary session state: %w", err) + } + if _, err := tmp.Write(b); err != nil { + _ = tmp.Close() + return fmt.Errorf("write temporary session state: %w", err) + } + if err := tmp.Sync(); err != nil { + _ = tmp.Close() + return fmt.Errorf("sync temporary session state: %w", err) + } + if err := tmp.Close(); err != nil { + return fmt.Errorf("close temporary session state: %w", err) + } + if err := os.Rename(tmpName, s.path); err != nil { + return fmt.Errorf("replace session state: %w", err) + } + s.data = next + return nil +} + +func validateSessionID(nativeSessionID string) error { + if _, err := uuid.Parse(nativeSessionID); err != nil { + return fmt.Errorf("invalid Claude session ID: %w", err) + } + return nil +} diff --git a/go/harness/claude/internal/session/store_test.go b/go/harness/claude/internal/session/store_test.go new file mode 100644 index 000000000..461965b33 --- /dev/null +++ b/go/harness/claude/internal/session/store_test.go @@ -0,0 +1,90 @@ +package session + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +const ( + sessionID = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb" +) + +func TestStoreFirstSessionReloadAndPermissions(t *testing.T) { + dir := t.TempDir() + store, err := New(dir) + if err != nil { + t.Fatal(err) + } + if _, ok, err := store.Load(); err != nil || ok { + t.Fatalf("empty Load() = ok %t, err %v", ok, err) + } + if err := store.Bind(sessionID); err != nil { + t.Fatal(err) + } + reloaded, err := New(dir) + if err != nil { + t.Fatal(err) + } + got, ok, err := reloaded.Load() + if err != nil || !ok || got != sessionID { + t.Fatalf("reloaded Load() = %q, %t, %v", got, ok, err) + } + info, err := os.Stat(filepath.Join(dir, "state.json")) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != 0o600 { + t.Errorf("state permissions = %o, want 600", info.Mode().Perm()) + } + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatal(err) + } + if len(entries) != 1 || strings.Contains(entries[0].Name(), ".tmp") { + t.Errorf("atomic replacement left entries: %v", entries) + } +} + +func TestStoreRejectsCorruptAndUnsupportedState(t *testing.T) { + tests := []string{ + "not json", + `{"version":1,"runtime":"claude","session_id":"` + sessionID + `"}`, + `{"version":2,"runtime":"codex","session_id":"` + sessionID + `"}`, + `{"version":2,"runtime":"claude","session_id":"not-a-session"}`, + } + for _, contents := range tests { + t.Run(contents, func(t *testing.T) { + dir := t.TempDir() + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "state.json"), []byte(contents), 0o600); err != nil { + t.Fatal(err) + } + if _, err := New(dir); err == nil { + t.Fatal("New() succeeded for invalid state") + } + }) + } +} + +func TestStoreRejectsInvalidAndConflictingSessionIDs(t *testing.T) { + store, err := New(t.TempDir()) + if err != nil { + t.Fatal(err) + } + if err := store.Bind("not-a-session"); err == nil { + t.Fatal("Bind() accepted invalid session ID") + } + if err := store.Bind(sessionID); err != nil { + t.Fatal(err) + } + if err := store.Bind(sessionID); err != nil { + t.Fatalf("idempotent Bind() failed: %v", err) + } + if err := store.Bind("cccccccc-cccc-4ccc-8ccc-cccccccccccc"); err == nil { + t.Fatal("Bind() accepted a conflicting native session") + } +} diff --git a/go/harness/claude/testdata/agent-card.json b/go/harness/claude/testdata/agent-card.json new file mode 100644 index 000000000..b18bde4d1 --- /dev/null +++ b/go/harness/claude/testdata/agent-card.json @@ -0,0 +1,8 @@ +{ + "name": "claude-code-poc", + "description": "Local Claude Code Harness proof of concept", + "version": "0.1.0", + "capabilities": {"streaming": true}, + "defaultInputModes": ["text/plain"], + "defaultOutputModes": ["text/plain"] +} diff --git a/go/harness/claude/testdata/poc-config.json b/go/harness/claude/testdata/poc-config.json new file mode 100644 index 000000000..aad0bd153 --- /dev/null +++ b/go/harness/claude/testdata/poc-config.json @@ -0,0 +1,11 @@ +{ + "version": 2, + "claude_executable": "claude", + "expected_claude_version": "2.1.217", + "strict_version": true, + "model": "", + "append_system_prompt": "You are running inside a kagent Actor.", + "max_event_bytes": 1048576, + "max_stderr_bytes": 65536, + "interrupt_grace_millis": 2000 +} diff --git a/go/harness/claude/testdata/stream-error.jsonl b/go/harness/claude/testdata/stream-error.jsonl new file mode 100644 index 000000000..fce88776f --- /dev/null +++ b/go/harness/claude/testdata/stream-error.jsonl @@ -0,0 +1,2 @@ +{"type":"system","subtype":"init","session_id":"22222222-2222-4222-8222-222222222222"} +{"type":"result","subtype":"error_max_budget_usd","is_error":true,"result":"budget limit reached","session_id":"22222222-2222-4222-8222-222222222222"} diff --git a/go/harness/claude/testdata/stream-resume.jsonl b/go/harness/claude/testdata/stream-resume.jsonl new file mode 100644 index 000000000..670e2ef71 --- /dev/null +++ b/go/harness/claude/testdata/stream-resume.jsonl @@ -0,0 +1,3 @@ +{"type":"system","subtype":"init","session_id":"11111111-1111-4111-8111-111111111111"} +{"type":"assistant","message":{"id":"msg_resumed","content":[{"type":"text","text":"resumed"}]}} +{"type":"result","subtype":"success","is_error":false,"result":"resumed","session_id":"11111111-1111-4111-8111-111111111111","usage":{"input_tokens":4,"output_tokens":1}} diff --git a/go/harness/claude/testdata/stream-success.jsonl b/go/harness/claude/testdata/stream-success.jsonl new file mode 100644 index 000000000..378a6e02a --- /dev/null +++ b/go/harness/claude/testdata/stream-success.jsonl @@ -0,0 +1,7 @@ +{"type":"system","subtype":"init","session_id":"11111111-1111-4111-8111-111111111111","cwd":"/scrubbed/workspace","tools":[]} +{"type":"stream_event","event":{"type":"message_start","message":{"id":"msg_scrubbed"}}} +{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"hel"}}} +{"type":"future_additive_event","ignored":true} +{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"lo"}}} +{"type":"assistant","message":{"id":"msg_scrubbed","content":[{"type":"text","text":"hello"}]},"session_id":"11111111-1111-4111-8111-111111111111"} +{"type":"result","subtype":"success","is_error":false,"result":"hello","session_id":"11111111-1111-4111-8111-111111111111","usage":{"input_tokens":3,"output_tokens":2}} diff --git a/go/harness/runtime/a2a/executor.go b/go/harness/runtime/a2a/executor.go new file mode 100644 index 000000000..d207536fc --- /dev/null +++ b/go/harness/runtime/a2a/executor.go @@ -0,0 +1,285 @@ +// Package a2a supervises Harness runtime turns behind kagent's private A2A +// service. Public Task persistence remains owned by the controller gateway. +package a2a + +import ( + "context" + "errors" + "fmt" + "iter" + "sync" + + a2atype "github.com/a2aproject/a2a-go/v2/a2a" + "github.com/a2aproject/a2a-go/v2/a2asrv" + "github.com/kagent-dev/kagent/go/harness/runtime" +) + +// Runner is the execution capability consumed by the A2A supervisor. +type Runner interface { + Run(context.Context, runtime.Turn, runtime.EventSink) (runtime.Outcome, error) +} + +// ContinuationStore persists the one native conversation owned by an Actor. +// A2A contexts identify controller history; they do not select native sessions. +type ContinuationStore interface { + Load() (string, bool, error) + Bind(continuationID string) error +} + +type Executor struct { + runner Runner + continuation ContinuationStore + + mu sync.Mutex + active *activeTask +} + +type activeTask struct { + taskID a2atype.TaskID + contextID string + cancel context.CancelFunc + done chan struct{} +} + +type executionSink struct { + reqCtx *a2asrv.ExecutorContext + yield func(a2atype.Event, error) bool + continuation ContinuationStore + artifactID a2atype.ArtifactID +} + +var ( + errBusy = errors.New("runtime actor already has an active task") + errYieldStopped = errors.New("A2A event consumer stopped") +) + +func New(runner Runner, continuation ContinuationStore) (*Executor, error) { + if runner == nil || continuation == nil { + return nil, fmt.Errorf("runner and continuation store are required") + } + return &Executor{runner: runner, continuation: continuation}, nil +} + +func (e *Executor) Execute(ctx context.Context, reqCtx *a2asrv.ExecutorContext) iter.Seq2[a2atype.Event, error] { + return func(yield func(a2atype.Event, error) bool) { + prompt, err := validateRequest(reqCtx) + if err != nil { + yield(nil, err) + return + } + continuationID, _, err := e.continuation.Load() + if err != nil { + yield(nil, err) + return + } + runCtx, cancel := context.WithCancel(ctx) + active := &activeTask{taskID: reqCtx.TaskID, contextID: reqCtx.ContextID, cancel: cancel, done: make(chan struct{})} + if !e.activate(active) { + cancel() + yield(nil, errBusy) + return + } + var finishOnce sync.Once + finish := func() { + finishOnce.Do(func() { + cancel() + e.deactivate(active) + close(active.done) + }) + } + defer finish() + + if !yield(a2atype.NewStatusUpdateEvent(reqCtx, a2atype.TaskStateWorking, nil), nil) { + return + } + outcome, runErr := e.runner.Run(runCtx, runtime.Turn{ + Prompt: prompt, ContinuationID: continuationID, + }, &executionSink{reqCtx: reqCtx, yield: yield, continuation: e.continuation}) + if errors.Is(runErr, errYieldStopped) { + return + } + if errors.Is(runErr, context.Canceled) || errors.Is(runErr, context.DeadlineExceeded) { + return + } + if runErr != nil { + finish() + message := taskMessage(reqCtx, "Harness runtime execution failed") + yield(a2atype.NewStatusUpdateEvent(reqCtx, a2atype.TaskStateFailed, message), nil) + return + } + + // Reap the runtime process and release the Actor's active-task slot before + // publishing a terminal state. A client may submit its next turn as soon + // as it observes this event. + finish() + if outcome.Failure == nil { + yield(a2atype.NewStatusUpdateEvent(reqCtx, a2atype.TaskStateCompleted, nil), nil) + return + } + message := taskMessage(reqCtx, safeFailure(outcome.Failure.Message)) + yield(a2atype.NewStatusUpdateEvent(reqCtx, a2atype.TaskStateFailed, message), nil) + } +} + +func (s *executionSink) SessionStarted(event runtime.SessionStarted) error { + if event.ContinuationID == "" { + return fmt.Errorf("runtime continuation ID is required") + } + if err := s.continuation.Bind(event.ContinuationID); err != nil { + return fmt.Errorf("persist runtime continuation: %w", err) + } + return nil +} + +func (s *executionSink) TextDelta(event runtime.TextDelta) error { + if event.Text == "" { + return nil + } + var update *a2atype.TaskArtifactUpdateEvent + if s.artifactID == "" { + update = a2atype.NewArtifactEvent(s.reqCtx, a2atype.NewTextPart(event.Text)) + s.artifactID = update.Artifact.ID + } else { + update = a2atype.NewArtifactUpdateEvent(s.reqCtx, s.artifactID, a2atype.NewTextPart(event.Text)) + } + if !s.yield(update, nil) { + return errYieldStopped + } + return nil +} + +func (s *executionSink) ToolCall(event runtime.ToolCall) error { + message, err := toolCallMessage(s.reqCtx, event) + if err != nil { + return err + } + if !s.yield(a2atype.NewStatusUpdateEvent(s.reqCtx, a2atype.TaskStateWorking, message), nil) { + return errYieldStopped + } + return nil +} + +func (s *executionSink) ToolResult(event runtime.ToolResult) error { + message, err := toolResultMessage(s.reqCtx, event) + if err != nil { + return err + } + if !s.yield(a2atype.NewStatusUpdateEvent(s.reqCtx, a2atype.TaskStateWorking, message), nil) { + return errYieldStopped + } + return nil +} + +func toolCallMessage(reqCtx *a2asrv.ExecutorContext, event runtime.ToolCall) (*a2atype.Message, error) { + if event.ID == "" || event.Name == "" { + return nil, fmt.Errorf("runtime tool call requires an ID and name") + } + args := event.Arguments + if args == nil { + args = map[string]any{} + } + return toolActivityMessage(reqCtx, "function_call", map[string]any{ + "id": event.ID, "name": event.Name, "args": args, + }), nil +} + +func toolResultMessage(reqCtx *a2asrv.ExecutorContext, event runtime.ToolResult) (*a2atype.Message, error) { + if event.ID == "" || event.Name == "" { + return nil, fmt.Errorf("runtime tool result requires an ID and name") + } + response := map[string]any{"result": event.Result} + if event.IsError { + response["isError"] = true + } + return toolActivityMessage(reqCtx, "function_response", map[string]any{ + "id": event.ID, "name": event.Name, "response": response, + }), nil +} + +func toolActivityMessage(reqCtx *a2asrv.ExecutorContext, partType string, data map[string]any) *a2atype.Message { + part := a2atype.NewDataPart(data) + part.Metadata = map[string]any{"kagent_type": partType} + message := a2atype.NewMessage(a2atype.MessageRoleAgent, part) + message.TaskID, message.ContextID = reqCtx.TaskID, reqCtx.ContextID + return message +} + +func taskMessage(reqCtx *a2asrv.ExecutorContext, text string) *a2atype.Message { + message := a2atype.NewMessage(a2atype.MessageRoleAgent, a2atype.NewTextPart(text)) + message.TaskID, message.ContextID = reqCtx.TaskID, reqCtx.ContextID + return message +} + +func (e *Executor) Cancel(ctx context.Context, reqCtx *a2asrv.ExecutorContext) iter.Seq2[a2atype.Event, error] { + return func(yield func(a2atype.Event, error) bool) { + if reqCtx == nil || reqCtx.TaskID == "" || reqCtx.ContextID == "" { + yield(nil, fmt.Errorf("task ID and context ID are required for cancellation")) + return + } + e.mu.Lock() + active := e.active + if active == nil { + e.mu.Unlock() + return + } + if active.taskID != reqCtx.TaskID || active.contextID != reqCtx.ContextID { + e.mu.Unlock() + yield(nil, fmt.Errorf("cancellation does not match the active task")) + return + } + active.cancel() + done := active.done + e.mu.Unlock() + select { + case <-done: + yield(a2atype.NewStatusUpdateEvent(reqCtx, a2atype.TaskStateCanceled, nil), nil) + case <-ctx.Done(): + yield(nil, ctx.Err()) + } + } +} + +func (e *Executor) activate(task *activeTask) bool { + e.mu.Lock() + defer e.mu.Unlock() + if e.active != nil { + return false + } + e.active = task + return true +} + +func (e *Executor) deactivate(task *activeTask) { + e.mu.Lock() + defer e.mu.Unlock() + if e.active == task { + e.active = nil + } +} + +func validateRequest(reqCtx *a2asrv.ExecutorContext) (string, error) { + if reqCtx == nil || reqCtx.Message == nil { + return "", fmt.Errorf("A2A request message is required") + } + if reqCtx.TaskID == "" || reqCtx.ContextID == "" { + return "", fmt.Errorf("task ID and context ID are required") + } + if reqCtx.Message.Role != a2atype.MessageRoleUser || len(reqCtx.Message.Parts) != 1 || reqCtx.Message.Parts[0] == nil { + return "", fmt.Errorf("Harness runtime accepts exactly one user text part") + } + text := reqCtx.Message.Parts[0].Text() + if text == "" { + return "", fmt.Errorf("Harness runtime accepts a non-empty text part") + } + return text, nil +} + +func safeFailure(message string) string { + if message == "" || len(message) > 512 { + return "Harness runtime execution failed" + } + return message +} + +var _ runtime.EventSink = (*executionSink)(nil) +var _ a2asrv.AgentExecutor = (*Executor)(nil) diff --git a/go/harness/runtime/a2a/executor_test.go b/go/harness/runtime/a2a/executor_test.go new file mode 100644 index 000000000..1cf76bfe7 --- /dev/null +++ b/go/harness/runtime/a2a/executor_test.go @@ -0,0 +1,246 @@ +package a2a + +import ( + "context" + "errors" + "iter" + "reflect" + "strings" + "sync" + "testing" + + a2atype "github.com/a2aproject/a2a-go/v2/a2a" + "github.com/a2aproject/a2a-go/v2/a2asrv" + "github.com/kagent-dev/kagent/go/harness/runtime" +) + +const ( + testContextID = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" + testSessionID = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb" +) + +type fakeRunner struct { + run func(context.Context, runtime.Turn, runtime.EventSink) (runtime.Outcome, error) +} + +func (f fakeRunner) Run(ctx context.Context, turn runtime.Turn, sink runtime.EventSink) (runtime.Outcome, error) { + return f.run(ctx, turn, sink) +} + +type fakeContinuation struct { + mu sync.Mutex + data string +} + +func (s *fakeContinuation) Load() (string, bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + return s.data, s.data != "", nil +} + +func (s *fakeContinuation) Bind(continuationID string) error { + s.mu.Lock() + defer s.mu.Unlock() + s.data = continuationID + return nil +} + +func TestExecuteStreamsCompletesAndPersistsSession(t *testing.T) { + continuation := &fakeContinuation{data: testSessionID} + var turn runtime.Turn + executor, err := New(fakeRunner{run: func(_ context.Context, got runtime.Turn, sink runtime.EventSink) (runtime.Outcome, error) { + turn = got + if err := sink.SessionStarted(runtime.SessionStarted{ContinuationID: testSessionID}); err != nil { + return runtime.Outcome{}, err + } + if err := sink.ToolCall(runtime.ToolCall{ID: "tool-1", Name: "Read", Arguments: map[string]any{"file_path": "/data/workspace/README.md"}}); err != nil { + return runtime.Outcome{}, err + } + if err := sink.ToolResult(runtime.ToolResult{ID: "tool-1", Name: "Read", Result: "contents"}); err != nil { + return runtime.Outcome{}, err + } + if err := sink.ToolCall(runtime.ToolCall{ID: "tool-2", Name: "Edit", Arguments: map[string]any{"file_path": "/data/workspace/missing.md"}}); err != nil { + return runtime.Outcome{}, err + } + if err := sink.ToolResult(runtime.ToolResult{ID: "tool-2", Name: "Edit", Result: "file not found", IsError: true}); err != nil { + return runtime.Outcome{}, err + } + if err := sink.TextDelta(runtime.TextDelta{Text: "hel"}); err != nil { + return runtime.Outcome{}, err + } + if err := sink.TextDelta(runtime.TextDelta{Text: "lo"}); err != nil { + return runtime.Outcome{}, err + } + return runtime.Outcome{}, nil + }}, continuation) + if err != nil { + t.Fatal(err) + } + events, errs := collect(executor.Execute(t.Context(), requestContext("task-1", "hello"))) + if len(errs) != 0 { + t.Fatalf("Execute() errors = %v", errs) + } + if turn.ContinuationID != testSessionID || turn.Prompt != "hello" { + t.Errorf("turn = %#v", turn) + } + var text strings.Builder + for _, event := range events { + if update, ok := event.(*a2atype.TaskArtifactUpdateEvent); ok { + text.WriteString(update.Artifact.Parts[0].Text()) + } + } + if text.String() != "hello" { + t.Errorf("stream text = %q", text.String()) + } + assertToolActivity(t, events, "function_call", map[string]any{ + "id": "tool-1", "name": "Read", "args": map[string]any{"file_path": "/data/workspace/README.md"}, + }) + assertToolActivity(t, events, "function_response", map[string]any{ + "id": "tool-1", "name": "Read", "response": map[string]any{"result": "contents"}, + }) + assertToolActivity(t, events, "function_response", map[string]any{ + "id": "tool-2", "name": "Edit", "response": map[string]any{"result": "file not found", "isError": true}, + }) + last := events[len(events)-1].(*a2atype.TaskStatusUpdateEvent) + if last.Status.State != a2atype.TaskStateCompleted { + t.Errorf("last state = %s", last.Status.State) + } +} + +func TestExecuteRejectsMalformedToolActivity(t *testing.T) { + tests := []func(runtime.EventSink) error{ + func(sink runtime.EventSink) error { return sink.ToolCall(runtime.ToolCall{Name: "Read"}) }, + func(sink runtime.EventSink) error { return sink.ToolResult(runtime.ToolResult{ID: "tool-1"}) }, + } + for _, emit := range tests { + executor, err := New(fakeRunner{run: func(_ context.Context, _ runtime.Turn, sink runtime.EventSink) (runtime.Outcome, error) { + return runtime.Outcome{}, emit(sink) + }}, &fakeContinuation{}) + if err != nil { + t.Fatal(err) + } + events, errs := collect(executor.Execute(t.Context(), requestContext("task-1", "hello"))) + if len(errs) != 0 { + t.Fatalf("Execute() errors = %v", errs) + } + last := events[len(events)-1].(*a2atype.TaskStatusUpdateEvent) + if last.Status.State != a2atype.TaskStateFailed { + t.Fatalf("last state = %s, want FAILED", last.Status.State) + } + } +} + +func assertToolActivity(t *testing.T, events []a2atype.Event, partType string, want map[string]any) { + t.Helper() + for _, event := range events { + update, ok := event.(*a2atype.TaskStatusUpdateEvent) + if !ok || update.Status.Message == nil || len(update.Status.Message.Parts) != 1 { + continue + } + part := update.Status.Message.Parts[0] + if part.Metadata["kagent_type"] != partType { + continue + } + if got := part.Data(); !reflect.DeepEqual(got, want) { + continue + } + return + } + t.Fatalf("did not find %s tool activity", partType) +} + +func TestExecuteFailureBoundary(t *testing.T) { + executor, err := New(fakeRunner{run: func(context.Context, runtime.Turn, runtime.EventSink) (runtime.Outcome, error) { + return runtime.Outcome{Failure: &runtime.Failure{Message: "budget limit reached"}}, nil + }}, &fakeContinuation{}) + if err != nil { + t.Fatal(err) + } + events, errs := collect(executor.Execute(t.Context(), requestContext("task-1", "hello"))) + if len(errs) != 0 { + t.Fatal(errs) + } + last := events[len(events)-1].(*a2atype.TaskStatusUpdateEvent) + if last.Status.State != a2atype.TaskStateFailed || last.Status.Message.Parts[0].Text() != "budget limit reached" { + t.Fatalf("failure event = %#v", last) + } +} + +func TestExecuteReleasesActiveTaskBeforeTerminalEvent(t *testing.T) { + runnerReturned := make(chan struct{}) + executor, err := New(fakeRunner{run: func(context.Context, runtime.Turn, runtime.EventSink) (runtime.Outcome, error) { + close(runnerReturned) + return runtime.Outcome{}, nil + }}, &fakeContinuation{}) + if err != nil { + t.Fatal(err) + } + + for event, err := range executor.Execute(t.Context(), requestContext("task-1", "hello")) { + if err != nil { + t.Fatal(err) + } + update, ok := event.(*a2atype.TaskStatusUpdateEvent) + if !ok || !update.Status.State.Terminal() { + continue + } + select { + case <-runnerReturned: + default: + t.Fatal("terminal event was published before the runtime runner returned") + } + executor.mu.Lock() + active := executor.active + executor.mu.Unlock() + if active != nil { + t.Fatal("terminal event was published before the active task was released") + } + } +} + +func TestBusyAndCancellation(t *testing.T) { + started := make(chan struct{}) + executor, err := New(fakeRunner{run: func(ctx context.Context, _ runtime.Turn, _ runtime.EventSink) (runtime.Outcome, error) { + close(started) + <-ctx.Done() + return runtime.Outcome{}, ctx.Err() + }}, &fakeContinuation{}) + if err != nil { + t.Fatal(err) + } + firstDone := make(chan struct{}) + go func() { + defer close(firstDone) + _, _ = collect(executor.Execute(context.Background(), requestContext("task-1", "first"))) + }() + <-started + _, errs := collect(executor.Execute(t.Context(), requestContext("task-2", "second"))) + if len(errs) != 1 || !errors.Is(errs[0], errBusy) { + t.Fatalf("busy errors = %v", errs) + } + cancelEvents, cancelErrs := collect(executor.Cancel(t.Context(), requestContext("task-1", "ignored"))) + if len(cancelErrs) != 0 || len(cancelEvents) != 1 { + t.Fatalf("Cancel() events/errors = %v/%v", cancelEvents, cancelErrs) + } + <-firstDone +} + +func requestContext(taskID a2atype.TaskID, text string) *a2asrv.ExecutorContext { + message := a2atype.NewMessage(a2atype.MessageRoleUser, a2atype.NewTextPart(text)) + message.TaskID, message.ContextID = taskID, testContextID + return &a2asrv.ExecutorContext{TaskID: taskID, ContextID: testContextID, Message: message} +} + +func collect(seq iter.Seq2[a2atype.Event, error]) ([]a2atype.Event, []error) { + var events []a2atype.Event + var errs []error + for event, err := range seq { + if event != nil { + events = append(events, event) + } + if err != nil { + errs = append(errs, err) + } + } + return events, errs +} diff --git a/go/harness/runtime/runtime.go b/go/harness/runtime/runtime.go new file mode 100644 index 000000000..f63a794b9 --- /dev/null +++ b/go/harness/runtime/runtime.go @@ -0,0 +1,53 @@ +// Package runtime defines the minimal turn and event vocabulary shared by +// Harness runtime adapters. +package runtime + +// Turn is one invocation of an Actor's root conversation. +type Turn struct { + Prompt string + ContinuationID string +} + +// EventSink receives ordered incremental runtime activity. Terminal state is +// returned as an Outcome from the runner rather than mixed into this stream. +type EventSink interface { + SessionStarted(SessionStarted) error + TextDelta(TextDelta) error + ToolCall(ToolCall) error + ToolResult(ToolResult) error +} + +// SessionStarted reports the stable private continuation selected by a runtime. +type SessionStarted struct { + ContinuationID string +} + +// TextDelta is one ordered fragment of assistant text. +type TextDelta struct { + Text string +} + +// ToolCall reports a runtime tool invocation after its arguments are complete. +type ToolCall struct { + ID string + Name string + Arguments map[string]any +} + +// ToolResult reports the result paired with one ToolCall ID. +type ToolResult struct { + ID string + Name string + Result any + IsError bool +} + +// Outcome is the terminal result of one runtime turn. A nil Failure is success. +type Outcome struct { + Failure *Failure +} + +// Failure contains only runtime-vetted information safe to expose publicly. +type Failure struct { + Message string +} From 6622299ab27537e30d3be63adf5775a29c566851 Mon Sep 17 00:00:00 2001 From: Jet Chiang Date: Thu, 27 Aug 2026 12:27:36 -0400 Subject: [PATCH 02/11] mcp, skills, plugins, fixes to claude Signed-off-by: Jet Chiang --- go/adk/cmd/main.go | 5 +- go/api/adk/types.go | 43 +--- go/api/agentplugin/types.go | 45 +++++ .../crd/bases/kagent.dev_agenttemplates.yaml | 8 +- go/api/v1alpha3/agenttemplate_types.go | 10 +- go/core/test/e2e/claude_interaction_test.go | 87 ++++++++- go/core/test/e2e/interaction_test.go | 56 +++++- .../mocks/invoke_claude_local_subagent.json | 3 +- .../e2e/mocks/invoke_claude_resources.json | 68 +++++++ go/core/v2/agentplugins/materialize.go | 119 +++++++---- go/core/v2/agentplugins/materialize_test.go | 57 +++++- go/core/v2/controller/reconciler.go | 9 + go/core/v2/translator/claude/compiler.go | 34 +++- go/core/v2/translator/claude/compiler_test.go | 147 +++++++++++++- go/core/v2/translator/claude/mcp.go | 184 ++++++++++++++++++ go/core/v2/translator/kagent/compiler.go | 70 +------ go/core/v2/translator/revision.go | 3 + go/core/v2/translator/revision_test.go | 16 ++ go/core/v2/translator/skills.go | 87 +++++++++ go/harness/claude/Dockerfile | 1 + go/harness/claude/config/config.go | 64 ++++-- go/harness/claude/config/config_test.go | 31 ++- go/harness/claude/internal/adapter/adapter.go | 52 ++++- .../claude/internal/adapter/adapter_test.go | 43 +++- go/harness/claude/internal/driver/process.go | 4 + .../claude/internal/driver/process_test.go | 6 +- go/harness/claude/testdata/poc-config.json | 11 -- .../templates/kagent.dev_agenttemplates.yaml | 8 +- ui/src/api/chat/a2aGrpcChatClient.test.ts | 34 ++++ ui/src/api/chat/a2aGrpcChatClient.ts | 14 +- 30 files changed, 1116 insertions(+), 203 deletions(-) create mode 100644 go/api/agentplugin/types.go create mode 100644 go/core/test/e2e/mocks/invoke_claude_resources.json create mode 100644 go/core/v2/translator/claude/mcp.go create mode 100644 go/core/v2/translator/skills.go delete mode 100644 go/harness/claude/testdata/poc-config.json diff --git a/go/adk/cmd/main.go b/go/adk/cmd/main.go index 8d537ce23..3e950146b 100644 --- a/go/adk/cmd/main.go +++ b/go/adk/cmd/main.go @@ -95,7 +95,10 @@ func main() { } if err := agentplugins.MaterializeAgentConfig( logr.NewContext(context.Background(), logger), agentConfig, - agentplugins.Paths{Plugins: agentplugins.DefaultPluginRoot, Skills: agentplugins.DefaultSkillsRoot, Data: agentplugins.DefaultDataRoot}, + agentplugins.ADKPaths{ + SkillPaths: agentplugins.SkillPaths{Plugins: agentplugins.DefaultPluginRoot, Skills: agentplugins.DefaultSkillsRoot}, + Data: agentplugins.DefaultDataRoot, + }, ); err != nil { logger.Error(err, "Failed to materialize Agent Plugins") os.Exit(1) diff --git a/go/api/adk/types.go b/go/api/adk/types.go index 561c3df96..776383320 100644 --- a/go/api/adk/types.go +++ b/go/api/adk/types.go @@ -5,6 +5,8 @@ import ( "database/sql/driver" "encoding/json" "fmt" + + "github.com/kagent-dev/kagent/go/api/agentplugin" ) type StreamableHTTPConnectionParams struct { @@ -548,44 +550,19 @@ type NetworkConfig struct { AllowedDomains []string `json:"allowed_domains,omitempty"` } -// AgentPluginConfig describes immutable Agent Plugin and standalone skill -// packages that the runtime must download before starting the agent. -type AgentPluginConfig struct { - Skills []StandaloneSkill `json:"skills,omitempty"` - Plugins []AgentPluginBundle `json:"plugins,omitempty"` -} +// These aliases preserve the existing ADK configuration API while sharing one +// runtime-neutral resource model with other Harness adapters. +type AgentPluginConfig = agentplugin.Resources -// StandaloneSkill identifies one independently sourced skill, rather than a -// skill selected from an Agent Plugin bundle. -type StandaloneSkill struct { - Name string `json:"name"` - Source AgentPluginSource `json:"source"` -} +type StandaloneSkill = agentplugin.Skill -type AgentPluginBundle struct { - Source AgentPluginSource `json:"source"` - Skills []string `json:"skills,omitempty"` -} +type AgentPluginBundle = agentplugin.Bundle -type AgentPluginSource struct { - OCI string `json:"oci,omitempty"` - Git *AgentPluginGit `json:"git,omitempty"` - S3 *AgentPluginS3 `json:"s3,omitempty"` - Path string `json:"path,omitempty"` -} +type AgentPluginSource = agentplugin.Source -type AgentPluginGit struct { - URL string `json:"url"` - Commit string `json:"commit"` -} +type AgentPluginGit = agentplugin.GitSource -type AgentPluginS3 struct { - Endpoint string `json:"endpoint"` - Bucket string `json:"bucket"` - Key string `json:"key"` - VersionID string `json:"versionId"` - Region string `json:"region,omitempty"` -} +type AgentPluginS3 = agentplugin.S3Source // AgentContextConfig is the context management configuration that flows through config.json to the Python runtime. type AgentContextConfig struct { diff --git a/go/api/agentplugin/types.go b/go/api/agentplugin/types.go new file mode 100644 index 000000000..87d7f1cb7 --- /dev/null +++ b/go/api/agentplugin/types.go @@ -0,0 +1,45 @@ +// Package agentplugin defines the runtime-neutral configuration for immutable +// standalone skills and Agent Plugin packages. +package agentplugin + +// Resources describes the skill resources a Harness must materialize before +// starting an agent. +type Resources struct { + Skills []Skill `json:"skills,omitempty"` + Plugins []Bundle `json:"plugins,omitempty"` +} + +// Skill identifies one independently sourced skill. +type Skill struct { + Name string `json:"name"` + Source Source `json:"source"` +} + +// Bundle selects named skills from one immutable Agent Plugin package. +type Bundle struct { + Source Source `json:"source"` + Skills []string `json:"skills,omitempty"` +} + +// Source selects one immutable artifact and an optional directory within it. +type Source struct { + OCI string `json:"oci,omitempty"` + Git *GitSource `json:"git,omitempty"` + S3 *S3Source `json:"s3,omitempty"` + Path string `json:"path,omitempty"` +} + +// GitSource identifies one immutable Git commit. +type GitSource struct { + URL string `json:"url"` + Commit string `json:"commit"` +} + +// S3Source identifies one immutable S3 object version. +type S3Source struct { + Endpoint string `json:"endpoint"` + Bucket string `json:"bucket"` + Key string `json:"key"` + VersionID string `json:"versionId"` + Region string `json:"region,omitempty"` +} diff --git a/go/api/config/crd/bases/kagent.dev_agenttemplates.yaml b/go/api/config/crd/bases/kagent.dev_agenttemplates.yaml index 1cc97d3e5..deef45c59 100644 --- a/go/api/config/crd/bases/kagent.dev_agenttemplates.yaml +++ b/go/api/config/crd/bases/kagent.dev_agenttemplates.yaml @@ -317,7 +317,7 @@ spec: - templateRef type: object mcp: - description: MCPToolBinding selects tools from a same-namespace + description: MCPToolBinding binds tools from a same-namespace MCP server. properties: server: @@ -337,16 +337,18 @@ spec: - name type: object tools: + description: |- + 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. items: minLength: 1 type: string maxItems: 50 - minItems: 1 type: array x-kubernetes-list-type: set required: - server - - tools type: object type: object x-kubernetes-validations: diff --git a/go/api/v1alpha3/agenttemplate_types.go b/go/api/v1alpha3/agenttemplate_types.go index 045518f87..cfddaf8e7 100644 --- a/go/api/v1alpha3/agenttemplate_types.go +++ b/go/api/v1alpha3/agenttemplate_types.go @@ -70,16 +70,18 @@ type AgentTemplatePromptSource struct { Alias string `json:"alias,omitempty"` } -// MCPToolBinding selects tools from a same-namespace MCP server. +// MCPToolBinding binds tools from a same-namespace MCP server. type MCPToolBinding struct { // +required Server AgentTemplateTypedLocalReference `json:"server"` - // +kubebuilder:validation:MinItems=1 + // 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. // +kubebuilder:validation:MaxItems=50 // +kubebuilder:validation:items:MinLength=1 // +listType=set - // +required - Tools []string `json:"tools"` + // +optional + Tools []string `json:"tools,omitempty"` } // AgentToolIsolation controls whether a referenced template shares its parent's runtime boundary. diff --git a/go/core/test/e2e/claude_interaction_test.go b/go/core/test/e2e/claude_interaction_test.go index 7be5940fd..cd04cca40 100644 --- a/go/core/test/e2e/claude_interaction_test.go +++ b/go/core/test/e2e/claude_interaction_test.go @@ -1,8 +1,10 @@ package e2e_test import ( + "bytes" "context" "embed" + "encoding/json" "errors" "io" "strings" @@ -21,7 +23,7 @@ import ( const claudeE2EHarness = "claude-e2e" -//go:embed mocks/invoke_claude_agent.json mocks/invoke_claude_builtin_tools.json mocks/invoke_claude_local_subagent.json +//go:embed mocks/invoke_claude_agent.json mocks/invoke_claude_builtin_tools.json mocks/invoke_claude_local_subagent.json mocks/invoke_claude_resources.json var claudeInteractionMocks embed.FS func TestClaudeMockInteractionResumeAndPersistence(t *testing.T) { @@ -423,3 +425,86 @@ func assertNoClaudeChildInstance(t *testing.T, fixture *interactionFixture, chil } } } + +func TestClaudeMockWholeServerMCP(t *testing.T) { + target := interactionTarget(t) + mcpURL, mcpMock := startMCPMock(t) + + kube := interactionKubeClient(t) + mcpServer := createClaudeMCPServer(t, kube, mcpURL) + toolName := "mcp__" + mcpServer.Name + "__add_numbers" + llmURL := startClaudeResourceMockLLM(t, toolName) + model := createClaudeMockModel(t, kube, llmURL) + template := createClaudeMCPTemplate(t, kube, model.Name, mcpServer.Name) + fixture := newInteractionFixtureForHarnessTemplate(t, target, claudeE2EHarness, template) + + streamed := sendClaudeStreaming(t, fixture, "Add 3 and 5 using the configured MCP server.") + if streamed.state != a2atype.TaskStateCompleted || !strings.Contains(streamed.text, "CLAUDE_MCP_DONE result is 8") { + t.Fatalf("whole-server MCP task state = %s, text = %q, failure = %q", streamed.state, streamed.text, streamed.failureText) + } + assertClaudeToolEvents(t, streamed.toolEvents, toolName) + assertClaudeToolEvents(t, claudeTaskToolEvents(getClaudeTask(t, fixture, streamed.taskID)), toolName) + + for _, request := range mcpMock.Requests() { + if bytes.Contains(request.Body, []byte(`"method":"tools/call"`)) && bytes.Contains(request.Body, []byte(`"name":"add_numbers"`)) { + return + } + } + t.Fatal("mock MCP server did not receive an add_numbers tool call") +} + +func createClaudeMCPServer(t *testing.T, kube ctrlclient.Client, mcpURL string) *v1alpha3.RemoteMCPServer { + t.Helper() + server := &v1alpha3.RemoteMCPServer{ + ObjectMeta: metav1.ObjectMeta{GenerateName: "claude-resources-", Namespace: "kagent"}, + Spec: v1alpha3.RemoteMCPServerSpec{ + Description: "Claude whole-server MCP E2E fixture", + Protocol: v1alpha3.RemoteMCPServerProtocolStreamableHttp, + URL: mcpURL, + }, + } + if err := kube.Create(t.Context(), server); err != nil { + t.Fatalf("create Claude RemoteMCPServer: %v", err) + } + t.Cleanup(func() { + if err := kube.Delete(context.Background(), server); err != nil && !apierrors.IsNotFound(err) { + t.Errorf("delete Claude RemoteMCPServer: %v", err) + } + }) + + return server +} + +func createClaudeMCPTemplate(t *testing.T, kube ctrlclient.Client, modelConfig, mcpServer string) string { + t.Helper() + template := &v1alpha3.AgentTemplate{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "claude-resources-", Namespace: "kagent", + Labels: map[string]string{"kagent.dev/e2e-runtime": "claude"}, + }, + Spec: v1alpha3.AgentTemplateSpec{ + 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{ + Server: v1alpha3.AgentTemplateTypedLocalReference{Kind: "RemoteMCPServer", Name: mcpServer}, + }}}, + }, + } + createAndWaitInteractionTemplateForHarness(t, kube, template, claudeE2EHarness) + return template.Name +} + +func startClaudeResourceMockLLM(t *testing.T, toolName string) string { + t.Helper() + raw, err := claudeInteractionMocks.ReadFile("mocks/invoke_claude_resources.json") + if err != nil { + t.Fatalf("read Claude resource mock fixture: %v", err) + } + raw = bytes.ReplaceAll(raw, []byte("MCP_TOOL_NAME"), []byte(toolName)) + var cfg mockllm.Config + if err := json.Unmarshal(raw, &cfg); err != nil { + t.Fatalf("decode Claude resource mock fixture: %v", err) + } + return reachableServerURL(t, startMockLLMConfig(t, cfg), "") +} diff --git a/go/core/test/e2e/interaction_test.go b/go/core/test/e2e/interaction_test.go index 499afd0e6..66a60bd70 100644 --- a/go/core/test/e2e/interaction_test.go +++ b/go/core/test/e2e/interaction_test.go @@ -24,10 +24,12 @@ import ( a2agrpc "github.com/a2aproject/a2a-go/v2/a2agrpc/v1" a2apb "github.com/a2aproject/a2a-go/v2/a2apb/v1" "github.com/a2aproject/a2a-go/v2/a2apb/v1/pbconv" + atev1alpha1 "github.com/agent-substrate/substrate/pkg/api/v1alpha1" "github.com/google/uuid" adka2a "github.com/kagent-dev/kagent/go/adk/pkg/a2a" apiv1alpha1 "github.com/kagent-dev/kagent/go/api/gen/kagent/api/v1alpha1" "github.com/kagent-dev/kagent/go/api/v1alpha3" + v2substrate "github.com/kagent-dev/kagent/go/core/v2/substrate" "github.com/kagent-dev/mockllm" "github.com/kagent-dev/mockmcp" "google.golang.org/grpc" @@ -101,7 +103,7 @@ func TestAgentInstanceCheckpoint(t *testing.T) { } checkpoint := created.GetCheckpoint() t.Cleanup(func() { - cleanupCtx, cleanupCancel := context.WithTimeout(metadata.AppendToOutgoingContext(context.Background(), "x-user-id", "e2e"), time.Minute) + cleanupCtx, cleanupCancel := context.WithTimeout(metadata.AppendToOutgoingContext(context.Background(), "x-user-id", "e2e"), 2*time.Minute) defer cleanupCancel() _, cleanupErr := fixture.checkpoints.DeleteCheckpoint(cleanupCtx, &apiv1alpha1.DeleteCheckpointRequest{ Namespace: "kagent", CheckpointId: checkpoint.GetId(), @@ -449,6 +451,8 @@ func newInteractionFixtureForHarnessTemplate(t *testing.T, target, harnessName, t.Cleanup(func() { cleanupCtx, cleanupCancel := context.WithTimeout(metadata.AppendToOutgoingContext(context.Background(), "x-user-id", "e2e"), time.Minute) defer cleanupCancel() + // DeleteAgentInstance returns only after its Substrate Actor has been + // suspended and deleted, so this cleanup covers both resources. _, cleanupErr := instances.DeleteAgentInstance(cleanupCtx, &apiv1alpha1.DeleteAgentInstanceRequest{ Namespace: "kagent", AgentInstanceId: instance.GetId(), }) @@ -559,6 +563,11 @@ func startMockLLMServer(t *testing.T, fixtures fs.ReadFileFS, fixture string) st if err != nil { t.Fatalf("load mock LLM response: %v", err) } + return startMockLLMConfig(t, cfg) +} + +func startMockLLMConfig(t *testing.T, cfg mockllm.Config) string { + t.Helper() server := mockllm.NewServer(cfg) baseURL, err := server.Start(t.Context()) if err != nil { @@ -767,6 +776,9 @@ func interactionKubeClient(t *testing.T) ctrlclient.Client { t.Fatalf("load Kubernetes config: %v", err) } clientScheme := k8sruntime.NewScheme() + if err := atev1alpha1.AddToScheme(clientScheme); err != nil { + t.Fatalf("register Substrate API: %v", err) + } if err := corev1.AddToScheme(clientScheme); err != nil { t.Fatalf("register Kubernetes core API: %v", err) } @@ -812,8 +824,48 @@ func createAndWaitInteractionTemplateForHarness(t *testing.T, kube ctrlclient.Cl t.Fatalf("create interaction AgentTemplate: %v", err) } t.Cleanup(func() { - if err := kube.Delete(context.Background(), template); err != nil && !apierrors.IsNotFound(err) { + cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cleanupCancel() + if err := kube.Delete(cleanupCtx, template); err != nil && !apierrors.IsNotFound(err) { t.Errorf("delete interaction AgentTemplate: %v", err) + return + } + if err := wait.PollUntilContextTimeout(cleanupCtx, time.Second, 2*time.Minute, true, func(ctx context.Context) (bool, error) { + current := &v1alpha3.AgentTemplate{} + if err := kube.Get(ctx, ctrlclient.ObjectKeyFromObject(template), current); err == nil { + return false, nil + } else if !apierrors.IsNotFound(err) { + return false, err + } + return true, nil + }); err != nil { + t.Errorf("wait for interaction AgentTemplate %s/%s to be deleted: %v", template.Namespace, template.Name, err) + return + } + + labels := ctrlclient.MatchingLabels{ + v2substrate.RevisionAgentTemplateLabel: template.Name, + v2substrate.RevisionHarnessLabel: harnessName, + } + actorTemplates := &atev1alpha1.ActorTemplateList{} + if err := kube.List(cleanupCtx, actorTemplates, ctrlclient.InNamespace(template.Namespace), labels); err != nil { + t.Errorf("list generated ActorTemplates for %s/%s harness %q: %v", template.Namespace, template.Name, harnessName, err) + return + } + for index := range actorTemplates.Items { + if err := kube.Delete(cleanupCtx, &actorTemplates.Items[index]); err != nil && !apierrors.IsNotFound(err) { + t.Errorf("delete generated ActorTemplate %s/%s: %v", actorTemplates.Items[index].Namespace, actorTemplates.Items[index].Name, err) + } + } + if err := wait.PollUntilContextTimeout(cleanupCtx, time.Second, 2*time.Minute, true, func(ctx context.Context) (bool, error) { + actorTemplates := &atev1alpha1.ActorTemplateList{} + if err := kube.List(ctx, actorTemplates, ctrlclient.InNamespace(template.Namespace), labels); err != nil { + return false, err + } + return len(actorTemplates.Items) == 0, nil + }); err != nil { + t.Errorf("wait for generated ActorTemplates for %s/%s harness %q to be deleted: %v", + template.Namespace, template.Name, harnessName, err) } }) diff --git a/go/core/test/e2e/mocks/invoke_claude_local_subagent.json b/go/core/test/e2e/mocks/invoke_claude_local_subagent.json index 747873b34..f4722e950 100644 --- a/go/core/test/e2e/mocks/invoke_claude_local_subagent.json +++ b/go/core/test/e2e/mocks/invoke_claude_local_subagent.json @@ -20,7 +20,8 @@ "input": { "description": "Run the local specialist", "prompt": "Return the specialist marker to the parent.", - "subagent_type": "specialist" + "subagent_type": "specialist", + "run_in_background": false } }], "model": "claude-sonnet-4-5", diff --git a/go/core/test/e2e/mocks/invoke_claude_resources.json b/go/core/test/e2e/mocks/invoke_claude_resources.json new file mode 100644 index 000000000..f68e883a6 --- /dev/null +++ b/go/core/test/e2e/mocks/invoke_claude_resources.json @@ -0,0 +1,68 @@ +{ + "anthropic": [ + { + "name": "claude_resource_start", + "match": { + "match_type": "contains", + "message": { + "role": "user", + "content": [{"type": "text", "text": "Add 3 and 5 using the configured MCP server."}] + }, + "system_contains": ["Use the configured MCP tool."], + "tool_names": ["MCP_TOOL_NAME"] + }, + "response": { + "id": "msg_claude_resource_add", + "type": "message", + "role": "assistant", + "content": [{ + "type": "tool_use", + "id": "toolu_claude_resource_add", + "name": "MCP_TOOL_NAME", + "input": {"a": 3, "b": 5} + }], + "model": "claude-sonnet-4-5", + "stop_reason": "tool_use", + "usage": {"input_tokens": 12, "output_tokens": 5} + } + }, + { + "name": "claude_resource_result", + "match": { + "match_type": "contains", + "message": { + "role": "user", + "content": [{"type": "tool_result", "tool_use_id": "toolu_claude_resource_add"}] + } + }, + "response": { + "id": "msg_claude_resource_done", + "type": "message", + "role": "assistant", + "content": [{"type": "text", "text": "CLAUDE_MCP_DONE result is 8"}], + "model": "claude-sonnet-4-5", + "stop_reason": "end_turn", + "usage": {"input_tokens": 16, "output_tokens": 6} + } + }, + { + "name": "claude_resource_auxiliary", + "match": { + "match_type": "contains", + "message": { + "role": "user", + "content": [{"type": "text", "text": ""}] + } + }, + "response": { + "id": "msg_claude_resource_auxiliary", + "type": "message", + "role": "assistant", + "content": [{"type": "text", "text": "OK"}], + "model": "claude-sonnet-4-5", + "stop_reason": "end_turn", + "usage": {"input_tokens": 2, "output_tokens": 1} + } + } + ] +} diff --git a/go/core/v2/agentplugins/materialize.go b/go/core/v2/agentplugins/materialize.go index 7579462c9..769a6b4a7 100644 --- a/go/core/v2/agentplugins/materialize.go +++ b/go/core/v2/agentplugins/materialize.go @@ -15,6 +15,7 @@ import ( "github.com/go-logr/logr" "github.com/kagent-dev/kagent/go/api/adk" + "github.com/kagent-dev/kagent/go/api/agentplugin" "github.com/kagent-dev/kagent/go/core/internal/skillsinit" ) @@ -30,22 +31,29 @@ const ( var pluginNamePattern = regexp.MustCompile(`^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$`) -type Paths struct { +// SkillPaths contains the package cache and selected-skill destinations shared +// by Harness adapters. +type SkillPaths struct { Plugins string Skills string - Data string } -type MCPConfig struct { +// ADKPaths adds the plugin data destination needed by ADK MCP servers. +type ADKPaths struct { + SkillPaths + Data string +} + +type mcpConfig struct { HTTP []adk.HttpMcpServerConfig SSE []adk.SseMcpServerConfig Stdio []adk.StdioMcpServerConfig } // MaterializeAgentConfig materializes plugins independently for every agent. -func MaterializeAgentConfig(ctx context.Context, config *adk.AgentConfig, paths Paths) error { +func MaterializeAgentConfig(ctx context.Context, config *adk.AgentConfig, paths ADKPaths) error { if config.AgentPlugins != nil { - plugins, err := Materialize(ctx, *config.AgentPlugins, paths) + plugins, err := materializeForADK(ctx, *config.AgentPlugins, paths) if err != nil { return err } @@ -56,10 +64,12 @@ func MaterializeAgentConfig(ctx context.Context, config *adk.AgentConfig, paths } for i, child := range config.SubAgents { childRoot := filepath.Join("subagents", fmt.Sprintf("%d", i)) - if err := MaterializeAgentConfig(ctx, child, Paths{ - Plugins: filepath.Join(paths.Plugins, childRoot), - Skills: filepath.Join(paths.Skills, childRoot), - Data: filepath.Join(paths.Data, childRoot), + if err := MaterializeAgentConfig(ctx, child, ADKPaths{ + SkillPaths: SkillPaths{ + Plugins: filepath.Join(paths.Plugins, childRoot), + Skills: filepath.Join(paths.Skills, childRoot), + }, + Data: filepath.Join(paths.Data, childRoot), }); err != nil { return fmt.Errorf("materialize sub-agent %q: %w", child.Name, err) } @@ -67,67 +77,92 @@ func MaterializeAgentConfig(ctx context.Context, config *adk.AgentConfig, paths return nil } -func Materialize(ctx context.Context, config adk.AgentPluginConfig, paths Paths) (MCPConfig, error) { - selectedSkills := make([]string, 0, len(config.Skills)) - for _, skill := range config.Skills { +// MaterializeSkills materializes standalone and plugin-selected skills without +// loading any plugin-provided MCP configuration. Runtimes that support only +// Agent Skills should use this narrower entrypoint. +func MaterializeSkills(ctx context.Context, resources agentplugin.Resources, paths SkillPaths) error { + _, err := materializeResources(ctx, resources, paths) + return err +} + +func materializeForADK(ctx context.Context, resources agentplugin.Resources, paths ADKPaths) (mcpConfig, error) { + plugins, err := materializeResources(ctx, resources, paths.SkillPaths) + if err != nil { + return mcpConfig{}, err + } + if err := os.MkdirAll(paths.Data, 0o755); err != nil { + return mcpConfig{}, fmt.Errorf("create plugin data directory: %w", err) + } + var result mcpConfig + for _, plugin := range plugins { + mcp := loadMCP(ctx, plugin.root, filepath.Join(paths.Data, plugin.name)) + result.HTTP = append(result.HTTP, mcp.HTTP...) + result.SSE = append(result.SSE, mcp.SSE...) + result.Stdio = append(result.Stdio, mcp.Stdio...) + } + return result, nil +} + +type materializedPlugin struct { + name string + root string +} + +func materializeResources(ctx context.Context, resources agentplugin.Resources, paths SkillPaths) ([]materializedPlugin, error) { + selectedSkills := make([]string, 0, len(resources.Skills)) + for _, skill := range resources.Skills { selectedSkills = append(selectedSkills, skill.Name) } - for _, plugin := range config.Plugins { + for _, plugin := range resources.Plugins { selectedSkills = append(selectedSkills, plugin.Skills...) } if err := validateSkillSelections(selectedSkills); err != nil { - return MCPConfig{}, err + return nil, err } if err := os.MkdirAll(paths.Skills, 0o755); err != nil { - return MCPConfig{}, fmt.Errorf("create skills directory: %w", err) + return nil, fmt.Errorf("create skills directory: %w", err) } if err := os.MkdirAll(paths.Plugins, 0o755); err != nil { - return MCPConfig{}, fmt.Errorf("create plugins directory: %w", err) - } - if err := os.MkdirAll(paths.Data, 0o755); err != nil { - return MCPConfig{}, fmt.Errorf("create plugin data directory: %w", err) + return nil, fmt.Errorf("create plugins directory: %w", err) } - var result MCPConfig - for i, skill := range config.Skills { + for i, skill := range resources.Skills { root := filepath.Join(paths.Plugins, fmt.Sprintf("standalone-%d", i)) sourceRoot, err := fetchSource(ctx, skill.Source, root, "SKILL.md") if err != nil { - return MCPConfig{}, fmt.Errorf("materialize skill %q: %w", skill.Name, err) + return nil, fmt.Errorf("materialize skill %q: %w", skill.Name, err) } if err := copySkill(sourceRoot, filepath.Join(paths.Skills, skill.Name)); err != nil { - return MCPConfig{}, fmt.Errorf("materialize skill %q: %w", skill.Name, err) + return nil, fmt.Errorf("materialize skill %q: %w", skill.Name, err) } } pluginNames := make(map[string]struct{}) - for i, plugin := range config.Plugins { + plugins := make([]materializedPlugin, 0, len(resources.Plugins)) + for i, plugin := range resources.Plugins { root := filepath.Join(paths.Plugins, fmt.Sprintf("plugin-%d", i)) pluginRoot, err := fetchSource(ctx, plugin.Source, root, "plugin.json") if err != nil { - return MCPConfig{}, fmt.Errorf("materialize plugin %d: %w", i, err) + return nil, fmt.Errorf("materialize plugin %d: %w", i, err) } manifest, err := loadManifest(pluginRoot) if err != nil { - return MCPConfig{}, fmt.Errorf("load plugin %d: %w", i, err) + return nil, fmt.Errorf("load plugin %d: %w", i, err) } if _, exists := pluginNames[manifest.Name]; exists { - return MCPConfig{}, fmt.Errorf("duplicate plugin name %q", manifest.Name) + return nil, fmt.Errorf("duplicate plugin name %q", manifest.Name) } pluginNames[manifest.Name] = struct{}{} for _, name := range plugin.Skills { source := filepath.Join(pluginRoot, "skills", name) if err := copySkill(source, filepath.Join(paths.Skills, name)); err != nil { - return MCPConfig{}, fmt.Errorf("plugin %q skill %q: %w", manifest.Name, name, err) + return nil, fmt.Errorf("plugin %q skill %q: %w", manifest.Name, name, err) } } - mcp := loadMCP(ctx, pluginRoot, filepath.Join(paths.Data, manifest.Name)) - result.HTTP = append(result.HTTP, mcp.HTTP...) - result.SSE = append(result.SSE, mcp.SSE...) - result.Stdio = append(result.Stdio, mcp.Stdio...) + plugins = append(plugins, materializedPlugin{name: manifest.Name, root: pluginRoot}) } - return result, nil + return plugins, nil } func validateSkillSelections(names []string) error { @@ -151,7 +186,7 @@ func validateSkillName(name string) error { return nil } -func fetchSource(ctx context.Context, source adk.AgentPluginSource, destination, requiredFile string) (string, error) { +func fetchSource(ctx context.Context, source agentplugin.Source, destination, requiredFile string) (string, error) { selected := 0 if source.OCI != "" { selected++ @@ -414,36 +449,36 @@ type mcpServer struct { Headers map[string]string `json:"headers,omitempty"` } -func loadMCP(ctx context.Context, root, dataRoot string) MCPConfig { +func loadMCP(ctx context.Context, root, dataRoot string) mcpConfig { raw, err := os.ReadFile(filepath.Join(root, "mcp.json")) if os.IsNotExist(err) { - return MCPConfig{} + return mcpConfig{} } log := logr.FromContextOrDiscard(ctx) if err != nil { log.Error(err, "Unable to read plugin MCP configuration", "pluginRoot", root) - return MCPConfig{} + return mcpConfig{} } var document mcpDocument decoder := json.NewDecoder(strings.NewReader(string(raw))) decoder.DisallowUnknownFields() if err := decoder.Decode(&document); err != nil { log.Error(err, "Invalid plugin MCP configuration", "pluginRoot", root) - return MCPConfig{} + return mcpConfig{} } if document.Schema != mcpSchema { log.Error(fmt.Errorf("unsupported MCP schema %q", document.Schema), "Invalid plugin MCP configuration", "pluginRoot", root) - return MCPConfig{} + return mcpConfig{} } if document.Servers == nil { log.Error(fmt.Errorf("mcpServers is required"), "Invalid plugin MCP configuration", "pluginRoot", root) - return MCPConfig{} + return mcpConfig{} } if err := os.MkdirAll(dataRoot, 0o755); err != nil { log.Error(err, "Unable to create plugin data directory", "pluginRoot", root) - return MCPConfig{} + return mcpConfig{} } - var result MCPConfig + var result mcpConfig names := make([]string, 0, len(document.Servers)) for name := range document.Servers { names = append(names, name) diff --git a/go/core/v2/agentplugins/materialize_test.go b/go/core/v2/agentplugins/materialize_test.go index c76dbd927..9f02d55c8 100644 --- a/go/core/v2/agentplugins/materialize_test.go +++ b/go/core/v2/agentplugins/materialize_test.go @@ -10,6 +10,7 @@ import ( "testing" "github.com/kagent-dev/kagent/go/api/adk" + "github.com/kagent-dev/kagent/go/api/agentplugin" ) func TestMaterializeGitPlugin(t *testing.T) { @@ -44,9 +45,12 @@ func TestMaterializeGitPlugin(t *testing.T) { commit := git("rev-parse", "HEAD") root := t.TempDir() - result, err := Materialize(context.Background(), adk.AgentPluginConfig{Plugins: []adk.AgentPluginBundle{{ + result, err := materializeForADK(context.Background(), adk.AgentPluginConfig{Plugins: []adk.AgentPluginBundle{{ Source: adk.AgentPluginSource{Git: &adk.AgentPluginGit{URL: repository, Commit: commit}}, Skills: []string{"review"}, - }}}, Paths{Plugins: filepath.Join(root, "plugins"), Skills: filepath.Join(root, "skills"), Data: filepath.Join(root, "data")}) + }}}, ADKPaths{ + SkillPaths: SkillPaths{Plugins: filepath.Join(root, "plugins"), Skills: filepath.Join(root, "skills")}, + Data: filepath.Join(root, "data"), + }) if err != nil { t.Fatal(err) } @@ -64,21 +68,21 @@ func TestFetchSourceReusesExistingMaterialization(t *testing.T) { t.Fatal(err) } - root, err := fetchSource(context.Background(), adk.AgentPluginSource{Git: &adk.AgentPluginGit{ + root, err := fetchSource(context.Background(), agentplugin.Source{Git: &agentplugin.GitSource{ URL: "does-not-exist", Commit: strings.Repeat("a", 40), }}, destination, "SKILL.md") if err != nil { t.Fatalf("fetchSource() redownloaded existing materialization: %v", err) } - if root != destination { - t.Fatalf("fetchSource() root = %q, want %q", root, destination) + if root != canonicalPath(destination) { + t.Fatalf("fetchSource() root = %q, want %q", root, canonicalPath(destination)) } } func TestFetchSourceDoesNotReuseIncompleteMaterialization(t *testing.T) { destination := t.TempDir() - _, err := fetchSource(context.Background(), adk.AgentPluginSource{Git: &adk.AgentPluginGit{ + _, err := fetchSource(context.Background(), agentplugin.Source{Git: &agentplugin.GitSource{ URL: "does-not-exist", Commit: strings.Repeat("a", 40), }}, destination, "SKILL.md") if err == nil { @@ -88,7 +92,10 @@ func TestFetchSourceDoesNotReuseIncompleteMaterialization(t *testing.T) { func TestMaterializeAgentConfigIsolatesSubagentSkills(t *testing.T) { root := t.TempDir() - paths := Paths{Plugins: filepath.Join(root, "plugins"), Skills: filepath.Join(root, "skills"), Data: filepath.Join(root, "data")} + paths := ADKPaths{ + SkillPaths: SkillPaths{Plugins: filepath.Join(root, "plugins"), Skills: filepath.Join(root, "skills")}, + Data: filepath.Join(root, "data"), + } source := adk.AgentPluginSource{Git: &adk.AgentPluginGit{URL: "unused", Commit: strings.Repeat("a", 40)}} config := &adk.AgentConfig{ AgentPlugins: &adk.AgentPluginConfig{Skills: []adk.StandaloneSkill{{Name: "root", Source: source}}}, @@ -121,6 +128,42 @@ func TestMaterializeAgentConfigIsolatesSubagentSkills(t *testing.T) { } } +func TestMaterializeSkillsCopiesSelectionsWithoutLoadingPluginMCP(t *testing.T) { + root := t.TempDir() + pluginRoot := filepath.Join(root, "plugins", "plugin-0") + if err := os.MkdirAll(filepath.Join(pluginRoot, "skills", "review"), 0o755); err != nil { + t.Fatal(err) + } + files := map[string]string{ + "plugin.json": `{"$schema":"https://agent-plugins.org/schemas/1.0.0/plugin.schema.json","name":"acme.test"}`, + "mcp.json": `{"$schema":"https://agent-plugins.org/schemas/1.0.0/mcp.schema.json","mcpServers":{"local":{"type":"stdio","command":"server"}}}`, + "skills/review/SKILL.md": "# Review", + } + for name, content := range files { + if err := os.WriteFile(filepath.Join(pluginRoot, filepath.FromSlash(name)), []byte(content), 0o644); err != nil { + t.Fatal(err) + } + } + paths := SkillPaths{ + Plugins: filepath.Join(root, "plugins"), + Skills: filepath.Join(root, "skills"), + } + resources := agentplugin.Resources{Plugins: []agentplugin.Bundle{{ + Source: agentplugin.Source{Git: &agentplugin.GitSource{URL: "unused", Commit: strings.Repeat("a", 40)}}, + Skills: []string{"review"}, + }}} + if err := MaterializeSkills(context.Background(), resources, paths); err != nil { + t.Fatal(err) + } + content, err := os.ReadFile(filepath.Join(paths.Skills, "review", "SKILL.md")) + if err != nil || string(content) != "# Review" { + t.Fatalf("materialized skill = %q, %v", content, err) + } + if _, err := os.Stat(filepath.Join(root, "data")); !os.IsNotExist(err) { + t.Fatalf("plugin MCP data directory was created: %v", err) + } +} + func TestLoadManifestUsesAgentPluginsV1Schema(t *testing.T) { root := t.TempDir() raw := `{ diff --git a/go/core/v2/controller/reconciler.go b/go/core/v2/controller/reconciler.go index b19550a83..ed21f1430 100644 --- a/go/core/v2/controller/reconciler.go +++ b/go/core/v2/controller/reconciler.go @@ -24,6 +24,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" "k8s.io/client-go/rest" + ctrllog "sigs.k8s.io/controller-runtime/pkg/log" ) // PairReconciliation is the complete desired and observed state for one @@ -224,6 +225,14 @@ func (r *Reconciler) reconcilePair(ctx context.Context, key string) error { if state.Revision == nil || state.RevisionID.IsZero() { return r.cleanupUnreferencedRevisions(ctx) } + for _, warning := range state.Revision.Warnings { + ctrllog.FromContext(ctx).Info("runtime configuration warning", + "namespace", state.Pair.AgentTemplate.Namespace, + "agentTemplate", state.Pair.AgentTemplate.Name, + "harness", state.Pair.Harness.Name, + "warning", warning, + ) + } pair := dbpkg.AgentTemplateHarnessPair{ Namespace: state.Pair.AgentTemplate.Namespace, AgentTemplateName: state.Pair.AgentTemplate.Name, diff --git a/go/core/v2/translator/claude/compiler.go b/go/core/v2/translator/claude/compiler.go index 193f6375d..3fa95ab9d 100644 --- a/go/core/v2/translator/claude/compiler.go +++ b/go/core/v2/translator/claude/compiler.go @@ -51,12 +51,6 @@ func (c *Compiler) Compile(ctx context.Context, input *v2translator.HarnessInput if input == nil || input.Harness == nil || input.Root == nil || input.Root.Template == nil || input.Root.ModelConfig == nil { return nil, fmt.Errorf("Claude compiler requires a resolved Harness, AgentTemplate, and ModelConfig") } - if len(input.Root.MCPTools) != 0 { - return nil, v2translator.NewValidationError("Claude external MCP tools are not supported yet") - } - if len(input.Root.Template.Spec.Skills) != 0 || len(input.Root.Template.Spec.Plugins) != 0 { - return nil, v2translator.NewValidationError("Claude skills and plugins are not supported yet") - } model := input.Root.ModelConfig if strings.TrimSpace(model.Spec.Model) == "" { return nil, v2translator.NewValidationError("Claude ModelConfig model is required") @@ -69,11 +63,23 @@ func (c *Compiler) Compile(ctx context.Context, input *v2translator.HarnessInput if err != nil { return nil, err } + skillResources, skillEgress, err := v2translator.CompileSkillResources(input.Root.Template) + if err != nil { + return nil, err + } + mcp, err := c.compileMCP(ctx, input.Root.Template.Namespace, input.Root.MCPTools) + if err != nil { + return nil, err + } environment := append([]corev1.EnvVar(nil), providerEnvironment...) + environment = append(environment, mcp.environment...) for _, variable := range input.Harness.Spec.Env { if _, reserved := ownedEnvironment[variable.Name]; reserved { return nil, v2translator.NewValidationError("Harness env %q conflicts with Claude's compiled provider configuration", variable.Name) } + if strings.HasPrefix(variable.Name, mcpCredentialPrefix) { + return nil, v2translator.NewValidationError("Harness env %q conflicts with Claude's compiled MCP credentials", variable.Name) + } envVar := corev1.EnvVar{Name: variable.Name} if variable.Value != nil { envVar.Value = *variable.Value @@ -95,6 +101,10 @@ func (c *Compiler) Compile(ctx context.Context, input *v2translator.HarnessInput } config := claudeconfig.Production(model.Spec.Model, input.Root.Instruction) config.Agents = localAgents + if len(skillResources.Skills) != 0 || len(skillResources.Plugins) != 0 { + config.SkillResources = &skillResources + } + config.MCPServers = mcp.servers if err := config.Validate(); err != nil { return nil, v2translator.NewValidationError("invalid compiled Claude configuration: %v", err) } @@ -115,6 +125,10 @@ func (c *Compiler) Compile(ctx context.Context, input *v2translator.HarnessInput return nil, fmt.Errorf("resolve Claude runtime environment: %w", err) } + egress = append(egress, skillEgress...) + egress = append(egress, mcp.egress...) + slices.Sort(egress) + egress = slices.Compact(egress) template, harness := input.Root.Template, input.Harness return &v2translator.Revision{ Namespace: template.Namespace, AgentTemplateName: template.Name, HarnessName: harness.Name, @@ -122,7 +136,7 @@ func (c *Compiler) Compile(ctx context.Context, input *v2translator.HarnessInput ConfigJSON: configJSON, AgentCardJSON: cardJSON, WorkerPoolName: harness.Spec.Substrate.WorkerPoolRef.Name, SnapshotLocation: harness.Spec.Substrate.SnapshotPolicy.Location, - Provenance: provenance, EgressDestinations: egress, + Provenance: provenance, EgressDestinations: egress, Warnings: mcp.warnings, }, nil } @@ -374,6 +388,12 @@ func (c *Compiler) buildProvenance(ctx context.Context, input *v2translator.Harn for _, child := range agent.Shared { addAgent(child.Agent) } + for _, tool := range agent.MCPTools { + server := tool.Server + if server != nil { + addObject("RemoteMCPServer", server.Name, server.UID, server.Generation, server.Spec) + } + } } addAgent(input.Root) for name := range configMaps { diff --git a/go/core/v2/translator/claude/compiler_test.go b/go/core/v2/translator/claude/compiler_test.go index 85183364e..3f600b7bf 100644 --- a/go/core/v2/translator/claude/compiler_test.go +++ b/go/core/v2/translator/claude/compiler_test.go @@ -74,7 +74,7 @@ func TestCompileSupportedProviders(t *testing.T) { secretData: map[string][]byte{"credentials.json": []byte(`{"type":"service_account","project_id":"project","token_uri":"https://oauth2.googleapis.com/token","private_key":"` + credentialValue + `"}`)}, wantEnv: map[string]string{useVertexEnv: "1", vertexProjectEnv: "project", vertexRegionEnv: "us-east5", claudeconfig.GoogleCredentialsJSONEnvName: `{"type":"service_account","project_id":"project","token_uri":"https://oauth2.googleapis.com/token","private_key":"` + credentialValue + `"}`}, - wantEgress: []string{"us-east5-aiplatform.googleapis.com", "oauth2.googleapis.com"}, + wantEgress: []string{"oauth2.googleapis.com", "us-east5-aiplatform.googleapis.com"}, }, } @@ -166,6 +166,151 @@ func TestCompileRejectsProviderOwnedHarnessEnvironment(t *testing.T) { } } +func TestCompileRootSkillsAndPluginSelections(t *testing.T) { + model := v1alpha3.ModelConfigSpec{ + Provider: v1alpha3.ModelProviderAnthropic, Model: "claude-sonnet-4-5", + APIKeySecret: "model-auth", APIKeySecretKey: "api-key", + } + input, reader := testInput(t, model, map[string][]byte{"api-key": []byte("secret")}) + input.Root.Template.Spec.Skills = []v1alpha3.AgentTemplateSkill{{ + Name: "review", Source: v1alpha3.ArtifactSource{Git: &v1alpha3.GitArtifact{ + URL: "https://git.example.com/skills.git", Commit: strings.Repeat("a", 40), + }}, + }} + input.Root.Template.Spec.Plugins = []v1alpha3.PluginBundle{{ + Source: v1alpha3.ArtifactSource{OCI: "registry.example.com/team/plugin@sha256:" + strings.Repeat("b", 64)}, + Skills: []string{"deploy"}, + }} + + revision, err := NewCompiler(reader).Compile(context.Background(), input) + if err != nil { + t.Fatal(err) + } + var cfg claudeconfig.Config + if err := json.Unmarshal(revision.ConfigJSON, &cfg); err != nil { + t.Fatal(err) + } + if cfg.SkillResources == nil || len(cfg.SkillResources.Skills) != 1 || cfg.SkillResources.Skills[0].Name != "review" || + len(cfg.SkillResources.Plugins) != 1 || !reflect.DeepEqual(cfg.SkillResources.Plugins[0].Skills, []string{"deploy"}) { + t.Fatalf("compiled skills = %#v", cfg.SkillResources) + } + wantEgress := []string{"api.anthropic.com", "git.example.com", "registry.example.com"} + if !reflect.DeepEqual(revision.EgressDestinations, wantEgress) { + t.Fatalf("egress = %v, want %v", revision.EgressDestinations, wantEgress) + } + + input.Root.Template.Spec.Plugins[0].Skills = []string{"review"} + if _, err := NewCompiler(reader).Compile(context.Background(), input); err == nil || !strings.Contains(err.Error(), "duplicate skill name") { + t.Fatalf("duplicate skill Compile() error = %v", err) + } +} + +func TestCompileDirectWholeServerMCP(t *testing.T) { + model := v1alpha3.ModelConfigSpec{ + Provider: v1alpha3.ModelProviderAnthropic, Model: "claude-sonnet-4-5", + APIKeySecret: "model-auth", APIKeySecretKey: "api-key", + } + input, reader := testInput(t, model, map[string][]byte{ + "api-key": []byte("model-secret"), "mcp-token": []byte(credentialValue), + }) + server := &v1alpha3.RemoteMCPServer{ + ObjectMeta: metav1.ObjectMeta{Name: "math-server", Namespace: "test", UID: "mcp-uid", Generation: 3}, + Spec: v1alpha3.RemoteMCPServerSpec{ + Protocol: v1alpha3.RemoteMCPServerProtocolStreamableHttp, + URL: "https://mcp.example.com/mcp", + HeadersFrom: []v1alpha3.ValueRef{ + {Name: "X-Tenant", Value: "test"}, + {Name: "Authorization", ValueFrom: &v1alpha3.ValueSource{Type: v1alpha3.SecretValueSource, Name: "model-auth", Key: "mcp-token"}}, + }, + }, + Status: v1alpha3.RemoteMCPServerStatus{ObservedGeneration: 3, DiscoveredTools: []*v1alpha3.MCPTool{ + {Name: "echo"}, {Name: "add_numbers"}, {Name: "get_time"}, + }}, + } + input.Root.Template.Spec.Tools = []v1alpha3.ToolBinding{{MCP: &v1alpha3.MCPToolBinding{ + Server: v1alpha3.AgentTemplateTypedLocalReference{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}} + + revision, err := NewCompiler(reader).Compile(context.Background(), input) + if err != nil { + t.Fatal(err) + } + if len(revision.Warnings) != 0 { + t.Fatalf("whole-server selection warnings = %v", revision.Warnings) + } + var cfg claudeconfig.Config + if err := json.Unmarshal(revision.ConfigJSON, &cfg); err != nil { + t.Fatal(err) + } + compiled := cfg.MCPServers["math-server"] + if compiled.Type != "http" || compiled.URL != server.Spec.URL || compiled.Headers["X-Tenant"] != "test" || + !strings.HasPrefix(compiled.Headers["Authorization"], "${"+mcpCredentialPrefix) { + t.Fatalf("compiled MCP = %#v", cfg.MCPServers) + } + if bytes.Contains(revision.ConfigJSON, []byte(credentialValue)) || bytes.Contains(revision.Provenance, []byte(credentialValue)) { + t.Fatal("compiled MCP config or provenance contains credential material") + } + foundSecret := false + for _, variable := range revision.Environment { + if strings.HasPrefix(variable.Name, mcpCredentialPrefix) && variable.Value == credentialValue { + foundSecret = true + } + } + if !foundSecret { + t.Fatalf("MCP credential environment missing: %#v", revision.Environment) + } + if !reflect.DeepEqual(revision.EgressDestinations, []string{"api.anthropic.com", "mcp.example.com"}) { + t.Fatalf("egress = %v", revision.EgressDestinations) + } + if !bytes.Contains(revision.Provenance, []byte(`"kind":"RemoteMCPServer"`)) { + t.Fatalf("provenance omits RemoteMCPServer: %s", revision.Provenance) + } +} + +func TestCompileWholeServerMCPSelectionWarnings(t *testing.T) { + model := v1alpha3.ModelConfigSpec{ + Provider: v1alpha3.ModelProviderAnthropic, Model: "claude-sonnet-4-5", + APIKeySecret: "model-auth", APIKeySecretKey: "api-key", + } + input, reader := testInput(t, model, map[string][]byte{"api-key": []byte("secret")}) + server := &v1alpha3.RemoteMCPServer{ + ObjectMeta: metav1.ObjectMeta{Name: "tools", Namespace: "test", Generation: 1}, + Spec: v1alpha3.RemoteMCPServerSpec{URL: "https://mcp.example.com/mcp"}, + Status: v1alpha3.RemoteMCPServerStatus{ObservedGeneration: 1, DiscoveredTools: []*v1alpha3.MCPTool{ + {Name: "one"}, {Name: "two"}, + }}, + } + binding := v1alpha3.MCPToolBinding{Server: v1alpha3.AgentTemplateTypedLocalReference{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 { + t.Fatalf("omitted selection Compile() error = %v", err) + } + if len(revision.Warnings) != 0 { + t.Fatalf("omitted selection warnings = %v", revision.Warnings) + } + + input.Root.MCPTools[0].Binding.Tools = []string{"one"} + revision, err = NewCompiler(reader).Compile(context.Background(), input) + if err != nil { + t.Fatalf("partial selection Compile() error = %v", err) + } + if len(revision.Warnings) != 1 || !strings.Contains(revision.Warnings[0], "exposing the whole server") { + t.Fatalf("partial selection warnings = %v", revision.Warnings) + } + + server.Status.ObservedGeneration = 0 + revision, err = NewCompiler(reader).Compile(context.Background(), input) + if err != nil { + t.Fatalf("stale discovery Compile() error = %v", err) + } + if len(revision.Warnings) != 1 || !strings.Contains(revision.Warnings[0], "no current discovered tool set") { + t.Fatalf("stale discovery warnings = %v", revision.Warnings) + } +} + func TestCompileLocalSharedAgent(t *testing.T) { modelSpec := v1alpha3.ModelConfigSpec{ Provider: v1alpha3.ModelProviderAnthropic, Model: "claude-root", diff --git a/go/core/v2/translator/claude/mcp.go b/go/core/v2/translator/claude/mcp.go new file mode 100644 index 000000000..8bb361da6 --- /dev/null +++ b/go/core/v2/translator/claude/mcp.go @@ -0,0 +1,184 @@ +package claude + +import ( + "context" + "crypto/sha256" + "fmt" + "net/url" + "slices" + "strings" + "time" + + "github.com/kagent-dev/kagent/go/api/v1alpha3" + v2translator "github.com/kagent-dev/kagent/go/core/v2/translator" + claudeconfig "github.com/kagent-dev/kagent/go/harness/claude/config" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/types" +) + +const mcpCredentialPrefix = "KAGENT_CLAUDE_MCP_CREDENTIAL_" + +type mcpCompilation struct { + servers map[string]claudeconfig.MCPServer + environment []corev1.EnvVar + egress []string + warnings []string +} + +func (c *Compiler) compileMCP( + ctx context.Context, + namespace string, + tools []v2translator.ResolvedMCPTool, +) (mcpCompilation, error) { + if len(tools) == 0 { + return mcpCompilation{}, nil + } + result := mcpCompilation{servers: make(map[string]claudeconfig.MCPServer, len(tools))} + identities := make(map[string]string, len(tools)) + for _, tool := range tools { + server := tool.Server + if server == nil { + return mcpCompilation{}, fmt.Errorf("resolved Claude MCP binding has no server") + } + name := strings.ReplaceAll(server.Name, ".", "_") + if previous, exists := identities[name]; exists { + return mcpCompilation{}, v2translator.NewValidationError("Claude MCP servers %q and %q map to the same native name %q", previous, server.Name, name) + } + identities[name] = server.Name + if _, exists := result.servers[name]; exists { + return mcpCompilation{}, v2translator.NewValidationError("RemoteMCPServer %q is bound more than once", server.Name) + } + + if warning := mcpSelectionWarning(tool.Binding.Tools, server); warning != "" { + result.warnings = append(result.warnings, warning) + } + + transport, err := claudeMCPTransport(server) + if err != nil { + return mcpCompilation{}, err + } + hostname, err := mcpHostname(server.Spec.URL) + if err != nil { + return mcpCompilation{}, err + } + headers, headerEnvironment, err := c.compileMCPHeaders(ctx, namespace, server.Spec.HeadersFrom) + if err != nil { + return mcpCompilation{}, fmt.Errorf("compile RemoteMCPServer %q headers: %w", server.Name, err) + } + result.servers[name] = claudeconfig.MCPServer{Type: transport, URL: server.Spec.URL, Headers: headers} + result.environment = append(result.environment, headerEnvironment...) + result.egress = append(result.egress, hostname) + } + return result, nil +} + +func mcpSelectionWarning(selectedTools []string, server *v1alpha3.RemoteMCPServer) string { + if len(selectedTools) == 0 { + return "" + } + selected := append([]string(nil), selectedTools...) + slices.Sort(selected) + selected = slices.Compact(selected) + discovered, current := currentDiscoveredToolNames(server) + if current && slices.Equal(selected, discovered) { + return "" + } + if !current { + return fmt.Sprintf( + "Claude RemoteMCPServer %q cannot verify selected tools %v because no current discovered tool set is available; exposing the whole server", + server.Name, selected, + ) + } + return fmt.Sprintf( + "Claude RemoteMCPServer %q does not support partial tool selection: selected %v, discovered %v; exposing the whole server", + server.Name, selected, discovered, + ) +} + +func currentDiscoveredToolNames(server *v1alpha3.RemoteMCPServer) ([]string, bool) { + if server.Status.ObservedGeneration != server.Generation || len(server.Status.DiscoveredTools) == 0 { + return nil, false + } + names := make([]string, 0, len(server.Status.DiscoveredTools)) + for _, tool := range server.Status.DiscoveredTools { + if tool == nil || strings.TrimSpace(tool.Name) == "" { + return nil, false + } + names = append(names, tool.Name) + } + slices.Sort(names) + if len(slices.Compact(append([]string(nil), names...))) != len(names) { + return nil, false + } + return names, true +} + +func claudeMCPTransport(server *v1alpha3.RemoteMCPServer) (string, error) { + if !server.Spec.TLS.IsEmpty() { + return "", v2translator.NewValidationError("Claude RemoteMCPServer %q does not support custom TLS configuration", server.Name) + } + if server.Spec.Timeout != nil && server.Spec.Timeout.Duration != 30*time.Second { + return "", v2translator.NewValidationError("Claude RemoteMCPServer %q supports only the default 30s timeout", server.Name) + } + if server.Spec.SseReadTimeout != nil { + return "", v2translator.NewValidationError("Claude RemoteMCPServer %q does not support sseReadTimeout", server.Name) + } + if server.Spec.TerminateOnClose != nil && !*server.Spec.TerminateOnClose { + return "", v2translator.NewValidationError("Claude RemoteMCPServer %q requires terminateOnClose", server.Name) + } + switch server.Spec.Protocol { + case v1alpha3.RemoteMCPServerProtocolSse: + return "sse", nil + case "", v1alpha3.RemoteMCPServerProtocolStreamableHttp: + return "http", nil + default: + return "", v2translator.NewValidationError("Claude RemoteMCPServer %q has unsupported protocol %q", server.Name, server.Spec.Protocol) + } +} + +func mcpHostname(raw string) (string, error) { + parsed, err := url.Parse(raw) + if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Hostname() == "" || parsed.User != nil || parsed.Fragment != "" { + return "", v2translator.NewValidationError("Claude RemoteMCPServer URL must be absolute HTTP(S) without credentials or fragment") + } + return parsed.Hostname(), nil +} + +func (c *Compiler) compileMCPHeaders(ctx context.Context, namespace string, refs []v1alpha3.ValueRef) (map[string]string, []corev1.EnvVar, error) { + if len(refs) == 0 { + return nil, nil, nil + } + headers := make(map[string]string, len(refs)) + var environment []corev1.EnvVar + for _, ref := range refs { + if strings.TrimSpace(ref.Name) == "" { + return nil, nil, v2translator.NewValidationError("MCP header name is required") + } + if _, exists := headers[ref.Name]; exists { + return nil, nil, v2translator.NewValidationError("duplicate MCP header %q", ref.Name) + } + switch { + case ref.ValueFrom == nil: + headers[ref.Name] = ref.Value + case ref.ValueFrom.Type == v1alpha3.ConfigMapValueSource: + configMap := &corev1.ConfigMap{} + key := types.NamespacedName{Namespace: namespace, Name: ref.ValueFrom.Name} + if err := c.kube.Get(ctx, key, configMap); err != nil { + return nil, nil, err + } + value, exists := configMap.Data[ref.ValueFrom.Key] + if !exists { + return nil, nil, fmt.Errorf("ConfigMap %q does not contain key %q", configMap.Name, ref.ValueFrom.Key) + } + headers[ref.Name] = value + case ref.ValueFrom.Type == v1alpha3.SecretValueSource: + sum := sha256.Sum256([]byte(namespace + "\x00" + ref.ValueFrom.Name + "\x00" + ref.ValueFrom.Key)) + name := mcpCredentialPrefix + strings.ToUpper(fmt.Sprintf("%x", sum[:8])) + headers[ref.Name] = "${" + name + "}" + environment = append(environment, secretEnvironment(name, ref.ValueFrom.Name, ref.ValueFrom.Key)) + default: + return nil, nil, v2translator.NewValidationError("unsupported MCP header value source %q", ref.ValueFrom.Type) + } + } + return headers, environment, nil +} diff --git a/go/core/v2/translator/kagent/compiler.go b/go/core/v2/translator/kagent/compiler.go index 5be6d5890..5b54b81f1 100644 --- a/go/core/v2/translator/kagent/compiler.go +++ b/go/core/v2/translator/kagent/compiler.go @@ -133,7 +133,7 @@ func (c *Compiler) compileAgent(ctx context.Context, input *v2translator.AgentIn } stream := true cfg := &adk.AgentConfig{Model: modelRuntime.Model, Description: input.Template.Spec.Description, Instruction: input.Instruction, Stream: &stream} - pluginConfig, err := agentPluginConfig(input.Template) + pluginConfig, pluginEgress, err := v2translator.CompileSkillResources(input.Template) if err != nil { return nil, err } @@ -161,7 +161,7 @@ func (c *Compiler) compileAgent(ctx context.Context, input *v2translator.AgentIn 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), agentPluginSourceDestinations(pluginConfig)...), + egress: append(agentConfigDestinations(cfg, input.ModelConfig, modelRuntime.Model), pluginEgress...), } for _, binding := range input.Shared { child, err := c.compileAgent(ctx, binding.Agent) @@ -178,72 +178,6 @@ func (c *Compiler) compileAgent(ctx context.Context, input *v2translator.AgentIn return result, nil } -func agentPluginSourceDestinations(config adk.AgentPluginConfig) []string { - var result []string - appendSource := func(source adk.AgentPluginSource) { - switch { - case source.Git != nil: - result = appendURLHost(result, source.Git.URL) - case source.OCI != "": - repository := strings.SplitN(source.OCI, "@", 2)[0] - first, _, found := strings.Cut(repository, "/") - if found && (strings.Contains(first, ".") || strings.Contains(first, ":") || first == "localhost") { - result = append(result, first) - } else { - result = append(result, "registry-1.docker.io") - } - case source.S3 != nil: - result = appendURLHost(result, source.S3.Endpoint) - } - } - for _, skill := range config.Skills { - appendSource(skill.Source) - } - for _, plugin := range config.Plugins { - appendSource(plugin.Source) - } - return result -} - -func agentPluginConfig(template *v1alpha3.AgentTemplate) (adk.AgentPluginConfig, error) { - result := adk.AgentPluginConfig{ - Skills: make([]adk.StandaloneSkill, 0, len(template.Spec.Skills)), - Plugins: make([]adk.AgentPluginBundle, 0, len(template.Spec.Plugins)), - } - names := make(map[string]struct{}) - for _, skill := range template.Spec.Skills { - if _, exists := names[skill.Name]; exists { - return adk.AgentPluginConfig{}, v2translator.NewValidationError("duplicate skill name %q", skill.Name) - } - names[skill.Name] = struct{}{} - result.Skills = append(result.Skills, adk.StandaloneSkill{Name: skill.Name, Source: agentPluginSource(skill.Source)}) - } - for _, plugin := range template.Spec.Plugins { - for _, name := range plugin.Skills { - if _, exists := names[name]; exists { - return adk.AgentPluginConfig{}, v2translator.NewValidationError("duplicate skill name %q", name) - } - names[name] = struct{}{} - } - result.Plugins = append(result.Plugins, adk.AgentPluginBundle{Source: agentPluginSource(plugin.Source), Skills: append([]string(nil), plugin.Skills...)}) - } - return result, nil -} - -func agentPluginSource(source v1alpha3.ArtifactSource) adk.AgentPluginSource { - result := adk.AgentPluginSource{OCI: source.OCI, Path: source.Path} - if source.Git != nil { - result.Git = &adk.AgentPluginGit{URL: source.Git.URL, Commit: source.Git.Commit} - } - if source.Bucket != nil { - result.S3 = &adk.AgentPluginS3{ - Endpoint: source.Bucket.S3.Endpoint, Bucket: source.Bucket.S3.Bucket, Key: source.Bucket.S3.Key, - VersionID: source.Bucket.S3.VersionID, Region: source.Bucket.S3.Region, - } - } - return result -} - // 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) { diff --git a/go/core/v2/translator/revision.go b/go/core/v2/translator/revision.go index fa72e0a7b..a900bb65b 100644 --- a/go/core/v2/translator/revision.go +++ b/go/core/v2/translator/revision.go @@ -47,6 +47,9 @@ type Revision struct { Provenance json.RawMessage // EgressDestinations is the hostname allowlist required by this revision. EgressDestinations []string + // Warnings are non-blocking compilation diagnostics. They are deliberately + // excluded from Digest because they do not change runtime behavior. + Warnings []string } // Digest returns the immutable identity of every input that affects runtime diff --git a/go/core/v2/translator/revision_test.go b/go/core/v2/translator/revision_test.go index b137771b9..583912a06 100644 --- a/go/core/v2/translator/revision_test.go +++ b/go/core/v2/translator/revision_test.go @@ -23,3 +23,19 @@ func TestRevisionDigestIncludesProvenance(t *testing.T) { t.Fatalf("short revision %q is not a prefix of %q", first.Short(), first.String()) } } + +func TestRevisionDigestExcludesWarnings(t *testing.T) { + revision := &Revision{Namespace: "agents", AgentTemplateName: "helper", HarnessName: "claude"} + first, err := revision.Digest() + if err != nil { + t.Fatal(err) + } + revision.Warnings = []string{"partial MCP selection is not enforced"} + second, err := revision.Digest() + if err != nil { + t.Fatal(err) + } + if first != second { + t.Fatal("non-behavioral warning changed runtime revision") + } +} diff --git a/go/core/v2/translator/skills.go b/go/core/v2/translator/skills.go new file mode 100644 index 000000000..54b98b5f4 --- /dev/null +++ b/go/core/v2/translator/skills.go @@ -0,0 +1,87 @@ +package translator + +import ( + "net/url" + "strings" + + "github.com/kagent-dev/kagent/go/api/agentplugin" + "github.com/kagent-dev/kagent/go/api/v1alpha3" +) + +// CompileSkillResources translates portable AgentTemplate skill selections +// into the runtime-neutral resource contract shared by Harness adapters. +func CompileSkillResources(template *v1alpha3.AgentTemplate) (agentplugin.Resources, []string, error) { + resources := agentplugin.Resources{ + Skills: make([]agentplugin.Skill, 0, len(template.Spec.Skills)), + Plugins: make([]agentplugin.Bundle, 0, len(template.Spec.Plugins)), + } + selected := make(map[string]struct{}) + var egress []string + for _, skill := range template.Spec.Skills { + if _, exists := selected[skill.Name]; exists { + return agentplugin.Resources{}, nil, NewValidationError("duplicate skill name %q", skill.Name) + } + selected[skill.Name] = struct{}{} + source := compileArtifactSource(skill.Source) + resources.Skills = append(resources.Skills, agentplugin.Skill{Name: skill.Name, Source: source}) + egress = appendArtifactSourceDestination(egress, source) + } + for _, plugin := range template.Spec.Plugins { + for _, name := range plugin.Skills { + if _, exists := selected[name]; exists { + return agentplugin.Resources{}, nil, NewValidationError("duplicate skill name %q", name) + } + selected[name] = struct{}{} + } + source := compileArtifactSource(plugin.Source) + resources.Plugins = append(resources.Plugins, agentplugin.Bundle{ + Source: source, + Skills: append([]string(nil), plugin.Skills...), + }) + egress = appendArtifactSourceDestination(egress, source) + } + return resources, egress, nil +} + +func compileArtifactSource(source v1alpha3.ArtifactSource) agentplugin.Source { + result := agentplugin.Source{OCI: source.OCI, Path: source.Path} + if source.Git != nil { + result.Git = &agentplugin.GitSource{URL: source.Git.URL, Commit: source.Git.Commit} + } + if source.Bucket != nil { + result.S3 = &agentplugin.S3Source{ + Endpoint: source.Bucket.S3.Endpoint, + Bucket: source.Bucket.S3.Bucket, + Key: source.Bucket.S3.Key, + VersionID: source.Bucket.S3.VersionID, + Region: source.Bucket.S3.Region, + } + } + return result +} + +func appendArtifactSourceDestination(destinations []string, source agentplugin.Source) []string { + switch { + case source.Git != nil: + return appendURLHostname(destinations, source.Git.URL) + case source.OCI != "": + repository := strings.SplitN(source.OCI, "@", 2)[0] + first, _, found := strings.Cut(repository, "/") + if found && (strings.Contains(first, ".") || strings.Contains(first, ":") || first == "localhost") { + return append(destinations, first) + } + return append(destinations, "registry-1.docker.io") + case source.S3 != nil: + return appendURLHostname(destinations, source.S3.Endpoint) + default: + return destinations + } +} + +func appendURLHostname(destinations []string, rawURL string) []string { + parsed, err := url.Parse(rawURL) + if err == nil && parsed.Hostname() != "" { + return append(destinations, parsed.Hostname()) + } + return destinations +} diff --git a/go/harness/claude/Dockerfile b/go/harness/claude/Dockerfile index 048d80316..d0d0f572d 100644 --- a/go/harness/claude/Dockerfile +++ b/go/harness/claude/Dockerfile @@ -11,6 +11,7 @@ RUN --mount=type=cache,target=/root/go/pkg/mod,rw \ go mod download COPY api/ api/ COPY adk/ adk/ +COPY core/ core/ COPY harness/ harness/ RUN --mount=type=cache,target=/root/go/pkg/mod,rw \ --mount=type=cache,target=/root/.cache/go-build,rw \ diff --git a/go/harness/claude/config/config.go b/go/harness/claude/config/config.go index 43f33975f..de8b34db8 100644 --- a/go/harness/claude/config/config.go +++ b/go/harness/claude/config/config.go @@ -9,27 +9,40 @@ import ( "regexp" "strings" "time" + + "github.com/kagent-dev/kagent/go/api/agentplugin" ) var agentNamePattern = regexp.MustCompile(`^[A-Za-z0-9_-]+$`) const ( - Version = 2 + Version = 3 PinnedClaudeVersion = "2.1.217" GoogleCredentialsJSONEnvName = "KAGENT_CLAUDE_GOOGLE_CREDENTIALS_JSON" ) type Config struct { - Version int `json:"version"` - ClaudeExecutable string `json:"claude_executable"` - ExpectedClaudeVersion string `json:"expected_claude_version"` - StrictVersion bool `json:"strict_version"` - Model string `json:"model,omitempty"` - AppendSystemPrompt string `json:"append_system_prompt,omitempty"` - Agents map[string]Agent `json:"agents,omitempty"` - MaxEventBytes int `json:"max_event_bytes"` - MaxStderrBytes int `json:"max_stderr_bytes"` - InterruptGraceMillis int `json:"interrupt_grace_millis"` + Version int `json:"version"` + ClaudeExecutable string `json:"claude_executable"` + ExpectedClaudeVersion string `json:"expected_claude_version"` + StrictVersion bool `json:"strict_version"` + Model string `json:"model,omitempty"` + AppendSystemPrompt string `json:"append_system_prompt,omitempty"` + Agents map[string]Agent `json:"agents,omitempty"` + SkillResources *agentplugin.Resources `json:"skill_resources,omitempty"` + MCPServers map[string]MCPServer `json:"mcp_servers,omitempty"` + MaxEventBytes int `json:"max_event_bytes"` + MaxStderrBytes int `json:"max_stderr_bytes"` + InterruptGraceMillis int `json:"interrupt_grace_millis"` +} + +// MCPServer is one compiler-owned direct remote server. Claude's strict MCP +// file is generated by the adapter; no project or user MCP configuration is +// inherited. +type MCPServer struct { + Type string `json:"type"` + URL string `json:"url"` + Headers map[string]string `json:"headers,omitempty"` } // Agent is one compiler-owned local Claude subagent passed through --agents. @@ -88,6 +101,22 @@ func (c Config) Validate() error { return fmt.Errorf("Claude agent %q requires a non-empty description and prompt", name) } } + for name, server := range c.MCPServers { + if !agentNamePattern.MatchString(name) { + return fmt.Errorf("Claude MCP server name %q must contain only letters, numbers, underscores, or hyphens", name) + } + if server.Type != "http" && server.Type != "sse" { + return fmt.Errorf("Claude MCP server %q has unsupported transport %q", name, server.Type) + } + if strings.TrimSpace(server.URL) == "" { + return fmt.Errorf("Claude MCP server %q URL is required", name) + } + for header, value := range server.Headers { + if strings.TrimSpace(header) == "" || strings.TrimSpace(value) == "" { + return fmt.Errorf("Claude MCP server %q headers require non-empty names and values", name) + } + } + } return nil } @@ -102,6 +131,19 @@ func (c Config) AgentsJSON() (string, error) { return string(raw), nil } +func (c Config) MCPConfigJSON() ([]byte, error) { + if len(c.MCPServers) == 0 { + return nil, nil + } + raw, err := json.Marshal(struct { + Servers map[string]MCPServer `json:"mcpServers"` + }{Servers: c.MCPServers}) + if err != nil { + return nil, fmt.Errorf("encode Claude MCP configuration: %w", err) + } + return raw, nil +} + func (c Config) InterruptGrace() time.Duration { return time.Duration(c.InterruptGraceMillis) * time.Millisecond } diff --git a/go/harness/claude/config/config_test.go b/go/harness/claude/config/config_test.go index d16e1e8ff..b79a6c82a 100644 --- a/go/harness/claude/config/config_test.go +++ b/go/harness/claude/config/config_test.go @@ -4,6 +4,8 @@ import ( "reflect" "strings" "testing" + + "github.com/kagent-dev/kagent/go/api/agentplugin" ) func TestProductionRoundTrip(t *testing.T) { @@ -16,6 +18,27 @@ func TestProductionRoundTrip(t *testing.T) { } } +func TestMCPConfigJSONAndSkillsRoundTrip(t *testing.T) { + cfg := Production("claude-test", "help") + cfg.SkillResources = &agentplugin.Resources{Skills: []agentplugin.Skill{{ + Name: "review", Source: agentplugin.Source{Git: &agentplugin.GitSource{URL: "https://example.com/repo", Commit: strings.Repeat("a", 40)}}, + }}} + cfg.MCPServers = map[string]MCPServer{"tools": { + Type: "http", URL: "https://mcp.example.com/mcp", Headers: map[string]string{"Authorization": "Bearer ${TOKEN}"}, + }} + if err := cfg.Validate(); err != nil { + t.Fatal(err) + } + raw, err := cfg.MCPConfigJSON() + if err != nil { + t.Fatal(err) + } + want := `{"mcpServers":{"tools":{"type":"http","url":"https://mcp.example.com/mcp","headers":{"Authorization":"Bearer ${TOKEN}"}}}}` + if string(raw) != want { + t.Fatalf("MCPConfigJSON() = %s, want %s", raw, want) + } +} + func TestAgentsJSON(t *testing.T) { cfg := Production("claude-test", "help") cfg.Agents = map[string]Agent{ @@ -34,7 +57,7 @@ func TestAgentsJSON(t *testing.T) { if raw != want { t.Fatalf("AgentsJSON() = %s, want %s", raw, want) } - parsed, err := Parse([]byte(`{"version":2,"claude_executable":"claude","expected_claude_version":"2.1.217","strict_version":true,"agents":` + raw + `,"max_event_bytes":100,"max_stderr_bytes":100,"interrupt_grace_millis":100}`)) + parsed, err := Parse([]byte(`{"version":3,"claude_executable":"claude","expected_claude_version":"2.1.217","strict_version":true,"agents":` + raw + `,"max_event_bytes":100,"max_stderr_bytes":100,"interrupt_grace_millis":100}`)) if err != nil { t.Fatal(err) } @@ -59,7 +82,7 @@ func TestConfigRejectsInvalidAgents(t *testing.T) { } func TestParseValidates(t *testing.T) { - contents := `{"version":2,"claude_executable":"claude","expected_claude_version":"2.1.217","strict_version":true,"model":"claude-test","append_system_prompt":"help","max_event_bytes":100,"max_stderr_bytes":100,"interrupt_grace_millis":100}` + contents := `{"version":3,"claude_executable":"claude","expected_claude_version":"2.1.217","strict_version":true,"model":"claude-test","append_system_prompt":"help","max_event_bytes":100,"max_stderr_bytes":100,"interrupt_grace_millis":100}` cfg, err := Parse([]byte(contents)) if err != nil { t.Fatal(err) @@ -70,7 +93,7 @@ func TestParseValidates(t *testing.T) { } func TestConfigRejectsUnknownFields(t *testing.T) { - if _, err := Parse([]byte(`{"version":2,"surprise":true}`)); err == nil { + if _, err := Parse([]byte(`{"version":3,"surprise":true}`)); err == nil { t.Fatal("Parse() accepted an unknown field") } } @@ -82,7 +105,7 @@ func TestConfigRejectsTrailingValue(t *testing.T) { } func TestConfigRejectsMissingLimits(t *testing.T) { - _, err := Parse([]byte(`{"version":2,"claude_executable":"claude"}`)) + _, err := Parse([]byte(`{"version":3,"claude_executable":"claude"}`)) if err == nil || !strings.Contains(err.Error(), "limits must be positive") { t.Fatalf("Parse() error = %v", err) } diff --git a/go/harness/claude/internal/adapter/adapter.go b/go/harness/claude/internal/adapter/adapter.go index 58e223355..da425c612 100644 --- a/go/harness/claude/internal/adapter/adapter.go +++ b/go/harness/claude/internal/adapter/adapter.go @@ -3,12 +3,14 @@ package adapter import ( + "context" "encoding/json" "fmt" "os" "path/filepath" "strings" + "github.com/kagent-dev/kagent/go/core/v2/agentplugins" "github.com/kagent-dev/kagent/go/harness/claude/config" "github.com/kagent-dev/kagent/go/harness/claude/internal/driver" ) @@ -22,6 +24,7 @@ const ( // Input contains compiler output and Actor-owned locations used to construct // the Claude driver. type Input struct { + Context context.Context ConfigJSON []byte Workspace string DurableDir string @@ -39,6 +42,10 @@ func New(input Input) (*driver.ProcessDriver, error) { if err != nil { return nil, err } + mcpJSON, err := cfg.MCPConfigJSON() + if err != nil { + return nil, err + } if !filepath.IsAbs(input.Workspace) || !filepath.IsAbs(input.DurableDir) || !filepath.IsAbs(input.EphemeralDir) { return nil, fmt.Errorf("workspace, durable, and ephemeral directories must be absolute paths") } @@ -52,21 +59,64 @@ func New(input Input) (*driver.ProcessDriver, error) { return nil, fmt.Errorf("prepare %s directory: %w", directory.name, err) } } + if cfg.SkillResources != nil { + materializeContext := input.Context + if materializeContext == nil { + materializeContext = context.Background() + } + if err := agentplugins.MaterializeSkills(materializeContext, *cfg.SkillResources, agentplugins.SkillPaths{ + Plugins: filepath.Join(claudeDir, "packages"), + Skills: filepath.Join(claudeDir, "skills"), + }); err != nil { + return nil, fmt.Errorf("materialize Claude skills: %w", err) + } + } environment := setEnvironment(input.Environment, claudeConfigDirEnv, claudeDir) environment = setEnvironment(environment, disableUpdaterEnv, "1") environment, err = materializeGoogleCredentials(environment, input.EphemeralDir) if err != nil { return nil, err } + var mcpConfigPath string + if len(mcpJSON) != 0 { + if err := ensurePrivateDir(input.EphemeralDir); err != nil { + return nil, fmt.Errorf("prepare ephemeral MCP directory: %w", err) + } + mcpConfigPath = filepath.Join(input.EphemeralDir, "mcp.json") + if err := replacePrivateFile(mcpConfigPath, mcpJSON); err != nil { + return nil, fmt.Errorf("materialize Claude MCP configuration: %w", err) + } + } return driver.NewProcessDriver(driver.ProcessConfig{ Executable: cfg.ClaudeExecutable, ExpectedVersion: cfg.ExpectedClaudeVersion, StrictVersion: cfg.StrictVersion, Workspace: input.Workspace, Model: cfg.Model, - AppendSystemPrompt: cfg.AppendSystemPrompt, AgentsJSON: agentsJSON, Environment: environment, + AppendSystemPrompt: cfg.AppendSystemPrompt, AgentsJSON: agentsJSON, MCPConfigPath: mcpConfigPath, Environment: environment, MaxEventBytes: cfg.MaxEventBytes, MaxStderrBytes: cfg.MaxStderrBytes, InterruptGrace: cfg.InterruptGrace(), }), nil } +func replacePrivateFile(path string, contents []byte) error { + temporary, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+"-*.tmp") + if err != nil { + return err + } + temporaryPath := temporary.Name() + defer os.Remove(temporaryPath) + if err := temporary.Chmod(0o600); err != nil { + _ = temporary.Close() + return err + } + if _, err := temporary.Write(contents); err != nil { + _ = temporary.Close() + return err + } + if err := temporary.Close(); err != nil { + return err + } + return os.Rename(temporaryPath, path) +} + func materializeGoogleCredentials(environment []string, directory string) ([]string, error) { prefix := config.GoogleCredentialsJSONEnvName + "=" var credentials string diff --git a/go/harness/claude/internal/adapter/adapter_test.go b/go/harness/claude/internal/adapter/adapter_test.go index 780011353..3ccfa7a06 100644 --- a/go/harness/claude/internal/adapter/adapter_test.go +++ b/go/harness/claude/internal/adapter/adapter_test.go @@ -1,10 +1,13 @@ package adapter import ( + "encoding/json" "os" "path/filepath" + "strings" "testing" + "github.com/kagent-dev/kagent/go/api/agentplugin" "github.com/kagent-dev/kagent/go/harness/claude/config" ) @@ -13,7 +16,7 @@ func TestNewMaterializesDurableDirectories(t *testing.T) { ephemeralDir := filepath.Join(t.TempDir(), "credentials") workspace := filepath.Join(durableDir, "workspace") runner, err := New(Input{ - ConfigJSON: []byte(`{"version":2,"claude_executable":"claude","expected_claude_version":"2.1.217","strict_version":true,"max_event_bytes":100,"max_stderr_bytes":100,"interrupt_grace_millis":100}`), + ConfigJSON: []byte(`{"version":3,"claude_executable":"claude","expected_claude_version":"2.1.217","strict_version":true,"max_event_bytes":100,"max_stderr_bytes":100,"interrupt_grace_millis":100}`), Workspace: workspace, DurableDir: durableDir, EphemeralDir: ephemeralDir, Environment: []string{"PATH=/bin", "CLAUDE_CONFIG_DIR=/wrong", "DISABLE_AUTOUPDATER=0"}, @@ -35,6 +38,44 @@ func TestNewMaterializesDurableDirectories(t *testing.T) { } } +func TestNewMaterializesSkillsAndMCPConfig(t *testing.T) { + durableDir := filepath.Join(t.TempDir(), "data") + claudeDir := filepath.Join(durableDir, "claude") + packageRoot := filepath.Join(claudeDir, "packages", "standalone-0") + if err := os.MkdirAll(packageRoot, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(packageRoot, "SKILL.md"), []byte("# Review"), 0o644); err != nil { + t.Fatal(err) + } + cfg := config.Production("claude-test", "help") + cfg.StrictVersion = false + cfg.SkillResources = &agentplugin.Resources{Skills: []agentplugin.Skill{{ + Name: "review", Source: agentplugin.Source{Git: &agentplugin.GitSource{URL: "unused", Commit: strings.Repeat("a", 40)}}, + }}} + cfg.MCPServers = map[string]config.MCPServer{"tools": {Type: "http", URL: "https://mcp.example.com/mcp"}} + raw, err := json.Marshal(cfg) + if err != nil { + t.Fatal(err) + } + ephemeralDir := filepath.Join(t.TempDir(), "generated") + if _, err := New(Input{ + ConfigJSON: raw, Workspace: filepath.Join(durableDir, "workspace"), DurableDir: durableDir, + EphemeralDir: ephemeralDir, Environment: []string{"PATH=/bin"}, + }); err != nil { + t.Fatal(err) + } + for path, want := range map[string]string{ + filepath.Join(claudeDir, "skills", "review", "SKILL.md"): "# Review", + filepath.Join(ephemeralDir, "mcp.json"): `{"mcpServers":{"tools":{"type":"http","url":"https://mcp.example.com/mcp"}}}`, + } { + contents, err := os.ReadFile(path) + if err != nil || string(contents) != want { + t.Fatalf("%s = %q, %v; want %q", path, contents, err, want) + } + } +} + func TestNewRejectsInvalidInput(t *testing.T) { input := Input{ConfigJSON: []byte(`{}`), Workspace: "relative", DurableDir: "relative", EphemeralDir: "relative"} if _, err := New(input); err == nil { diff --git a/go/harness/claude/internal/driver/process.go b/go/harness/claude/internal/driver/process.go index 8026f6d2d..e7f671e54 100644 --- a/go/harness/claude/internal/driver/process.go +++ b/go/harness/claude/internal/driver/process.go @@ -20,6 +20,7 @@ type ProcessConfig struct { Model string AppendSystemPrompt string AgentsJSON string + MCPConfigPath string Environment []string MaxEventBytes int MaxStderrBytes int @@ -71,6 +72,9 @@ func (d *ProcessDriver) Args(turn runtime.Turn) []string { if d.config.AgentsJSON != "" { args = append(args, "--agents", d.config.AgentsJSON) } + if d.config.MCPConfigPath != "" { + args = append(args, "--mcp-config", d.config.MCPConfigPath) + } if turn.ContinuationID != "" { // Resume the Actor's exact root conversation. --continue selects Claude's // latest session and can be redirected by subagents or interrupted attempts. diff --git a/go/harness/claude/internal/driver/process_test.go b/go/harness/claude/internal/driver/process_test.go index eff80a172..0161e2b66 100644 --- a/go/harness/claude/internal/driver/process_test.go +++ b/go/harness/claude/internal/driver/process_test.go @@ -32,7 +32,8 @@ func TestProcessDriverArgumentsAndStream(t *testing.T) { t.Fatal(err) } agentsJSON := `{"reviewer":{"description":"Reviews changes","prompt":"Review carefully","tools":["Read"]}}` - d := NewProcessDriver(ProcessConfig{Executable: executable, ExpectedVersion: pinnedClaudeVersion, StrictVersion: true, Workspace: dir, Model: "claude-test", AppendSystemPrompt: "extra", AgentsJSON: agentsJSON, Environment: []string{"CAPTURE=" + capture}, MaxEventBytes: 4096, MaxStderrBytes: 1024, InterruptGrace: time.Second}) + mcpConfigPath := filepath.Join(dir, "mcp.json") + d := NewProcessDriver(ProcessConfig{Executable: executable, ExpectedVersion: pinnedClaudeVersion, StrictVersion: true, Workspace: dir, Model: "claude-test", AppendSystemPrompt: "extra", AgentsJSON: agentsJSON, MCPConfigPath: mcpConfigPath, Environment: []string{"CAPTURE=" + capture}, MaxEventBytes: 4096, MaxStderrBytes: 1024, InterruptGrace: time.Second}) if err := d.Validate(t.Context()); err != nil { t.Fatalf("Validate() error = %v", err) } @@ -61,6 +62,9 @@ func TestProcessDriverArgumentsAndStream(t *testing.T) { if !strings.Contains(string(args), "--agents\n"+agentsJSON+"\n") { t.Error("arguments do not contain compiler-owned local agents JSON") } + if !strings.Contains(string(args), "--mcp-config\n"+mcpConfigPath+"\n") { + t.Error("arguments do not contain compiler-owned MCP configuration") + } if strings.Contains(string(args), "--permission-prompt-tool\n") { t.Error("arguments unexpectedly configure Claude's native permission bridge") } diff --git a/go/harness/claude/testdata/poc-config.json b/go/harness/claude/testdata/poc-config.json deleted file mode 100644 index aad0bd153..000000000 --- a/go/harness/claude/testdata/poc-config.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "version": 2, - "claude_executable": "claude", - "expected_claude_version": "2.1.217", - "strict_version": true, - "model": "", - "append_system_prompt": "You are running inside a kagent Actor.", - "max_event_bytes": 1048576, - "max_stderr_bytes": 65536, - "interrupt_grace_millis": 2000 -} diff --git a/helm/kagent-crds/templates/kagent.dev_agenttemplates.yaml b/helm/kagent-crds/templates/kagent.dev_agenttemplates.yaml index 1cc97d3e5..deef45c59 100644 --- a/helm/kagent-crds/templates/kagent.dev_agenttemplates.yaml +++ b/helm/kagent-crds/templates/kagent.dev_agenttemplates.yaml @@ -317,7 +317,7 @@ spec: - templateRef type: object mcp: - description: MCPToolBinding selects tools from a same-namespace + description: MCPToolBinding binds tools from a same-namespace MCP server. properties: server: @@ -337,16 +337,18 @@ spec: - name type: object tools: + description: |- + 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. items: minLength: 1 type: string maxItems: 50 - minItems: 1 type: array x-kubernetes-list-type: set required: - server - - tools type: object type: object x-kubernetes-validations: diff --git a/ui/src/api/chat/a2aGrpcChatClient.test.ts b/ui/src/api/chat/a2aGrpcChatClient.test.ts index 6d342e9f9..ed0af4247 100644 --- a/ui/src/api/chat/a2aGrpcChatClient.test.ts +++ b/ui/src/api/chat/a2aGrpcChatClient.test.ts @@ -699,6 +699,40 @@ describe("A2AGrpcChatClient.history", () => { expect(messages).toHaveLength(1); }); + it("coalesces persisted artifact chunks without crossing structured parts", async () => { + // `append: true` is projected by the gateway as several parts on one artifact. + // Those are transport chunks, not separate prose blocks, so reopening a task + // must look like the single message that the live stream accumulated. + serveTasks([ + { + id: "task-1", + contextId: CONVERSATION.id, + status: { state: TaskState.COMPLETED, timestamp: { seconds: 1767225600n } }, + history: [], + artifacts: [ + { + artifactId: "a-1", + parts: [ + text("alpha"), + text(" beta"), + data({ name: "lookup", args: {} }), + text(" gamma"), + text(" delta"), + ], + }, + ], + }, + ]); + + const { messages } = await new A2AGrpcChatClient().history(CONVERSATION); + expect(messages).toHaveLength(1); + expect(messages[0].parts).toEqual([ + { kind: "text", text: "alpha beta" }, + { kind: "data", dataKind: "tool_call", data: { name: "lookup", args: {} } }, + { kind: "text", text: " gamma delta" }, + ]); + }); + it("follows every page of a long conversation", async () => { // A conversation shown with its first page only, saying nothing, is the quiet // half-truth this codebase keeps having to undo. diff --git a/ui/src/api/chat/a2aGrpcChatClient.ts b/ui/src/api/chat/a2aGrpcChatClient.ts index 7b59efae1..eb4f44e5e 100644 --- a/ui/src/api/chat/a2aGrpcChatClient.ts +++ b/ui/src/api/chat/a2aGrpcChatClient.ts @@ -218,7 +218,19 @@ function dataKindOf(data: Record): ChatDataPart["dataKind"] { } function toParts(parts: readonly A2APart[] | undefined): ChatPart[] { - return (parts ?? []).map(toPart).filter((part): part is ChatPart => part !== undefined); + const result: ChatPart[] = []; + for (const source of parts ?? []) { + const part = toPart(source); + if (part === undefined) continue; + + const previous = result.at(-1); + if (part.kind === "text" && previous?.kind === "text") { + previous.text += part.text; + continue; + } + result.push(part); + } + return result; } /** The prose of a set of parts, for comparing a reply against the artifact repeating it. */ From 8e4e7e04033707443d2b9bb75da64a05482aa22a Mon Sep 17 00:00:00 2001 From: Jet Chiang Date: Thu, 27 Aug 2026 13:00:29 -0400 Subject: [PATCH 03/11] rebase and go mod Signed-off-by: Jet Chiang --- go/core/cmd/controller-v2/main.go | 10 ++++++++++ go/go.mod | 6 ++---- go/go.sum | 4 ++-- 3 files changed, 14 insertions(+), 6 deletions(-) diff --git a/go/core/cmd/controller-v2/main.go b/go/core/cmd/controller-v2/main.go index 4d1080c82..a06ff1655 100644 --- a/go/core/cmd/controller-v2/main.go +++ b/go/core/cmd/controller-v2/main.go @@ -41,12 +41,14 @@ import ( v2controller "github.com/kagent-dev/kagent/go/core/v2/controller" v2mcp "github.com/kagent-dev/kagent/go/core/v2/mcp" "github.com/kagent-dev/kagent/go/core/v2/substrate" + "go.uber.org/zap/zapcore" "golang.org/x/sync/errgroup" k8sruntime "k8s.io/apimachinery/pkg/runtime" utilruntime "k8s.io/apimachinery/pkg/util/runtime" clientgoscheme "k8s.io/client-go/kubernetes/scheme" "k8s.io/client-go/tools/clientcmd" ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/log/zap" metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" ) @@ -54,6 +56,14 @@ func main() { ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer stop() + logLevel := zapcore.InfoLevel + if value := os.Getenv("ZAP_LOG_LEVEL"); value != "" { + if err := logLevel.Set(value); err != nil { + log.Fatalf("parse ZAP_LOG_LEVEL: %v", err) + } + } + ctrl.SetLogger(zap.New(zap.Level(logLevel))) + dbURL, err := database.ResolveURL(env("POSTGRES_DATABASE_URL", "postgres://postgres:kagent@kagent-postgresql.kagent.svc.cluster.local:5432/postgres"), os.Getenv("POSTGRES_DATABASE_URL_FILE")) if err != nil { log.Fatal(err) diff --git a/go/go.mod b/go/go.mod index a7561a1da..686a30c31 100644 --- a/go/go.mod +++ b/go/go.mod @@ -31,12 +31,11 @@ require ( github.com/google/uuid v1.6.0 github.com/gorilla/mux v1.8.1 github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3 - github.com/hashicorp/go-multierror v1.1.1 github.com/improbable-eng/grpc-web v0.15.0 github.com/jackc/pgx/v5 v5.10.0 github.com/jedib0t/go-pretty/v6 v6.8.3 github.com/kagent-dev/kmcp v0.3.0 - github.com/kagent-dev/mockllm v0.0.5 + github.com/kagent-dev/mockllm v0.0.6 github.com/kagent-dev/mockmcp v0.0.0-20260520211643-dcd475b74085 github.com/modelcontextprotocol/go-sdk v1.7.0 github.com/muesli/reflow v0.3.0 @@ -148,7 +147,6 @@ require ( github.com/bahlo/generic-list-go v0.2.0 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/bkielbasa/cyclop v1.2.3 // indirect - github.com/blang/semver/v4 v4.0.0 // indirect github.com/blizzy78/varnamelen v0.8.0 // indirect github.com/bombsimon/wsl/v4 v4.7.0 // indirect github.com/bombsimon/wsl/v5 v5.8.0 // indirect @@ -270,6 +268,7 @@ require ( github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-immutable-radix/v2 v2.1.0 // indirect + github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/hashicorp/go-version v1.9.0 // indirect github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect github.com/hexops/gotextdiff v1.0.3 // indirect @@ -465,7 +464,6 @@ require ( istio.io/api v1.31.0-alpha.1.0.20260819121012-5803fb6accf7 // indirect istio.io/client-go v1.31.0-alpha.0.0.20260807010324-676a810f2c1f // indirect k8s.io/apiserver v0.36.3 // indirect - k8s.io/component-base v0.36.3 // indirect k8s.io/gengo/v2 v2.0.0-20260408192533-25e2208e0dc3 // indirect k8s.io/klog/v2 v2.140.0 // indirect k8s.io/kube-openapi v0.0.0-20260721132016-d427ff9ee9ad // indirect diff --git a/go/go.sum b/go/go.sum index 12f7e73e7..84cb0278c 100644 --- a/go/go.sum +++ b/go/go.sum @@ -716,8 +716,8 @@ github.com/julz/importas v0.2.0 h1:y+MJN/UdL63QbFJHws9BVC5RpA2iq0kpjrFajTGivjQ= github.com/julz/importas v0.2.0/go.mod h1:pThlt589EnCYtMnmhmRYY/qn9lCf/frPOK+WMx3xiJY= github.com/kagent-dev/kmcp v0.3.0 h1:CuF8LN6JbPoy75saRzwI5OIcSVZUgiwB0BeWtq+rAO4= github.com/kagent-dev/kmcp v0.3.0/go.mod h1:g7wS/3m2wonRo/1DMwVoHxnilr/urPgV2hwV1DwkwrQ= -github.com/kagent-dev/mockllm v0.0.5 h1:mm9Ml3NH6/E/YKVMgMwWYMNsNGkDze6I6TC0ppHZAo8= -github.com/kagent-dev/mockllm v0.0.5/go.mod h1:tDLemRsTZa1NdHaDbg3sgFk9cT1QWvMPlBtLVD6I2mA= +github.com/kagent-dev/mockllm v0.0.6 h1:tCCUnweWven4Y8HTkdjtgAiNqWgFwk7WQ00cqO0F0RE= +github.com/kagent-dev/mockllm v0.0.6/go.mod h1:tDLemRsTZa1NdHaDbg3sgFk9cT1QWvMPlBtLVD6I2mA= github.com/kagent-dev/mockmcp v0.0.0-20260520211643-dcd475b74085 h1:5bYxRc6K6+JiFMYGSJpQ7y18PzTGXUmUtVXY7Fqh0Ew= github.com/kagent-dev/mockmcp v0.0.0-20260520211643-dcd475b74085/go.mod h1:vTMste7c+XiDOQyhe6iGPKLbMy3chY/h2fL3Dox5fAE= github.com/kagent-dev/substrate v0.0.20 h1:AfiT+QH8hHBVIxXQbTTsYOQUpn46ngAM10TiWEIRz/k= From 9618f39441002040388f8bdc15691eaf448653f1 Mon Sep 17 00:00:00 2001 From: Jet Chiang Date: Thu, 27 Aug 2026 13:37:46 -0400 Subject: [PATCH 04/11] add claude code docs Signed-off-by: Jet Chiang --- go/harness/claude/README.md | 74 +++++++++++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 go/harness/claude/README.md diff --git a/go/harness/claude/README.md b/go/harness/claude/README.md new file mode 100644 index 000000000..f597a7081 --- /dev/null +++ b/go/harness/claude/README.md @@ -0,0 +1,74 @@ +# Claude Harness + +The Claude Harness runs Claude Code as a native Kagent runtime. It compiles an +`AgentTemplate` into Claude Code configuration, runs each turn in a Substrate +Actor, and exposes the result through Kagent's A2A API. + +## Working + +- [x] Anthropic, Amazon Bedrock, and Vertex AI model providers +- [x] Streaming text, tool calls, and tool results over A2A +- [x] Task cancellation +- [x] Durable Claude session resume between turns +- [x] Claude Code built-in tools +- [x] Shared local subagents +- [x] Standalone skills and plugin-provided skills +- [x] Direct HTTP and SSE MCP servers with whole-server tool access + +## Planned / not yet supported + +- [ ] Human-in-the-loop tool approval with deferred tool calls and session resume +- [ ] Checkpoint and fork continuity for Claude sessions +- [ ] Enforced selection of individual tools from an MCP server +- [ ] Dedicated subagents running in separate AgentInstances +- [ ] Skills, MCP tools, and nested subagents on local subagents +- [ ] Configuring Claude Code permission mode and trust boundary in Harness CRD + +## Example Usage + +```yaml +apiVersion: kagent.dev/v1alpha3 +kind: Harness +metadata: + name: claude-e2e + namespace: kagent +spec: + claude: {} + workload: + image: ${KAGENT_CLAUDE_IMAGE} + substrate: + workerPoolRef: + name: kagent-default + snapshotPolicy: + location: gs://ate-snapshots/kagent/ + allowedAgentTemplates: + selector: + matchLabels: + kagent.dev/e2e-runtime: claude +--- +apiVersion: kagent.dev/v1alpha3 +kind: AgentTemplate +metadata: + labels: + kagent.dev/e2e-runtime: claude + name: kagent-claude + namespace: kagent +spec: + description: test + modelConfig: + name: bedrock-claude # Assuming you have created a modelconfig using Bedrock Anthropic + systemPrompt: | + Follow the selected skill and use the configured MCP tool. + tools: + - mcp: + server: + kind: RemoteMCPServer + name: kagent-tool-server + plugins: + - source: + git: + url: https://github.com/agentplugins/agent-plugins-example.git + commit: 5f3f5084a821aefa792e79500dd8f0462ab83473 + skills: + - migrate-agent-plugin +``` \ No newline at end of file From 793ffaa40bb4cd0d88d566937ded2d1a2002131a Mon Sep 17 00:00:00 2001 From: Jet Chiang Date: Thu, 27 Aug 2026 16:08:36 -0400 Subject: [PATCH 05/11] fix lint and test Signed-off-by: Jet Chiang --- go/core/test/e2e/claude_interaction_test.go | 18 ----------- go/core/v2/translator/claude/compiler.go | 8 ++--- go/core/v2/translator/claude/compiler_test.go | 10 +++--- go/core/v2/translator/claude/mcp.go | 10 ++++-- go/harness/claude/config/config.go | 12 +++---- go/harness/claude/internal/driver/parser.go | 16 +++++----- go/harness/claude/internal/driver/process.go | 31 ++++++++++++------- go/harness/runtime/a2a/executor.go | 4 +-- 8 files changed, 53 insertions(+), 56 deletions(-) diff --git a/go/core/test/e2e/claude_interaction_test.go b/go/core/test/e2e/claude_interaction_test.go index cd04cca40..2c9a82027 100644 --- a/go/core/test/e2e/claude_interaction_test.go +++ b/go/core/test/e2e/claude_interaction_test.go @@ -243,24 +243,6 @@ func assertClaudeToolEvents(t *testing.T, events []claudeToolEvent, toolNames .. } } -func firstClaudeToolPairName(events []claudeToolEvent) string { - calls := map[string]map[string]struct{}{} - for _, event := range events { - if event.partType == "function_call" { - if calls[event.name] == nil { - calls[event.name] = map[string]struct{}{} - } - calls[event.name][event.id] = struct{}{} - } - if event.partType == "function_response" { - if _, ok := calls[event.name][event.id]; ok { - return event.name - } - } - } - return "" -} - func getClaudeTask(t *testing.T, fixture *interactionFixture, taskID a2atype.TaskID) *a2atype.Task { t.Helper() request, err := pbconv.ToProtoGetTaskRequest(&a2atype.GetTaskRequest{ID: taskID}) diff --git a/go/core/v2/translator/claude/compiler.go b/go/core/v2/translator/claude/compiler.go index 3fa95ab9d..6a56c8c79 100644 --- a/go/core/v2/translator/claude/compiler.go +++ b/go/core/v2/translator/claude/compiler.go @@ -49,7 +49,7 @@ func NewCompiler(kube v2translator.Reader) *Compiler { return &Compiler{kube: ku func (c *Compiler) Compile(ctx context.Context, input *v2translator.HarnessInput) (*v2translator.Revision, error) { if input == nil || input.Harness == nil || input.Root == nil || input.Root.Template == nil || input.Root.ModelConfig == nil { - return nil, fmt.Errorf("Claude compiler requires a resolved Harness, AgentTemplate, and ModelConfig") + return nil, fmt.Errorf("claude compiler requires a resolved Harness, AgentTemplate, and ModelConfig") } model := input.Root.ModelConfig if strings.TrimSpace(model.Spec.Model) == "" { @@ -148,7 +148,7 @@ func (c *Compiler) compileLocalAgents(root *v2translator.AgentInput) (map[string for _, binding := range root.Shared { child := binding.Agent if child == nil || child.Template == nil || child.ModelConfig == nil { - return nil, fmt.Errorf("Claude local agent %q is not fully resolved", binding.Name) + return nil, fmt.Errorf("claude local agent %q is not fully resolved", binding.Name) } if len(child.MCPTools) != 0 || len(child.Shared) != 0 || len(child.Template.Spec.Tools) != 0 { return nil, v2translator.NewValidationError("Claude local agent %q cannot contain MCP or nested agent tools yet", binding.Name) @@ -420,7 +420,7 @@ func (c *Compiler) buildProvenance(ctx context.Context, input *v2translator.Harn } value, ok := secret.Data[ref.Key] if !ok { - return nil, fmt.Errorf("Secret %q does not contain key %q", ref.Name, ref.Key) + 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[:])}) @@ -453,7 +453,7 @@ func (c *Compiler) resolveEnvironment(ctx context.Context, namespace string, env } value, ok := secret.Data[ref.Key] if !ok { - return nil, fmt.Errorf("Secret %q does not contain key %q", ref.Name, ref.Key) + return nil, fmt.Errorf("secret %q does not contain key %q", ref.Name, ref.Key) } resolved[i].Value, resolved[i].ValueFrom = string(value), nil } diff --git a/go/core/v2/translator/claude/compiler_test.go b/go/core/v2/translator/claude/compiler_test.go index 3f600b7bf..005f0a4ca 100644 --- a/go/core/v2/translator/claude/compiler_test.go +++ b/go/core/v2/translator/claude/compiler_test.go @@ -8,6 +8,7 @@ import ( "reflect" "strings" "testing" + "time" "github.com/kagent-dev/kagent/go/api/v1alpha3" v2translator "github.com/kagent-dev/kagent/go/core/v2/translator" @@ -216,8 +217,9 @@ func TestCompileDirectWholeServerMCP(t *testing.T) { server := &v1alpha3.RemoteMCPServer{ ObjectMeta: metav1.ObjectMeta{Name: "math-server", Namespace: "test", UID: "mcp-uid", Generation: 3}, Spec: v1alpha3.RemoteMCPServerSpec{ - Protocol: v1alpha3.RemoteMCPServerProtocolStreamableHttp, - URL: "https://mcp.example.com/mcp", + Protocol: v1alpha3.RemoteMCPServerProtocolStreamableHttp, + URL: "https://mcp.example.com/mcp", + SseReadTimeout: &metav1.Duration{Duration: 5 * time.Minute}, HeadersFrom: []v1alpha3.ValueRef{ {Name: "X-Tenant", Value: "test"}, {Name: "Authorization", ValueFrom: &v1alpha3.ValueSource{Type: v1alpha3.SecretValueSource, Name: "model-auth", Key: "mcp-token"}}, @@ -237,8 +239,8 @@ func TestCompileDirectWholeServerMCP(t *testing.T) { if err != nil { t.Fatal(err) } - if len(revision.Warnings) != 0 { - t.Fatalf("whole-server selection warnings = %v", revision.Warnings) + if len(revision.Warnings) != 1 || !strings.Contains(revision.Warnings[0], "ignores sseReadTimeout 5m0s") { + t.Fatalf("MCP compatibility warnings = %v", revision.Warnings) } var cfg claudeconfig.Config if err := json.Unmarshal(revision.ConfigJSON, &cfg); err != nil { diff --git a/go/core/v2/translator/claude/mcp.go b/go/core/v2/translator/claude/mcp.go index 8bb361da6..0b5d527f9 100644 --- a/go/core/v2/translator/claude/mcp.go +++ b/go/core/v2/translator/claude/mcp.go @@ -52,6 +52,13 @@ func (c *Compiler) compileMCP( if warning := mcpSelectionWarning(tool.Binding.Tools, server); warning != "" { result.warnings = append(result.warnings, warning) } + if server.Spec.SseReadTimeout != nil { + result.warnings = append(result.warnings, fmt.Sprintf( + "Claude RemoteMCPServer %q ignores sseReadTimeout %s because Claude does not expose that transport setting", + server.Name, + server.Spec.SseReadTimeout.Duration, + )) + } transport, err := claudeMCPTransport(server) if err != nil { @@ -120,9 +127,6 @@ func claudeMCPTransport(server *v1alpha3.RemoteMCPServer) (string, error) { if server.Spec.Timeout != nil && server.Spec.Timeout.Duration != 30*time.Second { return "", v2translator.NewValidationError("Claude RemoteMCPServer %q supports only the default 30s timeout", server.Name) } - if server.Spec.SseReadTimeout != nil { - return "", v2translator.NewValidationError("Claude RemoteMCPServer %q does not support sseReadTimeout", server.Name) - } if server.Spec.TerminateOnClose != nil && !*server.Spec.TerminateOnClose { return "", v2translator.NewValidationError("Claude RemoteMCPServer %q requires terminateOnClose", server.Name) } diff --git a/go/harness/claude/config/config.go b/go/harness/claude/config/config.go index de8b34db8..ee32aeb87 100644 --- a/go/harness/claude/config/config.go +++ b/go/harness/claude/config/config.go @@ -95,25 +95,25 @@ func (c Config) Validate() error { } for name, agent := range c.Agents { if !agentNamePattern.MatchString(name) { - return fmt.Errorf("Claude agent name %q must contain only letters, numbers, underscores, or hyphens", name) + return fmt.Errorf("claude agent name %q must contain only letters, numbers, underscores, or hyphens", name) } if strings.TrimSpace(agent.Description) == "" || strings.TrimSpace(agent.Prompt) == "" { - return fmt.Errorf("Claude agent %q requires a non-empty description and prompt", name) + return fmt.Errorf("claude agent %q requires a non-empty description and prompt", name) } } for name, server := range c.MCPServers { if !agentNamePattern.MatchString(name) { - return fmt.Errorf("Claude MCP server name %q must contain only letters, numbers, underscores, or hyphens", name) + return fmt.Errorf("claude MCP server name %q must contain only letters, numbers, underscores, or hyphens", name) } if server.Type != "http" && server.Type != "sse" { - return fmt.Errorf("Claude MCP server %q has unsupported transport %q", name, server.Type) + return fmt.Errorf("claude MCP server %q has unsupported transport %q", name, server.Type) } if strings.TrimSpace(server.URL) == "" { - return fmt.Errorf("Claude MCP server %q URL is required", name) + return fmt.Errorf("claude MCP server %q URL is required", name) } for header, value := range server.Headers { if strings.TrimSpace(header) == "" || strings.TrimSpace(value) == "" { - return fmt.Errorf("Claude MCP server %q headers require non-empty names and values", name) + return fmt.Errorf("claude MCP server %q headers require non-empty names and values", name) } } } diff --git a/go/harness/claude/internal/driver/parser.go b/go/harness/claude/internal/driver/parser.go index f0c3ec38b..d315bf748 100644 --- a/go/harness/claude/internal/driver/parser.go +++ b/go/harness/claude/internal/driver/parser.go @@ -42,7 +42,7 @@ func ParseJSONL(r io.Reader, maxEventBytes int, emit func(Event) error) error { } } if !p.terminal { - return fmt.Errorf("Claude process exited without a terminal result event") + return fmt.Errorf("claude process exited without a terminal result event") } return nil } @@ -52,7 +52,7 @@ func readBoundedLine(r *bufio.Reader, max int) ([]byte, error) { for { fragment, err := r.ReadSlice('\n') if len(line)+len(fragment) > max { - return nil, fmt.Errorf("Claude event exceeds %d bytes", max) + return nil, fmt.Errorf("claude event exceeds %d bytes", max) } line = append(line, fragment...) if err != bufio.ErrBufferFull { @@ -141,10 +141,10 @@ func (p *parser) parseStreamEvent(raw json.RawMessage, emit func(Event) error) e case "content_block_start": if event.ContentBlock.Type == "tool_use" { if event.ContentBlock.ID == "" || event.ContentBlock.Name == "" { - return fmt.Errorf("Claude tool_use start requires an id and name") + return fmt.Errorf("claude tool_use start requires an id and name") } if previous := p.tools[event.ContentBlock.ID]; previous != "" && previous != event.ContentBlock.Name { - return fmt.Errorf("Claude tool_use %q changed name from %q to %q", event.ContentBlock.ID, previous, event.ContentBlock.Name) + return fmt.Errorf("claude tool_use %q changed name from %q to %q", event.ContentBlock.ID, previous, event.ContentBlock.Name) } p.tools[event.ContentBlock.ID] = event.ContentBlock.Name } @@ -192,10 +192,10 @@ func (p *parser) parseAssistant(raw json.RawMessage, emit func(Event) error) err } case "tool_use": if content.ID == "" || content.Name == "" { - return fmt.Errorf("Claude assistant tool_use requires an id and name") + return fmt.Errorf("claude assistant tool_use requires an id and name") } if previous := p.tools[content.ID]; previous != "" && previous != content.Name { - return fmt.Errorf("Claude tool_use %q changed name from %q to %q", content.ID, previous, content.Name) + return fmt.Errorf("claude tool_use %q changed name from %q to %q", content.ID, previous, content.Name) } p.tools[content.ID] = content.Name if _, emitted := p.emittedToolCalls[content.ID]; emitted { @@ -228,10 +228,10 @@ func (p *parser) parseUser(raw json.RawMessage, emit func(Event) error) error { } name := p.tools[content.ToolUseID] if content.ToolUseID == "" || name == "" { - return fmt.Errorf("Claude tool_result references unknown tool_use id %q", content.ToolUseID) + return fmt.Errorf("claude tool_result references unknown tool_use id %q", content.ToolUseID) } if _, emitted := p.emittedResults[content.ToolUseID]; emitted { - return fmt.Errorf("Claude tool_result for %q was emitted more than once", content.ToolUseID) + return fmt.Errorf("claude tool_result for %q was emitted more than once", content.ToolUseID) } var result any if len(content.Content) != 0 && string(content.Content) != "null" { diff --git a/go/harness/claude/internal/driver/process.go b/go/harness/claude/internal/driver/process.go index e7f671e54..c3b706d47 100644 --- a/go/harness/claude/internal/driver/process.go +++ b/go/harness/claude/internal/driver/process.go @@ -7,6 +7,7 @@ import ( "os" "os/exec" "strings" + "sync" "time" "github.com/kagent-dev/kagent/go/harness/runtime" @@ -49,7 +50,7 @@ func (d *ProcessDriver) Validate(ctx context.Context) error { } version := strings.TrimSpace(string(output)) if d.config.StrictVersion && !strings.Contains(version, d.config.ExpectedVersion) { - return fmt.Errorf("Claude version mismatch: got %q, expected %q", version, d.config.ExpectedVersion) + return fmt.Errorf("claude version mismatch: got %q, expected %q", version, d.config.ExpectedVersion) } return nil } @@ -118,14 +119,20 @@ func (d *ProcessDriver) Run(ctx context.Context, turn runtime.Turn, sink runtime } }() waitDone := make(chan error, 1) - go func() { waitDone <- cmd.Wait() }() + var waitOnce sync.Once + waitForExit := func() <-chan error { + waitOnce.Do(func() { + go func() { waitDone <- cmd.Wait() }() + }) + return waitDone + } var terminal *runtime.Outcome for { select { case item, ok := <-items: if !ok { - return runtime.Outcome{}, fmt.Errorf("Claude parser stopped without a result") + return runtime.Outcome{}, fmt.Errorf("claude parser stopped without a result") } if item.event != nil { outcome, err := emitEvent(*item.event, sink, terminal != nil) @@ -136,26 +143,28 @@ func (d *ProcessDriver) Run(ctx context.Context, turn runtime.Turn, sink runtime continue } close(stopEmit) - d.terminate(cmd, waitDone) + d.terminate(cmd, waitForExit()) for range items { } return runtime.Outcome{}, err } if item.err != nil { close(stopEmit) - d.terminate(cmd, waitDone) + d.terminate(cmd, waitForExit()) return runtime.Outcome{}, item.err } - if waitErr := <-waitDone; waitErr != nil { - return runtime.Outcome{}, fmt.Errorf("Claude exited with an error: %w: %s", waitErr, stderr.String()) + // StdoutPipe requires all reads to complete before Wait closes the pipe. + // The parser's nil result is the EOF boundary, so start Wait only now. + if waitErr := <-waitForExit(); waitErr != nil { + return runtime.Outcome{}, fmt.Errorf("claude exited with an error: %w: %s", waitErr, stderr.String()) } if terminal == nil { - return runtime.Outcome{}, fmt.Errorf("Claude process exited without a terminal result") + return runtime.Outcome{}, fmt.Errorf("claude process exited without a terminal result") } return *terminal, nil case <-ctx.Done(): close(stopEmit) - d.terminate(cmd, waitDone) + d.terminate(cmd, waitForExit()) for range items { } return runtime.Outcome{}, ctx.Err() @@ -165,7 +174,7 @@ func (d *ProcessDriver) Run(ctx context.Context, turn runtime.Turn, sink runtime func emitEvent(event Event, sink runtime.EventSink, terminal bool) (*runtime.Outcome, error) { if terminal { - return nil, fmt.Errorf("Claude emitted activity after its terminal result") + return nil, fmt.Errorf("claude emitted activity after its terminal result") } switch event.Kind { case EventSessionStarted: @@ -183,7 +192,7 @@ func emitEvent(event Event, sink runtime.EventSink, terminal bool) (*runtime.Out ID: event.ToolID, Name: event.ToolName, Result: event.ToolResult, IsError: event.ToolError, }) default: - return nil, fmt.Errorf("Claude tool activity has unsupported phase %q", event.ToolPhase) + return nil, fmt.Errorf("claude tool activity has unsupported phase %q", event.ToolPhase) } case EventCompleted: return &runtime.Outcome{}, nil diff --git a/go/harness/runtime/a2a/executor.go b/go/harness/runtime/a2a/executor.go index d207536fc..0c53558a1 100644 --- a/go/harness/runtime/a2a/executor.go +++ b/go/harness/runtime/a2a/executor.go @@ -265,11 +265,11 @@ func validateRequest(reqCtx *a2asrv.ExecutorContext) (string, error) { return "", fmt.Errorf("task ID and context ID are required") } if reqCtx.Message.Role != a2atype.MessageRoleUser || len(reqCtx.Message.Parts) != 1 || reqCtx.Message.Parts[0] == nil { - return "", fmt.Errorf("Harness runtime accepts exactly one user text part") + return "", fmt.Errorf("harness runtime accepts exactly one user text part") } text := reqCtx.Message.Parts[0].Text() if text == "" { - return "", fmt.Errorf("Harness runtime accepts a non-empty text part") + return "", fmt.Errorf("harness runtime accepts a non-empty text part") } return text, nil } From be71e965c2057044b5479a91f722b2a5dfb3fb8a Mon Sep 17 00:00:00 2001 From: Jet Chiang Date: Thu, 27 Aug 2026 16:54:08 -0400 Subject: [PATCH 06/11] add remotemcpserver back, fixes to make claude mcp work e2e Signed-off-by: Jet Chiang --- go/core/cmd/controller-v2/main.go | 60 ++++- go/core/cmd/controller-v2/main_test.go | 16 ++ .../controller/remotemcpserver/reconciler.go | 240 ++++++++++++++++++ .../remotemcpserver/reconciler_test.go | 229 +++++++++++++++++ go/core/v2/translator/claude/compiler_test.go | 2 +- go/core/v2/translator/claude/mcp.go | 8 - 6 files changed, 540 insertions(+), 15 deletions(-) create mode 100644 go/core/internal/controller/remotemcpserver/reconciler.go create mode 100644 go/core/internal/controller/remotemcpserver/reconciler_test.go diff --git a/go/core/cmd/controller-v2/main.go b/go/core/cmd/controller-v2/main.go index a06ff1655..6eab4000d 100644 --- a/go/core/cmd/controller-v2/main.go +++ b/go/core/cmd/controller-v2/main.go @@ -28,11 +28,17 @@ import ( "syscall" "time" + atev1alpha1 "github.com/agent-substrate/substrate/pkg/api/v1alpha1" kagentv1alpha3 "github.com/kagent-dev/kagent/go/api/v1alpha3" + remotemcpcontroller "github.com/kagent-dev/kagent/go/core/internal/controller/remotemcpserver" "github.com/kagent-dev/kagent/go/core/internal/database" "github.com/kagent-dev/kagent/go/core/internal/grpcserver" authimpl "github.com/kagent-dev/kagent/go/core/internal/httpserver/auth" "github.com/kagent-dev/kagent/go/core/internal/service/kubecrud" + modelservice "github.com/kagent-dev/kagent/go/core/internal/service/model" + prompttemplateservice "github.com/kagent-dev/kagent/go/core/internal/service/prompttemplate" + systemservice "github.com/kagent-dev/kagent/go/core/internal/service/system" + toolservice "github.com/kagent-dev/kagent/go/core/internal/service/tool" "github.com/kagent-dev/kagent/go/core/pkg/auth" "github.com/kagent-dev/kagent/go/core/pkg/migrations" "github.com/kagent-dev/kagent/go/core/v2/a2agateway" @@ -41,13 +47,17 @@ import ( v2controller "github.com/kagent-dev/kagent/go/core/v2/controller" v2mcp "github.com/kagent-dev/kagent/go/core/v2/mcp" "github.com/kagent-dev/kagent/go/core/v2/substrate" + kmcp "github.com/kagent-dev/kmcp/api/v1alpha1" "go.uber.org/zap/zapcore" "golang.org/x/sync/errgroup" + corev1 "k8s.io/api/core/v1" k8sruntime "k8s.io/apimachinery/pkg/runtime" utilruntime "k8s.io/apimachinery/pkg/util/runtime" clientgoscheme "k8s.io/client-go/kubernetes/scheme" "k8s.io/client-go/tools/clientcmd" ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/cache" + "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/log/zap" metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" ) @@ -91,8 +101,21 @@ func main() { managerScheme := k8sruntime.NewScheme() utilruntime.Must(clientgoscheme.AddToScheme(managerScheme)) utilruntime.Must(kagentv1alpha3.AddToScheme(managerScheme)) + utilruntime.Must(atev1alpha1.AddToScheme(managerScheme)) + utilruntime.Must(kmcp.AddToScheme(managerScheme)) + watchNamespaces := namespaces(os.Getenv("WATCH_NAMESPACES")) + managerClientOptions := client.Options{} + managerCacheOptions := cache.Options{DefaultNamespaces: namespaceCache(watchNamespaces)} + if len(watchNamespaces) > 0 { + // A namespaced Role cannot list cluster-scoped Namespace objects. Read them + // directly so SystemService can fall back to the configured names on a + // Forbidden response without a failing Namespace informer blocking startup. + managerClientOptions.Cache = &client.CacheOptions{DisableFor: []client.Object{&corev1.Namespace{}}} + } manager, err := ctrl.NewManager(kubeConfig, ctrl.Options{ Scheme: managerScheme, + Cache: managerCacheOptions, + Client: managerClientOptions, Metrics: metricsserver.Options{BindAddress: "0"}, LeaderElection: envBool("LEADER_ELECT"), LeaderElectionID: "0e9f6799.kagent.dev", @@ -101,7 +124,7 @@ func main() { if err != nil { log.Fatalf("create controller manager: %v", err) } - runtime, err := v2controller.NewRuntime(kubeConfig, namespaces(os.Getenv("WATCH_NAMESPACES")), ctx.Done()) + runtime, err := v2controller.NewRuntime(kubeConfig, watchNamespaces, ctx.Done()) if err != nil { log.Fatal(err) } @@ -112,6 +135,11 @@ func main() { if err := manager.Add(reconciler); err != nil { log.Fatalf("add reconciler to controller manager: %v", err) } + mcpClient := toolservice.NewRuntimeMCPClient(manager.GetClient()) + remoteMCPDiscovery := remotemcpcontroller.New(manager.GetClient(), mcpClient, store) + if err := remoteMCPDiscovery.SetupWithManager(manager); err != nil { + log.Fatalf("set up RemoteMCPServer discovery: %v", err) + } actors, err := substrate.Dial(ctx, substrate.Config{ AteAPIEndpoint: env("SUBSTRATE_ATE_API_ENDPOINT", "dns:///api.ate-system.svc:443"), @@ -126,6 +154,11 @@ func main() { authenticator := &authimpl.UnsecureAuthenticator{} authorizer := &authimpl.NoopAuthorizer{} + resourceNamespace := env("KAGENT_NAMESPACE", "kagent") + models := modelservice.NewService(manager.GetClient(), authorizer, resourceNamespace) + tools := toolservice.NewService(manager.GetClient(), store, authorizer, resourceNamespace, mcpClient) + prompts := prompttemplateservice.NewService(manager.GetClient(), authorizer) + system := systemservice.NewService(systemservice.WithInventory(manager.GetClient(), watchNamespaces, authorizer, actors)) instanceWorkflow := agentinstance.NewActorWorkflow(store, actors) instances := agentinstance.NewService(store, authorizer, instanceWorkflow) checkpoints := checkpoint.NewService(store, authorizer, actors, instanceWorkflow) @@ -143,11 +176,15 @@ func main() { log.Fatal(err) } server, err := grpcserver.New(grpcserver.Config{ - BindAddress: env("GRPC_BIND_ADDRESS", ":8084"), - Reflection: envBool("GRPC_REFLECTION"), - Authenticator: authenticator, - ShareStore: store, - AgentInstanceService: instances, + BindAddress: env("GRPC_BIND_ADDRESS", ":8084"), + Reflection: envBool("GRPC_REFLECTION"), + Authenticator: authenticator, + ShareStore: store, + ModelService: models, + ToolService: tools, + PromptTemplateService: prompts, + SystemService: system, + AgentInstanceService: instances, // Both halves of the pair CreateAgentInstance names. Without these two // the only way to author a Harness or an AgentTemplate is kubectl. AgentTemplateService: kubecrud.NewService(manager.GetClient(), authorizer, &kagentv1alpha3.AgentTemplate{}, &kagentv1alpha3.AgentTemplateList{}, "AgentTemplate"), @@ -215,3 +252,14 @@ func namespaces(value string) []string { } return result } + +func namespaceCache(names []string) map[string]cache.Config { + if len(names) == 0 { + return nil + } + result := make(map[string]cache.Config, len(names)) + for _, name := range names { + result[name] = cache.Config{} + } + return result +} diff --git a/go/core/cmd/controller-v2/main_test.go b/go/core/cmd/controller-v2/main_test.go index d93c97346..81c6d0c83 100644 --- a/go/core/cmd/controller-v2/main_test.go +++ b/go/core/cmd/controller-v2/main_test.go @@ -11,3 +11,19 @@ func TestNamespaces(t *testing.T) { t.Fatalf("namespaces() = %q, want %q", got, want) } } + +func TestNamespaceCache(t *testing.T) { + if got := namespaceCache(nil); got != nil { + t.Fatalf("namespaceCache(nil) = %#v, want nil", got) + } + got := namespaceCache([]string{"team-a", "team-b"}) + if len(got) != 2 { + t.Fatalf("namespaceCache() = %#v", got) + } + if _, ok := got["team-a"]; !ok { + t.Fatal("namespaceCache() missing team-a") + } + if _, ok := got["team-b"]; !ok { + t.Fatal("namespaceCache() missing team-b") + } +} diff --git a/go/core/internal/controller/remotemcpserver/reconciler.go b/go/core/internal/controller/remotemcpserver/reconciler.go new file mode 100644 index 000000000..5e3be5335 --- /dev/null +++ b/go/core/internal/controller/remotemcpserver/reconciler.go @@ -0,0 +1,240 @@ +/* +Copyright 2026. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package remotemcpserver reconciles the discovered tool catalog published in +// RemoteMCPServer status. +package remotemcpserver + +import ( + "context" + "errors" + "fmt" + "reflect" + "slices" + "strings" + "time" + + dbmodel "github.com/kagent-dev/kagent/go/api/database" + "github.com/kagent-dev/kagent/go/api/v1alpha3" + toolservice "github.com/kagent-dev/kagent/go/core/internal/service/tool" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + apiMeta "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/builder" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/handler" + "sigs.k8s.io/controller-runtime/pkg/predicate" + "sigs.k8s.io/controller-runtime/pkg/reconcile" +) + +const ( + conditionAccepted = "Accepted" + remoteGroupKind = "RemoteMCPServer.kagent.dev" + refreshInterval = 5 * time.Minute +) + +// ToolDiscoverer returns the tools currently advertised by one MCP server. +type ToolDiscoverer interface { + ListTools(context.Context, toolservice.MCPServerRef) ([]toolservice.MCPAppTool, error) +} + +// CatalogStore keeps the gRPC ToolService catalog aligned with Kubernetes status. +// RemoteMCPServer status remains the source used by harness compilers, while the +// database projection serves list RPCs without making those RPCs perform discovery. +type CatalogStore interface { + StoreToolServer(context.Context, *dbmodel.ToolServer) (*dbmodel.ToolServer, error) + RefreshToolsForServer(context.Context, string, string, ...*v1alpha3.MCPTool) error + DeleteToolsForServer(context.Context, string, string) error + DeleteToolServer(context.Context, string, string) error +} + +// Reconciler publishes RemoteMCPServer discovery results to its status. +type Reconciler struct { + client client.Client + discoverer ToolDiscoverer + catalog CatalogStore +} + +func New(client client.Client, discoverer ToolDiscoverer, catalog CatalogStore) *Reconciler { + return &Reconciler{client: client, discoverer: discoverer, catalog: catalog} +} + +func (r *Reconciler) SetupWithManager(manager ctrl.Manager) error { + return ctrl.NewControllerManagedBy(manager). + For(&v1alpha3.RemoteMCPServer{}, builder.WithPredicates(predicate.GenerationChangedPredicate{})). + Watches(&corev1.Secret{}, handler.EnqueueRequestsFromMapFunc(r.requestsForDependency)). + Watches(&corev1.ConfigMap{}, handler.EnqueueRequestsFromMapFunc(r.requestsForDependency)). + Complete(r) +} + +func (r *Reconciler) Reconcile(ctx context.Context, request reconcile.Request) (reconcile.Result, error) { + server := &v1alpha3.RemoteMCPServer{} + if err := r.client.Get(ctx, request.NamespacedName, server); err != nil { + if !apierrors.IsNotFound(err) { + return reconcile.Result{}, err + } + return reconcile.Result{}, r.deleteCatalog(ctx, request.String()) + } + + tools, err := r.discoverer.ListTools(ctx, toolservice.MCPServerRef{ + Ref: request.NamespacedName, GroupKind: remoteGroupKind, + }) + if err != nil { + statusErr := r.updateStatus(ctx, server, nil, metav1.ConditionFalse, "DiscoveryFailed", err.Error()) + catalogErr := r.updateCatalog(ctx, server, nil, false) + return reconcile.Result{}, errors.Join( + fmt.Errorf("discover RemoteMCPServer tools: %w", err), + wrapError("update RemoteMCPServer discovery failure", statusErr), + wrapError("clear RemoteMCPServer tool catalog", catalogErr), + ) + } + + discovered, err := normalizeTools(tools) + if err != nil { + statusErr := r.updateStatus(ctx, server, nil, metav1.ConditionFalse, "InvalidDiscovery", err.Error()) + catalogErr := r.updateCatalog(ctx, server, nil, false) + return reconcile.Result{}, errors.Join( + err, + wrapError("update invalid RemoteMCPServer discovery", statusErr), + wrapError("clear invalid RemoteMCPServer tool catalog", catalogErr), + ) + } + message := fmt.Sprintf("Discovered %d MCP tools", len(discovered)) + if err := r.updateStatus(ctx, server, discovered, metav1.ConditionTrue, "DiscoverySucceeded", message); err != nil { + return reconcile.Result{}, fmt.Errorf("update RemoteMCPServer discovery status: %w", err) + } + if err := r.updateCatalog(ctx, server, discovered, true); err != nil { + return reconcile.Result{}, fmt.Errorf("update RemoteMCPServer tool catalog: %w", err) + } + return reconcile.Result{RequeueAfter: refreshInterval}, nil +} + +func (r *Reconciler) updateCatalog(ctx context.Context, server *v1alpha3.RemoteMCPServer, tools []*v1alpha3.MCPTool, connected bool) error { + name := client.ObjectKeyFromObject(server).String() + var lastConnected *time.Time + if connected { + now := time.Now().UTC() + lastConnected = &now + } + if _, err := r.catalog.StoreToolServer(ctx, &dbmodel.ToolServer{ + Name: name, GroupKind: remoteGroupKind, Description: server.Spec.Description, LastConnected: lastConnected, + }); err != nil { + return fmt.Errorf("store server: %w", err) + } + if err := r.catalog.RefreshToolsForServer(ctx, name, remoteGroupKind, tools...); err != nil { + return fmt.Errorf("refresh tools: %w", err) + } + return nil +} + +func (r *Reconciler) deleteCatalog(ctx context.Context, name string) error { + return errors.Join( + wrapError("delete tools", r.catalog.DeleteToolsForServer(ctx, name, remoteGroupKind)), + wrapError("delete server", r.catalog.DeleteToolServer(ctx, name, remoteGroupKind)), + ) +} + +func wrapError(action string, err error) error { + if err == nil { + return nil + } + return fmt.Errorf("%s: %w", action, err) +} + +func normalizeTools(tools []toolservice.MCPAppTool) ([]*v1alpha3.MCPTool, error) { + discovered := make([]*v1alpha3.MCPTool, 0, len(tools)) + seen := make(map[string]struct{}, len(tools)) + for _, tool := range tools { + if strings.TrimSpace(tool.Name) == "" { + return nil, fmt.Errorf("MCP discovery returned a tool with an empty name") + } + if _, exists := seen[tool.Name]; exists { + return nil, fmt.Errorf("MCP discovery returned duplicate tool %q", tool.Name) + } + seen[tool.Name] = struct{}{} + discovered = append(discovered, &v1alpha3.MCPTool{Name: tool.Name, Description: tool.Description}) + } + slices.SortFunc(discovered, func(a, b *v1alpha3.MCPTool) int { + return strings.Compare(a.Name, b.Name) + }) + return discovered, nil +} + +func (r *Reconciler) updateStatus( + ctx context.Context, + server *v1alpha3.RemoteMCPServer, + tools []*v1alpha3.MCPTool, + conditionStatus metav1.ConditionStatus, + reason string, + message string, +) error { + original := server.DeepCopy() + server.Status.ObservedGeneration = server.Generation + server.Status.DiscoveredTools = tools + apiMeta.SetStatusCondition(&server.Status.Conditions, metav1.Condition{ + Type: conditionAccepted, Status: conditionStatus, Reason: reason, Message: message, + ObservedGeneration: server.Generation, + }) + if reflect.DeepEqual(original.Status, server.Status) { + return nil + } + return r.client.Status().Patch(ctx, server, client.MergeFrom(original)) +} + +func (r *Reconciler) requestsForDependency(ctx context.Context, object client.Object) []reconcile.Request { + servers := &v1alpha3.RemoteMCPServerList{} + if err := r.client.List(ctx, servers, client.InNamespace(object.GetNamespace())); err != nil { + return nil + } + requests := make([]reconcile.Request, 0) + for i := range servers.Items { + server := &servers.Items[i] + if referencesDependency(server, object) { + requests = append(requests, reconcile.Request{NamespacedName: types.NamespacedName{ + Namespace: server.Namespace, Name: server.Name, + }}) + } + } + return requests +} + +func referencesDependency(server *v1alpha3.RemoteMCPServer, object client.Object) bool { + switch object.(type) { + case *corev1.Secret: + if server.Spec.TLS != nil && server.Spec.TLS.CACertSecretRef == object.GetName() { + return true + } + for i := range server.Spec.HeadersFrom { + from := server.Spec.HeadersFrom[i].ValueFrom + if from != nil && from.Type == v1alpha3.SecretValueSource && from.Name == object.GetName() { + return true + } + } + case *corev1.ConfigMap: + for i := range server.Spec.HeadersFrom { + from := server.Spec.HeadersFrom[i].ValueFrom + if from != nil && from.Type == v1alpha3.ConfigMapValueSource && from.Name == object.GetName() { + return true + } + } + } + return false +} + +var _ reconcile.Reconciler = (*Reconciler)(nil) diff --git a/go/core/internal/controller/remotemcpserver/reconciler_test.go b/go/core/internal/controller/remotemcpserver/reconciler_test.go new file mode 100644 index 000000000..f6a4e6118 --- /dev/null +++ b/go/core/internal/controller/remotemcpserver/reconciler_test.go @@ -0,0 +1,229 @@ +/* +Copyright 2026. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package remotemcpserver + +import ( + "context" + "errors" + "testing" + "time" + + dbmodel "github.com/kagent-dev/kagent/go/api/database" + "github.com/kagent-dev/kagent/go/api/v1alpha3" + toolservice "github.com/kagent-dev/kagent/go/core/internal/service/tool" + corev1 "k8s.io/api/core/v1" + apiMeta "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +type fakeDiscoverer struct { + tools []toolservice.MCPAppTool + err error + ref toolservice.MCPServerRef +} + +type fakeCatalog struct { + server *dbmodel.ToolServer + tools []*v1alpha3.MCPTool + deletedTools string + deleted string +} + +func (f *fakeCatalog) StoreToolServer(_ context.Context, server *dbmodel.ToolServer) (*dbmodel.ToolServer, error) { + f.server = server + return server, nil +} + +func (f *fakeCatalog) RefreshToolsForServer(_ context.Context, name, groupKind string, tools ...*v1alpha3.MCPTool) error { + f.tools = tools + return nil +} + +func (f *fakeCatalog) DeleteToolsForServer(_ context.Context, name, groupKind string) error { + f.deletedTools = name + "|" + groupKind + return nil +} + +func (f *fakeCatalog) DeleteToolServer(_ context.Context, name, groupKind string) error { + f.deleted = name + "|" + groupKind + return nil +} + +func (f *fakeDiscoverer) ListTools(_ context.Context, ref toolservice.MCPServerRef) ([]toolservice.MCPAppTool, error) { + f.ref = ref + return f.tools, f.err +} + +func TestReconcilePublishesSortedDiscovery(t *testing.T) { + server := testServer() + kube := testClient(t, server) + discoverer := &fakeDiscoverer{tools: []toolservice.MCPAppTool{ + {Name: "zeta", Description: "last"}, + {Name: "alpha", Description: "first"}, + }} + catalog := &fakeCatalog{} + reconciler := New(kube, discoverer, catalog) + + result, err := reconciler.Reconcile(t.Context(), ctrl.Request{NamespacedName: client.ObjectKeyFromObject(server)}) + if err != nil { + t.Fatalf("Reconcile() error = %v", err) + } + if result.RequeueAfter != 5*time.Minute { + t.Fatalf("Reconcile() requeue = %s, want 5m", result.RequeueAfter) + } + if discoverer.ref.Ref != client.ObjectKeyFromObject(server) || discoverer.ref.GroupKind != "RemoteMCPServer.kagent.dev" { + t.Fatalf("discovery ref = %#v", discoverer.ref) + } + + updated := getServer(t, kube, server) + if updated.Status.ObservedGeneration != server.Generation { + t.Fatalf("observed generation = %d, want %d", updated.Status.ObservedGeneration, server.Generation) + } + if len(updated.Status.DiscoveredTools) != 2 || updated.Status.DiscoveredTools[0].Name != "alpha" || updated.Status.DiscoveredTools[1].Name != "zeta" { + t.Fatalf("discovered tools = %#v", updated.Status.DiscoveredTools) + } + condition := apiMeta.FindStatusCondition(updated.Status.Conditions, conditionAccepted) + if condition == nil || condition.Status != metav1.ConditionTrue || condition.Reason != "DiscoverySucceeded" { + t.Fatalf("Accepted condition = %#v", condition) + } + if catalog.server == nil || catalog.server.Name != "test/tools" || catalog.server.GroupKind != remoteGroupKind || catalog.server.LastConnected == nil { + t.Fatalf("catalog server = %#v", catalog.server) + } + if len(catalog.tools) != 2 || catalog.tools[0].Name != "alpha" || catalog.tools[1].Name != "zeta" { + t.Fatalf("catalog tools = %#v", catalog.tools) + } +} + +func TestReconcilePublishesFailureAndClearsStaleTools(t *testing.T) { + server := testServer() + server.Status.DiscoveredTools = []*v1alpha3.MCPTool{{Name: "stale", Description: "stale"}} + kube := testClient(t, server) + discoverer := &fakeDiscoverer{err: errors.New("upstream unavailable")} + catalog := &fakeCatalog{} + + _, err := New(kube, discoverer, catalog).Reconcile(t.Context(), ctrl.Request{NamespacedName: client.ObjectKeyFromObject(server)}) + if err == nil { + t.Fatal("Reconcile() error = nil, want discovery failure") + } + updated := getServer(t, kube, server) + if updated.Status.ObservedGeneration != server.Generation || len(updated.Status.DiscoveredTools) != 0 { + t.Fatalf("failed discovery status = %#v", updated.Status) + } + condition := apiMeta.FindStatusCondition(updated.Status.Conditions, conditionAccepted) + if condition == nil || condition.Status != metav1.ConditionFalse || condition.Reason != "DiscoveryFailed" || condition.Message != "upstream unavailable" { + t.Fatalf("Accepted condition = %#v", condition) + } + if catalog.server == nil || catalog.server.LastConnected != nil || len(catalog.tools) != 0 { + t.Fatalf("failed discovery catalog = server %#v, tools %#v", catalog.server, catalog.tools) + } +} + +func TestReconcileDeletesCatalogProjection(t *testing.T) { + catalog := &fakeCatalog{} + request := ctrl.Request{NamespacedName: types.NamespacedName{Namespace: "test", Name: "gone"}} + + if _, err := New(testClient(t), &fakeDiscoverer{}, catalog).Reconcile(t.Context(), request); err != nil { + t.Fatalf("Reconcile() error = %v", err) + } + want := "test/gone|" + remoteGroupKind + if catalog.deletedTools != want || catalog.deleted != want { + t.Fatalf("catalog deletes = tools %q, server %q, want %q", catalog.deletedTools, catalog.deleted, want) + } +} + +func TestNormalizeToolsRejectsInvalidCatalog(t *testing.T) { + tests := []struct { + name string + tools []toolservice.MCPAppTool + }{ + {name: "empty name", tools: []toolservice.MCPAppTool{{Name: " "}}}, + {name: "duplicate name", tools: []toolservice.MCPAppTool{{Name: "lookup"}, {Name: "lookup"}}}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if _, err := normalizeTools(test.tools); err == nil { + t.Fatal("normalizeTools() error = nil") + } + }) + } +} + +func TestReferencesDependency(t *testing.T) { + server := testServer() + server.Spec.HeadersFrom = []v1alpha3.ValueRef{ + {Name: "Authorization", ValueFrom: &v1alpha3.ValueSource{Type: v1alpha3.SecretValueSource, Name: "auth", Key: "token"}}, + {Name: "X-Config", ValueFrom: &v1alpha3.ValueSource{Type: v1alpha3.ConfigMapValueSource, Name: "headers", Key: "value"}}, + } + server.Spec.TLS = &v1alpha3.TLSConfig{CACertSecretRef: "ca", CACertSecretKey: "ca.crt"} + + tests := []struct { + name string + obj client.Object + want bool + }{ + {name: "header secret", obj: &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "auth"}}, want: true}, + {name: "CA secret", obj: &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "ca"}}, want: true}, + {name: "header ConfigMap", obj: &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "headers"}}, want: true}, + {name: "unrelated secret", obj: &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "other"}}}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := referencesDependency(server, test.obj); got != test.want { + t.Fatalf("referencesDependency() = %t, want %t", got, test.want) + } + }) + } +} + +func testServer() *v1alpha3.RemoteMCPServer { + return &v1alpha3.RemoteMCPServer{ + ObjectMeta: metav1.ObjectMeta{Namespace: "test", Name: "tools", Generation: 3}, + Spec: v1alpha3.RemoteMCPServerSpec{ + Description: "tools", Protocol: v1alpha3.RemoteMCPServerProtocolStreamableHttp, + URL: "https://tools.example/mcp", + }, + } +} + +func testClient(t *testing.T, objects ...client.Object) client.Client { + t.Helper() + scheme := runtime.NewScheme() + if err := v1alpha3.AddToScheme(scheme); err != nil { + t.Fatal(err) + } + if err := corev1.AddToScheme(scheme); err != nil { + t.Fatal(err) + } + return fake.NewClientBuilder().WithScheme(scheme). + WithStatusSubresource(&v1alpha3.RemoteMCPServer{}). + WithObjects(objects...).Build() +} + +func getServer(t *testing.T, kube client.Client, source *v1alpha3.RemoteMCPServer) *v1alpha3.RemoteMCPServer { + t.Helper() + result := &v1alpha3.RemoteMCPServer{} + key := types.NamespacedName{Namespace: source.Namespace, Name: source.Name} + if err := kube.Get(t.Context(), key, result); err != nil { + t.Fatal(err) + } + return result +} diff --git a/go/core/v2/translator/claude/compiler_test.go b/go/core/v2/translator/claude/compiler_test.go index 005f0a4ca..b5e1ed7c7 100644 --- a/go/core/v2/translator/claude/compiler_test.go +++ b/go/core/v2/translator/claude/compiler_test.go @@ -239,7 +239,7 @@ func TestCompileDirectWholeServerMCP(t *testing.T) { if err != nil { t.Fatal(err) } - if len(revision.Warnings) != 1 || !strings.Contains(revision.Warnings[0], "ignores sseReadTimeout 5m0s") { + if len(revision.Warnings) != 0 { t.Fatalf("MCP compatibility warnings = %v", revision.Warnings) } var cfg claudeconfig.Config diff --git a/go/core/v2/translator/claude/mcp.go b/go/core/v2/translator/claude/mcp.go index 0b5d527f9..6f7fffabd 100644 --- a/go/core/v2/translator/claude/mcp.go +++ b/go/core/v2/translator/claude/mcp.go @@ -52,14 +52,6 @@ func (c *Compiler) compileMCP( if warning := mcpSelectionWarning(tool.Binding.Tools, server); warning != "" { result.warnings = append(result.warnings, warning) } - if server.Spec.SseReadTimeout != nil { - result.warnings = append(result.warnings, fmt.Sprintf( - "Claude RemoteMCPServer %q ignores sseReadTimeout %s because Claude does not expose that transport setting", - server.Name, - server.Spec.SseReadTimeout.Duration, - )) - } - transport, err := claudeMCPTransport(server) if err != nil { return mcpCompilation{}, err From 91162c6ad41dbca27c461d33d46a0d4dab0602cf Mon Sep 17 00:00:00 2001 From: Jet Chiang Date: Thu, 27 Aug 2026 18:02:42 -0400 Subject: [PATCH 07/11] fix codex review comments Signed-off-by: Jet Chiang --- go/harness/claude/internal/driver/process.go | 9 ++- .../internal/driver/process_group_other.go | 18 +++++ .../internal/driver/process_group_unix.go | 21 +++++ .../driver/process_group_unix_test.go | 79 +++++++++++++++++++ 4 files changed, 124 insertions(+), 3 deletions(-) create mode 100644 go/harness/claude/internal/driver/process_group_other.go create mode 100644 go/harness/claude/internal/driver/process_group_unix.go create mode 100644 go/harness/claude/internal/driver/process_group_unix_test.go diff --git a/go/harness/claude/internal/driver/process.go b/go/harness/claude/internal/driver/process.go index c3b706d47..799372571 100644 --- a/go/harness/claude/internal/driver/process.go +++ b/go/harness/claude/internal/driver/process.go @@ -4,7 +4,6 @@ import ( "bytes" "context" "fmt" - "os" "os/exec" "strings" "sync" @@ -86,6 +85,7 @@ func (d *ProcessDriver) Args(turn runtime.Turn) []string { func (d *ProcessDriver) Run(ctx context.Context, turn runtime.Turn, sink runtime.EventSink) (runtime.Outcome, error) { cmd := exec.Command(d.config.Executable, d.Args(turn)...) + configureProcessGroup(cmd) cmd.Dir = d.config.Workspace cmd.Env = append([]string(nil), d.config.Environment...) stdout, err := cmd.StdoutPipe() @@ -204,13 +204,16 @@ func emitEvent(event Event, sink runtime.EventSink, terminal bool) (*runtime.Out } func (d *ProcessDriver) terminate(cmd *exec.Cmd, waitDone <-chan error) { - _ = cmd.Process.Signal(os.Interrupt) + _ = interruptProcessGroup(cmd.Process) timer := time.NewTimer(d.config.InterruptGrace) defer timer.Stop() select { case <-waitDone: + // The group leader can exit on the interrupt while a descendant that + // ignores it remains alive. Kill any processes still in the group. + _ = killProcessGroup(cmd.Process) case <-timer.C: - _ = cmd.Process.Kill() + _ = killProcessGroup(cmd.Process) <-waitDone } } diff --git a/go/harness/claude/internal/driver/process_group_other.go b/go/harness/claude/internal/driver/process_group_other.go new file mode 100644 index 000000000..6ea9e86da --- /dev/null +++ b/go/harness/claude/internal/driver/process_group_other.go @@ -0,0 +1,18 @@ +//go:build !unix + +package driver + +import ( + "os" + "os/exec" +) + +func configureProcessGroup(*exec.Cmd) {} + +func interruptProcessGroup(process *os.Process) error { + return process.Signal(os.Interrupt) +} + +func killProcessGroup(process *os.Process) error { + return process.Kill() +} diff --git a/go/harness/claude/internal/driver/process_group_unix.go b/go/harness/claude/internal/driver/process_group_unix.go new file mode 100644 index 000000000..ba01a2c59 --- /dev/null +++ b/go/harness/claude/internal/driver/process_group_unix.go @@ -0,0 +1,21 @@ +//go:build unix + +package driver + +import ( + "os" + "os/exec" + "syscall" +) + +func configureProcessGroup(cmd *exec.Cmd) { + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} +} + +func interruptProcessGroup(process *os.Process) error { + return syscall.Kill(-process.Pid, syscall.SIGINT) +} + +func killProcessGroup(process *os.Process) error { + return syscall.Kill(-process.Pid, syscall.SIGKILL) +} diff --git a/go/harness/claude/internal/driver/process_group_unix_test.go b/go/harness/claude/internal/driver/process_group_unix_test.go new file mode 100644 index 000000000..895d5e82b --- /dev/null +++ b/go/harness/claude/internal/driver/process_group_unix_test.go @@ -0,0 +1,79 @@ +//go:build unix + +package driver + +import ( + "context" + "errors" + "os" + "path/filepath" + "testing" + "time" + + "github.com/kagent-dev/kagent/go/harness/runtime" +) + +func TestProcessDriverCancellationTerminatesDescendants(t *testing.T) { + dir := t.TempDir() + executable := filepath.Join(dir, "claude") + childActivityPath := filepath.Join(dir, "child-activity") + script := `#!/bin/sh +trap 'exit 0' INT +sh -c 'trap "" INT; while :; do printf x >> "$CHILD_ACTIVITY_PATH"; sleep 0.02; done' & +printf '%s\n' '{"type":"system","subtype":"init","session_id":"11111111-1111-4111-8111-111111111111"}' +sleep 60 +` + if err := os.WriteFile(executable, []byte(script), 0o700); err != nil { + t.Fatal(err) + } + d := NewProcessDriver(ProcessConfig{ + Executable: executable, + Workspace: dir, + Environment: []string{"CHILD_ACTIVITY_PATH=" + childActivityPath}, + MaxEventBytes: 4096, + MaxStderrBytes: 1024, + InterruptGrace: 100 * time.Millisecond, + }) + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + result := make(chan error, 1) + go func() { + _, err := d.Run(ctx, runtime.Turn{Prompt: "hello"}, &recordingSink{}) + result <- err + }() + + waitForFile(t, childActivityPath) + cancel() + if err := <-result; !errors.Is(err, context.Canceled) { + t.Fatalf("Run() error = %v, want context canceled", err) + } + before, err := os.Stat(childActivityPath) + if err != nil { + t.Fatal(err) + } + time.Sleep(100 * time.Millisecond) + after, err := os.Stat(childActivityPath) + if err != nil { + t.Fatal(err) + } + if after.Size() != before.Size() { + t.Fatalf("descendant continued running after cancellation: activity grew from %d to %d bytes", before.Size(), after.Size()) + } +} + +func waitForFile(t *testing.T, path string) { + t.Helper() + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + info, err := os.Stat(path) + if err == nil { + if info.Size() > 0 { + return + } + } else if !errors.Is(err, os.ErrNotExist) { + t.Fatalf("stat child activity: %v", err) + } + time.Sleep(10 * time.Millisecond) + } + t.Fatal("timed out waiting for child activity") +} From a7843205ebcafaf0212636519a17d3ec559c6a78 Mon Sep 17 00:00:00 2001 From: Jet Chiang Date: Thu, 27 Aug 2026 18:16:07 -0400 Subject: [PATCH 08/11] e2e: add fork and resume Signed-off-by: Jet Chiang --- go/core/test/e2e/claude_interaction_test.go | 82 +++++++++++++++++++++ 1 file changed, 82 insertions(+) diff --git a/go/core/test/e2e/claude_interaction_test.go b/go/core/test/e2e/claude_interaction_test.go index 2c9a82027..2bb8084ad 100644 --- a/go/core/test/e2e/claude_interaction_test.go +++ b/go/core/test/e2e/claude_interaction_test.go @@ -9,12 +9,17 @@ import ( "io" "strings" "testing" + "time" a2atype "github.com/a2aproject/a2a-go/v2/a2a" "github.com/a2aproject/a2a-go/v2/a2apb/v1/pbconv" + "github.com/google/uuid" apiv1alpha1 "github.com/kagent-dev/kagent/go/api/gen/kagent/api/v1alpha1" "github.com/kagent-dev/kagent/go/api/v1alpha3" "github.com/kagent-dev/mockllm" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/status" corev1 "k8s.io/api/core/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -57,6 +62,83 @@ func TestClaudeMockInteractionResumeAndPersistence(t *testing.T) { assertClaudeTaskHistory(t, fixture, first.ID, resumed.ID) } +func TestClaudeMockCheckpointForkAndResume(t *testing.T) { + target := interactionTarget(t) + modelURL := reachableServerURL(t, startMockLLMServer(t, claudeInteractionMocks, "mocks/invoke_claude_agent.json"), "") + template := createClaudeMockTemplate(t, modelURL) + fixture := newInteractionFixtureForHarnessTemplate(t, target, claudeE2EHarness, template) + + _, _, first := fixture.send(t, "Return exactly CLAUDE_MOCK_FIRST.") + if first.Status.State != a2atype.TaskStateCompleted { + t.Fatalf("initial mock Claude task state = %s, want COMPLETED", first.Status.State) + } + + created, err := fixture.checkpoints.CreateCheckpoint(fixture.ctx, &apiv1alpha1.CreateCheckpointRequest{ + Namespace: "kagent", AgentInstanceId: fixture.instanceID, RequestId: uuid.NewString(), + }) + if err != nil { + t.Fatalf("create Claude checkpoint: %v", err) + } + checkpointID := created.GetCheckpoint().GetId() + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(metadata.AppendToOutgoingContext(context.Background(), "x-user-id", "e2e"), time.Minute) + defer cancel() + _, cleanupErr := fixture.checkpoints.DeleteCheckpoint(ctx, &apiv1alpha1.DeleteCheckpointRequest{ + Namespace: "kagent", CheckpointId: checkpointID, + }) + if cleanupErr != nil && status.Code(cleanupErr) != codes.NotFound { + t.Errorf("delete Claude checkpoint: %v", cleanupErr) + } + }) + + forked, err := fixture.checkpoints.ForkAgentInstance(fixture.ctx, &apiv1alpha1.ForkAgentInstanceRequest{ + Namespace: "kagent", CheckpointId: checkpointID, RequestId: uuid.NewString(), + }) + if err != nil { + t.Fatalf("fork Claude AgentInstance: %v", err) + } + fork := forked.GetAgentInstance() + if fork.GetState() != apiv1alpha1.AgentInstanceState_AGENT_INSTANCE_STATE_READY { + t.Fatalf("forked Claude AgentInstance state = %s, want READY", fork.GetState()) + } + forkID := fork.GetId() + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(metadata.AppendToOutgoingContext(context.Background(), "x-user-id", "e2e"), time.Minute) + defer cancel() + _, cleanupErr := fixture.instances.DeleteAgentInstance(ctx, &apiv1alpha1.DeleteAgentInstanceRequest{ + Namespace: "kagent", AgentInstanceId: forkID, + }) + if cleanupErr != nil && status.Code(cleanupErr) != codes.NotFound { + t.Errorf("delete forked Claude AgentInstance: %v", cleanupErr) + } + }) + + forkCtx, forkCancel := context.WithTimeout(metadata.AppendToOutgoingContext(t.Context(), + "x-user-id", "e2e", + "x-kagent-agent-instance-namespace", "kagent", + "x-kagent-agent-instance-id", forkID, + ), 4*time.Minute) + t.Cleanup(forkCancel) + listRequest, err := pbconv.ToProtoListTasksRequest(&a2atype.ListTasksRequest{ContextID: forkID, PageSize: 10}) + if err != nil { + t.Fatalf("build forked Claude task list request: %v", err) + } + listedResponse, err := fixture.client.ListTasks(forkCtx, listRequest) + if err != nil { + t.Fatalf("list forked Claude tasks: %v", err) + } + listed, err := pbconv.FromProtoListTasksResponse(listedResponse) + if err != nil || len(listed.Tasks) != 1 || listed.Tasks[0].ContextID != forkID || listed.Tasks[0].Status.State != a2atype.TaskStateCompleted { + t.Fatalf("forked Claude tasks = %+v, error %v; want one copied task in context %s", listed, err, forkID) + } + + forkFixture := &interactionFixture{ctx: forkCtx, client: fixture.client, instanceID: forkID} + _, _, resumed := forkFixture.send(t, "Return exactly CLAUDE_MOCK_SECOND.") + if resumed.Status.State != a2atype.TaskStateCompleted || !strings.Contains(taskText(resumed), "CLAUDE_MOCK_SECOND") { + t.Fatalf("forked Claude task state = %s, text = %q, want completed resumed response", resumed.Status.State, taskText(resumed)) + } +} + func TestClaudeMockActiveTaskCancellation(t *testing.T) { target := interactionTarget(t) modelURL, started := startBlockingClaudeMock(t) From a50cdd387f0054ab9221896b302b3def365be54b Mon Sep 17 00:00:00 2001 From: Jet Chiang Date: Fri, 28 Aug 2026 12:53:48 -0400 Subject: [PATCH 09/11] review comments Signed-off-by: Jet Chiang --- go/api/adk/types.go | 18 ++----------- go/api/adk/types_test.go | 6 +++-- go/core/cmd/controller-v2/main.go | 6 +++++ go/core/v2/agentplugins/materialize_test.go | 10 ++++---- go/harness/claude/Dockerfile | 25 +++++++++++++------ go/harness/claude/cmd/kagent-claude/main.go | 6 ++--- go/harness/claude/internal/adapter/adapter.go | 24 +++++++++--------- .../claude/internal/adapter/adapter_test.go | 11 ++++---- 8 files changed, 55 insertions(+), 51 deletions(-) diff --git a/go/api/adk/types.go b/go/api/adk/types.go index 776383320..1518a29f2 100644 --- a/go/api/adk/types.go +++ b/go/api/adk/types.go @@ -550,20 +550,6 @@ type NetworkConfig struct { AllowedDomains []string `json:"allowed_domains,omitempty"` } -// These aliases preserve the existing ADK configuration API while sharing one -// runtime-neutral resource model with other Harness adapters. -type AgentPluginConfig = agentplugin.Resources - -type StandaloneSkill = agentplugin.Skill - -type AgentPluginBundle = agentplugin.Bundle - -type AgentPluginSource = agentplugin.Source - -type AgentPluginGit = agentplugin.GitSource - -type AgentPluginS3 = agentplugin.S3Source - // AgentContextConfig is the context management configuration that flows through config.json to the Python runtime. type AgentContextConfig struct { Compaction *AgentCompressionConfig `json:"compaction,omitempty"` @@ -619,7 +605,7 @@ type AgentConfig struct { Stream *bool `json:"stream,omitempty"` Memory *MemoryConfig `json:"memory,omitempty"` Network *NetworkConfig `json:"network,omitempty"` - AgentPlugins *AgentPluginConfig `json:"agent_plugins,omitempty"` + AgentPlugins *agentplugin.Resources `json:"agent_plugins,omitempty"` ContextConfig *AgentContextConfig `json:"context_config,omitempty"` ShareTools *bool `json:"share_tools,omitempty"` SessionDBURL string `json:"session_db_url,omitempty"` @@ -648,7 +634,7 @@ func (a *AgentConfig) UnmarshalJSON(data []byte) error { Stream *bool `json:"stream,omitempty"` Memory json.RawMessage `json:"memory"` Network *NetworkConfig `json:"network,omitempty"` - AgentPlugins *AgentPluginConfig `json:"agent_plugins,omitempty"` + AgentPlugins *agentplugin.Resources `json:"agent_plugins,omitempty"` ContextConfig *AgentContextConfig `json:"context_config,omitempty"` ShareTools *bool `json:"share_tools,omitempty"` SessionDBURL string `json:"session_db_url,omitempty"` diff --git a/go/api/adk/types_test.go b/go/api/adk/types_test.go index c9abf0b18..3bbfc4c8c 100644 --- a/go/api/adk/types_test.go +++ b/go/api/adk/types_test.go @@ -4,13 +4,15 @@ import ( "encoding/json" "reflect" "testing" + + "github.com/kagent-dev/kagent/go/api/agentplugin" ) func TestAgentConfigStdioToolsRoundTrip(t *testing.T) { want := []StdioMcpServerConfig{{Command: "server", Args: []string{"--stdio"}, Env: map[string]string{"KEY": "value"}, Dir: "/plugin"}} - wantPlugins := &AgentPluginConfig{Skills: []StandaloneSkill{{ + wantPlugins := &agentplugin.Resources{Skills: []agentplugin.Skill{{ Name: "review", - Source: AgentPluginSource{Git: &AgentPluginGit{ + Source: agentplugin.Source{Git: &agentplugin.GitSource{ URL: "https://example.com/plugin.git", Commit: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", }}, }}} diff --git a/go/core/cmd/controller-v2/main.go b/go/core/cmd/controller-v2/main.go index 6eab4000d..ae8bf6319 100644 --- a/go/core/cmd/controller-v2/main.go +++ b/go/core/cmd/controller-v2/main.go @@ -34,7 +34,9 @@ import ( "github.com/kagent-dev/kagent/go/core/internal/database" "github.com/kagent-dev/kagent/go/core/internal/grpcserver" authimpl "github.com/kagent-dev/kagent/go/core/internal/httpserver/auth" + feedbackservice "github.com/kagent-dev/kagent/go/core/internal/service/feedback" "github.com/kagent-dev/kagent/go/core/internal/service/kubecrud" + memoryservice "github.com/kagent-dev/kagent/go/core/internal/service/memory" modelservice "github.com/kagent-dev/kagent/go/core/internal/service/model" prompttemplateservice "github.com/kagent-dev/kagent/go/core/internal/service/prompttemplate" systemservice "github.com/kagent-dev/kagent/go/core/internal/service/system" @@ -159,6 +161,8 @@ func main() { tools := toolservice.NewService(manager.GetClient(), store, authorizer, resourceNamespace, mcpClient) prompts := prompttemplateservice.NewService(manager.GetClient(), authorizer) system := systemservice.NewService(systemservice.WithInventory(manager.GetClient(), watchNamespaces, authorizer, actors)) + feedback := feedbackservice.NewService(store) + memory := memoryservice.NewService(store) instanceWorkflow := agentinstance.NewActorWorkflow(store, actors) instances := agentinstance.NewService(store, authorizer, instanceWorkflow) checkpoints := checkpoint.NewService(store, authorizer, actors, instanceWorkflow) @@ -184,6 +188,8 @@ func main() { ToolService: tools, PromptTemplateService: prompts, SystemService: system, + FeedbackService: feedback, + MemoryService: memory, AgentInstanceService: instances, // Both halves of the pair CreateAgentInstance names. Without these two // the only way to author a Harness or an AgentTemplate is kubectl. diff --git a/go/core/v2/agentplugins/materialize_test.go b/go/core/v2/agentplugins/materialize_test.go index 9f02d55c8..09e5563a4 100644 --- a/go/core/v2/agentplugins/materialize_test.go +++ b/go/core/v2/agentplugins/materialize_test.go @@ -45,8 +45,8 @@ func TestMaterializeGitPlugin(t *testing.T) { commit := git("rev-parse", "HEAD") root := t.TempDir() - result, err := materializeForADK(context.Background(), adk.AgentPluginConfig{Plugins: []adk.AgentPluginBundle{{ - Source: adk.AgentPluginSource{Git: &adk.AgentPluginGit{URL: repository, Commit: commit}}, Skills: []string{"review"}, + result, err := materializeForADK(context.Background(), agentplugin.Resources{Plugins: []agentplugin.Bundle{{ + Source: agentplugin.Source{Git: &agentplugin.GitSource{URL: repository, Commit: commit}}, Skills: []string{"review"}, }}}, ADKPaths{ SkillPaths: SkillPaths{Plugins: filepath.Join(root, "plugins"), Skills: filepath.Join(root, "skills")}, Data: filepath.Join(root, "data"), @@ -96,10 +96,10 @@ func TestMaterializeAgentConfigIsolatesSubagentSkills(t *testing.T) { SkillPaths: SkillPaths{Plugins: filepath.Join(root, "plugins"), Skills: filepath.Join(root, "skills")}, Data: filepath.Join(root, "data"), } - source := adk.AgentPluginSource{Git: &adk.AgentPluginGit{URL: "unused", Commit: strings.Repeat("a", 40)}} + source := agentplugin.Source{Git: &agentplugin.GitSource{URL: "unused", Commit: strings.Repeat("a", 40)}} config := &adk.AgentConfig{ - AgentPlugins: &adk.AgentPluginConfig{Skills: []adk.StandaloneSkill{{Name: "root", Source: source}}}, - SubAgents: []*adk.AgentConfig{{Name: "child", AgentPlugins: &adk.AgentPluginConfig{Skills: []adk.StandaloneSkill{{Name: "child", Source: source}}}}}, + AgentPlugins: &agentplugin.Resources{Skills: []agentplugin.Skill{{Name: "root", Source: source}}}, + SubAgents: []*adk.AgentConfig{{Name: "child", AgentPlugins: &agentplugin.Resources{Skills: []agentplugin.Skill{{Name: "child", Source: source}}}}}, } for _, path := range []string{ filepath.Join(paths.Plugins, "standalone-0"), diff --git a/go/harness/claude/Dockerfile b/go/harness/claude/Dockerfile index d0d0f572d..303e55be6 100644 --- a/go/harness/claude/Dockerfile +++ b/go/harness/claude/Dockerfile @@ -18,17 +18,26 @@ RUN --mount=type=cache,target=/root/go/pkg/mod,rw \ CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH} \ go build -trimpath -o /kagent-claude ./harness/claude/cmd/kagent-claude -FROM node:22-trixie-slim +FROM alpine:3.22 ARG CLAUDE_CODE_VERSION=2.1.217 +ARG CLAUDE_CODE_AMD64_SHA256=7c44188927edbf7b918b41bc7a284292359b1ad556b7802db0148adb4b395732 +ARG CLAUDE_CODE_ARM64_SHA256=e219a631e194e71bccbea2f81f5678c75f745aa6e7a74e8a407610c906d1299b +ARG TARGETARCH ARG VERSION -ENV DISABLE_AUTOUPDATER=1 -RUN apt-get update \ - && apt-get install -y --no-install-recommends ca-certificates git \ - && rm -rf /var/lib/apt/lists/* \ - && npm install -g "@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}" \ - && groupadd --system --gid 65532 kagent \ - && useradd --system --uid 65532 --gid 65532 --home-dir /data --shell /usr/sbin/nologin kagent \ +ENV USE_BUILTIN_RIPGREP=0 +RUN apk add --no-cache bash ca-certificates git libgcc libstdc++ ripgrep \ + && case "${TARGETARCH}" in \ + amd64) platform="linux-x64-musl"; checksum="${CLAUDE_CODE_AMD64_SHA256}" ;; \ + arm64) platform="linux-arm64-musl"; checksum="${CLAUDE_CODE_ARM64_SHA256}" ;; \ + *) echo "unsupported target architecture: ${TARGETARCH}" >&2; exit 1 ;; \ + esac \ + && wget -q -O /usr/local/bin/claude \ + "https://downloads.claude.ai/claude-code-releases/${CLAUDE_CODE_VERSION}/${platform}/claude" \ + && echo "${checksum} /usr/local/bin/claude" | sha256sum -c - \ + && chmod 0755 /usr/local/bin/claude \ + && addgroup -S -g 65532 kagent \ + && adduser -S -D -H -u 65532 -G kagent -h /data -s /sbin/nologin kagent \ && mkdir -p /data/adapter /data/claude /data/generated /data/workspace \ && chown -R 65532:65532 /data diff --git a/go/harness/claude/cmd/kagent-claude/main.go b/go/harness/claude/cmd/kagent-claude/main.go index 2764b2c73..f6b432ce8 100644 --- a/go/harness/claude/cmd/kagent-claude/main.go +++ b/go/harness/claude/cmd/kagent-claude/main.go @@ -28,12 +28,12 @@ const ( func main() { check := flag.Bool("check", false, "validate configuration and Claude version, then exit") flag.Parse() - if err := run(*check, os.Getenv, os.Environ()); err != nil { + if err := run(context.Background(), *check, os.Getenv, os.Environ()); err != nil { log.Fatal(err) } } -func run(check bool, getenv func(string) string, environment []string) error { +func run(ctx context.Context, check bool, getenv func(string) string, environment []string) error { configJSON, err := requiredEnvironment(getenv, configEnv) if err != nil { return err @@ -50,7 +50,7 @@ func run(check bool, getenv func(string) string, environment []string) error { return fmt.Errorf("agent card name is required") } - runner, err := adapter.New(adapter.Input{ + runner, err := adapter.New(ctx, adapter.Input{ ConfigJSON: configJSON, Workspace: dataDir + "/workspace", DurableDir: dataDir, EphemeralDir: "/tmp/kagent-claude", diff --git a/go/harness/claude/internal/adapter/adapter.go b/go/harness/claude/internal/adapter/adapter.go index da425c612..aac47abad 100644 --- a/go/harness/claude/internal/adapter/adapter.go +++ b/go/harness/claude/internal/adapter/adapter.go @@ -16,15 +16,14 @@ import ( ) const ( - claudeConfigDirEnv = "CLAUDE_CONFIG_DIR" - disableUpdaterEnv = "DISABLE_AUTOUPDATER" - googleCredsEnv = "GOOGLE_APPLICATION_CREDENTIALS" + claudeConfigDirEnv = "CLAUDE_CONFIG_DIR" + disableUpdatesEnv = "DISABLE_UPDATES" + googleApplicationCredentialsEnv = "GOOGLE_APPLICATION_CREDENTIALS" ) // Input contains compiler output and Actor-owned locations used to construct // the Claude driver. type Input struct { - Context context.Context ConfigJSON []byte Workspace string DurableDir string @@ -33,7 +32,7 @@ type Input struct { } // New validates and materializes Claude-owned state, then constructs its driver. -func New(input Input) (*driver.ProcessDriver, error) { +func New(ctx context.Context, input Input) (*driver.ProcessDriver, error) { cfg, err := config.Parse(input.ConfigJSON) if err != nil { return nil, err @@ -60,11 +59,7 @@ func New(input Input) (*driver.ProcessDriver, error) { } } if cfg.SkillResources != nil { - materializeContext := input.Context - if materializeContext == nil { - materializeContext = context.Background() - } - if err := agentplugins.MaterializeSkills(materializeContext, *cfg.SkillResources, agentplugins.SkillPaths{ + if err := agentplugins.MaterializeSkills(ctx, *cfg.SkillResources, agentplugins.SkillPaths{ Plugins: filepath.Join(claudeDir, "packages"), Skills: filepath.Join(claudeDir, "skills"), }); err != nil { @@ -72,7 +67,9 @@ func New(input Input) (*driver.ProcessDriver, error) { } } environment := setEnvironment(input.Environment, claudeConfigDirEnv, claudeDir) - environment = setEnvironment(environment, disableUpdaterEnv, "1") + // The image and compiler pin an exact Claude version. Prevent both automatic + // and manual update paths from changing that runtime after validation. + environment = setEnvironment(environment, disableUpdatesEnv, "1") environment, err = materializeGoogleCredentials(environment, input.EphemeralDir) if err != nil { return nil, err @@ -118,6 +115,9 @@ func replacePrivateFile(path string, contents []byte) error { } func materializeGoogleCredentials(environment []string, directory string) ([]string, error) { + // The compiler injects the Secret value as JSON, while Google ADC expects a + // file path. Keep the credential in ephemeral Actor storage rather than the + // well-known path under /data, which is durable and may be snapshotted. prefix := config.GoogleCredentialsJSONEnvName + "=" var credentials string filtered := make([]string, 0, len(environment)) @@ -161,7 +161,7 @@ func materializeGoogleCredentials(environment []string, directory string) ([]str if err := os.Rename(temporaryPath, path); err != nil { return nil, fmt.Errorf("replace Google credentials: %w", err) } - return setEnvironment(filtered, googleCredsEnv, path), nil + return setEnvironment(filtered, googleApplicationCredentialsEnv, path), nil } func ensurePrivateDir(path string) error { diff --git a/go/harness/claude/internal/adapter/adapter_test.go b/go/harness/claude/internal/adapter/adapter_test.go index 3ccfa7a06..b5df63710 100644 --- a/go/harness/claude/internal/adapter/adapter_test.go +++ b/go/harness/claude/internal/adapter/adapter_test.go @@ -1,6 +1,7 @@ package adapter import ( + "context" "encoding/json" "os" "path/filepath" @@ -15,11 +16,11 @@ func TestNewMaterializesDurableDirectories(t *testing.T) { durableDir := filepath.Join(t.TempDir(), "data") ephemeralDir := filepath.Join(t.TempDir(), "credentials") workspace := filepath.Join(durableDir, "workspace") - runner, err := New(Input{ + runner, err := New(context.Background(), Input{ ConfigJSON: []byte(`{"version":3,"claude_executable":"claude","expected_claude_version":"2.1.217","strict_version":true,"max_event_bytes":100,"max_stderr_bytes":100,"interrupt_grace_millis":100}`), Workspace: workspace, DurableDir: durableDir, EphemeralDir: ephemeralDir, - Environment: []string{"PATH=/bin", "CLAUDE_CONFIG_DIR=/wrong", "DISABLE_AUTOUPDATER=0"}, + Environment: []string{"PATH=/bin", "CLAUDE_CONFIG_DIR=/wrong", "DISABLE_UPDATES=0"}, }) if err != nil { t.Fatal(err) @@ -59,7 +60,7 @@ func TestNewMaterializesSkillsAndMCPConfig(t *testing.T) { t.Fatal(err) } ephemeralDir := filepath.Join(t.TempDir(), "generated") - if _, err := New(Input{ + if _, err := New(context.Background(), Input{ ConfigJSON: raw, Workspace: filepath.Join(durableDir, "workspace"), DurableDir: durableDir, EphemeralDir: ephemeralDir, Environment: []string{"PATH=/bin"}, }); err != nil { @@ -78,7 +79,7 @@ func TestNewMaterializesSkillsAndMCPConfig(t *testing.T) { func TestNewRejectsInvalidInput(t *testing.T) { input := Input{ConfigJSON: []byte(`{}`), Workspace: "relative", DurableDir: "relative", EphemeralDir: "relative"} - if _, err := New(input); err == nil { + if _, err := New(context.Background(), input); err == nil { t.Fatal("New() accepted invalid input") } } @@ -98,7 +99,7 @@ func TestMaterializeGoogleCredentials(t *testing.T) { if string(contents) != raw { t.Fatalf("credentials = %q", contents) } - if len(environment) != 2 || environment[0] != "A=1" || environment[1] != googleCredsEnv+"="+path { + if len(environment) != 2 || environment[0] != "A=1" || environment[1] != googleApplicationCredentialsEnv+"="+path { t.Fatalf("environment = %v", environment) } if info, err := os.Stat(path); err != nil || info.Mode().Perm() != 0o600 { From 1a6da577c2bdae8678f66d252ae33518ce299488 Mon Sep 17 00:00:00 2001 From: Jet Chiang Date: Fri, 28 Aug 2026 13:03:25 -0400 Subject: [PATCH 10/11] add timeout to claude Signed-off-by: Jet Chiang --- go/harness/claude/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go/harness/claude/Dockerfile b/go/harness/claude/Dockerfile index 303e55be6..9c4b0cec9 100644 --- a/go/harness/claude/Dockerfile +++ b/go/harness/claude/Dockerfile @@ -32,7 +32,7 @@ RUN apk add --no-cache bash ca-certificates git libgcc libstdc++ ripgrep \ arm64) platform="linux-arm64-musl"; checksum="${CLAUDE_CODE_ARM64_SHA256}" ;; \ *) echo "unsupported target architecture: ${TARGETARCH}" >&2; exit 1 ;; \ esac \ - && wget -q -O /usr/local/bin/claude \ + && wget -T 60 -t 3 -O /usr/local/bin/claude \ "https://downloads.claude.ai/claude-code-releases/${CLAUDE_CODE_VERSION}/${platform}/claude" \ && echo "${checksum} /usr/local/bin/claude" | sha256sum -c - \ && chmod 0755 /usr/local/bin/claude \ From 7559cf19866bafac8fa39552e2d19c7f90e9de70 Mon Sep 17 00:00:00 2001 From: Jet Chiang Date: Fri, 28 Aug 2026 13:44:55 -0400 Subject: [PATCH 11/11] review comments Signed-off-by: Jet Chiang --- go/harness/claude/Dockerfile | 2 +- go/harness/claude/cmd/{kagent-claude => }/main.go | 0 go/harness/claude/cmd/{kagent-claude => }/main_test.go | 0 .../{process_group_other.go => process_group_windows.go} | 2 -- go/harness/claude/internal/session/store.go | 3 --- 5 files changed, 1 insertion(+), 6 deletions(-) rename go/harness/claude/cmd/{kagent-claude => }/main.go (100%) rename go/harness/claude/cmd/{kagent-claude => }/main_test.go (100%) rename go/harness/claude/internal/driver/{process_group_other.go => process_group_windows.go} (93%) diff --git a/go/harness/claude/Dockerfile b/go/harness/claude/Dockerfile index 9c4b0cec9..64f33291d 100644 --- a/go/harness/claude/Dockerfile +++ b/go/harness/claude/Dockerfile @@ -16,7 +16,7 @@ COPY harness/ harness/ RUN --mount=type=cache,target=/root/go/pkg/mod,rw \ --mount=type=cache,target=/root/.cache/go-build,rw \ CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH} \ - go build -trimpath -o /kagent-claude ./harness/claude/cmd/kagent-claude + go build -trimpath -o /kagent-claude ./harness/claude/cmd FROM alpine:3.22 ARG CLAUDE_CODE_VERSION=2.1.217 diff --git a/go/harness/claude/cmd/kagent-claude/main.go b/go/harness/claude/cmd/main.go similarity index 100% rename from go/harness/claude/cmd/kagent-claude/main.go rename to go/harness/claude/cmd/main.go diff --git a/go/harness/claude/cmd/kagent-claude/main_test.go b/go/harness/claude/cmd/main_test.go similarity index 100% rename from go/harness/claude/cmd/kagent-claude/main_test.go rename to go/harness/claude/cmd/main_test.go diff --git a/go/harness/claude/internal/driver/process_group_other.go b/go/harness/claude/internal/driver/process_group_windows.go similarity index 93% rename from go/harness/claude/internal/driver/process_group_other.go rename to go/harness/claude/internal/driver/process_group_windows.go index 6ea9e86da..f88bd0563 100644 --- a/go/harness/claude/internal/driver/process_group_other.go +++ b/go/harness/claude/internal/driver/process_group_windows.go @@ -1,5 +1,3 @@ -//go:build !unix - package driver import ( diff --git a/go/harness/claude/internal/session/store.go b/go/harness/claude/internal/session/store.go index 2e8bb494d..aa7ac6685 100644 --- a/go/harness/claude/internal/session/store.go +++ b/go/harness/claude/internal/session/store.go @@ -25,9 +25,6 @@ type Store struct { } func New(durableDir string) (*Store, error) { - if durableDir == "" { - return nil, fmt.Errorf("durable directory is required") - } if err := os.MkdirAll(durableDir, 0o700); err != nil { return nil, fmt.Errorf("create session state directory: %w", err) }