From 4e2eb4f604d6d9fd4d3b9d6bd8de7ec0345a8b69 Mon Sep 17 00:00:00 2001 From: Austin Kurpuis Date: Fri, 7 Aug 2026 15:31:33 -0700 Subject: [PATCH] Route pod-based coding agents to BridgedAgentWorkflow claude-code-swe-agent and opencode-swe-agent never set the durable-agents.dev/bridged annotation the Temporal engine's agentWorkflowNameFor uses to pick BridgedAgentWorkflow over the declarative planner loop. Without it, both fell through to the declarative AgentWorkflow, which got the agent's real prompt (telling it to use git/gh) but an empty tools list -- so the planner tried to call "gh" as a declarative Tool and was refused with "tool not available to this agent", exactly matching a captured production failure. stub-agent (the e2e stand-in for claude-code-swe-agent) had the same gap and is fixed the same way, to stay a faithful stand-in once a cluster routes turns through the Temporal engine. Adds a hermetic Go regression test pinning agentWorkflowNameFor's routing contract, plus an e2e spec asserting the live deployed Agent CRs carry the annotation. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01HctRMtdZixptEZeMebhADq --- .../templates/agent-claude-code-swe.yaml | 5 ++ .../templates/agent-opencode-swe.yaml | 5 ++ .../templates/agent-stub.yaml | 8 ++ e2e/specs/bridged-agent-routing.e2e.ts | 53 +++++++++++++ .../workflows/agent_workflow_routing_test.go | 77 +++++++++++++++++++ 5 files changed, 148 insertions(+) create mode 100644 e2e/specs/bridged-agent-routing.e2e.ts create mode 100644 engines/temporal/internal/temporal/workflows/agent_workflow_routing_test.go 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)) + }) + } +}