Skip to content

Commit 6509be1

Browse files
feat: add AgentCard for self-describing agent capabilities (#296)
* Add AgentCard feature for self-describing agent capabilities via registration_metadata * Add tests for AgentCard feature, fix PEP 604 union unwrap in extract_literal_values * Fix ruff import sorting in __init__.py and test file * Fix pyright strict errors: use Enum isinstance checks, add override decorators in tests * Add AgentCard.from_states() classmethod for list[State] + initial_state usage * Minimize registration.py diff: only add agent_card param and merge logic * Add missing AGENTEX_DEPLOYMENT_ID to test mock env vars
1 parent 67c38ee commit 6509be1

File tree

8 files changed

+607
-7
lines changed

8 files changed

+607
-7
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
@@ -82,6 +82,9 @@ def __init__(self):
8282
# Agent info to return in healthz
8383
self.agent_id: str | None = None
8484

85+
# Optional agent card for registration metadata
86+
self._agent_card: Any | None = None
87+
8588
@classmethod
8689
def create(cls):
8790
"""Create and initialize BaseACPServer instance"""
@@ -99,7 +102,7 @@ def get_lifespan_function(self):
99102
async def lifespan_context(app: FastAPI): # noqa: ARG001
100103
env_vars = EnvironmentVariables.refresh()
101104
if env_vars.AGENTEX_BASE_URL:
102-
await register_agent(env_vars)
105+
await register_agent(env_vars, agent_card=self._agent_card)
103106
self.agent_id = env_vars.AGENT_ID
104107
else:
105108
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
Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,16 @@
1+
from agentex.lib.types.agent_card import AgentCard, AgentLifecycle, LifecycleState
2+
13
from .state import State
24
from .noop_workflow import NoOpWorkflow
35
from .state_machine import StateMachine
46
from .state_workflow import StateWorkflow
57

6-
__all__ = ["StateMachine", "StateWorkflow", "State", "NoOpWorkflow"]
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: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

33
from abc import ABC, abstractmethod
4+
from enum import Enum
45
from typing import Any, Generic, TypeVar
56

67
from agentex.lib import adk
@@ -129,6 +130,28 @@ async def reset_to_initial_state(self):
129130
span.output = {"output_state": self._initial_state} # type: ignore[assignment,union-attr]
130131
await adk.tracing.end_span(trace_id=self._task_id, span=span)
131132

133+
def get_lifecycle(self) -> dict[str, Any]:
134+
"""Export the state machine's lifecycle as a dict suitable for AgentCard."""
135+
states = []
136+
for state in self._state_map.values():
137+
workflow = state.workflow
138+
states.append({
139+
"name": state.name,
140+
"description": workflow.description,
141+
"waits_for_input": workflow.waits_for_input,
142+
"accepts": list(workflow.accepts),
143+
"transitions": [
144+
t.value if isinstance(t, Enum) else str(t)
145+
for t in workflow.transitions
146+
],
147+
})
148+
initial: str = self._initial_state.value if isinstance(self._initial_state, Enum) else self._initial_state
149+
150+
return {
151+
"states": states,
152+
"initial_state": initial,
153+
}
154+
132155
def dump(self) -> dict[str, Any]:
133156
"""
134157
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: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
from __future__ import annotations
2+
3+
import types
4+
import typing
5+
from enum import Enum
6+
from typing import TYPE_CHECKING, Any, get_args, get_origin
7+
8+
from pydantic import BaseModel
9+
10+
if TYPE_CHECKING:
11+
from agentex.lib.sdk.state_machine.state import State
12+
13+
14+
class LifecycleState(BaseModel):
15+
name: str
16+
description: str = ""
17+
waits_for_input: bool = False
18+
accepts: list[str] = []
19+
transitions: list[str] = []
20+
21+
22+
class AgentLifecycle(BaseModel):
23+
states: list[LifecycleState]
24+
initial_state: str
25+
queries: list[str] = []
26+
27+
28+
class AgentCard(BaseModel):
29+
protocol: str = "acp"
30+
lifecycle: AgentLifecycle | None = None
31+
data_events: list[str] = []
32+
input_types: list[str] = []
33+
output_schema: dict | None = None
34+
35+
@classmethod
36+
def from_states(
37+
cls,
38+
initial_state: str | Enum,
39+
states: list[State],
40+
output_event_model: type[BaseModel] | None = None,
41+
extra_input_types: list[str] | None = None,
42+
queries: list[str] | None = None,
43+
) -> AgentCard:
44+
"""Build an AgentCard directly from a list[State] + initial_state.
45+
46+
Agents can share their `states` list between the StateMachine and acp.py
47+
without constructing a temporary StateMachine instance.
48+
"""
49+
lifecycle_states = [
50+
LifecycleState(
51+
name=state.name,
52+
description=state.workflow.description,
53+
waits_for_input=state.workflow.waits_for_input,
54+
accepts=list(state.workflow.accepts),
55+
transitions=[
56+
t.value if isinstance(t, Enum) else str(t)
57+
for t in state.workflow.transitions
58+
],
59+
)
60+
for state in states
61+
]
62+
63+
initial = initial_state.value if isinstance(initial_state, Enum) else initial_state
64+
65+
data_events: list[str] = []
66+
output_schema: dict | None = None
67+
if output_event_model:
68+
data_events = extract_literal_values(output_event_model, "type")
69+
output_schema = output_event_model.model_json_schema()
70+
71+
derived_input_types: set[str] = set()
72+
for ls in lifecycle_states:
73+
derived_input_types.update(ls.accepts)
74+
75+
return cls(
76+
lifecycle=AgentLifecycle(
77+
states=lifecycle_states,
78+
initial_state=initial,
79+
queries=queries or [],
80+
),
81+
data_events=data_events,
82+
input_types=sorted(derived_input_types | set(extra_input_types or [])),
83+
output_schema=output_schema,
84+
)
85+
86+
@classmethod
87+
def from_state_machine(
88+
cls,
89+
state_machine: Any,
90+
output_event_model: type[BaseModel] | None = None,
91+
extra_input_types: list[str] | None = None,
92+
queries: list[str] | None = None,
93+
) -> AgentCard:
94+
"""Build an AgentCard from a StateMachine instance. Delegates to from_states()."""
95+
lifecycle = state_machine.get_lifecycle()
96+
states_data = lifecycle["states"]
97+
initial = lifecycle["initial_state"]
98+
99+
# Reconstruct lightweight State-like objects from the lifecycle dict
100+
# so we can reuse from_states logic via the dict path
101+
data_events: list[str] = []
102+
output_schema: dict | None = None
103+
if output_event_model:
104+
data_events = extract_literal_values(output_event_model, "type")
105+
output_schema = output_event_model.model_json_schema()
106+
107+
derived_input_types: set[str] = set()
108+
lifecycle_states = []
109+
for s in states_data:
110+
derived_input_types.update(s.get("accepts", []))
111+
lifecycle_states.append(LifecycleState(
112+
name=s["name"],
113+
description=s.get("description", ""),
114+
waits_for_input=s.get("waits_for_input", False),
115+
accepts=s.get("accepts", []),
116+
transitions=s.get("transitions", []),
117+
))
118+
119+
return cls(
120+
lifecycle=AgentLifecycle(
121+
states=lifecycle_states,
122+
initial_state=initial,
123+
queries=queries or [],
124+
),
125+
data_events=data_events,
126+
input_types=sorted(derived_input_types | set(extra_input_types or [])),
127+
output_schema=output_schema,
128+
)
129+
130+
131+
def extract_literal_values(model: type[BaseModel], field: str) -> list[str]:
132+
"""Extract allowed values from a Literal[...] type annotation on a Pydantic model field."""
133+
field_info = model.model_fields.get(field)
134+
if field_info is None:
135+
return []
136+
137+
annotation = field_info.annotation
138+
if annotation is None:
139+
return []
140+
141+
# Unwrap Optional (Union[X, None] or PEP 604 X | None) to get the inner type
142+
if get_origin(annotation) is typing.Union or isinstance(annotation, types.UnionType):
143+
args = [a for a in get_args(annotation) if a is not type(None)]
144+
annotation = args[0] if len(args) == 1 else annotation
145+
146+
if get_origin(annotation) is typing.Literal:
147+
return list(get_args(annotation))
148+
149+
return []

src/agentex/lib/utils/registration.py

Lines changed: 4 additions & 1 deletion
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")
@@ -48,6 +48,9 @@ async def register_agent(env_vars: EnvironmentVariables):
4848
registration_metadata = get_build_info() or {}
4949
if env_vars.AGENTEX_DEPLOYMENT_ID:
5050
registration_metadata["deployment_id"] = env_vars.AGENTEX_DEPLOYMENT_ID
51+
if agent_card is not None:
52+
card_data = agent_card.model_dump() if hasattr(agent_card, "model_dump") else agent_card
53+
registration_metadata["agent_card"] = card_data
5154

5255
# Prepare registration data
5356
registration_data = {

0 commit comments

Comments
 (0)