feat(responses): pass conversation context and stream thinks in real time

- delegate_to_* now receives a trimmed conversation history (last ~6
  turns, 500 chars/turn) as context on both live direct-delegation
  paths (streaming and steward non-streaming), via new
  build_delegation_context helper
- _stream_direct_delegation restructured as an async generator: the
  butler 'start' think message streams BEFORE the expert runs and the
  success/error message right after it finishes, instead of all
  messages arriving after the research completed

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-14 10:30:34 +02:00
co-authored by Claude Fable 5
parent 0708c759fc
commit 7ce1c1a314
6 changed files with 355 additions and 41 deletions
+44
View File
@@ -128,6 +128,50 @@ def _detect_action_type(expert: str, task: str) -> ActionType:
return ActionType.RETRIEVE
def build_delegation_context(
conversation_history: list[dict] | None,
max_turns: int = 6,
max_chars_per_turn: int = 500,
) -> str:
"""
Format the most recent conversation turns as delegation context.
Experts accept a context string but the live paths never passed the
in-scope conversation history; this trims it to the last few turns
so follow-up questions ("and what about X?") keep their referent.
Args:
conversation_history: Prior messages as {"role", "content"} dicts
max_turns: How many trailing turns to include
max_chars_per_turn: Truncation limit per turn
Returns:
str: Newline-joined "role: content" lines ("" when no history)
"""
if not conversation_history:
return ""
lines = []
for msg in conversation_history[-max_turns:]:
if not isinstance(msg, dict):
continue
role = msg.get("role", "user")
content = msg.get("content", "")
if isinstance(content, list):
# Tolerate structured content parts
content = " ".join(
part.get("text", "") if isinstance(part, dict) else str(part)
for part in content
)
content = str(content).strip()
if content:
lines.append(f"{role}: {content[:max_chars_per_turn]}")
if not lines:
return ""
return "Recent conversation:\n" + "\n".join(lines)
def get_think_message(expert: str, task: str, phase: str) -> str:
"""
Get the appropriate think message for an expert delegation.
+15 -6
View File
@@ -12,7 +12,7 @@ import secrets
import time
from collections.abc import AsyncGenerator
from src.agents.delegation import get_think_message
from src.agents.delegation import build_delegation_context, get_think_message
from src.agents.registry import ModelRegistry
from src.agents.steward.schemas import StewardRecommendation
from src.core.context import current_conversation, current_user, get_default_user
@@ -63,6 +63,7 @@ async def _execute_single_delegation(
agent_name: str,
task: str,
tracker: "ToolCallTracker",
context: str = "",
) -> tuple[str, str, bool]:
"""
Execute a single delegation to an agent.
@@ -71,6 +72,7 @@ async def _execute_single_delegation(
agent_name: Name of agent (biographer, librarian, housekeeper)
task: Task description
tracker: Tool call tracker
context: Trimmed conversation context for the expert
Returns:
tuple: (agent_name, result_summary, success). On failure the
@@ -81,21 +83,21 @@ async def _execute_single_delegation(
if agent_name == "biographer":
from src.agents.delegation import delegate_to_biographer
result = await delegate_to_biographer(task=task)
result = await delegate_to_biographer(task=task, context=context)
duration = time.time() - start_time
await tracker.track_call("delegate_to_biographer", duration)
return (agent_name, result.output, result.success)
elif agent_name == "librarian":
from src.agents.delegation import delegate_to_librarian
result = await delegate_to_librarian(task=task)
result = await delegate_to_librarian(task=task, context=context)
duration = time.time() - start_time
await tracker.track_call("delegate_to_librarian", duration)
return (agent_name, result.output, result.success)
elif agent_name == "housekeeper":
from src.agents.delegation import delegate_to_housekeeper
result = await delegate_to_housekeeper(task=task)
result = await delegate_to_housekeeper(task=task, context=context)
duration = time.time() - start_time
await tracker.track_call("delegate_to_housekeeper", duration)
return (agent_name, result.output, result.success)
@@ -295,6 +297,7 @@ async def _direct_delegation_with_results(
recommendation: "StewardRecommendation",
tracker: "ToolCallTracker",
conversation_id: str,
conversation_history: list | None = None,
) -> dict:
"""
Directly delegate to expert agents and return structured results.
@@ -308,6 +311,7 @@ async def _direct_delegation_with_results(
recommendation: Steward's recommendation
tracker: Tool call tracker
conversation_id: Conversation ID
conversation_history: Prior turns, trimmed into expert context
Returns:
dict: Orchestration results with expert_results, tool_outputs, etc.
@@ -320,11 +324,12 @@ async def _direct_delegation_with_results(
expert_results = {}
tools_called = []
context = build_delegation_context(conversation_history)
for agent in recommendation.recommended_capabilities:
try:
agent_name, result, success = await _execute_single_delegation(
agent, user_message, tracker
agent, user_message, tracker, context=context
)
expert_results[agent_name] = result
if success:
@@ -634,7 +639,11 @@ async def create_response_with_steward(request: ResponseRequest) -> Response:
if delegation_only:
# Direct delegation path - collect results then synthesize
orchestration_results = await _direct_delegation_with_results(
effective_query, enriched.recommendation, tracker, conversation_id
effective_query,
enriched.recommendation,
tracker,
conversation_id,
conversation_history=conversation_history,
)
else:
# Phase 1: Orchestrate tool calls
+52 -35
View File
@@ -189,20 +189,18 @@ class StreamingCoordinator:
tatlock = TatlockAgent()
if delegation_only:
# Direct delegation path with streaming think slugs
orchestration_results = await self._stream_direct_delegation(
# Direct delegation path - think slugs stream in real time,
# BEFORE and after each expert runs (not after the fact)
orchestration_results: dict = {}
async for event in self._stream_direct_delegation(
user_message=user_message,
recommendation=enriched.recommendation,
tracker=tracker,
conversation_id=conversation_id,
)
# Stream think slugs that were collected during delegation
# Each think message is complete, so we signal done after each
for think_msg in orchestration_results.get("think_messages", []):
yield ReasoningSummaryDelta(delta=think_msg)
yield ReasoningSummaryDone()
await asyncio.sleep(0.05)
conversation_history=conversation_history,
results=orchestration_results,
):
yield event
else:
# Phase 1: Orchestrate tool calls
@@ -272,24 +270,33 @@ class StreamingCoordinator:
recommendation: "StewardRecommendation", # type: ignore
tracker: "ToolCallTracker", # type: ignore
conversation_id: str,
) -> dict:
conversation_history: list | None = None,
results: dict | None = None,
) -> AsyncGenerator[StreamEvent, None]:
"""
Execute direct delegation with streaming think messages.
Execute direct delegation, streaming think messages in real time.
Collects think messages as delegations execute for streaming to client.
An async generator: the "start" think message for each expert is
yielded BEFORE its research runs (so the user sees 'Allow me to
consult the archives, sir.' while waiting), and the success/error
message right after it finishes.
Args:
user_message: User's request
recommendation: Steward's recommendation
tracker: Tool call tracker
conversation_id: Conversation ID
conversation_history: Prior turns, trimmed into expert context
results: Mutable dict populated with orchestration results
(expert_results, tools_called, think_messages, ...)
Returns:
dict: Orchestration results with think_messages list
Yields:
StreamEvent: Reasoning summary events as delegation progresses
"""
import time as time_module
from src.agents.delegation import (
build_delegation_context,
delegate_to_biographer,
delegate_to_housekeeper,
delegate_to_librarian,
@@ -299,21 +306,30 @@ class StreamingCoordinator:
expert_results = {}
tools_called = []
think_messages = []
context = build_delegation_context(conversation_history)
for agent in recommendation.recommended_capabilities:
# Emit start think message
# Emit start think message BEFORE the expert runs
start_msg = get_think_message(agent, user_message, "start")
think_messages.append(start_msg + "\n")
yield ReasoningSummaryDelta(delta=start_msg + "\n")
yield ReasoningSummaryDone()
start_time = time_module.time()
try:
# Execute delegation
if agent == "librarian":
result = await delegate_to_librarian(task=user_message)
result = await delegate_to_librarian(
task=user_message, context=context
)
elif agent == "biographer":
result = await delegate_to_biographer(task=user_message)
result = await delegate_to_biographer(
task=user_message, context=context
)
elif agent == "housekeeper":
result = await delegate_to_housekeeper(task=user_message)
result = await delegate_to_housekeeper(
task=user_message, context=context
)
else:
result = None
@@ -324,18 +340,15 @@ class StreamingCoordinator:
expert_results[agent] = result.output
tools_called.append(f"delegate_to_{agent}")
# Emit success think message
success_msg = get_think_message(agent, user_message, "success")
think_messages.append(success_msg + "\n")
phase_msg = get_think_message(agent, user_message, "success")
else:
# Failed delegations carry a curated user-safe sentence
# in output; exception detail is already in the logs.
error_think = get_think_message(agent, user_message, "error")
phase_msg = get_think_message(agent, user_message, "error")
if result and result.output:
expert_results[agent] = result.output
else:
expert_results[agent] = error_think
# Emit error think message
think_messages.append(error_think + "\n")
expert_results[agent] = phase_msg
except Exception as e:
logger.error(
@@ -345,17 +358,21 @@ class StreamingCoordinator:
conversation_id=conversation_id,
exc_info=True,
)
error_think = get_think_message(agent, user_message, "error")
expert_results[agent] = error_think
think_messages.append(error_think + "\n")
phase_msg = get_think_message(agent, user_message, "error")
expert_results[agent] = phase_msg
return {
"tools_called": tools_called,
"expert_results": expert_results,
"tool_outputs": {},
"raw_output": "",
"think_messages": think_messages,
}
think_messages.append(phase_msg + "\n")
yield ReasoningSummaryDelta(delta=phase_msg + "\n")
yield ReasoningSummaryDone()
if results is not None:
results.update({
"tools_called": tools_called,
"expert_results": expert_results,
"tool_outputs": {},
"raw_output": "",
"think_messages": think_messages,
})
async def stream_response(
self,