Skip to content

Commit 225e3a7

Browse files
committed
feat(agentex): materialized Task.current_state for reactive state observability
Expose an agent's StateMachine current state to REST/SSE consumers via a nullable, opaque `Task.current_state` label. It is written through the existing UPDATE_TASK / PUT /tasks/{id} path, which already publishes a `task_updated` SSE event carrying the full task, so updates are reactive (push) while GET /tasks/{id} remains the authoritative point-read for load/reconnect reconciliation. The field is framework-agnostic and default-null: agents opt in by writing it; existing tasks and stream consumers are unaffected (additive, backward compatible). - ORM column + Alembic migration (nullable add, down_revision a1b2c3d4e5f6) - Task response schema, UpdateTaskRequest, TaskEntity + converter - update_mutable_fields_on_task applies it in the same update_task write as task_metadata (single task_updated publish) - both PUT routes thread it through - regenerated openapi.yaml - tests: route PUT/null/no-clobber, task_updated carries current_state, service-layer emit+persist, extra="ignore" forward-compat pin
1 parent c045ad0 commit 225e3a7

10 files changed

Lines changed: 242 additions & 4 deletions

File tree

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
"""add task current_state
2+
3+
Revision ID: b2c3d4e5f6a7
4+
Revises: a1b2c3d4e5f6
5+
Create Date: 2026-07-22 12:00:00.000000
6+
7+
"""
8+
from typing import Sequence, Union
9+
10+
from alembic import op
11+
import sqlalchemy as sa
12+
13+
14+
# revision identifiers, used by Alembic.
15+
revision: str = 'b2c3d4e5f6a7'
16+
down_revision: Union[str, None] = 'a1b2c3d4e5f6'
17+
branch_labels: Union[str, Sequence[str], None] = None
18+
depends_on: Union[str, Sequence[str], None] = None
19+
20+
21+
def upgrade() -> None:
22+
# Opaque label mirroring an agent's StateMachine current state. Nullable and
23+
# additive; agents opt in by emitting it. Metadata-only add, non-blocking.
24+
op.add_column('tasks', sa.Column('current_state', sa.String(), nullable=True))
25+
26+
27+
def downgrade() -> None:
28+
op.drop_column('tasks', 'current_state')

agentex/openapi.yaml

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6588,6 +6588,12 @@ components:
65886588
type: object
65896589
- type: 'null'
65906590
title: Task metadata
6591+
current_state:
6592+
anyOf:
6593+
- type: string
6594+
- type: 'null'
6595+
title: Opaque label mirroring the agent's StateMachine current state; null
6596+
when the agent does not emit one. Orthogonal to 'status'.
65916597
type: object
65926598
required:
65936599
- id
@@ -6811,6 +6817,12 @@ components:
68116817
type: object
68126818
- type: 'null'
68136819
title: Task metadata
6820+
current_state:
6821+
anyOf:
6822+
- type: string
6823+
- type: 'null'
6824+
title: Opaque label mirroring the agent's StateMachine current state; null
6825+
when the agent does not emit one. Orthogonal to 'status'.
68146826
agents:
68156827
anyOf:
68166828
- items:
@@ -7396,6 +7408,11 @@ components:
73967408
- type: 'null'
73977409
title: Optional shallow-merge patch applied to the task's params column.
73987410
Top-level keys overwrite; pass full nested objects to change subfields.
7411+
current_state:
7412+
anyOf:
7413+
- type: string
7414+
- type: 'null'
7415+
title: If provided, replaces the task's current_state label.
73997416
type: object
74007417
title: UpdateTaskRequest
74017418
ValidationError:

