Skip to content

Commit 0157ed0

Browse files
Add AgentCard feature for self-describing agent capabilities via registration_metadata
1 parent 1ffe32f commit 0157ed0

File tree

7 files changed

+142
-8
lines changed

7 files changed

+142
-8
lines changed

src/agentex/lib/sdk/fastacp/base/base_acp_server.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,9 @@ def __init__(self):
8181
# Agent info to return in healthz
8282
self.agent_id: str | None = None
8383

84+
# Optional agent card for registration metadata
85+
self._agent_card: Any | None = None
86+
8487
@classmethod
8588
def create(cls):
8689
"""Create and initialize BaseACPServer instance"""
@@ -98,7 +101,7 @@ def get_lifespan_function(self):
98101
async def lifespan_context(app: FastAPI): # noqa: ARG001
99102
env_vars = EnvironmentVariables.refresh()
100103
if env_vars.AGENTEX_BASE_URL:
101-
await register_agent(env_vars)
104+
await register_agent(env_vars, agent_card=self._agent_card)
102105
self.agent_id = env_vars.AGENT_ID
103106
else:
104107
logger.warning("AGENTEX_BASE_URL not set, skipping agent registration")

src/agentex/lib/sdk/fastacp/fastacp.py

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import os
44
import inspect
5-
from typing import Literal
5+
from typing import Any, Literal
66
from pathlib import Path
77
from typing_extensions import deprecated
88

@@ -88,7 +88,10 @@ def locate_build_info_path() -> None:
8888

