Compare commits

...

1 Commits

Author SHA1 Message Date
camilleAND c4e715aebc add way for tools (summarize) to stop agent run and return tool output to user 2025-12-18 16:25:04 +01:00
4 changed files with 227 additions and 50 deletions
+112 -39
View File
@@ -549,6 +549,10 @@ class AIAgentService: # pylint: disable=too-many-instance-attributes
_final_output_from_tool = None
_ui_sources = []
# Track if a tool (like summarize) should directly provide the final answer
_final_response_from_tool_streamed = False
_summarize_tool_call_ids: set[str] = set()
_stop_after_tool = False
# Help Mistral to prevent `Unexpected role 'user' after role 'tool'` error.
if history and history[-1].kind == "request":
@@ -574,6 +578,10 @@ class AIAgentService: # pylint: disable=too-many-instance-attributes
logger.debug("node.run result: %s", result)
for part in result.model_response.parts:
if isinstance(part, TextPart):
# If a tool (like summarize) is the final answer,
# do not stream additional model text.
if _final_response_from_tool_streamed:
continue
if self._fake_streaming_delay:
for i in range(0, len(part.content), 4):
await self._agent_stop_streaming()
@@ -605,7 +613,10 @@ class AIAgentService: # pylint: disable=too-many-instance-attributes
logger.debug("PartStartEvent: %s", dataclasses.asdict(event))
if isinstance(event.part, TextPart):
yield events_v4.TextPart(text=event.part.content)
# If a tool (like summarize) is the final answer,
# do not stream additional model text.
if not _final_response_from_tool_streamed:
yield events_v4.TextPart(text=event.part.content)
elif isinstance(event.part, ToolCallPart):
yield events_v4.ToolCallStreamingStartPart(
tool_call_id=event.part.tool_call_id,
@@ -623,7 +634,12 @@ class AIAgentService: # pylint: disable=too-many-instance-attributes
dataclasses.asdict(event),
)
if isinstance(event.delta, TextPartDelta):
yield events_v4.TextPart(text=event.delta.content_delta)
# If a tool (like summarize) is the final answer,
# do not stream additional model text.
if not _final_response_from_tool_streamed:
yield events_v4.TextPart(
text=event.delta.content_delta
)
elif isinstance(event.delta, ToolCallPartDelta):
_tool_is_streaming = True
yield events_v4.ToolCallDeltaPart(
@@ -648,6 +664,10 @@ class AIAgentService: # pylint: disable=too-many-instance-attributes
)
if isinstance(event, FunctionToolCallEvent):
if not _tool_is_streaming:
# Track summarize tool calls so we can treat their
# result as the final answer.
if event.part.tool_name == "summarize":
_summarize_tool_call_ids.add(event.tool_call_id)
yield events_v4.ToolCallPart(
tool_call_id=event.tool_call_id,
tool_name=event.part.tool_name,
@@ -656,6 +676,9 @@ class AIAgentService: # pylint: disable=too-many-instance-attributes
else {},
)
elif isinstance(event, FunctionToolResultEvent):
_is_summarize_result = (
event.tool_call_id in _summarize_tool_call_ids
)
if isinstance(event.result, ToolReturnPart):
if event.result.metadata and (
sources := event.result.metadata.get("sources")
@@ -675,21 +698,40 @@ class AIAgentService: # pylint: disable=too-many-instance-attributes
**_new_source_ui.source.model_dump()
)
yield events_v4.ToolResultPart(
tool_call_id=event.tool_call_id,
result=event.result.content,
)
if _is_summarize_result:
# For summarize, the tool output IS the final answer.
_final_output_from_tool = event.result.content
_final_response_from_tool_streamed = True
_stop_after_tool = True
if event.result.content:
yield events_v4.TextPart(
text=event.result.content
)
else:
yield events_v4.ToolResultPart(
tool_call_id=event.tool_call_id,
result=event.result.content,
)
elif isinstance(event.result, RetryPromptPart):
yield events_v4.ToolResultPart(
tool_call_id=event.tool_call_id,
result=event.result.content,
)
# RetryPrompts are internal hints for the model,
# they should not replace the final user-visible answer.
if not _is_summarize_result:
yield events_v4.ToolResultPart(
tool_call_id=event.tool_call_id,
result=event.result.content,
)
else:
logger.warning(
"Unexpected tool result type: %s %s",
type(event.result),
dataclasses.asdict(event.result),
)
if _stop_after_tool:
# Stop processing further tool events/nodes once summarize
# has produced the final answer.
break
if _stop_after_tool:
break
elif Agent.is_end_node(node):
# Once an End node is reached, the agent run is complete
logger.debug("v: %s", dataclasses.asdict(node))
@@ -710,7 +752,7 @@ class AIAgentService: # pylint: disable=too-many-instance-attributes
message_id=_model_response_message_id,
)
# Final usage summary
# Final usage summary (even if we stopped early after a tool)
final_usage = run.usage()
usage["promptTokens"] = final_usage.input_tokens
usage["completionTokens"] = final_usage.output_tokens
@@ -718,19 +760,34 @@ class AIAgentService: # pylint: disable=too-many-instance-attributes
await self._agent_stop_streaming(force_cache_check=True)
# Persist conversation
await sync_to_async(self._update_conversation)(
final_output=run.result.new_messages(),
usage=usage,
final_output_from_tool=_final_output_from_tool,
ui_sources=_ui_sources,
model_response_message_id=_model_response_message_id,
image_key_mapping=image_key_mapping or None,
)
if self._langfuse_available:
langfuse.update_current_trace(
output=run.result.output if self._store_analytics else "REDACTED"
if _final_output_from_tool:
# When a tool (like summarize) produced the final answer, we don't rely
# on the agent's `run.result` (which might be incomplete if we stopped early).
await sync_to_async(self._update_conversation)(
final_output=[],
usage=usage,
final_output_from_tool=_final_output_from_tool,
ui_sources=_ui_sources,
model_response_message_id=_model_response_message_id,
image_key_mapping=image_key_mapping or None,
)
if self._langfuse_available:
langfuse.update_current_trace(
output=_final_output_from_tool if self._store_analytics else "REDACTED"
)
else:
await sync_to_async(self._update_conversation)(
final_output=run.result.new_messages(),
usage=usage,
final_output_from_tool=None,
ui_sources=_ui_sources,
model_response_message_id=_model_response_message_id,
image_key_mapping=image_key_mapping or None,
)
if self._langfuse_available:
langfuse.update_current_trace(
output=run.result.output if self._store_analytics else "REDACTED"
)
# Vercel finish message
yield events_v4.FinishMessagePart(
@@ -769,13 +826,24 @@ class AIAgentService: # pylint: disable=too-many-instance-attributes
],
kind="request",
)
_merged_final_output_message = ModelResponse(
parts=[
part for msg in final_output if isinstance(msg, ModelResponse) for part in msg.parts
]
+ ([TextPart(content=final_output_from_tool)] if final_output_from_tool else []),
kind="response",
)
if final_output_from_tool:
# When a tool (like summarize) produced the final answer, we only keep
# that content as the assistant message, to avoid the main model
# rephrasing or duplicating it.
_merged_final_output_message = ModelResponse(
parts=[TextPart(content=final_output_from_tool)],
kind="response",
)
else:
_merged_final_output_message = ModelResponse(
parts=[
part
for msg in final_output
if isinstance(msg, ModelResponse)
for part in msg.parts
],
kind="response",
)
if image_key_mapping:
for part in _merged_final_output_request.parts:
@@ -786,18 +854,23 @@ class AIAgentService: # pylint: disable=too-many-instance-attributes
):
content.url = unsigned_url
_request_ui_message = model_message_to_ui_message(_merged_final_output_request)
_output_ui_message = model_message_to_ui_message(_merged_final_output_message)
if ui_sources:
_output_ui_message.parts += ui_sources
if model_response_message_id:
_output_ui_message.id = model_response_message_id
else:
logger.warning("model_response_message_id is None")
self.conversation.messages += [
model_message_to_ui_message(_merged_final_output_request),
_output_ui_message,
if ui_sources and _output_ui_message is not None:
_output_ui_message.parts += ui_sources
if _output_ui_message is not None:
if model_response_message_id:
_output_ui_message.id = model_response_message_id
else:
logger.warning("model_response_message_id is None")
# Only append non-empty UI messages to avoid None values,
# which would break Pydantic validation on list[UIMessage].
new_messages = [
msg for msg in (_request_ui_message, _output_ui_message) if msg is not None
]
self.conversation.messages += new_messages
self.conversation.agent_usage = usage
final_output_json = json.loads(
+15 -7
View File
@@ -26,12 +26,14 @@ def read_document_content(doc):
return doc.file_name, f.read().decode("utf-8")
async def summarize_chunk(idx, chunk, total_chunks, summarization_agent, ctx):
async def summarize_chunk(idx, chunk, total_chunks, summarization_agent, ctx, language: str):
"""Summarize a single chunk of text."""
sum_prompt = (
"You are an agent specializing in text summarization. "
"Generate a clear and concise summary of the following passage "
f"(part {idx}/{total_chunks}):\n'''\n{chunk}\n'''\n\n"
f"(part {idx}/{total_chunks}).\n"
f"The summary must be written in {language}.\n"
f"Passage:\n'''\n{chunk}\n'''\n\n"
)
logger.debug(
@@ -52,7 +54,7 @@ async def summarize_chunk(idx, chunk, total_chunks, summarization_agent, ctx):
@last_model_retry_soft_fail
async def document_summarize( # pylint: disable=too-many-locals
ctx: RunContext, *, instructions: str | None = None
ctx: RunContext, *, instructions: str | None = None, language: str = "french"
) -> ToolReturn:
"""
Generate a complete, ready-to-use summary of the documents in context
@@ -66,16 +68,19 @@ async def document_summarize( # pylint: disable=too-many-locals
Examples:
"Summarize this doc in 2 paragraphs" -> instructions = "summary in 2 paragraphs"
"Summarize this doc in English" -> instructions = "In English"
"Summarize this doc" -> instructions = "" (default)
"Summarize this doc in English" -> language = "English"
"Summarize this doc with one paragraph on topic1 and one paragraph on topic2" -> instructions = "summary in 2 paragraphs, one paragraph on topic1 and one paragraph on topic2"
"Summarize this doc" -> instructions = "" (default) language = "french" (default)
Args:
instructions (str | None): The instructions the user gave to use for the summarization
language (str): The language in which the summary must be generated (default: "french")
"""
try:
instructions_hint = (
instructions.strip() if instructions else "The summary should contain 2 or 3 parts."
)
language_hint = (language or "french").strip()
summarization_agent = SummarizationAgent()
# Collect documents content
@@ -118,7 +123,9 @@ async def document_summarize( # pylint: disable=too-many-locals
async def summarize_chunk_with_semaphore(idx, chunk, total_chunks):
"""Summarize a chunk with semaphore-controlled concurrency."""
async with semaphore:
return await summarize_chunk(idx, chunk, total_chunks, summarization_agent, ctx)
return await summarize_chunk(
idx, chunk, total_chunks, summarization_agent, ctx, language_hint
)
doc_chunk_summaries = []
try:
@@ -154,7 +161,8 @@ async def document_summarize( # pylint: disable=too-many-locals
"- Harmonize style and terminology.\n"
"- The final summary must be well-structured and formatted in markdown.\n"
f"- Follow the instructions: {instructions_hint}\n"
"Respond directly with the final summary."
f"- The final summary must be written in {language_hint}.\n"
"Respond directly with the final summary. Begin with a title."
)
logger.debug("[summarize] MERGE prompt=> %s", merged_prompt)
@@ -0,0 +1,77 @@
{
"models": [
{
"hrid": "default-model",
"model_name": "settings.AI_MODEL",
"human_readable_name": "Default Model",
"provider_name": "default-provider",
"profile": {
"openai_supports_strict_tool_definition": false,
"openai_supports_tool_choice_required": false
},
"supports_streaming": false,
"settings": {},
"is_active": true,
"icon": [
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABwAAAAcCAMAAABF0y+mAAAAn1BMVEUALosAKoovTZjw8vb////+9/jlPUniAAz",
"iABUAGIWbpsTwq7HhAAAAI4dle7DrdX4AJohRaaboXWj7+/zn6On5//9NZaT29vfoWmVHYKDoUl/k5OUAIYddc6vpbHYCM47Y3+v53+LiFCUA",
"HIWnsckYPJHi6PL77O7jJjW3wdf1w8jre4QgQ5TZ2txwg7Pr3+I8WZ6OnsTuoamClL7tlZ5xz5y8AAAAzUlEQVR4AZ3RRQKDQBBEUSTu7h5c4",
"vc/W6Yp3KG2Dz4ynDdeEBvOmq12xx2E1u0B+4NOEocj4DgNJ1PgLAvni8WyBq5Yc71ubFJx23C2q4P7dRYejg1xzvCUgvz5guz11k7gXYKF/1",
"8oyiYuvHAYeVkhXCzolVStHcGDjiQzNmMQxsMI5rEJRdQSPZvbpE2E8aY6gC6Z+2Hg4dFA0Yb4YedNL/v4Fk8WJuwiGhrChJNXI210rnib9Fs",
"JlXRUC/HwTscPIXf/iklq/tjb/gHAdxkCUjAg2QAAAABJRU5ErkJggg=="
],
"system_prompt": "settings.AI_AGENT_INSTRUCTIONS",
"tools": "settings.AI_AGENT_TOOLS"
},
{
"hrid": "default-summarization-model",
"model_name": "settings.AI_MODEL",
"human_readable_name": "Default Summarization Model",
"provider_name": "default-provider",
"profile": {
"openai_supports_strict_tool_definition": false,
"openai_supports_tool_choice_required": false
},
"supports_streaming": false,
"settings": {},
"is_active": true,
"icon": null,
"system_prompt": "settings.SUMMARIZATION_SYSTEM_PROMPT",
"tools": []
},
{
"hrid": "etalab-plateform-mistral-medium-2508",
"model_name": "mistral-medium-2508",
"human_readable_name": "Mistral Medium 2508 (Plateforme Etalab)",
"provider_name": "mistral-plateform-etalab",
"profile": null,
"supports_streaming": false,
"settings": {},
"is_active": true,
"icon": [
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABwAAAAcCAMAAABF0y+mAAAAn1BMVEUALosAKoovTZjw8vb////+9/jlPUniAAz",
"iABUAGIWbpsTwq7HhAAAAI4dle7DrdX4AJohRaaboXWj7+/zn6On5//9NZaT29vfoWmVHYKDoUl/k5OUAIYddc6vpbHYCM47Y3+v53+LiFCUA",
"HIWnsckYPJHi6PL77O7jJjW3wdf1w8jre4QgQ5TZ2txwg7Pr3+I8WZ6OnsTuoamClL7tlZ5xz5y8AAAAzUlEQVR4AZ3RRQKDQBBEUSTu7h5c4",
"vc/W6Yp3KG2Dz4ynDdeEBvOmq12xx2E1u0B+4NOEocj4DgNJ1PgLAvni8WyBq5Yc71ubFJx23C2q4P7dRYejg1xzvCUgvz5guz11k7gXYKF/1",
"8oyiYuvHAYeVkhXCzolVStHcGDjiQzNmMQxsMI5rEJRdQSPZvbpE2E8aY6gC6Z+2Hg4dFA0Yb4YedNL/v4Fk8WJuwiGhrChJNXI210rnib9Fs",
"JlXRUC/HwTscPIXf/iklq/tjb/gHAdxkCUjAg2QAAAABJRU5ErkJggg=="
],
"system_prompt": "settings.AI_AGENT_INSTRUCTIONS",
"tools": "settings.AI_AGENT_TOOLS"
}
],
"providers": [
{
"hrid": "default-provider",
"base_url": "settings.AI_BASE_URL",
"api_key": "settings.AI_API_KEY",
"kind": "openai"
},
{
"hrid": "mistral-plateform-etalab",
"base_url": "https://api.mistral.etalab.gouv.fr/",
"api_key": "environ.MISTRAL_ETALAB_API_KEY",
"kind": "mistral"
}
]
}
@@ -791,20 +791,39 @@ export const Chat = ({
<Loader />
<Text $variation="600" $size="md">
{(() => {
const toolInvocation = message.parts?.find(
// Find the tool invocation that is currently running (not completed)
const toolInvocations = message.parts?.filter(
(part) =>
part.type === 'tool-invocation' &&
part.toolInvocation.toolName !==
'document_parsing',
);
) || [];
// Find the last tool invocation that is not yet completed
const activeToolInvocation = [...toolInvocations]
.reverse()
.find(
(part) =>
part.type === 'tool-invocation' &&
part.toolInvocation.state !== 'result',
);
if (
toolInvocation?.type ===
activeToolInvocation?.type ===
'tool-invocation' &&
toolInvocation.toolInvocation.toolName ===
activeToolInvocation.toolInvocation.toolName ===
'summarize'
) {
return t('Summarizing...');
}
if (
activeToolInvocation?.type ===
'tool-invocation' &&
activeToolInvocation.toolInvocation.toolName ===
'fetch_url'
) {
return t('Fetching URL...');
}
return t('Search...');
})()}
</Text>