Skip to content
Closed
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
81 changes: 81 additions & 0 deletions examples/agent_patterns/agents_as_tools_with_history.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
"""
Agent as tool with conversation history.

Demonstrates ``include_conversation_history=True`` on ``Agent.as_tool()``.
The orchestrator delegates to two sub-agents:

- **analyst** (with history): sees the full conversation via a <CONVERSATION HISTORY>
summary, so it can reference earlier facts and tool results.
- **blind** (without history): sees only the tool input string, proving the default
behavior is unchanged.

Try telling the orchestrator some facts, then asking it to delegate to the analyst
or blind agent to see the difference.
"""

import asyncio

from agents import Agent, Runner, TResponseInputItem

analyst = Agent(
name="analyst",
instructions=(
"You analyze conversations. Reference specific facts, names, and numbers "
"from the conversation history to show you have full context."
),
)

blind = Agent(
name="blind",
instructions="Answer questions based on whatever conversation you can see.",
)

orchestrator = Agent(
name="orchestrator",
instructions=(
"You are a helpful assistant. For normal questions, answer directly.\n"
"You have two tools:\n"
"- ask_analyst: delegate to an analyst who can see the FULL conversation history\n"
"- ask_blind: delegate to an agent WITHOUT conversation history\n"
"Use the appropriate tool when asked."
),
tools=[
analyst.as_tool(
tool_name="ask_analyst",
tool_description="Delegate to the analyst (has full conversation history).",
include_conversation_history=True,
),
blind.as_tool(
tool_name="ask_blind",
tool_description="Delegate to the blind agent (has NO conversation history).",
include_conversation_history=False,
),
],
)


async def main():
print("=== Agent as Tool with Conversation History ===")
print("Chat with the orchestrator. It can delegate to the analyst (with history)")
print("or blind agent (without history).")
print("Type 'quit' to exit.\n")

items: list[TResponseInputItem] = []

while True:
try:
user_input = input("You: ")
except (EOFError, KeyboardInterrupt):
break

if user_input.strip().lower() in ("quit", "exit", "q"):
break

items.append({"role": "user", "content": user_input})
result = await Runner.run(orchestrator, items)
print(f"\nOrchestrator: {result.final_output}\n")
items = result.to_input_list()


if __name__ == "__main__":
asyncio.run(main())
15 changes: 15 additions & 0 deletions src/agents/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -527,6 +527,7 @@ def as_tool(
parameters: type[Any] | None = None,
input_builder: StructuredToolInputBuilder | None = None,
include_input_schema: bool = False,
include_conversation_history: bool = False,
) -> FunctionTool:
"""Transform this agent into a tool, callable by other agents.

Expand Down Expand Up @@ -556,6 +557,10 @@ def as_tool(
parameters: Structured input type for the tool arguments (dataclass or Pydantic model).
input_builder: Optional function to build the nested agent input from structured data.
include_input_schema: Whether to include the full JSON schema in structured input.
include_conversation_history: Whether to prepend the parent agent's conversation history
to the sub-agent's input. When True, the sub-agent sees a summary of the full
conversation context from the parent run, followed by the tool input. The summary
uses the same format as handoff history nesting. Defaults to False.
"""

def _is_supported_parameters(value: Any) -> bool:
Expand Down Expand Up @@ -623,6 +628,16 @@ async def _run_agent_impl(context: ToolContext, input_json: str) -> Any:
if not isinstance(resolved_input, str) and not isinstance(resolved_input, list):
raise ModelBehaviorError("Agent tool called with invalid input")

if include_conversation_history and isinstance(context, RunContextWrapper):
exec_ctx = context.tool_execution_context
if exec_ctx:
from .handoffs.history import build_agent_tool_history
from .items import ItemHelpers

summary_items, forwarded_items = build_agent_tool_history(exec_ctx)
tool_input_items = ItemHelpers.input_to_new_input_list(resolved_input)
resolved_input = summary_items + forwarded_items + tool_input_items

resolved_max_turns = max_turns if max_turns is not None else DEFAULT_MAX_TURNS
resolved_run_config = run_config
if resolved_run_config is None and isinstance(context, ToolContext):
Expand Down
121 changes: 93 additions & 28 deletions src/agents/handoffs/history.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import json
from copy import deepcopy
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, cast

from ..items import (
Expand All @@ -12,9 +13,11 @@
)

if TYPE_CHECKING:
from ..run_context import ToolExecutionContext
from . import HandoffHistoryMapper, HandoffInputData