agentex/src/adapters/orm.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,10 @@ class TaskORM(BaseORM):
7575
cleaned_at = Column(DateTime(timezone=True), nullable=True)
7676
params = Column(JSONB, nullable=True)
7777
task_metadata = Column(JSONB, nullable=True)
78+
# Opaque, framework-agnostic label mirroring an agent's StateMachine current
79+
# state. Written best-effort by the agent on each transition; reconciled on
80+
# read. Orthogonal to `status` (Temporal workflow lifecycle).
81+
current_state = Column(String, nullable=True)
7882
# Many-to-Many relationship with agents
7983
agents = relationship("AgentORM", secondary="task_agents", back_populates="tasks")
8084

agentex/src/api/routes/tasks.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,7 @@ async def update_task(
196196
id=task_id,
197197
task_metadata=request.task_metadata,
198198
merge_params=request.merge_params,
199+
current_state=request.current_state,
199200
)
200201
return Task.model_validate(updated_task_entity)
201202

@@ -217,6 +218,7 @@ async def update_task_by_name(
217218
name=task_name,
218219
task_metadata=request.task_metadata,
219220
merge_params=request.merge_params,
221+
current_state=request.current_state,
220222
)
221223
return Task.model_validate(updated_task_entity)
222224

agentex/src/api/schemas/tasks.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,13 @@ class Task(BaseModel):
6363
None,
6464
title="Task metadata",
6565
)
66+
current_state: str | None = Field(
67+
None,
68+
title=(
69+
"Opaque label mirroring the agent's StateMachine current state; "
70+
"null when the agent does not emit one. Orthogonal to 'status'."
71+
),
72+
)
6673

6774

6875
class TaskResponse(Task):
@@ -87,6 +94,10 @@ class UpdateTaskRequest(BaseModel):
8794
"subfields."
8895
),
8996
)
97+
current_state: str | None = Field(
98+
None,
99+
title="If provided, replaces the task's current_state label.",
100+
)
90101

91102

92103
class TaskStatusReasonRequest(BaseModel):

agentex/src/domain/entities/tasks.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,10 @@ class TaskEntity(BaseModel):
6666
None,
6767
title="Task metadata",
6868
)
69+
current_state: str | None = Field(
70+
None,
71+
title="Opaque label mirroring the agent's StateMachine current state",
72+
)
6973

7074
# allow extra fields for agents relationships
7175
model_config = ConfigDict(extra="allow")
@@ -84,4 +88,5 @@ def convert_task_to_entity(task: Task) -> TaskEntity:
8488
cleaned_at=task.cleaned_at,
8589
params=task.params,
8690
task_metadata=task.task_metadata,
91+
current_state=task.current_state,
8792
)

agentex/src/domain/use_cases/tasks_use_case.py

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ async def update_mutable_fields_on_task(
9595
name: str | None = None,
9696
task_metadata: dict[str, Any] | None = None,
9797
merge_params: dict[str, Any] | None = None,
98+
current_state: str | None = None,
9899
) -> TaskEntity:
99100
"""Update mutable fields on a task entity. This is used by our API since not all fields should be mutable."""
100101

