Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c4e715aebc |
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user