diff --git a/agentex/database/migrations/alembic/versions/2026_05_29_1200_add_build_only_agent_status_c7a1b2d3e4f5.py b/agentex/database/migrations/alembic/versions/2026_05_29_1200_add_build_only_agent_status_c7a1b2d3e4f5.py new file mode 100644 index 00000000..4c00b99e --- /dev/null +++ b/agentex/database/migrations/alembic/versions/2026_05_29_1200_add_build_only_agent_status_c7a1b2d3e4f5.py @@ -0,0 +1,33 @@ +"""add build_only agent status + +Revision ID: c7a1b2d3e4f5 +Revises: 6c942325c828 +Create Date: 2026-05-29 12:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'c7a1b2d3e4f5' +down_revision: Union[str, None] = '6c942325c828' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.execute(""" + ALTER TYPE agentstatus ADD VALUE IF NOT EXISTS 'BUILD_ONLY'; + """) + # ### end Alembic commands ### + + +def downgrade() -> None: + # Postgres does not support removing a value from an enum type, so there is + # nothing to do on downgrade (mirrors the add_unhealthy_status migration). + # ### end Alembic commands ### + pass diff --git a/agentex/openapi.yaml b/agentex/openapi.yaml index 891b0337..5a28d955 100644 --- a/agentex/openapi.yaml +++ b/agentex/openapi.yaml @@ -208,6 +208,35 @@ paths: application/json: schema: $ref: '#/components/schemas/HTTPValidationError' + /agents/register-build: + post: + tags: + - Agents + summary: Register Build + description: Register an agent at build time, before it is deployed. Creates + the agent row in BUILD_ONLY status without an acp_url (there is no running + pod yet) so it can be permissioned and shared prior to deploy. Unlike /register, + this does not mint an API key. Idempotent by name. + operationId: register_build_agents_register_build_post + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/RegisterBuildRequest' + required: true + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/Agent' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' /agents/forward/name/{agent_name}/{path}: get: tags: @@ -3894,6 +3923,7 @@ components: - Unknown - Deleted - Unhealthy + - BuildOnly title: AgentStatus AgentTaskTracker: properties: @@ -5292,6 +5322,48 @@ components: - updated_at title: RegisterAgentResponse description: Response model for registering an agent. + RegisterBuildRequest: + properties: + name: + type: string + pattern: ^[a-z0-9-]+$ + title: Name + description: The unique name of the agent. + description: + type: string + title: Description + description: The description of the agent. + principal_context: + anyOf: + - {} + - type: 'null' + title: Principal Context + description: Principal used for authorization + registration_metadata: + anyOf: + - additionalProperties: true + type: object + - type: 'null' + title: Registration Metadata + description: The metadata for the agent's build registration. + agent_input_type: + anyOf: + - $ref: '#/components/schemas/AgentInputType' + - type: 'null' + description: The type of input the agent expects. + type: object + required: + - name + - description + title: RegisterBuildRequest + description: 'Request model for registering an agent at build time (pre-deploy). + + + Unlike RegisterAgentRequest, there is no acp_url (the agent is not running + + yet) and no acp_type is required. The created agent is left in BUILD_ONLY + + status so it can be permissioned/shared before it is deployed.' RehydrateTaskRequest: properties: task_id: diff --git a/agentex/src/api/routes/agents.py b/agentex/src/api/routes/agents.py index bfdc53b9..2908a067 100644 --- a/agentex/src/api/routes/agents.py +++ b/agentex/src/api/routes/agents.py @@ -6,7 +6,12 @@ from pydantic import ValidationError from src.adapters.crud_store.exceptions import ItemDoesNotExist -from src.api.schemas.agents import Agent, RegisterAgentRequest, RegisterAgentResponse +from src.api.schemas.agents import ( + Agent, + RegisterAgentRequest, + RegisterAgentResponse, + RegisterBuildRequest, +) from src.api.schemas.agents_rpc import ( AgentRPCRequest, AgentRPCResponse, @@ -216,6 +221,45 @@ async def register_agent( raise HTTPException(status_code=400, detail=str(e)) from e +@router.post( + "/register-build", + response_model=Agent, + summary="Register Build", + description=( + "Register an agent at build time, before it is deployed. Creates the " + "agent row in BUILD_ONLY status without an acp_url (there is no running " + "pod yet) so it can be permissioned and shared prior to deploy. Unlike " + "/register, this does not mint an API key. Idempotent by name." + ), +) +async def register_build( + request: RegisterBuildRequest, + agents_use_case: DAgentsUseCase, + authorization_service: DAuthorizationService, +) -> Agent: + """Create a build-only agent row and grant the caller access to it.""" + await authorization_service.check( + AgentexResource.agent("*"), + AuthorizedOperationType.create, + principal_context=request.principal_context, + ) + logger.info(f"Registering build for agent: {request.name}") + try: + agent_entity = await agents_use_case.register_build( + name=request.name, + description=request.description, + registration_metadata=request.registration_metadata, + agent_input_type=request.agent_input_type, + ) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + await authorization_service.grant( + AgentexResource.agent(agent_entity.id), + principal_context=request.principal_context, + ) + return Agent.model_validate(agent_entity) + + @router.get( "/forward/name/{agent_name}/{path:path}", summary="Forward GET request to agent by name", diff --git a/agentex/src/api/schemas/agents.py b/agentex/src/api/schemas/agents.py index 79724171..76c5a743 100644 --- a/agentex/src/api/schemas/agents.py +++ b/agentex/src/api/schemas/agents.py @@ -13,6 +13,9 @@ class AgentStatus(str, Enum): UNKNOWN = "Unknown" DELETED = "Deleted" UNHEALTHY = "Unhealthy" + # Agent row created at build time, before any deployment exists. It has no + # acp_url yet and is not routable; deploy-time registration flips it to READY. + BUILD_ONLY = "BuildOnly" class ACPType(str, Enum): @@ -103,3 +106,27 @@ class RegisterAgentResponse(Agent): agent_api_key: str | None = Field( None, description="The API key for the agent, if applicable." ) + + +class RegisterBuildRequest(BaseModel): + """Request model for registering an agent at build time (pre-deploy). + + Unlike RegisterAgentRequest, there is no acp_url (the agent is not running + yet) and no acp_type is required. The created agent is left in BUILD_ONLY + status so it can be permissioned/shared before it is deployed. + """ + + name: str = Field( + ..., pattern=r"^[a-z0-9-]+$", description="The unique name of the agent." + ) + description: str = Field(..., description="The description of the agent.") + principal_context: Any | None = Field( + default=None, description="Principal used for authorization" + ) + registration_metadata: dict[str, Any] | None = Field( + default=None, + description="The metadata for the agent's build registration.", + ) + agent_input_type: AgentInputType | None = Field( + default=None, description="The type of input the agent expects." + ) diff --git a/agentex/src/domain/entities/agents.py b/agentex/src/domain/entities/agents.py index 0d721de7..91152c02 100644 --- a/agentex/src/domain/entities/agents.py +++ b/agentex/src/domain/entities/agents.py @@ -13,6 +13,9 @@ class AgentStatus(str, Enum): UNKNOWN = "Unknown" DELETED = "Deleted" UNHEALTHY = "Unhealthy" + # Agent row created at build time, before any deployment exists. It has no + # acp_url yet and is not routable; deploy-time registration flips it to READY. + BUILD_ONLY = "BuildOnly" class ACPType(str, Enum): diff --git a/agentex/src/domain/use_cases/agents_use_case.py b/agentex/src/domain/use_cases/agents_use_case.py index 27b59f0c..f23f9c55 100644 --- a/agentex/src/domain/use_cases/agents_use_case.py +++ b/agentex/src/domain/use_cases/agents_use_case.py @@ -167,6 +167,55 @@ async def register_agent( await self.ensure_healthcheck_workflow(agent) return agent + async def register_build( + self, + name: str, + description: str, + registration_metadata: dict[str, Any] | None = None, + agent_input_type: AgentInputType | None = None, + ) -> AgentEntity: + """ + Create an agent row for a build, before any deployment exists. + + Unlike register_agent, this does NOT populate acp_url (there is no + running pod yet) and leaves the agent in BUILD_ONLY status so it can be + permissioned/shared prior to deploy. Deploy-time registration later + flips the agent to READY and sets the acp_url. + + Idempotent: if an agent with the same name already exists, it is + returned unchanged so that re-building an existing agent never clobbers + a live deployment's status or acp_url. + """ + try: + existing = await self.agent_repo.get(name=name) + logger.info( + f"Agent {name} already exists, returning existing agent for build" + ) + return existing + except ItemDoesNotExist: + logger.info(f"Agent {name} not found, creating build-only agent") + + agent = AgentEntity( + id=orm_id(), + name=name, + description=description, + status=AgentStatus.BUILD_ONLY, + status_reason="Agent build registered; not yet deployed.", + acp_url=None, + registration_metadata=registration_metadata, + agent_input_type=agent_input_type, + ) + # If multiple builds for the same new agent race, the first wins and the + # rest re-fetch the persisted row instead of erroring. + try: + agent = await self.agent_repo.create(item=agent) + except DuplicateItemError: + logger.info( + f"Agent {name} was likely created in parallel, returning existing" + ) + agent = await self.agent_repo.get(name=name) + return agent + async def complete_deployment_registration( self, agent: AgentEntity, diff --git a/agentex/tests/integration/api/agents/test_agents_api.py b/agentex/tests/integration/api/agents/test_agents_api.py index 464a4380..4e4015a9 100644 --- a/agentex/tests/integration/api/agents/test_agents_api.py +++ b/agentex/tests/integration/api/agents/test_agents_api.py @@ -47,6 +47,82 @@ async def test_register_with_agent_id(self, isolated_client): assert updated_agent_data["acp_type"] == "sync" assert updated_agent_data["id"] == agent_data["id"] + @pytest.mark.asyncio + async def test_register_build_creates_build_only_agent(self, isolated_client): + """register-build creates a BUILD_ONLY agent with no acp_url and no api key.""" + response = await isolated_client.post( + "/agents/register-build", + json={ + "name": "test-build-only-agent", + "description": "Created via register-build", + }, + ) + assert response.status_code == 200 + agent_data = response.json() + assert agent_data["name"] == "test-build-only-agent" + assert agent_data["description"] == "Created via register-build" + assert agent_data["status"] == "BuildOnly" + assert agent_data["id"] is not None + # Minimal endpoint: no API key is minted at build time + assert "agent_api_key" not in agent_data + # No running pod yet, so acp_url must not be populated + assert agent_data.get("acp_url") is None + + # And - the build-only agent is retrievable and listed like any agent + get_response = await isolated_client.get(f"/agents/{agent_data['id']}") + assert get_response.status_code == 200 + assert get_response.json()["status"] == "BuildOnly" + + @pytest.mark.asyncio + async def test_register_build_is_idempotent_by_name(self, isolated_client): + """A second register-build for the same name returns the existing agent.""" + payload = { + "name": "test-build-idempotent-agent", + "description": "first", + } + first = await isolated_client.post("/agents/register-build", json=payload) + assert first.status_code == 200 + first_id = first.json()["id"] + + second = await isolated_client.post( + "/agents/register-build", + json={**payload, "description": "second"}, + ) + assert second.status_code == 200 + # Same row returned; an existing agent is not clobbered by a rebuild + assert second.json()["id"] == first_id + assert second.json()["status"] == "BuildOnly" + + @pytest.mark.asyncio + async def test_build_only_agent_promoted_to_ready_on_register( + self, isolated_client + ): + """register-build then /register (with the agent_id) flips status to Ready.""" + build = await isolated_client.post( + "/agents/register-build", + json={ + "name": "test-build-then-deploy-agent", + "description": "build first", + }, + ) + assert build.status_code == 200 + assert build.json()["status"] == "BuildOnly" + agent_id = build.json()["id"] + + registered = await isolated_client.post( + "/agents/register", + json={ + "agent_id": agent_id, + "name": "test-build-then-deploy-agent", + "description": "now deployed", + "acp_url": "http://test-acp-server:8000", + "acp_type": "async", + }, + ) + assert registered.status_code == 200 + assert registered.json()["id"] == agent_id + assert registered.json()["status"] == "Ready" + @pytest.mark.asyncio async def test_register_agent_success_and_retrieve(self, isolated_client): """Test agent registration and retrieval via API endpoints"""