Raymond Lin
04/23/2026, 5:35 PMMarvin
04/23/2026, 5:35 PMMarvin
04/23/2026, 5:39 PMPrefectAgent appears to mis-handle tool timeouts when invoked outside a Prefect flow context.
What I found:
* PrefectAgent and related classes are coming from pydantic_ai.durable_exec.prefect
* The API does support tool_task_config / per-tool TaskConfig
* I did not find an existing Prefect GitHub issue or discussion matching this exact behavior
* The likely failure mode is that timeout enforcement depends on Prefect task execution context, and when you call the agent “bare” instead of from within a @flow, the timeout path is still activated but the surrounding runtime/context isn’t fully established
So the symptom you described:
* tool has timeout != None
* agent run is outside a flow/deployment
* timeout error is raised immediately
* removing timeout makes it work
…is consistent with a context-sensitive bug/limitation in the integration.
What I can say confidently:
* Running the agent inside a Prefect flow is the intended usage pattern for this integration
* Your workaround of removing timeout is valid
* This does not look like an actual tool-duration timeout if it fires instantaneously
Recommended workaround:
from prefect import flow
@flow
async def run_agent():
result = await agent.run("...")
return result
Then call the flow locally or deploy it.
If you need to run it outside a flow for local/dev usage, the safest options are:
* omit Prefect tool timeouts
* enforce timeout inside the tool implementation yourself
* or wrap the call in a lightweight @flow even for local execution
A couple relevant references:
* Prefect docs home: Prefect docs
* Example showing Prefect + PydanticAI usage in flow-oriented style: AI data analyst with PydanticAI
If you want, I can help with one of these next steps:
1. draft a minimal reproducible example for a GitHub issue
2. help confirm whether this is in pydantic-ai vs Prefect integration code
3. suggest a pattern for adding non-Prefect timeouts when running the agent outside flowsRaymond Lin
04/23/2026, 5:55 PMMarvin
04/23/2026, 5:55 PMNate
04/23/2026, 5:56 PMMarvin
04/23/2026, 5:57 PM@flow, you can use event_stream_handler.
A few key details:
* PrefectAgent accepts event_stream_handler directly
* Inside a flow, Prefect detects flow context and wraps event handling so it can run within Prefect’s task model
* So you can still consume/log the stream while using agent.run(...) inside the flow
Example shape:
python
from prefect import flow, get_run_logger
from pydantic_ai import Agent
from pydantic_ai.durable_exec.prefect import PrefectAgent
async def handle_events(ctx, stream):
logger = get_run_logger()
async for event in stream:
<http://logger.info|logger.info>(f"event: {type(event).__name__}")
base_agent = Agent("openai:gpt-4o")
agent = PrefectAgent(
base_agent,
event_stream_handler=handle_events,
)
@flow
async def run_agent():
result = await agent.run("hello")
return result.output
A few caveats:
* Inside flows, use agent.run(...) with event_stream_handler
* Don’t expect run_stream() / run_stream_events() to be the right pattern inside a Prefect flow
* The handler is best for side effects: logging, writing events somewhere, emitting metrics, buffering to a queue, etc.
* If you want to return streamed data to your caller, you’ll usually need to collect it yourself in some external structure/store as the handler runs
Relevant docs/example:
* AI data analyst with PydanticAI
If you want, I can also show:
* how to capture the streamed events into a list and return them from the flow, or
* how to wire the handler to a websocket / SSE endpoint for UI streaming.Nate
04/23/2026, 6:54 PMRaymond Lin
04/23/2026, 8:39 PMRaymond Lin
04/23/2026, 8:39 PMMarvin
04/23/2026, 8:39 PMMarvin
04/23/2026, 8:40 PMevent_stream_handler push agent events into that channel
3. run the @flow in the background
4. have your WebSocket or SSE endpoint read from the channel and forward events to the UI
Below is a minimal FastAPI example using an in-memory asyncio.Queue.
Important caveat: this works best when the flow is being run in-process with your API server. If you deploy the flow to a remote worker, your API process won’t share memory with the flow run, so you’d want Redis, Postgres, Kafka, etc. instead of an in-memory queue.
WebSocket example
python
import asyncio
import json
import uuid
from collections.abc import AsyncIterable
from typing import Any
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from prefect import flow, get_run_logger
from pydantic_ai import Agent, RunContext
from pydantic_ai.durable_exec.prefect import PrefectAgent
from pydantic_ai.messages import AgentStreamEvent
app = FastAPI()
streams: dict[str, asyncio.Queue] = {}
def serialize_event(event: AgentStreamEvent) -> dict[str, Any]:
if hasattr(event, "model_dump"):
payload = event.model_dump(mode="json")
else:
payload = {"repr": repr(event)}
return {
"type": type(event).__name__,
"payload": payload,
}
def build_agent(stream_id: str) -> PrefectAgent:
async def handler(
ctx: RunContext[Any],
stream: AsyncIterable[AgentStreamEvent],
) -> None:
logger = get_run_logger()
queue = streams[stream_id]
async for event in stream:
message = serialize_event(event)
<http://logger.info|logger.info>(f"Streaming event {message['type']}")
await queue.put({
"kind": "event",
"data": message,
})
base_agent = Agent(
"openai:gpt-4o",
system_prompt="You are a helpful assistant.",
)
return PrefectAgent(
base_agent,
event_stream_handler=handler,
)
@flow
async def run_agent_flow(prompt: str, stream_id: str) -> str:
queue = streams[stream_id]
agent = build_agent(stream_id)
try:
result = await agent.run(prompt)
await queue.put({
"kind": "result",
"data": {
"output": result.output,
},
})
return result.output
except Exception as exc:
await queue.put({
"kind": "error",
"data": {
"message": str(exc),
},
})
raise
finally:
await queue.put({
"kind": "done",
"data": {},
})
@app.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket):
await websocket.accept()
stream_id = str(uuid.uuid4())
queue: asyncio.Queue = asyncio.Queue()
streams[stream_id] = queue
flow_task = None
try:
init_msg = await websocket.receive_json()
prompt = init_msg["prompt"]
await websocket.send_json({
"kind": "session",
"data": {
"stream_id": stream_id,
},
})
flow_task = asyncio.create_task(run_agent_flow(prompt, stream_id))
while True:
item = await queue.get()
await websocket.send_json(item)
if item["kind"] == "done":
break
await flow_task
except WebSocketDisconnect:
if flow_task:
flow_task.cancel()
except Exception as exc:
await websocket.send_json({
"kind": "error",
"data": {
"message": str(exc),
},
})
finally:
streams.pop(stream_id, None)
await websocket.close()
Frontend messages look like:
python
{
"kind": "event",
"data": {
"type": "PartDeltaEvent",
"payload": {...}
}
}
and then finally:
```python
{
"kind": "result",
"data": {Marvin
04/23/2026, 8:40 PM*How the UI uses it*
* connect to `/ws`
* send `{ "prompt": "..." }`
* render `event` messages incrementally
* stop when `done` arrives
*SSE example*
If you prefer Server-Sent Events:
```python
import asyncio
import json
import uuid
from collections.abc import AsyncIterable, AsyncGenerator
from typing import Any
from fastapi import FastAPI
from fastapi.responses import StreamingResponse
from prefect import flow, get_run_logger
from pydantic_ai import Agent, RunContext
from pydantic_ai.durable_exec.prefect import PrefectAgent
from pydantic_ai.messages import AgentStreamEvent
app = FastAPI()
streams: dict[str, asyncio.Queue] = {}
def serialize_event(event: AgentStreamEvent) -> dict[str, Any]:
if hasattr(event, "model_dump"):
payload = event.model_dump(mode="json")
else:
payload = {"repr": repr(event)}
return {
"type": type(event).__name__,
"payload": payload,
}
def build_agent(stream_id: str) -> PrefectAgent:
async def handler(
ctx: RunContext[Any],
stream: AsyncIterable[AgentStreamEvent],
) -> None:
logger = get_run_logger()
queue = streams[stream_id]
async for event in stream:
message = {
"kind": "event",
"data": serialize_event(event),
}
<http://logger.info|logger.info>(f"Streaming event {message['data']['type']}")
await queue.put(message)
base_agent = Agent(
"openai:gpt-4o",
system_prompt="You are a helpful assistant.",
)
return PrefectAgent(
base_agent,
event_stream_handler=handler,
)
@flow
async def run_agent_flow(prompt: str, stream_id: str) -> str:
queue = streams[stream_id]
agent = build_agent(stream_id)
try:
result = await agent.run(prompt)
await queue.put({
"kind": "result",
"data": {
"output": result.output,
},
})
return result.output
except Exception as exc:
await queue.put({
"kind": "error",
"data": {
"message": str(exc),
},
})
raise
finally:
await queue.put({
"kind": "done",
"data": {},
})
@app.get("/sse")
async def sse(prompt: str):
stream_id = str(uuid.uuid4())
queue: asyncio.Queue = asyncio.Queue()
streams[stream_id] = queue
async def event_generator() -> AsyncGenerator[str, None]:
flow_task = asyncio.create_task(run_agent_flow(prompt, stream_id))
try:
yield f"data: {json.dumps({'kind': 'session', 'data': {'stream_id': stream_id}})}\n\n"
while True:
item = await queue.get()
yield f"data: {json.dumps(item)}\n\n"
if item["kind"] == "done":
break
await flow_task
except asyncio.CancelledError:
flow_task.cancel()
raise
finally:
streams.pop(stream_id, None)
return StreamingResponse(
event_generator(),
media_type="text/event-stream",
)
Production note
If your flow runs on a Prefect worker / separate process, replace asyncio.Queue with shared infrastructure:
* Redis pub/sub or Redis streams
* Postgres table polled by the API
* Kafka / NATS
* a durable event bus
Then your handler becomes:
python
async def handler(ctx, stream):
async for event in stream:
await redis.xadd(
f"agent-stream:{stream_id}",
{"data": json.dumps(serialize_event(event))}
)
and your WebSocket/SSE endpoint reads from Redis instead of local memory.
Recommended message envelope
I’d suggest standardizing on something like:
python
{
"kind": "event" | "result" | "error" | "done" | "session",
"data": {...},
"run_id": "...",
"timestamp": "..."
}Marvin
04/23/2026, 8:40 PM