Skip to content

Commit 3d6d75d

Browse files
fix(workers): improve startup configuration compatibility
1 parent 18926e8 commit 3d6d75d

3 files changed

Lines changed: 192 additions & 31 deletions

File tree

‎src/agentex/lib/utils/registration.py‎

Lines changed: 20 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,13 @@
1212

1313
logger = make_logger(__name__)
1414

15+
1516
def get_auth_principal(env_vars: EnvironmentVariables):
1617
if not env_vars.AUTH_PRINCIPAL_B64:
1718
return None
1819

1920
try:
20-
decoded_str = base64.b64decode(env_vars.AUTH_PRINCIPAL_B64).decode('utf-8')
21+
decoded_str = base64.b64decode(env_vars.AUTH_PRINCIPAL_B64).decode("utf-8")
2122
return json.loads(decoded_str)
2223
except Exception:
2324
return None
@@ -53,10 +54,7 @@ async def register_agent(env_vars: EnvironmentVariables, agent_card=None):
5354
# Build the agent's own URL
5455
full_acp_url = f"{env_vars.ACP_URL.rstrip('/')}:{env_vars.ACP_PORT}"
5556

56-
description = (
57-
env_vars.AGENT_DESCRIPTION
58-
or f"Generic description for agent: {env_vars.AGENT_NAME}"
59-
)
57+
description = env_vars.AGENT_DESCRIPTION or f"Generic description for agent: {env_vars.AGENT_NAME}"
6058

6159
registration_metadata = build_registration_metadata(env_vars, agent_card)
6260

@@ -77,6 +75,7 @@ async def register_agent(env_vars: EnvironmentVariables, agent_card=None):
7775

7876
# Make the registration request
7977
registration_url = f"{env_vars.AGENTEX_BASE_URL.rstrip('/')}/agents/register"
78+
registration_headers = {"x-agent-api-key": env_vars.AGENT_API_KEY} if env_vars.AGENT_API_KEY else {}
8079
# Retry logic with configurable attempts and delay
8180
max_retries = 3
8281
base_delay = 5 # seconds
@@ -87,46 +86,43 @@ async def register_agent(env_vars: EnvironmentVariables, agent_card=None):
8786
try:
8887
async with httpx.AsyncClient() as client:
8988
response = await client.post(
90-
registration_url, json=registration_data, timeout=30.0
89+
registration_url, json=registration_data, headers=registration_headers, timeout=30.0
9190
)
9291
if response.status_code == 200:
9392
agent = response.json()
9493
agent_id, agent_name = agent["id"], agent["name"]
95-
agent_api_key = agent["agent_api_key"]
94+
returned_key = agent.get("agent_api_key")
95+
if returned_key is not None and not isinstance(returned_key, str):
96+
raise ValueError("Unexpected API key type in registration response")
97+
agent_api_key = returned_key or env_vars.AGENT_API_KEY
9698

9799
os.environ["AGENT_ID"] = agent_id
98100
os.environ["AGENT_NAME"] = agent_name
99-
os.environ["AGENT_API_KEY"] = agent_api_key
100101
env_vars.AGENT_ID = agent_id
101102
env_vars.AGENT_NAME = agent_name
102-
env_vars.AGENT_API_KEY = agent_api_key
103+
if agent_api_key:
104+
os.environ["AGENT_API_KEY"] = agent_api_key
105+
env_vars.AGENT_API_KEY = agent_api_key
103106
global refreshed_environment_variables
104107
refreshed_environment_variables = env_vars
105108
logger.info(
106-
f"Successfully registered agent '{env_vars.AGENT_NAME}' with Agentex server with acp_url: {full_acp_url}. Registration data: {registration_data}"
109+
f"Successfully registered agent '{env_vars.AGENT_NAME}' with Agentex server with acp_url: {full_acp_url}."
107110
)
108111
return # Success, exit the retry loop
109112
else:
110-
error_msg = f"Failed to register agent. Status: {response.status_code}, Response: {response.text}"
113+
error_msg = f"Failed to register agent. Status: {response.status_code}"
111114
logger.error(error_msg)
112-
last_exception = Exception(
113-
f"Failed to startup agent: {response.text}"
114-
)
115+
last_exception = RuntimeError(error_msg)
115116