@@ -109,8 +110,8 @@ async def update_mutable_fields_on_task(
109110
else:
110111
raise ItemDoesNotExist(f"Task {name} not found")
111112

112-
# No-op if neither field was supplied.
113-
if task_metadata is None and merge_params is None:
113+
# No-op if no mutable field was supplied.
114+
if task_metadata is None and merge_params is None and current_state is None:
114115
return task_entity
115116

116117
# `merge_params` is a separate atomic JSONB shallow-merge so concurrent
@@ -126,8 +127,15 @@ async def update_mutable_fields_on_task(
126127
if merged is not None:
127128
task_entity = merged
128129

129-
if task_metadata is not None:
130-
task_entity.task_metadata = task_metadata
130+
# Apply the whole-row field updates (task_metadata, current_state) in a
131+
# single update_task write so they emit one task_updated event rather than
132+
# two. current_state rides the same publish that already powers reactive
133+
# task_updated delivery to stream consumers.
134+
if task_metadata is not None or current_state is not None:
135+
if task_metadata is not None:
136+
task_entity.task_metadata = task_metadata
137+
if current_state is not None:
138+
task_entity.current_state = current_state
131139
task_entity = await self.task_service.update_task(task=task_entity)
132140

133141
return task_entity

agentex/tests/integration/api/tasks/test_tasks_api.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -683,6 +683,85 @@ async def test_update_task_endpoint_success(
683683
assert response_data["task_metadata"]["configuration"]["version"] == "2.0.0"
684684
assert response_data["task_metadata"]["metrics"]["complexity_score"] == 75
685685

686+
async def test_update_task_current_state(
687+
self, isolated_client, isolated_repositories
688+
):
689+
"""PUT /tasks/{id} writes current_state; null/omitted leaves it unset."""
690+
agent_repo = isolated_repositories["agent_repository"]
691+
agent = AgentEntity(
692+
id=orm_id(),
693+
name="current-state-agent",
694+
description="Agent for current_state update testing",
695+
acp_url="http://test-acp:8000",
696+
acp_type=ACPType.SYNC,
697+
)
698+
await agent_repo.create(agent)
699+
700+
task_repo = isolated_repositories["task_repository"]
701+
task = TaskEntity(
702+
id=orm_id(),
703+
name="task-for-current-state",
704+
status=TaskStatus.RUNNING,
705+
status_reason="Test task for current_state",
706+
)
707+
created_task = await task_repo.create(agent_id=agent.id, task=task)
708+
709+
# Fresh task: current_state present in response and null by default.
710+
response = await isolated_client.get(f"/tasks/{created_task.id}")
711+
assert response.status_code == 200
712+
assert response.json()["current_state"] is None
713+
714+
# Setting current_state persists and echoes back.
715+
response = await isolated_client.put(
716+
f"/tasks/{created_task.id}", json={"current_state": "awaiting_input"}
717+
)
718+
assert response.status_code == 200
719+
assert response.json()["current_state"] == "awaiting_input"
720+
721+
# Point-read reflects the committed value (source of truth).
722+
response = await isolated_client.get(f"/tasks/{created_task.id}")
723+
assert response.status_code == 200
724+
assert response.json()["current_state"] == "awaiting_input"
725+
726+
# Updating only task_metadata leaves current_state untouched (no clobber).
727+
response = await isolated_client.put(
728+
f"/tasks/{created_task.id}", json={"task_metadata": {"k": "v"}}
729+
)
730+
assert response.status_code == 200
731+
assert response.json()["current_state"] == "awaiting_input"
732+
733+
async def test_update_task_request_ignores_unknown_fields(
734+
self, isolated_client, isolated_repositories
735+
):
736+
"""Guards the extra="ignore" assumption a newer SDK relies on: a payload
737+
carrying fields the server doesn't model must 200 (drop them), not 422.
738+
Breaks loudly if UpdateTaskRequest ever switches to extra="forbid"."""
739+
agent_repo = isolated_repositories["agent_repository"]
740+
agent = AgentEntity(
741+
id=orm_id(),
742+
name="unknown-fields-agent",
743+
description="Agent for unknown-field compat testing",
744+
acp_url="http://test-acp:8000",
745+
acp_type=ACPType.SYNC,
746+
)
747+
await agent_repo.create(agent)
748+
749+
task_repo = isolated_repositories["task_repository"]
750+
task = TaskEntity(
751+
id=orm_id(),
752+
name="task-for-unknown-fields",
753+
status=TaskStatus.RUNNING,
754+
status_reason="Test task for unknown-field compat",
755+
)
756+
created_task = await task_repo.create(agent_id=agent.id, task=task)
757+
758+
response = await isolated_client.put(
759+
f"/tasks/{created_task.id}",
760+
json={"current_state": "working", "field_from_a_newer_sdk": "ignored"},
761+
)
762+
assert response.status_code == 200
763+
assert response.json()["current_state"] == "working"
764+
686765
async def test_update_task_endpoint_validation(
687766
self, isolated_client, isolated_repositories
688767
):

agentex/tests/integration/test_task_stream.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,64 @@ async def collect_stream_events():
230230

231231
print("✅ Task metadata update successfully triggered stream event")
232232

233+
async def test_current_state_update_triggers_stream_event(
234+
self, test_agent_and_task, tasks_use_case, streams_use_case
235+
):
236+
"""current_state rides the existing task_updated event, so a client
237+
subscribed to the task stream is pushed the new state reactively."""
238+
_agent, task = test_agent_and_task
239+
240+
stream_events = []
241+
242+
async def collect_stream_events():
243+
try:
244+
async for event_data in streams_use_case.stream_task_events(
245+
task_id=task.id
246+
):
247+
if event_data.startswith("data: "):
248+
import json
249+
250+
event_json = event_data[6:].strip()
251+
if event_json:
252+
try:
253+
event = json.loads(event_json)
254+
stream_events.append(event)
255+
if event.get("type") == "task_updated":
256+
break
257+
except json.JSONDecodeError:
258+
pass
259+
except asyncio.CancelledError:
260+
pass
261+
262+
stream_task = asyncio.create_task(collect_stream_events())
263+
await asyncio.sleep(0.1)
264+
265+
updated_task = await tasks_use_case.update_mutable_fields_on_task(
266+
id=task.id, current_state="awaiting_input"
267+
)
268+
269+
await asyncio.sleep(0.5)
270+
271+
stream_task.cancel()
272+
try:
273+
await stream_task
274+
except asyncio.CancelledError:
275+
pass
276+
277+
task_updated_events = [
278+
e for e in stream_events if e.get("type") == "task_updated"
279+
]
280+
assert len(task_updated_events) >= 1, (
281+
f"Expected task_updated event, got events: {[e.get('type') for e in stream_events]}"
282+
)
283+
event_task = task_updated_events[0]["task"]
284+
assert event_task["id"] == task.id
285+
assert event_task["current_state"] == "awaiting_input"
286+
287+
assert updated_task.current_state == "awaiting_input"
288+
289+
print("✅ current_state update successfully triggered stream event")
290+
233291
async def test_get_task_returns_updated_metadata_after_stream_update(
234292
self, test_agent_and_task, tasks_use_case
235293
):

agentex/tests/unit/services/test_task_service.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -938,6 +938,32 @@ async def test_update_task_with_task_metadata_changes(
938938
assert event_data["type"] == "task_updated"
939939
assert event_data["task"]["task_metadata"] == updated_metadata
940940

941+
async def test_update_task_current_state_publishes_stream_event(
942+
self, task_service, agent_repository, sample_agent, redis_stream_repository
943+
):
944+
"""update_task persists current_state and carries it on the published
945+
task_updated event, so subscribed clients see the new state."""
946+
await create_or_get_agent(agent_repository, sample_agent)
947+
created_task = await task_service.create_task(
948+
agent=sample_agent, task_name="task-for-current-state"
949+
)
950+
951+
created_task.current_state = "working"
952+
redis_stream_repository.send_data = AsyncMock()
953+
954+
result = await task_service.update_task(created_task)
955+
956+
assert result.current_state == "working"
957+
retrieved_task = await task_service.get_task(id=created_task.id)
958+
assert retrieved_task.current_state == "working"
959+
960+
redis_stream_repository.send_data.assert_called_once()
961+
call_args = redis_stream_repository.send_data.call_args
962+
assert call_args[0][0] == f"task:{created_task.id}"
963+
event_data = call_args[0][1]
964+
assert event_data["type"] == "task_updated"
965+
assert event_data["task"]["current_state"] == "working"
966+
941967
async def test_get_task_preserves_task_metadata(
942968
self, task_service, agent_repository, sample_agent
943969
):

0 commit comments

Comments
 (0)