Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
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
6 changes: 3 additions & 3 deletions fasta2a/__init__.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from .applications import FastA2A
from .broker import Broker
from .schema import Skill
from .storage import Storage
from .schema import Skill, StreamEvent
from .storage import Storage, StreamingStorageWrapper
from .worker import Worker

__all__ = ['FastA2A', 'Skill', 'Storage', 'Broker', 'Worker']
__all__ = ['FastA2A', 'Skill', 'Storage', 'StreamingStorageWrapper', 'Broker', 'Worker', 'StreamEvent']
27 changes: 26 additions & 1 deletion fasta2a/applications.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
from __future__ import annotations as _annotations

import json
from collections.abc import AsyncIterator, Sequence
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Any

from sse_starlette import EventSourceResponse
from starlette.applications import Starlette
from starlette.middleware import Middleware
from starlette.requests import Request
Expand All @@ -21,6 +23,8 @@
a2a_request_ta,
a2a_response_ta,
agent_card_ta,
stream_event_ta,
stream_message_request_ta,
)
from .storage import Storage
from .task_manager import TaskManager
Expand All @@ -42,6 +46,7 @@ def __init__(
provider: AgentProvider | None = None,
skills: list[Skill] | None = None,
docs_url: str | None = '/docs',
streaming: bool = False,

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.

Do you want it to be False by default?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

set to True in 23907cf

# Starlette
debug: bool = False,
routes: Sequence[Route] | None = None,
Expand All @@ -67,6 +72,7 @@ def __init__(
self.provider = provider
self.skills = skills or []
self.docs_url = docs_url
self.streaming = streaming
# NOTE: For now, I don't think there's any reason to support any other input/output modes.
self.default_input_modes = ['application/json']
self.default_output_modes = ['application/json']
Expand Down Expand Up @@ -100,7 +106,7 @@ async def _agent_card_endpoint(self, request: Request) -> Response:
default_input_modes=self.default_input_modes,
default_output_modes=self.default_output_modes,
capabilities=AgentCapabilities(
streaming=False, push_notifications=False, state_transition_history=False
streaming=self.streaming, push_notifications=False, state_transition_history=False
),
)
if self.provider is not None:
Expand Down Expand Up @@ -131,6 +137,25 @@ async def _agent_run_endpoint(self, request: Request) -> Response:

if a2a_request['method'] == 'message/send':
jsonrpc_response = await self.task_manager.send_message(a2a_request)
elif a2a_request['method'] == 'message/stream':
# Parse the streaming request

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.

Suggested change
# Parse the streaming request

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

fixed in 33ee9c5

stream_request = stream_message_request_ta.validate_json(data)

# Create an async generator wrapper that formats events as JSON-RPC responses

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.

There's no need for a comment explaining each line.

Suggested change
# Create an async generator wrapper that formats events as JSON-RPC responses

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

fixed in 33ee9c5

async def sse_generator():
request_id = stream_request.get('id')
async for event in self.task_manager.stream_message(stream_request):
# Serialize event to ensure proper camelCase conversion
event_dict = stream_event_ta.dump_python(event, mode='json', by_alias=True)

# Wrap in JSON-RPC response
jsonrpc_response = {'jsonrpc': '2.0', 'id': request_id, 'result': event_dict}

# Convert to JSON string
yield json.dumps(jsonrpc_response)

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.

Do we need to do this serialization and then deserialization? Seems a bit weird. I'll check.

But for sure we are not going to use json.dumps. There's a function in pydantic_core for it.

@echarles echarles Mar 7, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

In d16abca

The 3-step serialize pattern in the SSE generator:

  1. stream_event_ta.dump_python→ intermediate Python dict
  2. Wrap in a plain dict {'jsonrpc': '2.0', ...}
  3. json.dumps(...) → JSON string

is replaced by a single call . This skips the intermediate dict materialization and uses pydantic_core' JSON serializer instead of json.dumps. The StreamMessageResponse TypeAdapter handles camelCase aliasing for the entire envelope + nested event in one pass.


# Return SSE response
Comment thread
echarles marked this conversation as resolved.
Outdated
return EventSourceResponse(sse_generator())
elif a2a_request['method'] == 'tasks/get':
jsonrpc_response = await self.task_manager.get_task(a2a_request)
elif a2a_request['method'] == 'tasks/cancel':
Expand Down
95 changes: 92 additions & 3 deletions fasta2a/broker.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,12 @@
from typing import Annotated, Any, Generic, Literal, TypeVar

import anyio
from anyio.streams.memory import MemoryObjectSendStream
from opentelemetry.trace import Span, get_current_span, get_tracer
from pydantic import Discriminator
from typing_extensions import Self, TypedDict

from .schema import TaskIdParams, TaskSendParams
from .schema import StreamEvent, TaskIdParams, TaskSendParams

tracer = get_tracer(__name__)

Expand All @@ -30,12 +31,32 @@ class Broker(ABC):
@abstractmethod
async def run_task(self, params: TaskSendParams) -> None:
"""Send a task to be executed by the worker."""
raise NotImplementedError('send_run_task is not implemented yet.')
...

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.

Why did you replace this?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

reverted in 1709673


@abstractmethod
async def cancel_task(self, params: TaskIdParams) -> None:
"""Cancel a task."""
raise NotImplementedError('send_cancel_task is not implemented yet.')
...

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.

Same question.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

reverted in 1709673


@abstractmethod
async def send_stream_event(self, task_id: str, event: StreamEvent) -> None:
"""Send a streaming event from worker to subscribers.

This is used by workers to publish status updates, messages, and artifacts
during task execution. Events are forwarded to all active subscribers of
the given task_id.
"""
...

@abstractmethod
def subscribe_to_stream(self, task_id: str) -> AsyncIterator[StreamEvent]:
"""Subscribe to streaming events for a specific task.

Returns an async iterator that yields events published by workers for the
given task_id. The iterator completes when a TaskStatusUpdateEvent with
final=True is received or the subscription is cancelled.
"""
...

@abstractmethod
async def __aenter__(self) -> Self: ...
Expand Down Expand Up @@ -73,6 +94,10 @@ class _TaskOperation(TypedDict, Generic[OperationT, ParamsT]):
class InMemoryBroker(Broker):
"""A broker that schedules tasks in memory."""

def __init__(self) -> None:
self._event_subscribers: dict[str, list[MemoryObjectSendStream[StreamEvent]]] = {}
self._subscriber_lock: anyio.Lock | None = None

async def __aenter__(self):
self.aexit_stack = AsyncExitStack()
await self.aexit_stack.__aenter__()
Expand All @@ -81,6 +106,8 @@ async def __aenter__(self):
await self.aexit_stack.enter_async_context(self._read_stream)
await self.aexit_stack.enter_async_context(self._write_stream)

self._subscriber_lock = anyio.Lock()

return self

async def __aexit__(self, exc_type: Any, exc_value: Any, traceback: Any):
Expand All @@ -96,3 +123,65 @@ async def receive_task_operations(self) -> AsyncIterator[TaskOperation]:
"""Receive task operations from the broker."""
async for task_operation in self._read_stream:
yield task_operation

async def send_stream_event(self, task_id: str, event: StreamEvent) -> None:
"""Send a streaming event from worker to subscribers."""
assert self._subscriber_lock is not None, 'Broker not initialized'

async with self._subscriber_lock:
subscribers = self._event_subscribers.get(task_id, [])
if not subscribers:
return

# Send event to all subscribers, removing closed streams
active_subscribers: list[MemoryObjectSendStream[StreamEvent]] = []
for stream in subscribers:
try:
await stream.send(event)
active_subscribers.append(stream)
except (anyio.ClosedResourceError, anyio.BrokenResourceError):
# Subscriber disconnected, remove from list
pass

# Update subscriber list with only active ones
if active_subscribers:
self._event_subscribers[task_id] = active_subscribers
elif task_id in self._event_subscribers:
# No active subscribers left, clean up
del self._event_subscribers[task_id]
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
Outdated

async def subscribe_to_stream(self, task_id: str) -> AsyncIterator[StreamEvent]:
"""Subscribe to streaming events for a specific task."""
assert self._subscriber_lock is not None, 'Broker not initialized'

# Create a new stream for this subscriber
send_stream, receive_stream = anyio.create_memory_object_stream[StreamEvent](max_buffer_size=100)

# Register the subscriber
async with self._subscriber_lock:
if task_id not in self._event_subscribers:
self._event_subscribers[task_id] = []
self._event_subscribers[task_id].append(send_stream)

try:
async with receive_stream:
async for event in receive_stream:
yield event

# Check if this is a final status update
if isinstance(event, dict) and event.get('kind') == 'status-update' and event.get('final', False):
break
finally:
# Clean up subscription on exit
async with self._subscriber_lock:
if task_id in self._event_subscribers:
try:
self._event_subscribers[task_id].remove(send_stream)
if not self._event_subscribers[task_id]:
del self._event_subscribers[task_id]
except ValueError:
# Already removed
pass

# Close the send stream
await send_stream.aclose()
6 changes: 6 additions & 0 deletions fasta2a/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -808,3 +808,9 @@ class JSONRPCResponse(JSONRPCMessage, Generic[ResultT, ErrorT]):
send_message_response_ta: TypeAdapter[SendMessageResponse] = TypeAdapter(SendMessageResponse)
stream_message_request_ta: TypeAdapter[StreamMessageRequest] = TypeAdapter(StreamMessageRequest)
stream_message_response_ta: TypeAdapter[StreamMessageResponse] = TypeAdapter(StreamMessageResponse)

# Type for streaming events (used by broker and task manager)
StreamEvent = Union[Task, Message, TaskStatusUpdateEvent, TaskArtifactUpdateEvent]
"""A streaming event that can be sent during message/stream requests."""

stream_event_ta: TypeAdapter[StreamEvent] = TypeAdapter(StreamEvent)
Comment on lines +997 to +1000

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🚩 StreamEvent type alias may not be useful without discriminator

The new StreamEvent = Union[Task, Message, TaskStatusUpdateEvent, TaskArtifactUpdateEvent] at fasta2a/schema.py:997 and its TypeAdapter are exported publicly but never used internally. These TypedDicts share overlapping field names (e.g., task_id, context_id) which could make Pydantic's union discrimination unreliable without an explicit Discriminator. This may cause unexpected validation behavior when deserializing ambiguous payloads. Worth verifying the intended use case for this type.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

94 changes: 92 additions & 2 deletions fasta2a/storage.py

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🚩 InMemoryStorage.load_task mutates the stored task's history in place

Pre-existing issue: InMemoryStorage.load_task at fasta2a/storage.py:85-86 does task['history'] = task['history'][-history_length:], which mutates the stored task dict in place, permanently truncating the history. This means subsequent calls to load_task without history_length will still see the truncated history. This is a pre-existing bug, not introduced by this PR, but relevant context since streaming relies on storage state.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,22 @@
import uuid
from abc import ABC, abstractmethod
from datetime import datetime
from typing import Any, Generic
from typing import TYPE_CHECKING, Any, Generic

from typing_extensions import TypeVar

from .schema import Artifact, Message, Task, TaskState, TaskStatus
from .schema import (
Artifact,
Message,
Task,
TaskArtifactUpdateEvent,
TaskState,
TaskStatus,
TaskStatusUpdateEvent,
)

if TYPE_CHECKING:
from .broker import Broker

ContextT = TypeVar('ContextT', default=Any)

Expand Down Expand Up @@ -129,3 +140,82 @@ async def update_context(self, context_id: str, context: ContextT) -> None:
async def load_context(self, context_id: str) -> ContextT | None:
"""Retrieve the stored context given the `context_id`."""
return self.contexts.get(context_id)


class StreamingStorageWrapper(Storage[ContextT]):

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.

Why do we need this instead of using Storage?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Storage is used in 2db81b8

"""A storage wrapper that publishes streaming events when tasks are updated.

This wrapper intercepts update_task calls and publishes TaskStatusUpdateEvent
and TaskArtifactUpdateEvent to the broker, enabling SSE streaming without
modifying the underlying storage or worker implementations.
"""

def __init__(self, storage: Storage[ContextT], broker: Broker):
self._storage = storage
self._broker = broker

async def load_task(self, task_id: str, history_length: int | None = None) -> Task | None:
return await self._storage.load_task(task_id, history_length)

async def submit_task(self, context_id: str, message: Message) -> Task:
return await self._storage.submit_task(context_id, message)

async def update_task(
self,
task_id: str,
state: TaskState,
new_artifacts: list[Artifact] | None = None,
new_messages: list[Message] | None = None,
) -> Task:
"""Update task and publish streaming events."""
# Update the underlying storage first
task = await self._storage.update_task(task_id, state, new_artifacts, new_messages)

# Determine if this is a final state
final = state in ('completed', 'failed', 'canceled')

@devin-ai-integration devin-ai-integration Bot Mar 7, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🚩 TaskState completeness: 'rejected', 'auth-required', 'unknown' and 'input-required' not treated as final

In fasta2a/worker.py:74, final is determined by state in ('completed', 'failed', 'canceled'). Looking at the TaskState type alias at fasta2a/schema.py:436-438, there are additional terminal-like states: 'rejected', 'auth-required', and 'unknown'. If a worker implementation ever sets the state to 'rejected', no final event would be sent to stream subscribers, causing them to hang. The current EchoWorker in tests only uses 'working', 'completed', and 'canceled', so this isn't triggered now, but could be a problem for future worker implementations.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

@devin-ai-integration devin-ai-integration Bot Mar 7, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 Worker.update_task missing 'rejected' from final states causes stream to never close

The Worker.update_task method at fasta2a/worker.py:88 defines final states as ('completed', 'failed', 'canceled'), but the TaskState type at fasta2a/schema.py:502 includes 'rejected' as a valid terminal state. The TaskManager.resubscribe_task at fasta2a/task_manager.py:218 correctly treats 'rejected' as terminal: terminal_states = {'completed', 'canceled', 'failed', 'rejected'}. If a worker calls self.update_task(task_id, 'rejected'), the final flag will be False, so the method will emit a non-final status update (lines 91-101) but will skip the final status emit and event_bus.close() call (lines 122-134). This means SSE subscribers will hang indefinitely waiting for more events that will never arrive.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.


# For non-final updates, publish status first
if not final:
status_event = TaskStatusUpdateEvent(
kind='status-update',
task_id=task_id,
context_id=task['context_id'],
status=task['status'],
final=False,
)
await self._broker.send_stream_event(task_id, status_event)

# Publish message events BEFORE final status (so subscriber receives them)
if new_messages:
for message in new_messages:
await self._broker.send_stream_event(task_id, message)

# Publish artifact events
if new_artifacts:
for artifact in new_artifacts:
artifact_event = TaskArtifactUpdateEvent(
kind='artifact-update',
task_id=task_id,
context_id=task['context_id'],
artifact=artifact,
)
await self._broker.send_stream_event(task_id, artifact_event)

# For final updates, publish status LAST (after messages and artifacts)
if final:
status_event = TaskStatusUpdateEvent(
kind='status-update',
task_id=task_id,
context_id=task['context_id'],
status=task['status'],
final=True,
)
await self._broker.send_stream_event(task_id, status_event)

return task

async def load_context(self, context_id: str) -> ContextT | None:
return await self._storage.load_context(context_id)

async def update_context(self, context_id: str, context: ContextT) -> None:
await self._storage.update_context(context_id, context)
Loading
Loading