8989
@staticmethod
9090
def create(
91-
acp_type: Literal["sync", "async", "agentic"], config: BaseACPConfig | None = None, **kwargs
91+
acp_type: Literal["sync", "async", "agentic"],
92+
config: BaseACPConfig | None = None,
93+
agent_card: Any | None = None,
94+
**kwargs,
9295
) -> BaseACPServer | SyncACP | AsyncBaseACP | TemporalACP:
9396
"""Main factory method to create any ACP type
9497
@@ -102,10 +105,17 @@ def create(
102105

103106
if acp_type == "sync":
104107
sync_config = config if isinstance(config, SyncACPConfig) else None
105-
return FastACP.create_sync_acp(sync_config, **kwargs)
108+
instance = FastACP.create_sync_acp(sync_config, **kwargs)
106109
elif acp_type == "async" or acp_type == "agentic":
107110
if config is None:
108111
config = AsyncACPConfig(type="base")
109112
if not isinstance(config, AsyncACPConfig):
110113
raise ValueError("AsyncACPConfig is required for async/agentic ACP type")
111-
return FastACP.create_async_acp(config, **kwargs)
114+
instance = FastACP.create_async_acp(config, **kwargs)
115+
else:
116+
raise ValueError(f"Unknown acp_type: {acp_type}")
117+
118+
if agent_card is not None:
119+
instance._agent_card = agent_card # type: ignore[attr-defined]
120+
121+
return instance

src/agentex/lib/sdk/state_machine/__init__.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,14 @@
33
from .state_machine import StateMachine
44
from .state_workflow import StateWorkflow
55

6-
__all__ = ["StateMachine", "StateWorkflow", "State", "NoOpWorkflow"]
6+
from agentex.lib.types.agent_card import AgentCard, AgentLifecycle, LifecycleState
7+
8+
__all__ = [
9+
"StateMachine",
10+
"StateWorkflow",
11+
"State",
12+
"NoOpWorkflow",
13+
"AgentCard",
14+
"AgentLifecycle",
15+
"LifecycleState",
16+
]

src/agentex/lib/sdk/state_machine/state_machine.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,30 @@ async def reset_to_initial_state(self):
129129
span.output = {"output_state": self._initial_state} # type: ignore[assignment,union-attr]
130130
await adk.tracing.end_span(trace_id=self._task_id, span=span)
131131

132+
def get_lifecycle(self) -> dict[str, Any]:
133+
"""Export the state machine's lifecycle as a dict suitable for AgentCard."""
134+
states = []
135+
for state in self._state_map.values():
136+
workflow = state.workflow
137+
states.append({
138+
"name": state.name,
139+
"description": workflow.description,
140+
"waits_for_input": workflow.waits_for_input,
141+
"accepts": list(workflow.accepts),
142+
"transitions": [
143+
t.value if hasattr(t, "value") else str(t)
144+
for t in workflow.transitions
145+
],
146+
})
147+
initial = self._initial_state
148+
if hasattr(initial, "value"):
149+
initial = initial.value
150+
151+
return {
152+
"states": states,
153+
"initial_state": initial,
154+
}
155+
132156
def dump(self) -> dict[str, Any]:
133157
"""
134158
Save the current state of the state machine to a serializable dictionary.

src/agentex/lib/sdk/state_machine/state_workflow.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,11 @@
1111

1212

1313
class StateWorkflow(ABC):
14+
description: str = ""
15+
waits_for_input: bool = False
16+
accepts: list[str] = []
17+
transitions: list[str] = []
18+
1419
@abstractmethod
1520
async def execute(
1621
self, state_machine: "StateMachine", state_machine_data: BaseModel | None = None
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
from __future__ import annotations
2+
3+
import typing
4+
from typing import Any, get_args, get_origin
5+
6+
from pydantic import BaseModel
7+
8+
9+
class LifecycleState(BaseModel):
10+
name: str
11+
description: str = ""
12+
waits_for_input: bool = False
13+
accepts: list[str] = []
14+
transitions: list[str] = []
15+
16+
17+
class AgentLifecycle(BaseModel):
18+
states: list[LifecycleState]
19+
initial_state: str
20+
queries: list[str] = []
21+
22+
23+
class AgentCard(BaseModel):
24+
protocol: str = "acp"
25+
lifecycle: AgentLifecycle | None = None
26+
data_events: list[str] = []
27+
input_types: list[str] = []
28+
output_schema: dict | None = None
29+
30+
@classmethod
31+
def from_state_machine(
32+
cls,
33+
state_machine: Any,
34+
output_event_model: type[BaseModel] | None = None,
35+
extra_input_types: list[str] | None = None,
36+
queries: list[str] | None = None,
37+
) -> AgentCard:
38+
lifecycle_data = state_machine.get_lifecycle()
39+
lifecycle_data["queries"] = queries or []
40+
41+
data_events: list[str] = []
42+
output_schema: dict | None = None
43+
if output_event_model:
44+
data_events = extract_literal_values(output_event_model, "type")
45+
output_schema = output_event_model.model_json_schema()
46+
47+
derived_input_types: set[str] = set()
48+
for state in lifecycle_data["states"]:
49+
derived_input_types.update(state.get("accepts", []))
50+
51+
return cls(
52+
lifecycle=AgentLifecycle.model_validate(lifecycle_data),
53+
data_events=data_events,
54+
input_types=sorted(derived_input_types | set(extra_input_types or [])),
55+
output_schema=output_schema,
56+
)
57+
58+
59+
def extract_literal_values(model: type[BaseModel], field: str) -> list[str]:
60+
"""Extract allowed values from a Literal[...] type annotation on a Pydantic model field."""
61+
field_info = model.model_fields.get(field)
62+
if field_info is None:
63+
return []
64+
65+
annotation = field_info.annotation
66+
if annotation is None:
67+
return []
68+
69+
# Unwrap Optional (Union[X, None]) to get the inner type
70+
if get_origin(annotation) is typing.Union:
71+
args = [a for a in get_args(annotation) if a is not type(None)]
72+
annotation = args[0] if len(args) == 1 else annotation
73+
74+
if get_origin(annotation) is typing.Literal:
75+
return list(get_args(annotation))
76+
77+
return []

src/agentex/lib/utils/registration.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ def get_build_info():
3131
except Exception:
3232
return None
3333

34-
async def register_agent(env_vars: EnvironmentVariables):
34+
async def register_agent(env_vars: EnvironmentVariables, agent_card=None):
3535
"""Register this agent with the Agentex server"""
3636
if not env_vars.AGENTEX_BASE_URL:
3737
logger.warning("AGENTEX_BASE_URL is not set, skipping registration")
@@ -45,13 +45,18 @@ async def register_agent(env_vars: EnvironmentVariables):
4545
)
4646

4747
# Prepare registration data
48+
registration_metadata = get_build_info() or {}
49+
if agent_card is not None:
50+
card_data = agent_card.model_dump() if hasattr(agent_card, "model_dump") else agent_card
51+
registration_metadata["agent_card"] = card_data
52+
4853
registration_data = {
4954
"name": env_vars.AGENT_NAME,
5055
"description": description,
5156
"acp_url": full_acp_url,
5257
"acp_type": env_vars.ACP_TYPE,
5358
"principal_context": get_auth_principal(env_vars),
54-
"registration_metadata": get_build_info()
59+
"registration_metadata": registration_metadata,
5560
}
5661

5762
if env_vars.AGENT_ID:

0 commit comments

Comments
 (0)