Skip to content

Commit 156ab00

Browse files
Add AgentCard.from_states() classmethod for list[State] + initial_state usage
1 parent 5269ead commit 156ab00

File tree

2 files changed

+143
-6
lines changed

2 files changed

+143
-6
lines changed

src/agentex/lib/types/agent_card.py

Lines changed: 77 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,14 @@
22

33
import types
44
import typing
5-
from typing import Any, get_args, get_origin
5+
from enum import Enum
6+
from typing import TYPE_CHECKING, Any, get_args, get_origin
67

78
from pydantic import BaseModel
89

10+
if TYPE_CHECKING:
11+
from agentex.lib.sdk.state_machine.state import State
12+
913

1014
class LifecycleState(BaseModel):
1115
name: str
@@ -28,6 +32,57 @@ class AgentCard(BaseModel):
2832
input_types: list[str] = []
2933
output_schema: dict | None = None
3034

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+
3186
@classmethod
3287
def from_state_machine(
3388
cls,
@@ -36,21 +91,37 @@ def from_state_machine(
3691
extra_input_types: list[str] | None = None,
3792
queries: list[str] | None = None,
3893
) -> AgentCard:
39-
lifecycle_data = state_machine.get_lifecycle()
40-
lifecycle_data["queries"] = queries or []
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"]
4198

99+
# Reconstruct lightweight State-like objects from the lifecycle dict
100+
# so we can reuse from_states logic via the dict path
42101
data_events: list[str] = []
43102
output_schema: dict | None = None
44103
if output_event_model:
45104
data_events = extract_literal_values(output_event_model, "type")
46105
output_schema = output_event_model.model_json_schema()
47106

48107
derived_input_types: set[str] = set()
49-
for state in lifecycle_data["states"]:
50-
derived_input_types.update(state.get("accepts", []))
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+
))
51118

52119
return cls(
53-
lifecycle=AgentLifecycle.model_validate(lifecycle_data),
120+
lifecycle=AgentLifecycle(
121+
states=lifecycle_states,
122+
initial_state=initial,
123+
queries=queries or [],
124+
),
54125
data_events=data_events,
55126
input_types=sorted(derived_input_types | set(extra_input_types or [])),
56127
output_schema=output_schema,

tests/lib/test_agent_card.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,72 @@ def test_serialization_roundtrip(self):
197197
assert restored == card
198198

199199

200+
# --- AgentCard.from_states ---
201+
202+
class TestAgentCardFromStates:
203+
def test_lifecycle_derivation(self, sample_states):
204+
card = AgentCard.from_states(initial_state=SampleState.WAITING, states=sample_states)
205+
206+
assert card.lifecycle is not None
207+
assert card.lifecycle.initial_state == "waiting"
208+
assert len(card.lifecycle.states) == 3
209+
210+
def test_initial_state_string(self, sample_states):
211+
card = AgentCard.from_states(initial_state="waiting", states=sample_states)
212+
assert card.lifecycle is not None
213+
assert card.lifecycle.initial_state == "waiting"
214+
215+
def test_input_types_union(self, sample_states):
216+
card = AgentCard.from_states(initial_state=SampleState.WAITING, states=sample_states)
217+
assert card.input_types == ["doc_upload", "text"]
218+
219+
def test_extra_input_types(self, sample_states):
220+
card = AgentCard.from_states(
221+
initial_state=SampleState.WAITING,
222+
states=sample_states,
223+
extra_input_types=["admin_command"],
224+
)
225+
assert card.input_types == ["admin_command", "doc_upload", "text"]
226+
227+
def test_data_events_and_schema(self, sample_states):
228+
card = AgentCard.from_states(
229+
initial_state=SampleState.WAITING,
230+
states=sample_states,
231+
output_event_model=SampleOutputEvent,
232+
queries=["get_current_state"],
233+
)
234+
assert card.data_events == ["plan_update", "status_change", "report_done"]
235+
assert card.output_schema is not None
236+
assert card.lifecycle is not None
237+
assert card.lifecycle.queries == ["get_current_state"]
238+
239+
def test_state_fields(self, sample_states):
240+
card = AgentCard.from_states(initial_state=SampleState.WAITING, states=sample_states)
241+
assert card.lifecycle is not None
242+
states_by_name = {s.name: s for s in card.lifecycle.states}
243+
244+
waiting = states_by_name["waiting"]
245+
assert waiting.description == "Waiting for input"
246+
assert waiting.waits_for_input is True
247+
assert waiting.accepts == ["text", "doc_upload"]
248+
assert waiting.transitions == ["processing"]
249+
250+
def test_matches_from_state_machine(self, sample_states, sample_sm):
251+
"""from_states and from_state_machine should produce identical cards."""
252+
card_states = AgentCard.from_states(
253+
initial_state=SampleState.WAITING,
254+
states=sample_states,
255+
output_event_model=SampleOutputEvent,
256+
queries=["get_current_state"],
257+
)
258+
card_sm = AgentCard.from_state_machine(
259+
state_machine=sample_sm,
260+
output_event_model=SampleOutputEvent,
261+
queries=["get_current_state"],
262+
)
263+
assert card_states == card_sm
264+
265+
200266
# --- AgentCard.from_state_machine ---
201267

202268
class TestAgentCardFromStateMachine:

0 commit comments

Comments
 (0)