Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions charts/community-components/templates/agent-opencode-swe.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions charts/community-components/templates/agent-stub.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
53 changes: 53 additions & 0 deletions e2e/specs/bridged-agent-routing.e2e.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> } }>([
"get",
"agent",
agentName,
]);
expect(agent.metadata?.annotations?.[BRIDGED_ANNOTATION]).toBe("true");
});
});
Original file line number Diff line number Diff line change
@@ -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))
})
}
}
Loading