116117
except Exception as e:
117-
logger.error(
118-
f"Exception during agent registration attempt {attempt + 1}: {e}"
119-
)
120-
last_exception = e
118+
logger.error(f"Exception during agent registration attempt {attempt + 1}: {type(e).__name__}")
119+
# Transport errors and response bodies may contain request credentials.
120+
last_exception = RuntimeError(f"Failed to register agent ({type(e).__name__})")
121121
attempt += 1
122122
if attempt < max_retries:
123123
delay = (attempt) * base_delay # 5, 10, 15 seconds
124-
logger.info(
125-
f"Retrying in {delay} seconds... (attempt {attempt}/{max_retries})"
126-
)
124+
logger.info(f"Retrying in {delay} seconds... (attempt {attempt}/{max_retries})")
127125
await asyncio.sleep(delay)
128126

129127
# If we get here, all retries failed
130-
raise last_exception or Exception(
131-
f"Failed to register agent after {max_retries} attempts"
132-
)
128+
raise last_exception or Exception(f"Failed to register agent after {max_retries} attempts")

‎tests/lib/test_agentex_worker.py‎

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,7 @@ def _env_vars_mock():
135135
env.ACP_PORT = 8000
136136
env.AGENT_DESCRIPTION = "test description"
137137
env.AGENT_NAME = "test-agent"
138+
env.AGENT_API_KEY = None
138139
env.ACP_TYPE = "agentic"
139140
env.AUTH_PRINCIPAL_B64 = None
140141
env.AGENTEX_DEPLOYMENT_ID = None
@@ -145,7 +146,7 @@ def _env_vars_mock():
145146
return env
146147

147148
@staticmethod
148-
def _httpx_client_mock(captured_payloads):
149+
def _httpx_client_mock(captured_payloads, captured_headers):
149150
response = MagicMock()
150151
response.status_code = 200
151152
response.json.return_value = {
@@ -154,8 +155,9 @@ def _httpx_client_mock(captured_payloads):
154155
"agent_api_key": "api-key",
155156
}
156157

157-
async def post(url, json=None, timeout=None): # noqa: ARG001
158+
async def post(url, json=None, headers=None, timeout=None): # noqa: ARG001
158159
captured_payloads.append(json)
160+
captured_headers.append(headers)
159161
return response
160162

161163
client = MagicMock()
@@ -227,7 +229,8 @@ async def test_supplied_card_forwarded_exactly_once_by_run_lifecycle(self):
227229

228230
mock_register.assert_awaited_once_with(env, agent_card=card)
229231

230-
async def test_worker_and_fastacp_paths_serialize_the_same_card_shape(self):
232+
@pytest.mark.parametrize("configured_key", [None, "configured-agent-key"])
233+
async def test_worker_and_fastacp_paths_serialize_the_same_card_shape(self, configured_key):
231234
"""The worker path and the FastACP/BaseACPServer lifespan path hand the
232235
same card to register_agent, so the registration payload's
233236
registration_metadata.agent_card is identical."""
@@ -238,6 +241,7 @@ async def test_worker_and_fastacp_paths_serialize_the_same_card_shape(self):
238241
card = AgentCard(metadata={"permits_capable": True, "region": "us"})
239242

240243
worker_payloads = []
244+
worker_headers = []
241245
worker = AgentexWorker(
242246
task_queue="test-queue", health_check_port=8080, agent_card=card
243247
)
@@ -248,12 +252,14 @@ async def test_worker_and_fastacp_paths_serialize_the_same_card_shape(self):
248252
"agentex.lib.core.temporal.workers.worker.EnvironmentVariables"
249253
) as mock_env_cls, patch(
250254
"agentex.lib.utils.registration.httpx.AsyncClient",
251-
new=self._httpx_client_mock(worker_payloads),
255+
new=self._httpx_client_mock(worker_payloads, worker_headers),
252256
):
253257
mock_env_cls.refresh.return_value = self._env_vars_mock()
258+
mock_env_cls.refresh.return_value.AGENT_API_KEY = configured_key
254259
await worker._register_agent()
255260

256261
acp_payloads = []
262+
acp_headers = []
257263
server = BaseACPServer.create()
258264
server._agent_card = card
259265
lifespan = server.get_lifespan_function()
@@ -267,9 +273,10 @@ async def test_worker_and_fastacp_paths_serialize_the_same_card_shape(self):
267273
new=AsyncMock(),
268274
), patch(
269275
"agentex.lib.utils.registration.httpx.AsyncClient",
270-
new=self._httpx_client_mock(acp_payloads),
276+
new=self._httpx_client_mock(acp_payloads, acp_headers),
271277
):
272278
mock_env_cls.refresh.return_value = self._env_vars_mock()
279+
mock_env_cls.refresh.return_value.AGENT_API_KEY = configured_key
273280
async with lifespan(MagicMock()):
274281
pass
275282

