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:
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user