fix(sdk): subagents: update prompt and make fetching of last message more robust (#3406)

This commit is contained in:
ccurme
2026-05-15 09:07:49 -04:00
committed by GitHub
parent 85d93015dd
commit 4421bec94f
2 changed files with 76 additions and 4 deletions
@@ -12,7 +12,7 @@ from langchain.agents.middleware.types import AgentMiddleware, ContextT, ModelRe
from langchain.agents.structured_output import ResponseFormat
from langchain.tools import BaseTool, ToolRuntime
from langchain_core.language_models import BaseChatModel
from langchain_core.messages import HumanMessage, ToolMessage
from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
from langchain_core.runnables import Runnable, RunnableConfig
from langchain_core.tools import StructuredTool
from langgraph.types import Command
@@ -168,7 +168,10 @@ class CompiledSubAgent(TypedDict):
"""
DEFAULT_SUBAGENT_PROMPT = "In order to complete the objective that the user asks of you, you have access to a number of standard tools."
DEFAULT_SUBAGENT_PROMPT = """In order to complete the objective that the user asks of you, you have access to a number of standard tools.
The calling agent only sees your final assistant message, not your intermediate work, tool results, or status tracking. Ensure your final
response contains the complete answer."""
# State keys that are excluded when passing state to subagents and when returning
# updates from subagents.
@@ -430,8 +433,17 @@ def _build_task_tool( # noqa: C901, PLR0915
else:
content = json.dumps(structured)
else:
# Strip trailing whitespace to prevent API errors with Anthropic
content = result["messages"][-1].text.rstrip() if result["messages"][-1].text else ""
# Walk back to the last AIMessage with non-empty text. Anthropic
# occasionally emits a trailing empty `end_turn` AIMessage after a
# successful final tool call, which would otherwise be forwarded
# as an empty ToolMessage.
content = ""
for msg in reversed(result["messages"]):
if isinstance(msg, AIMessage):
text = msg.text.rstrip() if msg.text else ""
if text:
content = text
break
return Command(
update={
@@ -1367,6 +1367,66 @@ class TestSubAgents:
task_tool_message = tool_messages[0]
assert task_tool_message.content == "Plain text result without structured response"
def test_fallback_skips_trailing_empty_ai_message(self) -> None:
"""Skip a trailing empty AIMessage and use the last AIMessage with text.
Anthropic/Bedrock occasionally emits an empty `end_turn` AIMessage after
a successful final tool call. The middleware should walk back to the
prior AIMessage carrying the real answer instead of forwarding an empty
ToolMessage.
"""
mock_subagent = RunnableLambda(
lambda _: {
"messages": [
AIMessage(content="The real answer from the subagent."),
AIMessage(content=""),
],
}
)
parent_chat_model = GenericFakeChatModel(
messages=iter(
[
AIMessage(
content="",
tool_calls=[
{
"name": "task",
"args": {
"description": "Do work",
"subagent_type": "worker",
},
"id": "call_trailing_empty",
"type": "tool_call",
}
],
),
AIMessage(content="Done"),
]
)
)
agent = create_deep_agent(
model=parent_chat_model,
checkpointer=InMemorySaver(),
subagents=[
CompiledSubAgent(
name="worker",
description="A worker agent",
runnable=mock_subagent,
),
],
)
result = agent.invoke(
{"messages": [HumanMessage(content="Test")]},
config={"configurable": {"thread_id": f"test-trailing-empty-{uuid.uuid4().hex}"}},
)
tool_messages = [msg for msg in result["messages"] if msg.type == "tool"]
assert len(tool_messages) == 1
assert tool_messages[0].content == "The real answer from the subagent."
def test_subagent_streaming_emits_messages_and_updates_from_subgraph(self) -> None:
"""Test end-to-end subagent streaming with `subgraphs=True`.