Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import logging
import sys
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from dataclasses import dataclass, field
from typing import Any, Literal, cast

from typing_extensions import Never
Expand Down Expand Up @@ -37,10 +37,15 @@ class AgentExecutorRequest:
messages: A list of chat messages to be processed by the agent.
should_respond: A flag indicating whether the agent should respond to the messages.
If False, the messages will be saved to the executor's cache but not sent to the agent.
fallback_messages: Messages used only if the agent would otherwise run with an empty
cache. The cache is cleared after every response, so a caller that cannot know
whether earlier context survived can supply a fallback here instead of guessing.
Ignored whenever the cache holds anything, so valid context is never displaced.
"""

messages: list[Message]
should_respond: bool = True
fallback_messages: list[Message] = field(default_factory=lambda: list[Message]())


@dataclass
Expand Down Expand Up @@ -208,6 +213,8 @@ async def run(
self._cache.extend(request.messages)

if request.should_respond:
if not self._cache:
self._cache.extend(request.fallback_messages)
await self._run_agent_and_emit(ctx)

@handler
Expand Down
36 changes: 36 additions & 0 deletions python/packages/core/tests/workflow/test_agent_executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

from agent_framework import (
AgentExecutor,
AgentExecutorRequest,
AgentResponse,
AgentResponseUpdate,
AgentRunInputs,
Expand Down Expand Up @@ -867,3 +868,38 @@ async def test_agent_executor_request_info_uses_user_input_request_id() -> None:


# endregion Tool approval emission


async def _run_request(agent: _MessageCapturingAgent, request: AgentExecutorRequest) -> None:
executor = AgentExecutor(agent, id="exec")
wf = WorkflowBuilder(start_executor=executor).build()
async for ev in wf.run(request, stream=True):
if ev.type == "status" and ev.state == WorkflowRunState.IDLE:
break


async def test_fallback_messages_used_when_cache_is_empty() -> None:
"""A request carrying no messages must fall back rather than invoke the agent with nothing."""
agent = _MessageCapturingAgent(id="a", name="A")

await _run_request(
agent,
AgentExecutorRequest(messages=[], fallback_messages=[Message("user", ["carry on"])]),
)

assert [m.text for m in agent.last_messages] == ["carry on"]


async def test_fallback_messages_ignored_when_cache_has_content() -> None:
"""Fallback messages must never displace real context."""
agent = _MessageCapturingAgent(id="a", name="A")

await _run_request(
agent,
AgentExecutorRequest(
messages=[Message("user", ["real task"])],
fallback_messages=[Message("user", ["carry on"])],
),
)

assert [m.text for m in agent.last_messages] == ["real task"]
Original file line number Diff line number Diff line change
Expand Up @@ -446,6 +446,7 @@ async def _send_request_to_participant(
ctx: WorkflowContext[AgentExecutorRequest | GroupChatRequestMessage],
*,
additional_instruction: str | None = None,
fallback_instruction: str | None = None,
metadata: dict[str, Any] | None = None,
) -> None:
"""Send a request to a participant.
Expand All @@ -459,6 +460,9 @@ async def _send_request_to_participant(
ctx: Workflow context for message routing
additional_instruction: Optional additional instruction for the participant.
This can be used to provide guidance to steer the participant's response.
fallback_instruction: Optional instruction applied only if the agent would
otherwise run with no messages at all. Ignored for custom executors, which
keep no message cache and always receive the full context envelope.
metadata: Optional metadata dict

Raises:
Expand All @@ -469,7 +473,13 @@ async def _send_request_to_participant(
messages: list[Message] = []
if additional_instruction:
messages.append(Message(role="user", contents=[additional_instruction]))
request = AgentExecutorRequest(messages=messages, should_respond=True)
request = AgentExecutorRequest(
messages=messages,
should_respond=True,
fallback_messages=(
[Message(role="user", contents=[fallback_instruction])] if fallback_instruction else []
),
)
await ctx.send_message(request, target_id=target)
await ctx.add_event(
WorkflowEvent(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,13 @@

logger = logging.getLogger(__name__)

# Applied only when the selected agent would otherwise run with no messages at all. This
# happens when it just spoke (it is excluded from the broadcast) or when cleaning stripped
# every broadcast message, since AgentExecutor clears its cache after each response. Some
# agents (for example A2AAgent) reject empty input. AgentExecutor drops this whenever real
# context is present, so it never displaces the conversation.
_CONTINUATION_DEFAULT_INSTRUCTION = "Continue the conversation."


@dataclass(frozen=True)
class GroupChatState:
Expand Down Expand Up @@ -233,6 +240,7 @@ async def _handle_response(
await self._send_request_to_participant(
next_speaker,
cast(WorkflowContext[AgentExecutorRequest | GroupChatRequestMessage], ctx),
fallback_instruction=_CONTINUATION_DEFAULT_INSTRUCTION,
)
self._increment_round()

Expand Down Expand Up @@ -413,6 +421,7 @@ async def _handle_response(
# If not terminating, next_speaker must be provided thus will not be None
agent_orchestration_output.next_speaker, # type: ignore[arg-type]
cast(WorkflowContext[AgentExecutorRequest | GroupChatRequestMessage], ctx),
fallback_instruction=_CONTINUATION_DEFAULT_INSTRUCTION,
)
self._increment_round()

Expand Down
Loading
Loading