@@ -278,6 +285,8 @@ async def test_worker_and_fastacp_paths_serialize_the_same_card_shape(self):
278285
worker_card = worker_payloads[0]["registration_metadata"]["agent_card"]
279286
acp_card = acp_payloads[0]["registration_metadata"]["agent_card"]
280287
assert worker_card == acp_card == card.model_dump()
288+
expected_headers = {"x-agent-api-key": configured_key} if configured_key else {}
289+
assert worker_headers == acp_headers == [expected_headers]
281290

282291

283292
class TestGetTemporalClientMetricsConfig:

‎tests/lib/utils/test_registration.py‎

Lines changed: 158 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,16 @@
1-
"""Registration metadata: what an agent reports about itself at startup."""
1+
"""Startup registration metadata and credential compatibility."""
22

33
from __future__ import annotations
44

5+
import os
6+
import json
7+
from unittest.mock import AsyncMock, call
8+
9+
import httpx
510
import pytest
611

7-
from agentex.lib.utils.registration import build_registration_metadata
12+
from agentex.lib.utils import registration
13+
from agentex.lib.utils.registration import register_agent, build_registration_metadata
814
from agentex.lib.environment_variables import EnvironmentVariables
915

1016
SHA = "b362b171a9c4e1f09d8e7a6b5c4d3e2f1a0b9c8d"
@@ -47,3 +53,153 @@ def model_dump(self):
4753
"deployment_id": "dep-1",
4854
"agent_card": {"name": "sample"},
4955
}
56+
57+
58+
@pytest.fixture
59+
def registration_env(monkeypatch):
60+
for name in ("AGENT_ID", "AGENT_NAME", "AGENT_API_KEY"):
61+
monkeypatch.delenv(name, raising=False)
62+
monkeypatch.setattr(registration, "refreshed_environment_variables", None, raising=False)
63+
return _env(AGENTEX_BASE_URL="https://agentex.example.test/")
64+
65+
66+
@pytest.fixture
67+
def retry_sleep(monkeypatch):
68+
sleep = AsyncMock()
69+
monkeypatch.setattr(registration.asyncio, "sleep", sleep)
70+
return sleep
71+
72+
73+
def _success(**overrides):
74+
return httpx.Response(200, json={"id": "agent-1", "name": "sample-agent", **overrides})
75+
76+
77+
@pytest.mark.parametrize("configured_key", [None, "", "configured-agent-key"])
78+
@pytest.mark.parametrize("response_key", [{}, {"agent_api_key": None}, {"agent_api_key": ""}])
79+
async def test_registration_preserves_configured_key_when_response_has_no_key(
80+
registration_env, respx_mock, configured_key, response_key
81+
):
82+
registration_env.AGENT_API_KEY = configured_key
83+
if configured_key is not None:
84+
os.environ["AGENT_API_KEY"] = configured_key
85+
route = respx_mock.post("https://agentex.example.test/agents/register").mock(return_value=_success(**response_key))
86+
87+
await register_agent(registration_env)
88+
89+
request = route.calls.last.request
90+
assert route.call_count == 1
91+
assert request.headers.get("x-agent-api-key") == (configured_key or None)
92+
assert "authorization" not in request.headers
93+
assert "agent_api_key" not in json.loads(request.content)
94+
assert registration_env.AGENT_API_KEY == configured_key
95+
assert os.environ.get("AGENT_API_KEY") == configured_key
96+
assert registration_env.AGENT_ID == os.environ["AGENT_ID"] == "agent-1"
97+
assert registration_env.AGENT_NAME == os.environ["AGENT_NAME"] == "sample-agent"
98+
99+
100+
@pytest.mark.parametrize("configured_key", [None, "older-agent-key"])
101+
async def test_registration_accepts_returned_key(registration_env, respx_mock, configured_key):
102+
registration_env.AGENT_API_KEY = configured_key
103+
route = respx_mock.post("https://agentex.example.test/agents/register").mock(
104+
return_value=_success(agent_api_key="returned-agent-key")
105+
)
106+
107+
await register_agent(registration_env)
108+
109+
assert route.calls.last.request.headers.get("x-agent-api-key") == configured_key
110+
assert registration_env.AGENT_API_KEY == os.environ["AGENT_API_KEY"] == "returned-agent-key"
111+
112+
113+
@pytest.mark.parametrize("status", [401, 403])
114+
async def test_rejected_registration_never_retries_anonymously(
115+
registration_env, respx_mock, retry_sleep, caplog, status
116+
):
117+
key = "configured-agent-key"
118+
registration_env.AGENT_API_KEY = key
119+
os.environ["AGENT_API_KEY"] = key
120+
route = respx_mock.post("https://agentex.example.test/agents/register").mock(
121+
return_value=httpx.Response(status, text=f"Rejected credential: {key}")
122+
)
123+
124+
with pytest.raises(RuntimeError, match=f"Status: {status}") as exc:
125+
await register_agent(registration_env)
126+
127+
assert route.call_count == 3
128+
assert all(item.request.headers["x-agent-api-key"] == key for item in route.calls)
129+
assert registration_env.AGENT_API_KEY == os.environ["AGENT_API_KEY"] == key
130+
assert registration_env.AGENT_ID is None
131+
assert "AGENT_ID" not in os.environ
132+
assert retry_sleep.await_args_list == [call(5), call(10)]
133+
assert key not in caplog.text
134+
assert key not in str(exc.value)
135+
136+
137+
async def test_transient_failure_retries_with_configured_key(registration_env, respx_mock, retry_sleep):
138+
registration_env.AGENT_API_KEY = "configured-agent-key"
139+
route = respx_mock.post("https://agentex.example.test/agents/register").mock(
140+
side_effect=[httpx.Response(503), _success(agent_api_key="returned-agent-key")]
141+
)
142+
143+
await register_agent(registration_env)
144+
145+
assert route.call_count == 2
146+
assert all(item.request.headers["x-agent-api-key"] == "configured-agent-key" for item in route.calls)
147+
assert registration_env.AGENT_API_KEY == os.environ["AGENT_API_KEY"] == "returned-agent-key"
148+
retry_sleep.assert_awaited_once_with(5)
149+
150+
151+
async def test_transport_error_does_not_expose_credential(registration_env, respx_mock, retry_sleep, caplog):
152+
key = "configured-agent-key"
153+
registration_env.AGENT_API_KEY = key
154+
route = respx_mock.post("https://agentex.example.test/agents/register").mock(
155+
side_effect=httpx.ReadTimeout(f"Timeout sending {key}")
156+
)
157+
158+
with pytest.raises(RuntimeError, match="ReadTimeout") as exc:
159+
await register_agent(registration_env)
160+
161+
assert route.call_count == 3
162+
assert all(item.request.headers["x-agent-api-key"] == key for item in route.calls)
163+
assert key not in caplog.text
164+
assert key not in str(exc.value)
165+
assert exc.value.__context__ is None
166+
167+
168+
async def test_success_does_not_log_registration_payload(registration_env, respx_mock, caplog):
169+
registration_env.AGENT_API_KEY = "configured-agent-key"
170+
registration_env.AGENT_DESCRIPTION = "private-description"
171+
respx_mock.post("https://agentex.example.test/agents/register").mock(
172+
return_value=_success(agent_api_key="returned-agent-key")
173+
)
174+
175+
await register_agent(registration_env, agent_card={"name": "private-card"})
176+
177+
assert "Successfully registered agent" in caplog.text
178+
for value in ("configured-agent-key", "returned-agent-key", "private-description", "private-card"):
179+
assert value not in caplog.text
180+
181+
182+
@pytest.mark.parametrize("returned_key", [123, {"value": "not-a-string"}])
183+
async def test_invalid_returned_key_does_not_overwrite_configuration(
184+
registration_env, respx_mock, retry_sleep, returned_key
185+
):
186+
registration_env.AGENT_API_KEY = "configured-agent-key"
187+
os.environ["AGENT_API_KEY"] = "configured-agent-key"
188+
respx_mock.post("https://agentex.example.test/agents/register").mock(
189+
return_value=_success(agent_api_key=returned_key)
190+
)
191+
192+
with pytest.raises(RuntimeError, match="ValueError"):
193+
await register_agent(registration_env)
194+
195+
assert registration_env.AGENT_API_KEY == os.environ["AGENT_API_KEY"] == "configured-agent-key"
196+
assert registration_env.AGENT_ID is None
197+
assert "AGENT_ID" not in os.environ
198+
199+
200+
async def test_missing_base_url_skips_registration(registration_env, respx_mock):
201+
registration_env.AGENTEX_BASE_URL = None
202+
203+
await register_agent(registration_env)
204+
205+
assert not respx_mock.calls

0 commit comments

Comments
 (0)