Stream v2 tool events, save artifacts, enrich conversation payload

stream_query emits the new tool_start/tool_end payloads, maps
on_tool_error to tool_end status=error, and saves an artifact linked to
the tool message. GET /api/conversation/<id> adds tool, label and
artifact to tool messages.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 17:53:16 -04:00
parent 0cf6229441
commit c678bf55bc
2 changed files with 417 additions and 20 deletions
+54 -20
View File
@@ -10,6 +10,8 @@ from quart import Blueprint, jsonify, make_response, request
from quart_jwt_extended import get_jwt_identity
import blueprints.users.models
from blueprints.artifacts.logic import artifact_ref, create_artifact_from_output
from blueprints.artifacts.models import Artifact
from blueprints.users.decorators import jwt_access_required
from metrics import count_result, observe_response, time_response
from utils.image_process import image_content_block
@@ -32,6 +34,8 @@ from .models import (
PydListConversation,
Speaker,
)
from .tool_events import tool_end_payload, tool_name_from_message, tool_start_payload
from .tool_labels import tool_label
TTFT_TIMEOUT = float(os.getenv("TTFT_TIMEOUT_SECONDS", "30"))
@@ -340,28 +344,47 @@ async def stream_query():
if event_type == "on_tool_start":
tool_timers[event["run_id"]] = time.monotonic()
yield f"data: {json.dumps({'type': 'tool_start', 'tool': event['name']})}\n\n"
yield f"data: {json.dumps(tool_start_payload(event))}\n\n"
elif event_type == "on_tool_end":
elif event_type in ("on_tool_end", "on_tool_error"):
now = time.monotonic()
start = tool_timers.pop(event["run_id"], None)
tool_spans.append(
{
"tool": event["name"],
"ms": round((time.monotonic() - start) * 1000)
if start
else None,
"ms": round((now - start) * 1000) if start else None,
}
)
yield f"data: {json.dumps({'type': 'tool_end', 'tool': event['name']})}\n\n"
ref = None
# Persist tool output so later turns keep the fetched context
output = event.get("data", {}).get("output")
if output is not None:
await add_message_to_conversation(
if event_type == "on_tool_end" and output is not None:
tool_message = await add_message_to_conversation(
conversation=conversation,
message=tool_result_text(event["name"], output),
speaker="tool",
user=user,
)
try:
artifact = await create_artifact_from_output(
output=output,
tool=event["name"],
user=user,
conversation=conversation,
message=tool_message,
)
except Exception as e:
logging.error(
f"Failed to save artifact from {event['name']}: {e}"
)
artifact = None
if artifact is not None:
ref = artifact_ref(artifact)
status = "error" if event_type == "on_tool_error" else "ok"
end_payload = tool_end_payload(
event, start, ref, now=now, status=status
)
yield f"data: {json.dumps(end_payload)}\n\n"
elif event_type == "on_chat_model_start":
turn_has_content = False
@@ -453,22 +476,33 @@ async def get_conversation(conversation_id: str):
if conversation is None:
return jsonify({"error": "conversation not found"}), 404
await conversation.fetch_related("messages")
artifacts_by_message = {
a.message_id: a
for a in await Artifact.filter(
conversation_id=conversation.id, message_id__isnull=False
).order_by("created_at")
}
# Manually serialize the conversation with messages
messages = []
for msg in conversation.messages:
# Tool messages hold raw fetched context for the LLM; only surface
# their header line (e.g. "[web_search result]") in the UI.
text = msg.text.split("\n", 1)[0] if msg.speaker == Speaker.TOOL else msg.text
messages.append(
{
"id": str(msg.id),
"text": text,
"speaker": msg.speaker.value,
"created_at": msg.created_at.isoformat(),
"image_key": msg.image_key,
}
)
item = {
"id": str(msg.id),
"text": msg.text,
"speaker": msg.speaker.value,
"created_at": msg.created_at.isoformat(),
"image_key": msg.image_key,
}
if msg.speaker == Speaker.TOOL:
# Tool messages hold raw fetched context for the LLM; only surface
# their header line (e.g. "[web_search result]") in the UI.
item["text"] = msg.text.split("\n", 1)[0]
tool = tool_name_from_message(msg.text)
artifact = artifacts_by_message.get(msg.id)
item["tool"] = tool
item["label"] = tool_label(tool)
item["artifact"] = artifact_ref(artifact) if artifact else None
messages.append(item)
return jsonify(
{
"id": str(conversation.id),