Skip to content

Commit cc9f3f4

Browse files
Merge branch 'main' into dm/agentex-webhook-trigger
2 parents 6ca6f70 + 5020e96 commit cc9f3f4

5 files changed

Lines changed: 152 additions & 58 deletions

File tree

agentex/openapi.yaml

Lines changed: 88 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -1213,19 +1213,25 @@ paths:
12131213
: {\n \"anyOf\": [\n {},\n {\n \
12141214
\ \"type\": \"null\"\n }\n ],\n \"default\"\
12151215
: null,\n \"description\": \"The result of the tool.\",\n \
1216-
\ \"title\": \"Content\"\n }\n },\n \"title\":\
1217-
\ \"ToolResponseContentEntityOptional\",\n \"type\": \"object\"\n\
1218-
\ }\n },\n \"description\": \"Filter model for TaskMessage - all\
1219-
\ fields optional for flexible filtering.\\n\\nThe `exclude` field determines\
1220-
\ whether this filter is inclusionary or exclusionary.\\nWhen multiple\
1221-
\ filters are provided:\\n- Inclusionary filters (exclude=False) are OR'd\
1222-
\ together\\n- Exclusionary filters (exclude=True) are OR'd together and\
1223-
\ negated with $nor\\n- The two groups are AND'd: (include1 OR include2)\
1224-
\ AND NOT (exclude1 OR exclude2)\",\n \"properties\": {\n \"content\"\
1225-
: {\n \"anyOf\": [\n {\n \"$ref\": \"#/$defs/ToolRequestContentEntityOptional\"\
1226-
\n },\n {\n \"$ref\": \"#/$defs/DataContentEntityOptional\"\
1227-
\n },\n {\n \"$ref\": \"#/$defs/TextContentEntityOptional\"\
1228-
\n },\n {\n \"$ref\": \"#/$defs/ToolResponseContentEntityOptional\"\
1216+
\ \"title\": \"Content\"\n },\n \"is_error\": {\n\
1217+
\ \"anyOf\": [\n {\n \"type\": \"boolean\"\
1218+
\n },\n {\n \"type\": \"null\"\n \
1219+
\ }\n ],\n \"default\": null,\n \"\
1220+
description\": \"Whether the tool call resulted in an error. `None` when\
1221+
\ the harness does not report a status.\",\n \"title\": \"Is\
1222+
\ Error\"\n }\n },\n \"title\": \"ToolResponseContentEntityOptional\"\
1223+
,\n \"type\": \"object\"\n }\n },\n \"description\": \"Filter\
1224+
\ model for TaskMessage - all fields optional for flexible filtering.\\\
1225+
n\\nThe `exclude` field determines whether this filter is inclusionary\
1226+
\ or exclusionary.\\nWhen multiple filters are provided:\\n- Inclusionary\
1227+
\ filters (exclude=False) are OR'd together\\n- Exclusionary filters (exclude=True)\
1228+
\ are OR'd together and negated with $nor\\n- The two groups are AND'd:\
1229+
\ (include1 OR include2) AND NOT (exclude1 OR exclude2)\",\n \"properties\"\
1230+
: {\n \"content\": {\n \"anyOf\": [\n {\n \"$ref\"\
1231+
: \"#/$defs/ToolRequestContentEntityOptional\"\n },\n {\n\
1232+
\ \"$ref\": \"#/$defs/DataContentEntityOptional\"\n },\n\
1233+
\ {\n \"$ref\": \"#/$defs/TextContentEntityOptional\"\n\
1234+
\ },\n {\n \"$ref\": \"#/$defs/ToolResponseContentEntityOptional\"\
12291235
\n },\n {\n \"$ref\": \"#/$defs/ReasoningContentEntityOptional\"\
12301236
\n },\n {\n \"type\": \"null\"\n }\n \
12311237
\ ],\n \"default\": null,\n \"description\": \"Filter by message\
@@ -1416,19 +1422,24 @@ paths:
14161422
\ {},\n {\n \"type\": \"null\"\n \
14171423
\ }\n ],\n \"default\": null,\n \"description\"\
14181424
: \"The result of the tool.\",\n \"title\": \"Content\"\n \
1419-
\ }\n },\n \"title\": \"ToolResponseContentEntityOptional\"\
1420-
,\n \"type\": \"object\"\n }\n },\n \"description\": \"Filter\
1421-
\ model for TaskMessage - all fields optional for flexible filtering.\\\
1422-
n\\nThe `exclude` field determines whether this filter is inclusionary or\
1423-
\ exclusionary.\\nWhen multiple filters are provided:\\n- Inclusionary filters\
1424-
\ (exclude=False) are OR'd together\\n- Exclusionary filters (exclude=True)\
1425-
\ are OR'd together and negated with $nor\\n- The two groups are AND'd:\
1426-
\ (include1 OR include2) AND NOT (exclude1 OR exclude2)\",\n \"properties\"\
1427-
: {\n \"content\": {\n \"anyOf\": [\n {\n \"$ref\"\
1428-
: \"#/$defs/ToolRequestContentEntityOptional\"\n },\n {\n\
1429-
\ \"$ref\": \"#/$defs/DataContentEntityOptional\"\n },\n\
1430-
\ {\n \"$ref\": \"#/$defs/TextContentEntityOptional\"\n\
1431-
\ },\n {\n \"$ref\": \"#/$defs/ToolResponseContentEntityOptional\"\
1425+
\ },\n \"is_error\": {\n \"anyOf\": [\n {\n\
1426+
\ \"type\": \"boolean\"\n },\n {\n \
1427+
\ \"type\": \"null\"\n }\n ],\n \
1428+
\ \"default\": null,\n \"description\": \"Whether the tool call\
1429+
\ resulted in an error. `None` when the harness does not report a status.\"\
1430+
,\n \"title\": \"Is Error\"\n }\n },\n \"title\"\
1431+
: \"ToolResponseContentEntityOptional\",\n \"type\": \"object\"\n \
1432+
\ }\n },\n \"description\": \"Filter model for TaskMessage - all fields\
1433+
\ optional for flexible filtering.\\n\\nThe `exclude` field determines whether\
1434+
\ this filter is inclusionary or exclusionary.\\nWhen multiple filters are\
1435+
\ provided:\\n- Inclusionary filters (exclude=False) are OR'd together\\\
1436+
n- Exclusionary filters (exclude=True) are OR'd together and negated with\
1437+
\ $nor\\n- The two groups are AND'd: (include1 OR include2) AND NOT (exclude1\
1438+
\ OR exclude2)\",\n \"properties\": {\n \"content\": {\n \"anyOf\"\
1439+
: [\n {\n \"$ref\": \"#/$defs/ToolRequestContentEntityOptional\"\
1440+
\n },\n {\n \"$ref\": \"#/$defs/DataContentEntityOptional\"\
1441+
\n },\n {\n \"$ref\": \"#/$defs/TextContentEntityOptional\"\
1442+
\n },\n {\n \"$ref\": \"#/$defs/ToolResponseContentEntityOptional\"\
14321443
\n },\n {\n \"$ref\": \"#/$defs/ReasoningContentEntityOptional\"\
14331444
\n },\n {\n \"type\": \"null\"\n }\n \
14341445
\ ],\n \"default\": null,\n \"description\": \"Filter by message\
@@ -1769,19 +1780,25 @@ paths:
17691780
: {\n \"anyOf\": [\n {},\n {\n \
17701781
\ \"type\": \"null\"\n }\n ],\n \"default\"\
17711782
: null,\n \"description\": \"The result of the tool.\",\n \
1772-
\ \"title\": \"Content\"\n }\n },\n \"title\":\
1773-
\ \"ToolResponseContentEntityOptional\",\n \"type\": \"object\"\n\
1774-
\ }\n },\n \"description\": \"Filter model for TaskMessage - all\
1775-
\ fields optional for flexible filtering.\\n\\nThe `exclude` field determines\
1776-
\ whether this filter is inclusionary or exclusionary.\\nWhen multiple\
1777-
\ filters are provided:\\n- Inclusionary filters (exclude=False) are OR'd\
1778-
\ together\\n- Exclusionary filters (exclude=True) are OR'd together and\
1779-
\ negated with $nor\\n- The two groups are AND'd: (include1 OR include2)\
1780-
\ AND NOT (exclude1 OR exclude2)\",\n \"properties\": {\n \"content\"\
1781-
: {\n \"anyOf\": [\n {\n \"$ref\": \"#/$defs/ToolRequestContentEntityOptional\"\
1782-
\n },\n {\n \"$ref\": \"#/$defs/DataContentEntityOptional\"\
1783-
\n },\n {\n \"$ref\": \"#/$defs/TextContentEntityOptional\"\
1784-
\n },\n {\n \"$ref\": \"#/$defs/ToolResponseContentEntityOptional\"\
1783+
\ \"title\": \"Content\"\n },\n \"is_error\": {\n\
1784+
\ \"anyOf\": [\n {\n \"type\": \"boolean\"\
1785+
\n },\n {\n \"type\": \"null\"\n \
1786+
\ }\n ],\n \"default\": null,\n \"\
1787+
description\": \"Whether the tool call resulted in an error. `None` when\
1788+
\ the harness does not report a status.\",\n \"title\": \"Is\
1789+
\ Error\"\n }\n },\n \"title\": \"ToolResponseContentEntityOptional\"\
1790+
,\n \"type\": \"object\"\n }\n },\n \"description\": \"Filter\
1791+
\ model for TaskMessage - all fields optional for flexible filtering.\\\
1792+
n\\nThe `exclude` field determines whether this filter is inclusionary\
1793+
\ or exclusionary.\\nWhen multiple filters are provided:\\n- Inclusionary\
1794+
\ filters (exclude=False) are OR'd together\\n- Exclusionary filters (exclude=True)\
1795+
\ are OR'd together and negated with $nor\\n- The two groups are AND'd:\
1796+
\ (include1 OR include2) AND NOT (exclude1 OR exclude2)\",\n \"properties\"\
1797+
: {\n \"content\": {\n \"anyOf\": [\n {\n \"$ref\"\
1798+
: \"#/$defs/ToolRequestContentEntityOptional\"\n },\n {\n\
1799+
\ \"$ref\": \"#/$defs/DataContentEntityOptional\"\n },\n\
1800+
\ {\n \"$ref\": \"#/$defs/TextContentEntityOptional\"\n\
1801+
\ },\n {\n \"$ref\": \"#/$defs/ToolResponseContentEntityOptional\"\
17851802
\n },\n {\n \"$ref\": \"#/$defs/ReasoningContentEntityOptional\"\
17861803
\n },\n {\n \"type\": \"null\"\n }\n \
17871804
\ ],\n \"default\": null,\n \"description\": \"Filter by message\
@@ -1972,19 +1989,24 @@ paths:
19721989
\ {},\n {\n \"type\": \"null\"\n \
19731990
\ }\n ],\n \"default\": null,\n \"description\"\
19741991
: \"The result of the tool.\",\n \"title\": \"Content\"\n \
1975-
\ }\n },\n \"title\": \"ToolResponseContentEntityOptional\"\
1976-
,\n \"type\": \"object\"\n }\n },\n \"description\": \"Filter\
1977-
\ model for TaskMessage - all fields optional for flexible filtering.\\\
1978-
n\\nThe `exclude` field determines whether this filter is inclusionary or\
1979-
\ exclusionary.\\nWhen multiple filters are provided:\\n- Inclusionary filters\
1980-
\ (exclude=False) are OR'd together\\n- Exclusionary filters (exclude=True)\
1981-
\ are OR'd together and negated with $nor\\n- The two groups are AND'd:\
1982-
\ (include1 OR include2) AND NOT (exclude1 OR exclude2)\",\n \"properties\"\
1983-
: {\n \"content\": {\n \"anyOf\": [\n {\n \"$ref\"\
1984-
: \"#/$defs/ToolRequestContentEntityOptional\"\n },\n {\n\
1985-
\ \"$ref\": \"#/$defs/DataContentEntityOptional\"\n },\n\
1986-
\ {\n \"$ref\": \"#/$defs/TextContentEntityOptional\"\n\
1987-
\ },\n {\n \"$ref\": \"#/$defs/ToolResponseContentEntityOptional\"\
1992+
\ },\n \"is_error\": {\n \"anyOf\": [\n {\n\
1993+
\ \"type\": \"boolean\"\n },\n {\n \
1994+
\ \"type\": \"null\"\n }\n ],\n \
1995+
\ \"default\": null,\n \"description\": \"Whether the tool call\
1996+
\ resulted in an error. `None` when the harness does not report a status.\"\
1997+
,\n \"title\": \"Is Error\"\n }\n },\n \"title\"\
1998+
: \"ToolResponseContentEntityOptional\",\n \"type\": \"object\"\n \
1999+
\ }\n },\n \"description\": \"Filter model for TaskMessage - all fields\
2000+
\ optional for flexible filtering.\\n\\nThe `exclude` field determines whether\
2001+
\ this filter is inclusionary or exclusionary.\\nWhen multiple filters are\
2002+
\ provided:\\n- Inclusionary filters (exclude=False) are OR'd together\\\
2003+
n- Exclusionary filters (exclude=True) are OR'd together and negated with\
2004+
\ $nor\\n- The two groups are AND'd: (include1 OR include2) AND NOT (exclude1\
2005+
\ OR exclude2)\",\n \"properties\": {\n \"content\": {\n \"anyOf\"\
2006+
: [\n {\n \"$ref\": \"#/$defs/ToolRequestContentEntityOptional\"\
2007+
\n },\n {\n \"$ref\": \"#/$defs/DataContentEntityOptional\"\
2008+
\n },\n {\n \"$ref\": \"#/$defs/TextContentEntityOptional\"\
2009+
\n },\n {\n \"$ref\": \"#/$defs/ToolResponseContentEntityOptional\"\
19882010
\n },\n {\n \"$ref\": \"#/$defs/ReasoningContentEntityOptional\"\
19892011
\n },\n {\n \"type\": \"null\"\n }\n \
19902012
\ ],\n \"default\": null,\n \"description\": \"Filter by message\
@@ -6571,6 +6593,13 @@ components:
65716593
content:
65726594
title: Content
65736595
description: The result of the tool.
6596+
is_error:
6597+
anyOf:
6598+
- type: boolean
6599+
- type: 'null'
6600+
title: Is Error
6601+
description: Whether the tool call resulted in an error. `None` when the
6602+
harness does not report a status.
65746603
type: object
65756604
required:
65766605
- author
@@ -6606,6 +6635,13 @@ components:
66066635
content:
66076636
title: Content
66086637
description: The result of the tool.
6638+
is_error:
6639+
anyOf:
6640+
- type: boolean
6641+
- type: 'null'
6642+
title: Is Error
6643+
description: Whether the tool call resulted in an error. `None` when the
6644+
harness does not report a status.
66096645
type: object
66106646
required:
66116647
- author

agentex/src/api/schemas/task_messages.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,10 @@ class ToolResponseContent(BaseTaskMessageContent):
108108
..., description="The name of the tool that is being responded to."
109109
)
110110
content: Any = Field(..., description="The result of the tool.")
111+
is_error: bool | None = Field(
112+
default=None,
113+
description="Whether the tool call resulted in an error. `None` when the harness does not report a status.",
114+
)
111115