__all__ = [
"build_agent_tool_history",
"default_handoff_history_mapper",
"get_conversation_history_wrappers",
"nest_handoff_history",
Expand Down Expand Up @@ -68,48 +71,110 @@ def get_conversation_history_wrappers() -> tuple[str, str]:
return (_conversation_history_start, _conversation_history_end)


def nest_handoff_history(
handoff_input_data: HandoffInputData,
@dataclass(frozen=True)
class _History:
"""Result of ``_build_history``."""

summary: list[TResponseInputItem]
filtered_pre_items: list[RunItem]
filtered_new_items: list[RunItem]


def _build_history(
input_history: str | tuple[TResponseInputItem, ...],
pre_items: tuple[RunItem, ...],
new_items: tuple[RunItem, ...],
*,
history_mapper: HandoffHistoryMapper | None = None,
) -> HandoffInputData:
"""Summarize the previous transcript for the next agent."""
) -> _History:
"""Shared logic for ``nest_handoff_history`` and ``build_agent_tool_history``.

normalized_history = _normalize_input_history(handoff_input_data.input_history)
flattened_history = _flatten_nested_history_messages(normalized_history)
Normalizes/flattens input_history, filters pre_items (strict) and new_items (permissive),
then builds a ``<CONVERSATION HISTORY>`` text summary of the full transcript.
"""
normalized = _normalize_input_history(input_history)
flattened = _flatten_nested_history_messages(normalized)

# Convert items to plain inputs for the transcript summary.
pre_items_as_inputs: list[TResponseInputItem] = []
filtered_pre_items: list[RunItem] = []
for run_item in handoff_input_data.pre_handoff_items:
# Pre-items: strict filter — drops assistant messages, tool calls, reasoning.
pre_inputs: list[TResponseInputItem] = []
filtered_pre: list[RunItem] = []
for run_item in pre_items:
if isinstance(run_item, ToolApprovalItem):
continue
plain_input = _run_item_to_plain_input(run_item)
pre_items_as_inputs.append(plain_input)
if _should_forward_pre_item(plain_input):
filtered_pre_items.append(run_item)

new_items_as_inputs: list[TResponseInputItem] = []
filtered_input_items: list[RunItem] = []
for run_item in handoff_input_data.new_items:
plain = _run_item_to_plain_input(run_item)
pre_inputs.append(plain)
if _should_forward_pre_item(plain):
filtered_pre.append(run_item)

# New items: permissive filter — keeps items with roles (including assistant).
new_inputs: list[TResponseInputItem] = []
filtered_new: list[RunItem] = []
for run_item in new_items:
if isinstance(run_item, ToolApprovalItem):
continue
plain_input = _run_item_to_plain_input(run_item)
new_items_as_inputs.append(plain_input)
if _should_forward_new_item(plain_input):
filtered_input_items.append(run_item)

transcript = flattened_history + pre_items_as_inputs + new_items_as_inputs
plain = _run_item_to_plain_input(run_item)
new_inputs.append(plain)
if _should_forward_new_item(plain):
filtered_new.append(run_item)

transcript = flattened + pre_inputs + new_inputs
mapper = history_mapper or default_handoff_history_mapper
history_items = mapper(transcript)
summary = mapper(transcript)

return _History(
summary=summary,
filtered_pre_items=filtered_pre,
filtered_new_items=filtered_new,
)


def nest_handoff_history(
handoff_input_data: HandoffInputData,
*,
history_mapper: HandoffHistoryMapper | None = None,
) -> HandoffInputData:
"""Summarize the previous transcript for the next agent."""
result = _build_history(
handoff_input_data.input_history,
handoff_input_data.pre_handoff_items,
handoff_input_data.new_items,
history_mapper=history_mapper,
)
return handoff_input_data.clone(
input_history=tuple(deepcopy(item) for item in history_items),
pre_handoff_items=tuple(filtered_pre_items),
input_history=tuple(deepcopy(item) for item in result.summary),
pre_handoff_items=tuple(result.filtered_pre_items),
# new_items stays unchanged for session history.
input_items=tuple(filtered_input_items),
input_items=tuple(result.filtered_new_items),
)


def build_agent_tool_history(
tool_execution_context: ToolExecutionContext,
) -> tuple[list[TResponseInputItem], list[TResponseInputItem]]:
"""Build a summary and filtered forwarded items for agent-as-tool with history.

Uses the same summarization logic as handoff history nesting to convert the parent
conversation into a ``<CONVERSATION HISTORY>`` text summary plus filtered raw items.

Args:
tool_execution_context: The conversation state captured during tool execution.

Returns:
A tuple of ``(summary_items, forwarded_items)``. ``summary_items`` is a single-element
list containing the summary assistant message. ``forwarded_items`` are the raw items
that should be sent alongside the summary.
"""

result = _build_history(
tool_execution_context.input_history,
tool_execution_context.pre_step_items,
tool_execution_context.new_step_items,
)
forwarded = [
_run_item_to_plain_input(item)
for item in result.filtered_pre_items + result.filtered_new_items
]
return result.summary, forwarded


def default_handoff_history_mapper(
Expand Down
24 changes: 24 additions & 0 deletions src/agents/run_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,24 @@
TContext = TypeVar("TContext", default=Any)


@dataclass(frozen=True)
class ToolExecutionContext:
"""Conversation state at the point of tool execution.

Mirrors ``HandoffInputData`` fields so ``build_agent_tool_history()`` can reuse
the same summarization logic as handoff history nesting.
"""

input_history: str | tuple[TResponseInputItem, ...]
"""Cross-run history — the ``original_input`` passed to ``Runner.run()``."""

pre_step_items: tuple[Any, ...]
"""Within-run ``RunItem`` objects from prior turns in this ``Runner.run()`` call."""

new_step_items: tuple[Any, ...]
"""Current turn ``RunItem`` objects generated before tool execution."""


@dataclass(eq=False)
class _ApprovalRecord:
"""Tracks approval/rejection state for a tool.
Expand Down Expand Up @@ -57,6 +75,10 @@ class RunContextWrapper(Generic[TContext]):
"""

turn_input: list[TResponseInputItem] = field(default_factory=list)
tool_execution_context: ToolExecutionContext | None = None
"""Conversation state at tool execution time. Set before tools run so agent-as-tool
implementations can access the parent conversation via ``include_conversation_history``."""

_approvals: dict[str, _ApprovalRecord] = field(default_factory=dict)
tool_input: Any | None = None
"""Structured input for the current agent tool run, when available."""
Expand Down Expand Up @@ -460,6 +482,7 @@ def _fork_with_tool_input(self, tool_input: Any) -> RunContextWrapper[TContext]:
fork.usage = self.usage
fork._approvals = self._approvals
fork.turn_input = self.turn_input
fork.tool_execution_context = self.tool_execution_context
fork.tool_input = tool_input
return fork

Expand All @@ -469,6 +492,7 @@ def _fork_without_tool_input(self) -> RunContextWrapper[TContext]:
fork.usage = self.usage
fork._approvals = self._approvals
fork.turn_input = self.turn_input
fork.tool_execution_context = self.tool_execution_context
return fork


Expand Down
12 changes: 12 additions & 0 deletions src/agents/run_internal/turn_resolution.py
Original file line number Diff line number Diff line change
Expand Up @@ -589,6 +589,16 @@ async def execute_tools_and_side_effects(
new_items=processed_response.new_items,
)

# Snapshot conversation state for agent-as-tool with include_conversation_history.
# Cleared after _execute_tool_plan since new_step_items becomes stale.
from ..run_context import ToolExecutionContext

context_wrapper.tool_execution_context = ToolExecutionContext(
input_history=tuple(original_input) if isinstance(original_input, list) else original_input,
pre_step_items=tuple(pre_step_items),
new_step_items=tuple(new_step_items),
)

(
function_results,
tool_input_guardrail_results,
Expand All @@ -605,6 +615,8 @@ async def execute_tools_and_side_effects(
context_wrapper=context_wrapper,
run_config=run_config,
)
context_wrapper.tool_execution_context = None

new_step_items.extend(
_build_tool_result_items(
function_results=function_results,
Expand Down
4 changes: 3 additions & 1 deletion src/agents/tool_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

from ._tool_identity import get_tool_call_namespace, tool_trace_name
from .agent_tool_state import get_agent_tool_state_scope, set_agent_tool_state_scope
from .run_context import RunContextWrapper, TContext
from .run_context import RunContextWrapper, TContext, ToolExecutionContext
from .usage import Usage

if TYPE_CHECKING:
Expand Down Expand Up @@ -70,6 +70,7 @@ def __init__(
agent: AgentBase[Any] | None = None,
run_config: RunConfig | None = None,
turn_input: list[TResponseInputItem] | None = None,
tool_execution_context: ToolExecutionContext | None = None,
_approvals: dict[str, _ApprovalRecord] | None = None,
tool_input: Any | None = None,
) -> None:
Expand All @@ -79,6 +80,7 @@ def __init__(
context=context,
usage=resolved_usage,
turn_input=list(turn_input or []),
tool_execution_context=tool_execution_context,
_approvals={} if _approvals is None else _approvals,
tool_input=tool_input,
)
Expand Down
Loading