Skip to content
Open
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
c69f8ca
fix(api): protocol command 支持透传 stream_modes / stream_subgraphs
LiangMuYuan Aug 5, 2026
e0959c1
fix(streaming): 默认改用官方 astream 输出,修复 Send 并行 updates 重复 (#48)
LiangMuYuan Aug 5, 2026
85305c8
fix(streaming): updates 内多条无 id 消息分配递增 message_index,避免协议消息 id 冲突
LiangMuYuan Aug 5, 2026
53d44d3
test(run_executor): 适配官方 astream 执行路径,覆盖 updates 不重复与协议事件
LiangMuYuan Aug 5, 2026
a430afb
fix(streaming): 通过原生 tools stream mode 恢复 tool 事件,tool_name 按 tool_ca…
LiangMuYuan Aug 5, 2026
d1a9a2c
fix(api): 默认 /runs/{id}/stream 重放线程级 protocol 事件(含 tool/message),end 收尾
LiangMuYuan Aug 5, 2026
9d05e61
test(streaming): 集成测试适配 protocol 事件(tools 通道/重放/子图 namespace)
LiangMuYuan Aug 5, 2026
be61bdd
fix(api): 默认重放线程事件带 seq 并按 after_seq 过滤,支持 Last-Event-ID 续传
LiangMuYuan Aug 5, 2026
a05dfab
test(e2e): live provider 流式断言适配(从 values 快照取最终 ai 文本)
LiangMuYuan Aug 5, 2026
cd0261b
fix(streaming): 中断改写为 values.__interrupt__ 事件并去重(对齐官方)
LiangMuYuan Aug 5, 2026
d8c6a78
test(e2e): live provider 适配(store PUT 204 断言 + hitl 落点到 /stream/events)
LiangMuYuan Aug 6, 2026
df80fbf
test(e2e): live provider 实时流 messages/partial 增量累积验证
LiangMuYuan Aug 6, 2026
f1802d5
test(run_executor): 补 events 模式 / subgraphs 三元组 / 中断合并覆盖,覆盖率回到 90%
LiangMuYuan Aug 6, 2026
d42138c
fix(ci): verify_docker_api 适配重放端点协议事件格式(tool-started/end.get)
LiangMuYuan Aug 6, 2026
9b53faf
fix(api): default run stream uses single monotonic SSE cursor for Las…
LiangMuYuan Aug 8, 2026
244e3a0
fix(streaming): publish protocol events into run stream for single mo…
LiangMuYuan Aug 10, 2026
32e744a
test(live-provider): align default-stream assertions with protocol re…
LiangMuYuan Aug 10, 2026
552d3ad
fix(api): return 400 for invalid stream modes; raise langgraph min to…
LiangMuYuan Aug 10, 2026
d6e7851
fix(streaming): publish raw astream_events items onto the events channel
LiangMuYuan Aug 10, 2026
18a0998
fix(streaming): normalize tuple subgraph namespaces to list for live …
LiangMuYuan Aug 10, 2026
90d0ed1
test(streaming): add HTTP-level regression for events mode raw astrea…
LiangMuYuan Aug 11, 2026
e62c8ec
test(streaming): add HTTP-level mid-run reconnect regressions for inl…
LiangMuYuan Aug 11, 2026
4087fca
test(api): protocol run.start invalid stream_mode returns 400
LiangMuYuan Aug 11, 2026
0298d79
test(ci): assert run-stream mid-run reconnect exactly-once in docker …
LiangMuYuan Aug 11, 2026
c08fe10
test(protocol): guard live namespace filter against tuple subgraph na…
LiangMuYuan Aug 11, 2026
0cb21d6
fix(streaming): keep run-stream seq monotonic across resume and cold …
LiangMuYuan Aug 11, 2026
d9b0f38
test(live-provider): restore token-level messages/partial assertion i…
LiangMuYuan Aug 11, 2026
9bd73bc
fix(streaming): keep thread-level protocol seq monotonic across cold …
LiangMuYuan Aug 12, 2026
e8a11e5
fix(streaming): atomic durable append for run/thread stream seq; defa…
LiangMuYuan Aug 12, 2026
4311a86
fix(streaming): derive id-less message identity from subgraph namespa…
LiangMuYuan Aug 12, 2026
a389d04
test(live-provider): assert messages/metadata identity aligns with st…
LiangMuYuan Aug 12, 2026
582e7b1
docs(AGENTS): align live-provider proof target with official messages…
LiangMuYuan Aug 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 20 additions & 6 deletions scripts/verify_docker_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -234,8 +234,13 @@ def _assert_common_flow(base_url: str) -> None:
assert isinstance(stream_body, str)
assert "text/event-stream" in stream_content_type
payloads = _stream_payloads(stream_body)
assert any(payload["event"] == "start" for payload in payloads)
assert any(payload["event"] == "end" and payload.get("status") == "success" for payload in payloads)
assert any(isinstance(payload, dict) and payload.get("event") == "start" for payload in payloads)
assert any(
isinstance(payload, dict)
and payload.get("event") == "end"
and payload.get("status") == "success"
for payload in payloads
)

_, stateless_run, _ = _request(
base_url=base_url,
Expand Down Expand Up @@ -326,7 +331,7 @@ def _assert_common_flow(base_url: str) -> None:
end_statuses = {
payload["status"]
for payload in _stream_payloads(resumed_stream)
if payload["event"] == "end"
if isinstance(payload, dict) and payload.get("event") == "end"
}
assert "success" in end_statuses
stress_waited, _ = _assert_sample_run(
Expand Down Expand Up @@ -358,7 +363,12 @@ def _assert_common_flow(base_url: str) -> None:
react_output = react_waited["output"]
assert isinstance(react_output, dict)
assert "42" in str(react_output["final_text"])
assert any(payload["event"] == "tool_start" and payload["name"] == "lookup" for payload in react_payloads)
assert any(
isinstance(payload, dict)
and payload.get("event") == "tool-started"
and payload.get("tool_name") == "lookup"
for payload in react_payloads
)

stress_tool_waited, stress_tool_payloads = _assert_sample_run(
base_url=base_url,
Expand All @@ -372,7 +382,11 @@ def _assert_common_flow(base_url: str) -> None:
tool_messages = [message for message in stress_tool_output["transcript"] if message["type"] == "ToolMessage"]
assert len(tool_messages) == 3
tool_starts = [
payload for payload in stress_tool_payloads if payload["event"] == "tool_start" and payload["name"] == "slow_process"
payload
for payload in stress_tool_payloads
if isinstance(payload, dict)
and payload.get("event") == "tool-started"
and payload.get("tool_name") == "slow_process"
]
assert len(tool_starts) == 3

Expand Down Expand Up @@ -501,7 +515,7 @@ def _assert_resume_check(base_url: str, *, thread_id: str, run_id: str, resume:
end_statuses = [
payload["status"]
for payload in _stream_payloads(run_stream)
if payload["event"] == "end"
if isinstance(payload, dict) and payload.get("event") == "end"
]
assert "interrupted" in end_statuses
assert "success" in end_statuses
Expand Down
77 changes: 73 additions & 4 deletions src/agentseek_api/api/runs.py
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,14 @@ def _protocol_channels_for_stream_modes(stream_modes: list[str]) -> list[str]:
return channels


# Channels replayed by the legacy GET /threads/{id}/runs/{run_id}/stream when no
# ``stream_mode`` query is given. The astream migration no longer publishes
# run-scoped stream events, so the run's protocol-v2 thread events (values /
# updates / messages / tools / custom) are replayed instead, alongside the
# run-scoped lifecycle records (start/end) still published by run_jobs.
DEFAULT_RUN_STREAM_REPLAY_CHANNELS = ["values", "updates", "messages", "tools", "custom", "input"]


async def _iter_persisted_protocol_run_events(
*,
thread_id: str,
Expand Down Expand Up @@ -400,6 +408,7 @@ def _is_block_message_event(event: dict[str, Any]) -> bool:
async def _event_iter() -> AsyncIterator[str]:
try:
current_seq = after_seq
saw_interrupt = False
if include_metadata:
yield _protocol_event_sse(event_name="metadata", data={"run_id": created.run_id, "attempt": 1})

Expand All @@ -417,10 +426,13 @@ async def _event_iter() -> AsyncIterator[str]:
continue
if suppress_block_messages and _is_block_message_event(event):
continue
event_data = event.get("params", {}).get("data", {})
if isinstance(event_data, dict) and "__interrupt__" in event_data:
saw_interrupt = True
yield _protocol_event_sse(
seq=current_seq,
event_name=str(event.get("method", "message")),
data=event.get("params", {}).get("data", {}),
data=event_data,
)

if _uses_redis_executor():
Expand All @@ -438,10 +450,13 @@ async def _event_iter() -> AsyncIterator[str]:
current_seq = max(current_seq, int(event.get("seq", 0)))
if suppress_block_messages and _is_block_message_event(event):
continue
event_data = event.get("params", {}).get("data", {})
if isinstance(event_data, dict) and "__interrupt__" in event_data:
saw_interrupt = True
yield _protocol_event_sse(
seq=current_seq,
event_name=str(event.get("method", "message")),
data=event.get("params", {}).get("data", {}),
data=event_data,
)
else:
async for event in iter_with_sse_keepalives(
Expand All @@ -462,10 +477,13 @@ async def _event_iter() -> AsyncIterator[str]:
current_seq = max(current_seq, int(event.get("seq", 0)))
if suppress_block_messages and _is_block_message_event(event):
continue
event_data = event.get("params", {}).get("data", {})
if isinstance(event_data, dict) and "__interrupt__" in event_data:
saw_interrupt = True
yield _protocol_event_sse(
seq=current_seq,
event_name=str(event.get("method", "message")),
data=event.get("params", {}).get("data", {}),
data=event_data,
)

final_run = (
Expand All @@ -482,7 +500,15 @@ async def _event_iter() -> AsyncIterator[str]:
)
return
interrupt_event = _interrupt_stream_event_name(stream_modes)
if final_run.status == "interrupted" and final_run.interrupts and interrupt_event is not None:
if (
final_run.status == "interrupted"
and final_run.interrupts
and interrupt_event is not None
# The interrupt is delivered in-stream (values/updates carrying
# ``__interrupt__``); only emit a trailing event when this
# connection never saw it.
and not saw_interrupt
):
current_seq += 1
yield _protocol_event_sse(
seq=current_seq,
Expand Down Expand Up @@ -894,8 +920,51 @@ async def _event_iter() -> AsyncIterator[str]:
seq: payload for seq, payload in await load_run_stream_events(run_id, after_seq=after_seq)
}
records_by_seq.update({seq: payload for seq, payload in run_broker.snapshot_records(run_id, after_seq=after_seq)})
# Run-scoped lifecycle records (start/end) from run_jobs. The terminal
# "end" record is deferred until after the protocol thread events so
# the stream ends with the run's terminal status.
end_records: dict[int, dict[str, object]] = {
seq: event
for seq, event in records_by_seq.items()
if str(event.get("event")) == "end"
}
for seq in sorted(records_by_seq):
event = records_by_seq[seq]
if str(event.get("event")) == "end":
continue
current_seq = max(current_seq, seq)
event_name = str(event.get("event", "message"))
event_payload: dict[str, object] = {"run_id": run_id, **event}
payload = safe_json_dumps(event_payload)
yield f"id: {seq}\nevent: {event_name}\ndata: {payload}\n\n"

# Replay the run's protocol-v2 thread events so the default endpoint
# still returns the full stream (run-scoped stream events are no longer
# published by the astream migration).
try:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Tail protocol events after the initial snapshot

This is the only read of values/updates/messages/tools. After it completes, both executor branches tail only run lifecycle records, even though the astream migration stopped publishing translated run-scoped protocol frames. Connecting while a run is active therefore returns start/end while omitting frames that are persisted later; I reproduced those missing values appearing only on a post-terminal request. Please persist or merge protocol and lifecycle frames into one run-scoped ordered log, tail it live for inline and Redis, and commit end only after earlier frames.

thread_events = await load_thread_stream_events(
thread_id,
channels=DEFAULT_RUN_STREAM_REPLAY_CHANNELS,
namespaces=None,
depth=None,
after_seq=0,
)
except Exception: # noqa: BLE001 - replay is best-effort
thread_events = []
for event in thread_events:
if event.get("params", {}).get("run_id") != run_id:
continue
event_seq = int(event.get("seq", 0) or 0)
if event_seq <= after_seq:
continue
yield _protocol_event_sse(
Comment thread
LiangMuYuan marked this conversation as resolved.
Outdated
seq=event_seq,
event_name=str(event.get("method", "message")),
data=event.get("params", {}).get("data", {}),
)

for seq in sorted(end_records):
event = end_records[seq]
current_seq = max(current_seq, seq)
event_name = str(event.get("event", "message"))
event_payload: dict[str, object] = {"run_id": run_id, **event}
Expand Down
8 changes: 8 additions & 0 deletions src/agentseek_api/api/streaming.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from agentseek_api.core.orm import Run, Thread
from agentseek_api.models.auth import User
from agentseek_api.models.protocol import ProtocolCommandRequest, ProtocolEventStreamRequest
from agentseek_api.services.stream_modes import normalize_stream_modes
from agentseek_api.services.run_preparation import (
ActiveThreadRunConflictError,
prepare_and_submit_run,
Expand Down Expand Up @@ -134,11 +135,18 @@ async def handle_protocol_command(
)

try:
run_kwargs: dict[str, Any] | None = None
if payload.params.get("stream_mode") is not None:
run_kwargs = {"stream_modes": normalize_stream_modes(payload.params.get("stream_mode"))}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Return 400 for invalid stream modes

normalize_stream_modes() raises ValueError, but the shared handler below maps every ValueError to 404 for missing resources. I reproduced an invalid run.start stream mode returning invalid_argument with HTTP 404. Validate stream controls separately and return 400; reserve 404 for unknown assistants or graphs.

if payload.params.get("stream_subgraphs"):
run_kwargs = run_kwargs or {}
run_kwargs["stream_subgraphs"] = True
run = await prepare_and_submit_run(
thread_id=thread_id,
assistant_id=assistant_id,
payload=_coerce_protocol_input(payload.params.get("input")),
user=user,
kwargs=run_kwargs,
)
except ValueError as exc:
return _protocol_error(request_id=payload.id, code="invalid_argument", message=str(exc), status_code=404)
Expand Down
Loading
Loading