112116

113117
class DataContent(BaseTaskMessageContent):

agentex/src/domain/entities/task_messages.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,10 @@ class ToolResponseContentEntity(BaseTaskMessageContentEntity):
125125
..., description="The name of the tool that is being responded to."
126126
)
127127
content: Any = Field(..., description="The result of the tool.")
128+
is_error: bool | None = Field(
129+
default=None,
130+
description="Whether the tool call resulted in an error. `None` when the harness does not report a status.",
131+
)
128132

129133

130134
OptionalToolResponseContentEntity = make_optional(ToolResponseContentEntity)
@@ -207,6 +211,7 @@ def convert_task_message_content_to_entity(
207211
tool_call_id=content.tool_call_id,
208212
name=content.name,
209213
content=content.content,
214+
is_error=content.is_error,
210215
)
211216

212217
assert_never(content)

agentex/src/domain/use_cases/streams_use_case.py

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -101,14 +101,15 @@ async def stream_task_events(
101101
task_id = task.id
102102

103103
stream_topic = get_task_event_stream_topic(task_id=task_id)
104+
# Snapshot the read cursor BEFORE yielding "connected". "connected" is
105+
# the client's cue to send its message, which makes the agent start
106+
# XADD-ing deltas. Snapshotting after the yield lets a congested relay
107+
# fall behind far enough that those deltas land before the snapshot and
108+
# are never read. Snapshotting first resolves to "0-0" (stream is empty
109+
# until the client sends), so we read from the beginning.
110+
last_id = await self.stream_repository.get_stream_tail_id(stream_topic)
104111
# Send initial connection data
105112
yield f"data: {TaskStreamConnectedEventEntity(type='connected', taskId=task_id).model_dump_json()}\n\n"
106-
# Snapshot the tail once on entry rather than passing "$" to every
107-
# XREAD. "$" re-resolves to the current tail on each call, so any
108-
# entry XADD'd in the gap between BLOCKing reads lands behind the
109-
# new "$" and is unreachable — silently dropping deltas from
110-
# fast-emitting agents.
111-
last_id = await self.stream_repository.get_stream_tail_id(stream_topic)
112113
last_message_time = asyncio.get_running_loop().time()
113114
ping_interval = float(
114115
self.environment_variables.SSE_KEEPALIVE_PING_INTERVAL

agentex/tests/integration/test_task_stream.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -563,6 +563,54 @@ async def reader():
563563
pass
564564
repo.read_messages = original_read_messages
565565

566+
async def test_event_xadded_after_connected_is_delivered(
567+
self, test_agent_and_task, streams_use_case
568+
):
569+
"""
570+
Regression for the SSE entry-gap drop bug: a delta XADDed right after
571+
"connected" must still be delivered.
572+
573+
We step the generator manually so the XADD lands while it's suspended
574+
at the "connected" yield. If the cursor is snapshotted after that yield
575+
the delta is lost; snapshotting before it (the fix) delivers it.
576+
"""
577+
from src.utils.stream_topics import get_task_event_stream_topic
578+
579+
_agent, task = test_agent_and_task
580+
stream_topic = get_task_event_stream_topic(task_id=task.id)
581+
repo = streams_use_case.stream_repository
582+
583+
gen = streams_use_case.stream_task_events(task_id=task.id)
584+
try:
585+
# First yielded event is "connected"; generator is now suspended here.
586+
first = await asyncio.wait_for(gen.__anext__(), timeout=5)
587+
assert "connected" in first, f"expected connected event first, got: {first}"
588+
589+
# Emit a delta while the generator is suspended at the yield.
590+
sentinel = "after-connected-sentinel"
591+
await repo.send_data(
592+
stream_topic, {"type": "error", "message": sentinel}
593+
)
594+
595+
# The delta must be delivered; a silent stream means it was dropped.
596+
received = False
597+
for _ in range(10):
598+
try:
599+
evt = await asyncio.wait_for(gen.__anext__(), timeout=4)
600+
except (TimeoutError, asyncio.TimeoutError):
601+
break # stream went silent — sentinel was dropped
602+
if evt.startswith("data: ") and sentinel in evt:
603+
received = True
604+
break
605+
606+
assert received, (
607+
"A delta XADDed after 'connected' was not delivered. The read "
608+
"cursor was snapshotted after signaling connected, so the entry "
609+
"landed behind the tail and was lost (the SSE entry-gap bug)."
610+
)
611+
finally:
612+
await gen.aclose()
613+
566614
async def test_stream_sends_keepalive_pings_during_idle_periods(
567615
self, test_agent_and_task, streams_use_case
568616
):

0 commit comments

Comments
 (0)