Skip to content

Commit 6a80ede

Browse files
declan-scaleclaude
andcommitted
feat(worker): let AgentexWorker publish an AgentCard at registration
Add an optional agent_card parameter to the AgentexWorker constructor. The worker stores the card and forwards it through the existing automatic register_agent call, so a Temporal worker can publish a card without a subclass override or a second registration. The default stays None and the wire behavior for existing callers does not change. Add tests that prove: - the default worker registers without card metadata; - a supplied card reaches register_agent exactly once through run(); - the worker path and the FastACP lifespan path serialize the same card shape into registration_metadata.agent_card. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 582600d commit 6a80ede

2 files changed

Lines changed: 164 additions & 1 deletion

File tree

src/agentex/lib/core/temporal/workers/worker.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,7 @@ def __init__(
178178
metrics_headers: dict[str, str] | None = None,
179179
metrics_use_http: bool = False,
180180
metrics_temporality_delta: bool = False,
181+
agent_card: Any | None = None,
181182
):
182183
self.task_queue = task_queue
183184
self.activity_handles = []
@@ -196,6 +197,7 @@ def __init__(
196197
self.metrics_temporality_delta = metrics_temporality_delta
197198
self.payload_codec = payload_codec
198199
self.data_converter = data_converter
200+
self.agent_card = agent_card
199201

200202
@overload
201203
async def run(
@@ -312,6 +314,6 @@ async def _register_agent(self):
312314
# the worker process never goes through the ACP server lifespan, so it needs its
313315
# own guard (mirrors base_acp_server.lifespan_context).
314316
await assert_backend_compatible(env_vars.AGENTEX_BASE_URL)
315-
await register_agent(env_vars)
317+
await register_agent(env_vars, agent_card=self.agent_card)
316318
else:
317319
logger.warning("AGENTEX_BASE_URL not set, skipping worker registration")

tests/lib/test_agentex_worker.py

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,167 @@ def test_worker_metrics_params_default_to_none_and_false(self):
117117
assert worker.metrics_temporality_delta is False
118118

119119

120+
class TestAgentexWorkerAgentCard:
121+
"""Tests that AgentexWorker publishes an optional AgentCard through the
122+
existing automatic registration lifecycle."""
123+
124+
@pytest.fixture(autouse=True)
125+
def cleanup_env(self):
126+
yield
127+
for key in ("AGENT_ID", "AGENT_NAME", "AGENT_API_KEY"):
128+
os.environ.pop(key, None)
129+
130+
@staticmethod
131+
def _env_vars_mock():
132+
env = MagicMock()
133+
env.AGENTEX_BASE_URL = "http://agentex.test"
134+
env.ACP_URL = "http://agent.test"
135+
env.ACP_PORT = 8000
136+
env.AGENT_DESCRIPTION = "test description"
137+
env.AGENT_NAME = "test-agent"
138+
env.ACP_TYPE = "agentic"
139+
env.AUTH_PRINCIPAL_B64 = None
140+
env.AGENTEX_DEPLOYMENT_ID = None
141+
env.AGENT_ID = None
142+
env.AGENT_INPUT_TYPE = None
143+
return env
144+
145+
@staticmethod
146+
def _httpx_client_mock(captured_payloads):
147+
response = MagicMock()
148+
response.status_code = 200
149+
response.json.return_value = {
150+
"id": "agent-id",
151+
"name": "test-agent",
152+
"agent_api_key": "api-key",
153+
}
154+
155+
async def post(url, json=None, timeout=None): # noqa: ARG001
156+
captured_payloads.append(json)
157+
return response
158+
159+
client = MagicMock()
160+
client.__aenter__ = AsyncMock(return_value=MagicMock(post=AsyncMock(side_effect=post)))
161+
client.__aexit__ = AsyncMock(return_value=False)
162+
return MagicMock(return_value=client)
163+
164+
def test_worker_agent_card_defaults_to_none(self):
165+
from agentex.lib.core.temporal.workers.worker import AgentexWorker
166+
167+
worker = AgentexWorker(task_queue="test-queue", health_check_port=8080)
168+
169+
assert worker.agent_card is None
170+
171+
async def test_default_registration_calls_register_agent_without_card(self):
172+
"""The default worker still registers automatically and passes no card,
173+
preserving existing callers and wire behavior."""
174+
from agentex.lib.core.temporal.workers.worker import AgentexWorker
175+
176+
worker = AgentexWorker(task_queue="test-queue", health_check_port=8080)
177+
178+
with patch(
179+
"agentex.lib.core.temporal.workers.worker.register_agent", new=AsyncMock()
180+
) as mock_register, patch(
181+
"agentex.lib.core.temporal.workers.worker.assert_backend_compatible",
182+
new=AsyncMock(),
183+
), patch(
184+
"agentex.lib.core.temporal.workers.worker.EnvironmentVariables"
185+
) as mock_env_cls:
186+
env = self._env_vars_mock()
187+
mock_env_cls.refresh.return_value = env
188+
189+
await worker._register_agent()
190+
191+
mock_register.assert_awaited_once_with(env, agent_card=None)
192+
193+
async def test_supplied_card_forwarded_exactly_once_by_run_lifecycle(self):
194+
"""A card passed to the constructor reaches register_agent exactly once
195+
through the existing automatic registration in run(); no second
196+
registration call is introduced."""
197+
from agentex.lib.types.agent_card import AgentCard
198+
from agentex.lib.core.temporal.workers.worker import AgentexWorker
199+
200+
card = AgentCard(metadata={"permits_capable": True})
201+
worker = AgentexWorker(
202+
task_queue="test-queue", health_check_port=8080, agent_card=card
203+
)
204+
205+
with patch.object(
206+
worker, "start_health_check_server", new=AsyncMock()
207+
), patch(
208+
"agentex.lib.core.temporal.workers.worker.register_agent", new=AsyncMock()
209+
) as mock_register, patch(
210+
"agentex.lib.core.temporal.workers.worker.assert_backend_compatible",
211+
new=AsyncMock(),
212+
), patch(
213+
"agentex.lib.core.temporal.workers.worker.EnvironmentVariables"
214+
) as mock_env_cls, patch(
215+
"agentex.lib.core.temporal.workers.worker.get_temporal_client",
216+
new=AsyncMock(return_value=MagicMock()),
217+
), patch(
218+
"agentex.lib.core.temporal.workers.worker.Worker"
219+
) as mock_worker_cls:
220+
env = self._env_vars_mock()
221+
mock_env_cls.refresh.return_value = env
222+
mock_worker_cls.return_value.run = AsyncMock()
223+
224+
await worker.run(activities=[], workflows=[MagicMock()])
225+
226+
mock_register.assert_awaited_once_with(env, agent_card=card)
227+
228+
async def test_worker_and_fastacp_paths_serialize_the_same_card_shape(self):
229+
"""The worker path and the FastACP/BaseACPServer lifespan path hand the
230+
same card to register_agent, so the registration payload's
231+
registration_metadata.agent_card is identical."""
232+
from agentex.lib.types.agent_card import AgentCard
233+
from agentex.lib.core.temporal.workers.worker import AgentexWorker
234+
from agentex.lib.sdk.fastacp.base.base_acp_server import BaseACPServer
235+
236+
card = AgentCard(metadata={"permits_capable": True, "region": "us"})
237+
238+
worker_payloads = []
239+
worker = AgentexWorker(
240+
task_queue="test-queue", health_check_port=8080, agent_card=card
241+
)
242+
with patch(
243+
"agentex.lib.core.temporal.workers.worker.assert_backend_compatible",
244+
new=AsyncMock(),
245+
), patch(
246+
"agentex.lib.core.temporal.workers.worker.EnvironmentVariables"
247+
) as mock_env_cls, patch(
248+
"agentex.lib.utils.registration.httpx.AsyncClient",
249+
new=self._httpx_client_mock(worker_payloads),
250+
):
251+
mock_env_cls.refresh.return_value = self._env_vars_mock()
252+
await worker._register_agent()
253+
254+
acp_payloads = []
255+
server = BaseACPServer.create()
256+
server._agent_card = card
257+
lifespan = server.get_lifespan_function()
258+
with patch(
259+
"agentex.lib.sdk.fastacp.base.base_acp_server.assert_backend_compatible",
260+
new=AsyncMock(),
261+
), patch(
262+
"agentex.lib.sdk.fastacp.base.base_acp_server.EnvironmentVariables"
263+
) as mock_env_cls, patch(
264+
"agentex.lib.sdk.fastacp.base.base_acp_server.shutdown_default_span_queue",
265+
new=AsyncMock(),
266+
), patch(
267+
"agentex.lib.utils.registration.httpx.AsyncClient",
268+
new=self._httpx_client_mock(acp_payloads),
269+
):
270+
mock_env_cls.refresh.return_value = self._env_vars_mock()
271+
async with lifespan(MagicMock()):
272+
pass
273+
274+
assert len(worker_payloads) == 1
275+
assert len(acp_payloads) == 1
276+
worker_card = worker_payloads[0]["registration_metadata"]["agent_card"]
277+
acp_card = acp_payloads[0]["registration_metadata"]["agent_card"]
278+
assert worker_card == acp_card == card.model_dump()
279+
280+
120281
class TestGetTemporalClientMetricsConfig:
121282
"""Tests that metrics params reach OpenTelemetryConfig correctly."""
122283

0 commit comments

Comments
 (0)