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
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ logs
npm-debug.log*
*.pth

# Spurious root-level OpenAPI spec written by the agentex gen-openapi
# pre-commit hook when uv resolves the workspace root as cwd. The canonical
# spec lives at agentex/openapi.yaml.
/openapi.yaml

# Runtime data
pids
*.pid
Expand Down
Original file line number Diff line number Diff line change
@@ -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
3 changes: 2 additions & 1 deletion agentex/database/migrations/migration_history.txt
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
a9959ebcbe98 -> 6c942325c828 (head), adding task cleaned at
6c942325c828 -> c7a1b2d3e4f5 (head), add build_only agent status
a9959ebcbe98 -> 6c942325c828, adding task cleaned at
e9c4ff9e6542 -> a9959ebcbe98, finalize_spans_task_id
9ff3ee32c81b -> e9c4ff9e6542, add_tasks_metadata_gin_index
57c5ed4f59ae -> 9ff3ee32c81b, uppercase deployment status enum labels
Expand Down
70 changes: 70 additions & 0 deletions agentex/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,33 @@ 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, so it can
be permissioned and shared prior to deploy. 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:
Expand Down Expand Up @@ -3894,6 +3921,7 @@ components:
- Unknown
- Deleted
- Unhealthy
- BuildOnly
title: AgentStatus
AgentTaskTracker:
properties:
Expand Down Expand Up @@ -5292,6 +5320,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:
Expand Down
44 changes: 43 additions & 1 deletion agentex/src/api/routes/agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -216,6 +221,43 @@ 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, so it can be "
"permissioned and shared prior to deploy. 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)
Comment thread
smoreinis marked this conversation as resolved.


@router.get(
"/forward/name/{agent_name}/{path:path}",
summary="Forward GET request to agent by name",
Expand Down
27 changes: 27 additions & 0 deletions agentex/src/api/schemas/agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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."
)
3 changes: 3 additions & 0 deletions agentex/src/domain/entities/agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
26 changes: 20 additions & 6 deletions agentex/src/domain/repositories/deployment_repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
DDatabaseAsyncReadOnlySessionMaker,
DDatabaseAsyncReadWriteSessionMaker,
)
from src.domain.entities.agents import AgentStatus
from src.domain.entities.deployments import DeploymentEntity, DeploymentStatus
from src.utils.logging import make_logger

Expand Down Expand Up @@ -79,6 +80,9 @@ async def promote(
1. Unset is_production on the current production deployment
2. Set is_production=True and promoted_at on the target deployment
3. Update Agent.production_deployment_id and Agent.acp_url
4. Promote a BUILD_ONLY agent to READY (the deployment-scoped flow's
equivalent of the legacy /register status flip; for that flow the
agent row is only mutated here, not at register time)
"""
async with self.start_async_db_session(allow_writes=True) as session:
# Validate the target deployment exists, belongs to this agent, and is not soft-deleted
Expand All @@ -99,6 +103,10 @@ async def promote(
f"Deployment must be in {DeploymentStatus.READY} status."
)

agent = await session.get(AgentORM, agent_id)
if not agent:
raise ItemDoesNotExist(f"Agent {agent_id} not found")

now = datetime.now(UTC)

# Demote current production deployment
Expand All @@ -114,13 +122,19 @@ async def promote(
target_deployment.promoted_at = now

# Update agent's denormalized fields
agent_values: dict[str, object] = {
"production_deployment_id": deployment_id,
"acp_url": target_deployment.acp_url,
}
# A build-only agent has never been deployed; its first promotion is
# what makes it live, so flip it to READY here. Leave any other
# status (e.g. UNHEALTHY) untouched.
if agent.status == AgentStatus.BUILD_ONLY:
agent_values["status"] = AgentStatus.READY
agent_values["status_reason"] = "Agent registered successfully."
agent_values["registered_at"] = now
await session.execute(
update(AgentORM)
.where(AgentORM.id == agent_id)
.values(
production_deployment_id=deployment_id,
acp_url=target_deployment.acp_url,
)
update(AgentORM).where(AgentORM.id == agent_id).values(**agent_values)
)

await session.commit()
Expand Down
49 changes: 49 additions & 0 deletions agentex/src/domain/use_cases/agents_use_case.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

only nit is what happens here for soft-deleted agents, should they stay deleted or do we fall through to the code below that 'resurrects' them as build-only?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it makes sense to ressurect

logger.info(
f"Agent {name} already exists, returning existing agent for build"
)
return existing
Comment thread
smoreinis marked this conversation as resolved.
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,
Expand Down
Loading
Loading