diff --git a/charts/community-components/templates/agent-claude-code-swe.yaml b/charts/community-components/templates/agent-claude-code-swe.yaml index 8bd6126..4c3b8a9 100644 --- a/charts/community-components/templates/agent-claude-code-swe.yaml +++ b/charts/community-components/templates/agent-claude-code-swe.yaml @@ -26,6 +26,11 @@ metadata: name: claude-code-swe-agent labels: {{- include "tools.labels" . | nindent 4 }} + annotations: + # Tells the Temporal engine's agentWorkflowNameFor (engines/temporal/internal/temporal/workflows/agent_workflow.go) + # to drive this Agent via BridgedAgentWorkflow -- it's a pod running the + # Claude Code CLI headless, not a declarative Tool-calling planner loop. + durable-agents.dev/bridged: "true" spec: description: >- Performs software-engineering work on GitHub end-to-end. Runs the Claude diff --git a/charts/community-components/templates/agent-opencode-swe.yaml b/charts/community-components/templates/agent-opencode-swe.yaml index 4e8df71..b24f1c9 100644 --- a/charts/community-components/templates/agent-opencode-swe.yaml +++ b/charts/community-components/templates/agent-opencode-swe.yaml @@ -22,6 +22,11 @@ metadata: name: opencode-swe-agent labels: {{- include "tools.labels" . | nindent 4 }} + annotations: + # Tells the Temporal engine's agentWorkflowNameFor (engines/temporal/internal/temporal/workflows/agent_workflow.go) + # to drive this Agent via BridgedAgentWorkflow -- it's a pod running the + # opencode CLI headless, not a declarative Tool-calling planner loop. + durable-agents.dev/bridged: "true" spec: description: >- Performs software-engineering work on GitHub end-to-end. Runs the diff --git a/charts/community-components/templates/agent-stub.yaml b/charts/community-components/templates/agent-stub.yaml index 3ee6b51..d4429d7 100644 --- a/charts/community-components/templates/agent-stub.yaml +++ b/charts/community-components/templates/agent-stub.yaml @@ -15,6 +15,14 @@ metadata: labels: {{- include "tools.labels" . | nindent 4 }} e2e: "true" + annotations: + # Mirrors claude-code-swe-agent/opencode-swe-agent (the pod/NATS-protocol + # agents this stands in for): without this, the Temporal engine's + # agentWorkflowNameFor (engines/temporal/internal/temporal/workflows/agent_workflow.go) + # would route this Agent to the declarative planner loop instead of + # BridgedAgentWorkflow, making the stub an unfaithful stand-in once a + # cluster routes turns through the Temporal engine. + durable-agents.dev/bridged: "true" spec: description: >- E2E test double. Speaks the real NATS agent protocol and returns a canned diff --git a/e2e/specs/bridged-agent-routing.e2e.ts b/e2e/specs/bridged-agent-routing.e2e.ts new file mode 100644 index 0000000..f343187 --- /dev/null +++ b/e2e/specs/bridged-agent-routing.e2e.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; +import { requireMinikubeContext } from "../support/guard.js"; +import { kubectlJson } from "../support/k8s.js"; + +requireMinikubeContext(); + +/** + * The Temporal engine (docs/adr/0036) decides which workflow drives an Agent + * purely from a CR annotation -- `durable-agents.dev/bridged: "true"` means + * `BridgedAgentWorkflow` (an unmodified upstream pod/CLI agent, speaking the + * real NATS protocol); its absence falls through to the declarative + * `AgentWorkflow`, an LLM planner that only ever calls `Tool`s named in the + * agent's own `toolRefs`. + * + * `claude-code-swe-agent` and `opencode-swe-agent` are pod-based coding + * agents: their `agentPrompt` tells the model to invoke `git`/`gh` as plain + * CLI commands, and neither declares any `toolRefs`. Deployed WITHOUT the + * annotation, both silently fell through to the declarative loop, which + * handed the planner that same prompt but an EMPTY tool list -- so it tried + * to call "gh" as a declarative Tool and got refused with "tool not + * available to this agent". That is a real incident this asserts against, + * not a hypothetical: charts/community-components/templates/ + * agent-claude-code-swe.yaml and agent-opencode-swe.yaml never set the + * annotation until this fix. + * + * This can't observe an actual `BridgedAgentWorkflow` execution end-to-end + * (this minikube profile has no Temporal server deployed alongside the + * engine), so it asserts the one thing that IS verifiable here and is + * exactly what regressed: the live, cluster-deployed `Agent` CR objects + * carry the annotation a Helm chart edit could silently drop again. + * `stub-agent` is included because it stands in for claude-code-swe-agent in + * happy-path.e2e.ts and must route identically to stay a faithful stand-in. + */ +describe("pod-based agents are annotated for BridgedAgentWorkflow routing", () => { + const BRIDGED_ANNOTATION = "durable-agents.dev/bridged"; + + // opencode-swe-agent is intentionally excluded here: it is disabled in this + // suite's deployed values (no built image to enable it with), so no live CR + // exists to assert against. Its routing contract is instead pinned + // hermetically in engines/temporal/internal/temporal/workflows/ + // agent_workflow_routing_test.go (TestPodAgentsRouteBridged), alongside + // claude-code-swe-agent. + const BRIDGED_AGENTS = ["claude-code-swe-agent", "stub-agent"]; + + it.each(BRIDGED_AGENTS)("Agent %s declares the bridged annotation", async (agentName) => { + const agent = await kubectlJson<{ metadata?: { annotations?: Record } }>([ + "get", + "agent", + agentName, + ]); + expect(agent.metadata?.annotations?.[BRIDGED_ANNOTATION]).toBe("true"); + }); +}); diff --git a/engines/temporal/internal/temporal/workflows/agent_workflow_routing_test.go b/engines/temporal/internal/temporal/workflows/agent_workflow_routing_test.go new file mode 100644 index 0000000..5e8bb36 --- /dev/null +++ b/engines/temporal/internal/temporal/workflows/agent_workflow_routing_test.go @@ -0,0 +1,77 @@ +package workflows + +import ( + "testing" + + "github.com/stretchr/testify/require" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + + "github.com/controller-agent/temporal-engine/internal/catalog" +) + +// TestAgentWorkflowNameFor pins agentWorkflowNameFor's routing contract: a +// regression here (e.g. an accidental case reorder, or a chart authoring gap +// clearing an annotation) previously misrouted a pod-based coding agent into +// the declarative planner loop, which then tried to call a native CLI command +// ("gh") as a declarative Tool and got refused with "tool not available to +// this agent" -- see the claude-code-swe-agent/opencode-swe-agent incident +// this test was added for. +func TestAgentWorkflowNameFor(t *testing.T) { + t.Run("bridged annotation routes to BridgedAgentWorkflow", func(t *testing.T) { + name := agentWorkflowNameFor(catalog.AgentDescriptor{ID: "claude-code-swe-agent", Bridged: true}) + require.Equal(t, BridgedAgentWorkflowName, name) + }) + + t.Run("step-tool annotation routes to PodAgentWorkflow and wins over bridged", func(t *testing.T) { + name := agentWorkflowNameFor(catalog.AgentDescriptor{ID: "x", StepToolRef: "some-tool", Bridged: true}) + require.Equal(t, PodAgentWorkflowName, name) + }) + + t.Run("neither annotation falls back to the declarative AgentWorkflow", func(t *testing.T) { + name := agentWorkflowNameFor(catalog.AgentDescriptor{ID: "x"}) + require.Equal(t, AgentWorkflowName, name) + }) +} + +// TestPodAgentsRouteBridged decodes Agent CRs shaped exactly like what +// charts/community-components/templates/agent-claude-code-swe.yaml and +// agent-opencode-swe.yaml render (name + the durable-agents.dev/bridged +// annotation, ADR 0028) and asserts they resolve to BridgedAgentWorkflow, not +// the declarative loop. +// +// This does not render the real Helm templates (that would need a `helm` +// binary, unavailable in this module's CI job) -- e2e/specs coverage owns +// asserting the LIVE deployed CR objects actually carry the annotation. This +// test instead pins the contract those two chart entries must keep meeting: +// a pod-based coding agent (image-driven, no toolRefs, an agentPrompt telling +// the model to invoke its CLI's own bash/gh/git directly) MUST declare +// `durable-agents.dev/bridged: "true"`, or its planner gets no `tools` and any +// CLI invocation the model narrates gets misread as a declarative tool call. +func TestPodAgentsRouteBridged(t *testing.T) { + for _, agentID := range []string{"claude-code-swe-agent", "opencode-swe-agent"} { + t.Run(agentID, func(t *testing.T) { + obj := &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": "core.controller-agent.dev/v1alpha1", + "kind": "Agent", + "metadata": map[string]any{ + "name": agentID, + "annotations": map[string]any{ + "durable-agents.dev/bridged": "true", + }, + }, + "spec": map[string]any{ + "description": "Performs software-engineering work on GitHub end-to-end.", + "allowedRoles": []any{"writer"}, + // No toolRefs: a bridged pod agent's tools are its CLI's own + // native built-ins, never declarative Tool CRs. + }, + }} + + descriptor, err := catalog.DecodeAgent(obj) + require.NoError(t, err) + require.True(t, descriptor.Bridged, "expected %s to decode with Bridged=true", agentID) + require.Empty(t, descriptor.ToolRefs) + require.Equal(t, BridgedAgentWorkflowName, agentWorkflowNameFor(descriptor)) + }) + } +}