feat: add agentic video analysis and clarify action results
Report device actions as dispatched and preserve full failure details. Route Flash through McpActionExecutor and use ToolFailure across helper tools and agent loops instead of inferring failure from response text. Require model-provided descriptions for coordinate targets. Keep observed element metadata separate from those descriptions in safety checks, visual summaries, history, and frontend labels. Add Gemini Interactions API video analysis with configurable processing mode, chunk size, and timeouts. Centralize Google provider capabilities and token usage normalization, and update google-genai to 2.22.0. Preserve system instructions on follow-up interactions, replan pending chunks when falling back to static processing, and reject unanswered native video and audio segments instead of caching placeholder results. Keep helper-tool failure status intact through history tools and committee calls. Validation: 1,762 Python unit tests passed (3 skipped), 40 frontend formatter tests passed, and the production UI build completed. Ruff lint, formatting, the quality ratchet, and git diff checks passed.
This commit is contained in:
1 parent
611d1a0947
commit
a45daf23f8
120 files changed
+6301
-1710
No files matched your search
@@ -26,7 +26,20 @@ from pathlib import Path
|
||||
from google.genai import types
|
||||
|
||||
from artemis.agents.video_analyzer import video_analyzer as _va
|
||||
from artemis.agents.video_analyzer.reliability import classify_video_failure
|
||||
from artemis.agents.video_analyzer.chunk_conversation import (
|
||||
_MISSING_SUMMARY_ERROR,
|
||||
_NO_TOOL_CALL_ERROR,
|
||||
_SUBMIT_NOW_WARNING,
|
||||
_WRONG_TOOL_ERROR,
|
||||
SUB_AGENT_MAX_TURNS,
|
||||
_append_rejection,
|
||||
_clean_text,
|
||||
_tool_name,
|
||||
)
|
||||
from artemis.agents.video_analyzer.reliability import (
|
||||
SubAgentAnswerExhausted,
|
||||
classify_video_failure,
|
||||
)
|
||||
from artemis.constants import SAFETY_SETTINGS_BLOCK_NONE
|
||||
from artemis.data_engine.trace import TraceSpan
|
||||
from artemis.llm.reliability import retry_policy_for
|
||||
@@ -34,6 +47,11 @@ from artemis.utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
_BAD_CONFIDENCE_ERROR = (
|
||||
"Error: 'confidence_score' is missing or out of bounds. It must be a float"
|
||||
" between 0.0 and 1.0 inclusive. Fix the payload and call submit_answer again."
|
||||
)
|
||||
|
||||
|
||||
class _AudioMedia:
|
||||
"""Mutable holder for per-attempt audio artifacts shared with failure handling."""
|
||||
@@ -427,29 +445,18 @@ async def _run_native_audio_conversation(
|
||||
|
||||
|
||||
async def _drive_audio_agent_loop(analyzer, sub_agent_contents: list) -> tuple[str, str, float]:
|
||||
"""Iterates the audio agent conversation until submit_answer is accepted."""
|
||||
sub_max_iterations = 2
|
||||
sub_iterations = 0
|
||||
final_confidence_score = 0.0
|
||||
final_summary = "No summary provided."
|
||||
final_analysis = "No analysis provided."
|
||||
final_full_text = ""
|
||||
"""Return a validated (summary, analysis, confidence_score).
|
||||
|
||||
while sub_iterations < sub_max_iterations:
|
||||
sub_iterations += 1
|
||||
if sub_iterations == sub_max_iterations - 1:
|
||||
Raise ``SubAgentAnswerExhausted`` if no answer is accepted within the turn limit.
|
||||
"""
|
||||
rejection = _NO_TOOL_CALL_ERROR
|
||||
|
||||
for turn in range(1, SUB_AGENT_MAX_TURNS + 1):
|
||||
if turn == SUB_AGENT_MAX_TURNS - 1:
|
||||
sub_agent_contents.append(
|
||||
types.Content(
|
||||
role="user",
|
||||
parts=[
|
||||
types.Part.from_text(
|
||||
text=(
|
||||
"[WARNING] You must call the"
|
||||
" submit_answer tool now, time"
|
||||
" is running out."
|
||||
)
|
||||
)
|
||||
],
|
||||
parts=[types.Part.from_text(text=_SUBMIT_NOW_WARNING)],
|
||||
)
|
||||
)
|
||||
|
||||
@@ -468,26 +475,16 @@ async def _drive_audio_agent_loop(analyzer, sub_agent_contents: list) -> tuple[s
|
||||
)
|
||||
|
||||
full_text = response.text or ""
|
||||
final_full_text += full_text + "\n"
|
||||
function_calls = response.function_calls or []
|
||||
|
||||
if function_calls:
|
||||
(
|
||||
answered,
|
||||
final_summary,
|
||||
final_analysis,
|
||||
final_confidence_score,
|
||||
) = _handle_audio_function_calls(
|
||||
sub_agent_contents,
|
||||
response,
|
||||
function_calls,
|
||||
final_summary,
|
||||
final_analysis,
|
||||
final_confidence_score,
|
||||
answer, rejection = _handle_audio_function_calls(
|
||||
sub_agent_contents, response, function_calls, full_text
|
||||
)
|
||||
if answered is True:
|
||||
break
|
||||
if answer is not None:
|
||||
return answer
|
||||
else:
|
||||
rejection = _NO_TOOL_CALL_ERROR
|
||||
sub_agent_contents.append(
|
||||
types.Content(
|
||||
role="model",
|
||||
@@ -495,83 +492,52 @@ async def _drive_audio_agent_loop(analyzer, sub_agent_contents: list) -> tuple[s
|
||||
)
|
||||
)
|
||||
|
||||
return final_summary, final_analysis, final_confidence_score
|
||||
raise SubAgentAnswerExhausted(SUB_AGENT_MAX_TURNS, rejection)
|
||||
|
||||
|
||||
def _validate_audio_answer(summary: str, confidence_score) -> str | None:
|
||||
"""Return a validation error for the audio answer, or None if valid."""
|
||||
if not summary:
|
||||
return _MISSING_SUMMARY_ERROR
|
||||
if (
|
||||
not isinstance(confidence_score, (int, float))
|
||||
or isinstance(confidence_score, bool)
|
||||
or not (0.0 <= float(confidence_score) <= 1.0)
|
||||
):
|
||||
return _BAD_CONFIDENCE_ERROR
|
||||
return None
|
||||
|
||||
|
||||
def _handle_audio_function_calls(
|
||||
sub_agent_contents: list,
|
||||
response,
|
||||
function_calls: list,
|
||||
final_summary: str,
|
||||
final_analysis: str,
|
||||
final_confidence_score: float,
|
||||
):
|
||||
"""Processes audio-agent function calls; returns (answered, summary, analysis, score)."""
|
||||
answered = False
|
||||
full_text: str,
|
||||
) -> tuple[tuple[str, str, float] | None, str | None]:
|
||||
"""Return (answer, rejection), appending any rejection to the conversation."""
|
||||
original_parts = response.candidates[0].content.parts
|
||||
for fc in function_calls:
|
||||
if (fc.name.split(":")[-1] if ":" in fc.name else fc.name) == "submit_answer":
|
||||
args = fc.args
|
||||
if (
|
||||
"confidence_score" not in args
|
||||
or not isinstance(
|
||||
args["confidence_score"],
|
||||
(int, float),
|
||||
)
|
||||
or not (0.0 <= float(args["confidence_score"]) <= 1.0)
|
||||
):
|
||||
error_msg = (
|
||||
"Error: 'confidence_score' is"
|
||||
" missing or out of bounds. It must"
|
||||
" be a float between 0.0 and 1.0"
|
||||
" inclusive. Fix the payload and"
|
||||
" call submit_answer again."
|
||||
)
|
||||
sub_agent_contents.append(
|
||||
types.Content(
|
||||
role="model",
|
||||
parts=response.candidates[0].content.parts,
|
||||
)
|
||||
)
|
||||
sub_agent_contents.append(
|
||||
types.Content(
|
||||
role="user",
|
||||
parts=[
|
||||
types.Part.from_function_response(
|
||||
name=fc.name,
|
||||
response={"error": error_msg},
|
||||
)
|
||||
],
|
||||
)
|
||||
)
|
||||
answered = "error"
|
||||
break
|
||||
if _tool_name(fc.name) != "submit_answer":
|
||||
continue
|
||||
args = dict(fc.args or {})
|
||||
summary = _clean_text(args.get("summary"))
|
||||
analysis = _clean_text(args.get("analysis"))
|
||||
|
||||
final_summary = args.get("summary", "No summary provided.").strip().replace("\n", " ")
|
||||
final_analysis = (
|
||||
args.get("analysis", "No analysis provided.").strip().replace("\n", " ")
|
||||
rejection = _validate_audio_answer(summary, args.get("confidence_score"))
|
||||
if rejection is not None:
|
||||
_append_rejection(
|
||||
sub_agent_contents, original_parts, function_calls, full_text, fc.name, rejection
|
||||
)
|
||||
final_confidence_score = float(args["confidence_score"])
|
||||
answered = True
|
||||
break
|
||||
return None, rejection
|
||||
|
||||
if answered is True or answered == "error":
|
||||
return answered, final_summary, final_analysis, final_confidence_score
|
||||
return (summary, analysis or "No analysis provided.", float(args["confidence_score"])), None
|
||||
|
||||
sub_agent_contents.append(
|
||||
types.Content(
|
||||
role="model",
|
||||
parts=response.candidates[0].content.parts,
|
||||
)
|
||||
_append_rejection(
|
||||
sub_agent_contents,
|
||||
original_parts,
|
||||
function_calls,
|
||||
full_text,
|
||||
function_calls[0].name,
|
||||
_WRONG_TOOL_ERROR,
|
||||
)
|
||||
sub_agent_contents.append(
|
||||
types.Content(
|
||||
role="user",
|
||||
parts=[
|
||||
types.Part.from_function_response(
|
||||
name=function_calls[0].name,
|
||||
response={"error": ("Tool not recognized. Please use submit_answer.")},
|
||||
)
|
||||
],
|
||||
)
|
||||
)
|
||||
return answered, final_summary, final_analysis, final_confidence_score
|
||||
return None, _WRONG_TOOL_ERROR
|
||||
@@ -0,0 +1,455 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""Analyze video chunks through the Gemini Interactions API.
|
||||
|
||||
``processing: "agentic"`` lets the model search and revisit an uploaded clip.
|
||||
Validation errors are returned as function results, with video context retained
|
||||
through ``previous_interaction_id``. Only accepted answers reach the blackboard.
|
||||
|
||||
The stream consumer reconstructs function arguments from step deltas. Completion
|
||||
events may omit steps, so it fetches the interaction when reconstruction fails.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import copy
|
||||
from dataclasses import dataclass, field
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
from artemis.agents.video_analyzer import gemini_files
|
||||
from artemis.agents.video_analyzer import video_analyzer as _va
|
||||
from artemis.agents.video_analyzer.chunk_conversation import (
|
||||
_NO_TOOL_CALL_ERROR,
|
||||
_SUBMIT_NOW_WARNING,
|
||||
_WRONG_TOOL_ERROR,
|
||||
SUB_AGENT_MAX_TURNS,
|
||||
SubAgentAnswerExhausted,
|
||||
_ChunkMedia,
|
||||
_clean_text,
|
||||
_persist_timeline_events,
|
||||
_tool_name,
|
||||
_validate_answer,
|
||||
)
|
||||
from artemis.agents.video_analyzer.universal_tools import UNIVERSAL_SUBMIT_ANSWER_TOOL
|
||||
from artemis.data_engine.trace import CURRENT_TRACE_ID, TraceSpan
|
||||
from artemis.llm.google import normalize_usage
|
||||
from artemis.utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
STREAM_START_TIMEOUT_SECONDS = 60.0
|
||||
INTERACTION_READ_TIMEOUT_SECONDS = 30.0
|
||||
|
||||
# Interaction statuses after which no follow-up turn can be sent
|
||||
# (``google.genai`` ``InteractionStatus``).
|
||||
_TERMINAL_FAILURE_STATUSES = frozenset({"failed", "cancelled", "incomplete", "budget_exceeded"})
|
||||
|
||||
|
||||
def build_interactions_submit_tool() -> dict[str, Any]:
|
||||
"""The ``submit_answer`` declaration in Interactions API (flat JSON schema) form.
|
||||
|
||||
Derived from the universal (OpenAI-style) declaration so the schema is
|
||||
written once; ``timeline_events`` is required to match the native path.
|
||||
"""
|
||||
function = UNIVERSAL_SUBMIT_ANSWER_TOOL["function"]
|
||||
parameters = copy.deepcopy(function["parameters"])
|
||||
required = list(parameters.get("required", []))
|
||||
if "timeline_events" not in required:
|
||||
required.append("timeline_events")
|
||||
parameters["required"] = required
|
||||
return {
|
||||
"type": "function",
|
||||
"name": function["name"],
|
||||
"description": function["description"],
|
||||
"parameters": parameters,
|
||||
}
|
||||
|
||||
|
||||
def _steps(interaction: Any) -> list:
|
||||
return list(getattr(interaction, "steps", None) or [])
|
||||
|
||||
|
||||
def _find_function_call(interaction: Any):
|
||||
"""The last ``function_call`` step of an interaction, if any."""
|
||||
for step in reversed(_steps(interaction)):
|
||||
if getattr(step, "type", None) == "function_call":
|
||||
return step
|
||||
return None
|
||||
|
||||
|
||||
def _raise_if_failed(interaction: Any) -> None:
|
||||
"""Raises when an interaction ended in a terminal failure state.
|
||||
|
||||
A follow-up with ``previous_interaction_id`` onto a failed interaction is
|
||||
rejected by the API, so the failure surfaces here as an exception and
|
||||
flows through the shared retry / classification layer instead.
|
||||
"""
|
||||
status = str(getattr(interaction, "status", None) or "").lower()
|
||||
errors = list(getattr(interaction, "errors", None) or [])
|
||||
if status not in _TERMINAL_FAILURE_STATUSES and not errors:
|
||||
return
|
||||
details = "; ".join(
|
||||
f"[{getattr(error, 'code', None)}] {getattr(error, 'message', error)}" for error in errors
|
||||
)
|
||||
raise RuntimeError(
|
||||
f"Gemini Interactions interaction {getattr(interaction, 'id', None)} ended with"
|
||||
f" status {status or 'unknown'}" + (f": {details}" if details else "")
|
||||
)
|
||||
|
||||
|
||||
async def run_agentic_chunk_conversation(
|
||||
analyzer,
|
||||
media: _ChunkMedia,
|
||||
current_start: float,
|
||||
current_end: float,
|
||||
start_time: float,
|
||||
end_time: float | None,
|
||||
specific_query: str,
|
||||
lease_owner: str,
|
||||
) -> str:
|
||||
"""Uploads the chunk and drives the agentic sub-agent to a committed answer."""
|
||||
file = None
|
||||
try:
|
||||
with TraceSpan(name="upload_video_to_gemini") as span:
|
||||
file = await analyzer.upload_and_poll_file(media.compressed_path)
|
||||
span.result = f"Uploaded {file.name}"
|
||||
logger.info(
|
||||
f"Invoking Gemini agentic video understanding for sub-agent task with model"
|
||||
f" {analyzer.model_name} (streaming)..."
|
||||
)
|
||||
trace_id = CURRENT_TRACE_ID.get()
|
||||
|
||||
if analyzer.ctx.data_engine and trace_id:
|
||||
analyzer.ctx.data_engine.record_trace(
|
||||
type="llm_call",
|
||||
name="video_sub_agent",
|
||||
payload={
|
||||
"contents": [
|
||||
f"file://{file.name}",
|
||||
media.prompt_with_context,
|
||||
],
|
||||
"system_instruction": analyzer.sub_system_prompt,
|
||||
"video_processing": "agentic",
|
||||
},
|
||||
parent_trace_id=trace_id,
|
||||
)
|
||||
|
||||
final_summary, final_analysis = await _drive_agentic_loop(
|
||||
analyzer,
|
||||
file,
|
||||
trace_id,
|
||||
media,
|
||||
start_time,
|
||||
end_time,
|
||||
specific_query,
|
||||
)
|
||||
|
||||
end_str = f"{current_end:.1f}s" if current_end is not None else "unknown"
|
||||
chunk_result = (
|
||||
f"[from {current_start:.1f}s to {end_str}] Summary:"
|
||||
f" {final_summary} Analysis: {final_analysis}"
|
||||
)
|
||||
analyzer.blackboard.complete_segment(
|
||||
current_start,
|
||||
current_end,
|
||||
specific_query,
|
||||
lease_owner,
|
||||
final_summary,
|
||||
final_analysis,
|
||||
)
|
||||
return chunk_result
|
||||
finally:
|
||||
if file:
|
||||
await gemini_files.delete_cloud_file(
|
||||
analyzer.client, file.name, analyzer.cloud_files_to_cleanup
|
||||
)
|
||||
|
||||
|
||||
def _initial_request(analyzer, file, prompt: str, tool: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"model": analyzer.model_name,
|
||||
"input": [
|
||||
{
|
||||
"type": "video",
|
||||
"uri": file.uri,
|
||||
"mime_type": file.mime_type,
|
||||
"processing": "agentic",
|
||||
},
|
||||
{"type": "text", "text": prompt},
|
||||
],
|
||||
"system_instruction": analyzer.sub_system_prompt,
|
||||
"tools": [tool],
|
||||
"stream": True,
|
||||
}
|
||||
|
||||
|
||||
def _follow_up_request(
|
||||
analyzer, previous_id: str, steps: list[dict[str, Any]], tool: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
"""A stateful follow-up turn: the server keeps the video context.
|
||||
|
||||
Only the conversation state is inherited through
|
||||
``previous_interaction_id``; ``system_instruction`` and ``tools`` are
|
||||
interaction-scoped and must be re-sent on every turn.
|
||||
"""
|
||||
return {
|
||||
"model": analyzer.model_name,
|
||||
"previous_interaction_id": previous_id,
|
||||
"input": steps,
|
||||
"system_instruction": analyzer.sub_system_prompt,
|
||||
"tools": [tool],
|
||||
"stream": True,
|
||||
}
|
||||
|
||||
|
||||
def _function_result(call_id: str, error: str) -> dict[str, Any]:
|
||||
return {
|
||||
"type": "function_result",
|
||||
"call_id": call_id,
|
||||
"name": "submit_answer",
|
||||
"result": {"error": error},
|
||||
}
|
||||
|
||||
|
||||
async def _drive_agentic_loop(
|
||||
analyzer,
|
||||
file,
|
||||
trace_id,
|
||||
media: _ChunkMedia,
|
||||
start_time: float,
|
||||
end_time: float | None,
|
||||
specific_query: str,
|
||||
) -> tuple[str, str]:
|
||||
"""Return a validated answer and persist its timeline events.
|
||||
|
||||
Raise ``SubAgentAnswerExhausted`` if no answer is accepted within the turn limit.
|
||||
"""
|
||||
tool = build_interactions_submit_tool()
|
||||
request = _initial_request(analyzer, file, media.prompt_with_context, tool)
|
||||
rejection = _NO_TOOL_CALL_ERROR
|
||||
|
||||
for turn in range(1, SUB_AGENT_MAX_TURNS + 1):
|
||||
interaction = await _stream_agentic_turn(analyzer, request, trace_id)
|
||||
interaction_id = getattr(interaction, "id", None) or ""
|
||||
call = _find_function_call(interaction)
|
||||
|
||||
if call is None:
|
||||
rejection = _NO_TOOL_CALL_ERROR
|
||||
steps = [{"type": "text", "text": _SUBMIT_NOW_WARNING}]
|
||||
elif _tool_name(getattr(call, "name", None)) != "submit_answer":
|
||||
rejection = _WRONG_TOOL_ERROR
|
||||
steps = [_function_result(call.id, rejection)]
|
||||
else:
|
||||
args = dict(getattr(call, "arguments", None) or {})
|
||||
timeline_events = args.get("timeline_events", [])
|
||||
if not isinstance(timeline_events, list):
|
||||
timeline_events = []
|
||||
summary = _clean_text(args.get("summary"))
|
||||
analysis = _clean_text(args.get("analysis"))
|
||||
|
||||
rejection = _validate_answer(summary, timeline_events, start_time, end_time)
|
||||
if rejection is None:
|
||||
await _persist_timeline_events(
|
||||
analyzer, timeline_events, media, start_time, end_time, specific_query
|
||||
)
|
||||
return summary, analysis or "No analysis provided."
|
||||
steps = [_function_result(call.id, rejection)]
|
||||
|
||||
if turn < SUB_AGENT_MAX_TURNS:
|
||||
request = _follow_up_request(analyzer, interaction_id, steps, tool)
|
||||
|
||||
raise SubAgentAnswerExhausted(SUB_AGENT_MAX_TURNS, rejection)
|
||||
|
||||
|
||||
async def _stream_agentic_turn(analyzer, request: dict[str, Any], trace_id):
|
||||
"""Runs one traced streaming Interactions turn and returns the completed interaction."""
|
||||
with TraceSpan(name="gemini_agentic_video_call") as span:
|
||||
span.payload = {
|
||||
"model": request.get("model"),
|
||||
"video_processing": "agentic",
|
||||
"previous_interaction_id": request.get("previous_interaction_id"),
|
||||
}
|
||||
stream = await asyncio.wait_for(
|
||||
analyzer.client.aio.interactions.create(**request),
|
||||
timeout=STREAM_START_TIMEOUT_SECONDS,
|
||||
)
|
||||
interaction = await asyncio.wait_for(
|
||||
_consume_agentic_stream(analyzer, span, stream, trace_id),
|
||||
timeout=analyzer.agentic_call_timeout_seconds,
|
||||
)
|
||||
call = _find_function_call(interaction)
|
||||
span.result = (
|
||||
f"Function call: {_tool_name(getattr(call, 'name', None))}"
|
||||
if call is not None
|
||||
else f"Status: {getattr(interaction, 'status', 'unknown')}"
|
||||
)
|
||||
return interaction
|
||||
|
||||
|
||||
@dataclass
|
||||
class _PendingStep:
|
||||
"""A step under construction while its start/delta/stop events stream in."""
|
||||
|
||||
kind: str
|
||||
call_id: str | None = None
|
||||
name: str | None = None
|
||||
seed_arguments: dict[str, Any] = field(default_factory=dict)
|
||||
arguments_json: list[str] = field(default_factory=list)
|
||||
text: list[str] = field(default_factory=list)
|
||||
|
||||
def finish(self):
|
||||
"""Materializes the step in the shape the SDK's typed steps expose.
|
||||
|
||||
Returns ``None`` for a function call whose streamed arguments are not
|
||||
valid JSON: a half-built call must not stand in for the real one, so
|
||||
the step is dropped and the interaction is re-read by id instead.
|
||||
"""
|
||||
if self.kind == "function_call":
|
||||
arguments: dict[str, Any] = dict(self.seed_arguments)
|
||||
raw = "".join(self.arguments_json)
|
||||
if raw.strip():
|
||||
try:
|
||||
parsed = json.loads(raw)
|
||||
except ValueError:
|
||||
logger.warning(
|
||||
f"Could not parse streamed arguments for {self.name}: {raw[:200]!r};"
|
||||
" dropping the step"
|
||||
)
|
||||
return None
|
||||
if isinstance(parsed, dict):
|
||||
arguments = parsed
|
||||
return SimpleNamespace(
|
||||
type="function_call", id=self.call_id, name=self.name, arguments=arguments
|
||||
)
|
||||
if self.kind == "model_output":
|
||||
return SimpleNamespace(
|
||||
type="model_output",
|
||||
content=[SimpleNamespace(type="text", text="".join(self.text))],
|
||||
)
|
||||
return SimpleNamespace(type=self.kind, id=self.call_id)
|
||||
|
||||
|
||||
async def _consume_agentic_stream(analyzer, span, stream, trace_id):
|
||||
"""Consumes Interactions SSE events, rebuilding steps and mirroring progress.
|
||||
|
||||
Returns an interaction-shaped object (``id`` / ``status`` / ``steps`` /
|
||||
``usage``). The interaction is re-read by id when the stream ended
|
||||
without a completion event, or when it stopped at ``requires_action``
|
||||
without a parsable function call.
|
||||
"""
|
||||
data_engine = analyzer.ctx.data_engine if trace_id else None
|
||||
interaction_id: str | None = None
|
||||
status: str | None = None
|
||||
completed_steps: list | None = None
|
||||
usage: dict[str, int] | None = None
|
||||
pending: dict[int, _PendingStep] = {}
|
||||
steps: list = []
|
||||
processing_calls = 0
|
||||
|
||||
def _emit(text: str, *, thinking: bool) -> None:
|
||||
if data_engine and text:
|
||||
data_engine.stream_output(trace_id, text, is_thinking=thinking)
|
||||
|
||||
async for event in stream:
|
||||
data = getattr(event, "data", event)
|
||||
event_type = getattr(data, "event_type", None)
|
||||
|
||||
if event_type == "interaction.created":
|
||||
created = getattr(data, "interaction", None)
|
||||
interaction_id = getattr(created, "id", None) or interaction_id
|
||||
elif event_type == "step.start":
|
||||
step = getattr(data, "step", None)
|
||||
index = getattr(data, "index", None)
|
||||
kind = str(getattr(step, "type", None) or "")
|
||||
pending[index] = _PendingStep(
|
||||
kind=kind,
|
||||
call_id=getattr(step, "id", None) or getattr(step, "call_id", None),
|
||||
name=getattr(step, "name", None),
|
||||
seed_arguments=dict(getattr(step, "arguments", None) or {}),
|
||||
)
|
||||
if kind == "processing_call":
|
||||
processing_calls += 1
|
||||
_emit(
|
||||
f"Inspecting the recording (video lookup {processing_calls})\n",
|
||||
thinking=True,
|
||||
)
|
||||
elif event_type == "step.delta":
|
||||
delta = getattr(data, "delta", None)
|
||||
delta_type = getattr(delta, "type", None)
|
||||
current = pending.get(getattr(data, "index", None))
|
||||
if delta_type == "arguments_delta":
|
||||
fragment = getattr(delta, "arguments", None)
|
||||
if current is not None and isinstance(fragment, str):
|
||||
current.arguments_json.append(fragment)
|
||||
elif delta_type == "text":
|
||||
text = getattr(delta, "text", None)
|
||||
if current is not None and isinstance(text, str):
|
||||
current.text.append(text)
|
||||
_emit(text or "", thinking=False)
|
||||
elif delta_type == "thought_summary":
|
||||
content = getattr(delta, "content", None)
|
||||
_emit(getattr(content, "text", None) or "", thinking=True)
|
||||
elif event_type == "step.stop":
|
||||
current = pending.pop(getattr(data, "index", None), None)
|
||||
finished = current.finish() if current is not None else None
|
||||
if finished is not None:
|
||||
steps.append(finished)
|
||||
usage = normalize_usage(getattr(data, "usage", None)) or usage
|
||||
elif event_type == "interaction.completed":
|
||||
completed = getattr(data, "interaction", None)
|
||||
interaction_id = getattr(completed, "id", None) or interaction_id
|
||||
status = getattr(completed, "status", None) or status
|
||||
completed_steps = getattr(completed, "steps", None) or None
|
||||
usage = normalize_usage(getattr(completed, "usage", None)) or usage
|
||||
_raise_if_failed(completed)
|
||||
elif event_type == "interaction.status_update":
|
||||
status = getattr(data, "status", None) or status
|
||||
elif event_type == "error":
|
||||
error = getattr(data, "error", None)
|
||||
raise RuntimeError(
|
||||
"Gemini Interactions stream error"
|
||||
f" [{getattr(error, 'code', None)}]: {getattr(error, 'message', None)}"
|
||||
)
|
||||
|
||||
for current in pending.values():
|
||||
finished = current.finish()
|
||||
if finished is not None:
|
||||
steps.append(finished)
|
||||
span.payload["processing_calls"] = processing_calls
|
||||
if completed_steps:
|
||||
steps = list(completed_steps)
|
||||
|
||||
needs_reread = status is None or (
|
||||
status == "requires_action" and _find_function_call(SimpleNamespace(steps=steps)) is None
|
||||
)
|
||||
if needs_reread:
|
||||
if not interaction_id:
|
||||
raise RuntimeError("Gemini Interactions stream ended without an interaction")
|
||||
logger.info(f"Re-reading interaction {interaction_id} (status={status})")
|
||||
fetched = await asyncio.wait_for(
|
||||
analyzer.client.aio.interactions.get(interaction_id),
|
||||
timeout=INTERACTION_READ_TIMEOUT_SECONDS,
|
||||
)
|
||||
_raise_if_failed(fetched)
|
||||
status = getattr(fetched, "status", None) or status
|
||||
steps = _steps(fetched) or steps
|
||||
usage = normalize_usage(getattr(fetched, "usage", None)) or usage
|
||||
|
||||
if usage:
|
||||
span.payload["usage_metadata"] = usage
|
||||
return SimpleNamespace(id=interaction_id, status=status, steps=steps, usage=usage)
|
||||
@@ -27,13 +27,46 @@ import re
|
||||
|
||||
from google.genai import types
|
||||
|
||||
from artemis.agents.video_analyzer import gemini_files
|
||||
from artemis.agents.video_analyzer import video_analyzer as _va
|
||||
from artemis.agents.video_analyzer.reliability import SubAgentAnswerExhausted
|
||||
from artemis.constants import SAFETY_SETTINGS_BLOCK_NONE
|
||||
from artemis.data_engine.trace import CURRENT_TRACE_ID, TraceSpan
|
||||
from artemis.llm.google import normalize_usage
|
||||
from artemis.utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
#: Turns the chunk sub-agent gets to produce an accepted ``submit_answer``
|
||||
#: (shared by the static and agentic paths).
|
||||
SUB_AGENT_MAX_TURNS = 2
|
||||
|
||||
_SUBMIT_NOW_WARNING = "[WARNING] You must call the submit_answer tool now, time is running out."
|
||||
_WRONG_TOOL_ERROR = "Tool not recognized. Please use submit_answer."
|
||||
_NO_TOOL_CALL_ERROR = "The model replied with text only and never called submit_answer."
|
||||
_MISSING_SUMMARY_ERROR = (
|
||||
"submit_answer was called without a 'summary'. Call it again with a non-empty"
|
||||
" 'summary' (and 'analysis') describing what you observed."
|
||||
)
|
||||
|
||||
|
||||
def _tool_name(name: str | None) -> str:
|
||||
name = name or ""
|
||||
return name.split(":")[-1] if ":" in name else name
|
||||
|
||||
|
||||
def _clean_text(value) -> str:
|
||||
return str(value or "").strip().replace("\n", " ")
|
||||
|
||||
|
||||
def _validate_answer(
|
||||
summary: str, timeline_events: list, start_time: float, end_time: float | None
|
||||
) -> str | None:
|
||||
"""Return a validation error for submit_answer, or None if valid."""
|
||||
if not summary:
|
||||
return _MISSING_SUMMARY_ERROR
|
||||
return _validate_timeline_events(timeline_events, start_time, end_time)
|
||||
|
||||
|
||||
class _ChunkMedia:
|
||||
"""Mutable holder for per-attempt media artifacts shared with failure handling."""
|
||||
@@ -120,15 +153,9 @@ async def _run_native_chunk_conversation(
|
||||
return chunk_result
|
||||
finally:
|
||||
if file:
|
||||
logger.info(f"Cleaning up cloud file {file.name}...")
|
||||
try:
|
||||
await asyncio.wait_for(
|
||||
analyzer.client.aio.files.delete(name=file.name),
|
||||
timeout=30,
|
||||
)
|
||||
analyzer.cloud_files_to_cleanup.discard(file.name)
|
||||
except Exception as ce:
|
||||
logger.error(f"Failed to delete cloud file {file.name}: {ce}")
|
||||
await gemini_files.delete_cloud_file(
|
||||
analyzer.client, file.name, analyzer.cloud_files_to_cleanup
|
||||
)
|
||||
|
||||
|
||||
async def _drive_sub_agent_loop(
|
||||
@@ -140,38 +167,27 @@ async def _drive_sub_agent_loop(
|
||||
end_time: float | None,
|
||||
specific_query: str,
|
||||
) -> tuple[str, str]:
|
||||
"""Iterates the sub-agent conversation until submit_answer is accepted."""
|
||||
sub_max_iterations = 2
|
||||
sub_iterations = 0
|
||||
final_analysis = "No analysis provided."
|
||||
final_summary = "No summary provided."
|
||||
final_full_text = ""
|
||||
"""Return a validated answer and persist its timeline events.
|
||||
|
||||
while sub_iterations < sub_max_iterations:
|
||||
sub_iterations += 1
|
||||
if sub_iterations == sub_max_iterations - 1:
|
||||
Raise ``SubAgentAnswerExhausted`` if no answer is accepted within the turn limit.
|
||||
"""
|
||||
rejection = _NO_TOOL_CALL_ERROR
|
||||
|
||||
for turn in range(1, SUB_AGENT_MAX_TURNS + 1):
|
||||
if turn == SUB_AGENT_MAX_TURNS - 1:
|
||||
sub_agent_contents.append(
|
||||
types.Content(
|
||||
role="user",
|
||||
parts=[
|
||||
types.Part.from_text(
|
||||
text=(
|
||||
"[WARNING] You must call the"
|
||||
" submit_answer tool now, time"
|
||||
" is running out."
|
||||
)
|
||||
)
|
||||
],
|
||||
parts=[types.Part.from_text(text=_SUBMIT_NOW_WARNING)],
|
||||
)
|
||||
)
|
||||
|
||||
full_text, function_calls, original_parts = await _stream_sub_agent_turn(
|
||||
analyzer, sub_agent_contents, trace_id
|
||||
)
|
||||
final_full_text += full_text + "\n"
|
||||
|
||||
if function_calls:
|
||||
answered, final_summary, final_analysis = await _handle_sub_agent_calls(
|
||||
answer, rejection = await _handle_sub_agent_calls(
|
||||
analyzer,
|
||||
sub_agent_contents,
|
||||
function_calls,
|
||||
@@ -181,12 +197,11 @@ async def _drive_sub_agent_loop(
|
||||
start_time,
|
||||
end_time,
|
||||
specific_query,
|
||||
final_summary,
|
||||
final_analysis,
|
||||
)
|
||||
if answered is True:
|
||||
break
|
||||
if answer is not None:
|
||||
return answer
|
||||
else:
|
||||
rejection = _NO_TOOL_CALL_ERROR
|
||||
sub_agent_contents.append(
|
||||
types.Content(
|
||||
role="model",
|
||||
@@ -194,7 +209,7 @@ async def _drive_sub_agent_loop(
|
||||
)
|
||||
)
|
||||
|
||||
return final_summary, final_analysis
|
||||
raise SubAgentAnswerExhausted(SUB_AGENT_MAX_TURNS, rejection)
|
||||
|
||||
|
||||
async def _stream_sub_agent_turn(
|
||||
@@ -228,24 +243,9 @@ async def _read_sub_agent_stream(analyzer, span, target_stream, current_trace_id
|
||||
function_calls = []
|
||||
accumulated_parts = []
|
||||
async for chunk in target_stream:
|
||||
if hasattr(chunk, "usage_metadata") and chunk.usage_metadata:
|
||||
span.payload["usage_metadata"] = {
|
||||
"prompt_token_count": getattr(
|
||||
chunk.usage_metadata,
|
||||
"prompt_token_count",
|
||||
0,
|
||||
),
|
||||
"candidates_token_count": getattr(
|
||||
chunk.usage_metadata,
|
||||
"candidates_token_count",
|
||||
0,
|
||||
),
|
||||
"total_token_count": getattr(
|
||||
chunk.usage_metadata,
|
||||
"total_token_count",
|
||||
0,
|
||||
),
|
||||
}
|
||||
usage = normalize_usage(getattr(chunk, "usage_metadata", None))
|
||||
if usage:
|
||||
span.payload["usage_metadata"] = usage
|
||||
|
||||
chunk_text = chunk.text or ""
|
||||
if chunk_text:
|
||||
@@ -287,42 +287,33 @@ async def _handle_sub_agent_calls(
|
||||
start_time: float,
|
||||
end_time: float | None,
|
||||
specific_query: str,
|
||||
final_summary: str,
|
||||
final_analysis: str,
|
||||
):
|
||||
"""Processes function calls from one turn; returns (answered, summary, analysis)."""
|
||||
answered = False
|
||||
) -> tuple[tuple[str, str] | None, str | None]:
|
||||
"""Return ``(answer, rejection)`` after processing one turn's tool calls.
|
||||
|
||||
Persist events for accepted answers; append rejected calls to the conversation
|
||||
with an error for the model to correct.
|
||||
"""
|
||||
for fc in function_calls:
|
||||
if (fc.name.split(":")[-1] if ":" in fc.name else fc.name) == "submit_answer":
|
||||
args = fc.args
|
||||
timeline_events = args.get("timeline_events", [])
|
||||
final_summary = args.get("summary", "No summary provided.").strip().replace("\n", " ")
|
||||
final_analysis = (
|
||||
args.get("analysis", "No analysis provided.").strip().replace("\n", " ")
|
||||
if _tool_name(fc.name) != "submit_answer":
|
||||
continue
|
||||
args = dict(fc.args or {})
|
||||
timeline_events = args.get("timeline_events", [])
|
||||
if not isinstance(timeline_events, list):
|
||||
timeline_events = []
|
||||
summary = _clean_text(args.get("summary"))
|
||||
analysis = _clean_text(args.get("analysis"))
|
||||
|
||||
rejection = _validate_answer(summary, timeline_events, start_time, end_time)
|
||||
if rejection is not None:
|
||||
_append_rejection(
|
||||
sub_agent_contents, original_parts, function_calls, full_text, fc.name, rejection
|
||||
)
|
||||
return None, rejection
|
||||
|
||||
error_msg = _validate_timeline_events(timeline_events, start_time, end_time)
|
||||
if error_msg is not None:
|
||||
_append_rejection(
|
||||
sub_agent_contents,
|
||||
original_parts,
|
||||
function_calls,
|
||||
full_text,
|
||||
fc.name,
|
||||
error_msg,
|
||||
)
|
||||
answered = "error"
|
||||
break
|
||||
|
||||
await _persist_timeline_events(
|
||||
analyzer, timeline_events, media, start_time, end_time, specific_query
|
||||
)
|
||||
|
||||
answered = True
|
||||
break
|
||||
|
||||
if answered is True or answered == "error":
|
||||
return answered, final_summary, final_analysis
|
||||
await _persist_timeline_events(
|
||||
analyzer, timeline_events, media, start_time, end_time, specific_query
|
||||
)
|
||||
return (summary, analysis or "No analysis provided."), None
|
||||
|
||||
_append_rejection(
|
||||
sub_agent_contents,
|
||||
@@ -330,9 +321,9 @@ async def _handle_sub_agent_calls(
|
||||
function_calls,
|
||||
full_text,
|
||||
function_calls[0].name,
|
||||
("Tool not recognized. Please use submit_answer."),
|
||||
_WRONG_TOOL_ERROR,
|
||||
)
|
||||
return answered, final_summary, final_analysis
|
||||
return None, _WRONG_TOOL_ERROR
|
||||
|
||||
|
||||
def _validate_timeline_events(
|
||||
|
||||
@@ -26,11 +26,16 @@ import asyncio
|
||||
from pathlib import Path
|
||||
|
||||
from artemis.agents.video_analyzer import video_analyzer as _va
|
||||
from artemis.agents.video_analyzer.chunk_agentic import run_agentic_chunk_conversation
|
||||
from artemis.agents.video_analyzer.chunk_conversation import (
|
||||
_ChunkMedia,
|
||||
_run_native_chunk_conversation,
|
||||
)
|
||||
from artemis.agents.video_analyzer.reliability import classify_video_failure
|
||||
from artemis.agents.video_analyzer.reliability import (
|
||||
AgenticVideoDegraded,
|
||||
classify_video_failure,
|
||||
is_agentic_rejection,
|
||||
)
|
||||
from artemis.llm.reliability import retry_policy_for
|
||||
from artemis.utils.logger import get_logger
|
||||
|
||||
@@ -76,12 +81,19 @@ async def exec_single_chunk(
|
||||
|
||||
media = _ChunkMedia()
|
||||
|
||||
for attempt in range(max_retries + 1):
|
||||
attempt = 0
|
||||
while attempt <= max_retries:
|
||||
# The mode this attempt was launched in: a parallel chunk may degrade
|
||||
# the run while this one is still in flight.
|
||||
attempt_mode = analyzer.video_processing
|
||||
try:
|
||||
logger.info(
|
||||
f"Attempt {attempt + 1}/{max_retries + 1} for sub-agent:"
|
||||
f" start={current_start}, end={current_end}"
|
||||
)
|
||||
# A sibling may switch the run to static during queuing or media
|
||||
# preparation. Check the size before preparation and before dispatch.
|
||||
_ensure_fits_static_ceiling(analyzer, current_start, current_end)
|
||||
result, slowdown_factor = await _prepare_chunk_media(
|
||||
analyzer, media, current_start, current_end
|
||||
)
|
||||
@@ -94,6 +106,7 @@ async def exec_single_chunk(
|
||||
end_time,
|
||||
specific_query,
|
||||
)
|
||||
_ensure_fits_static_ceiling(analyzer, current_start, current_end)
|
||||
|
||||
if not analyzer.use_native_gemini:
|
||||
chunk_result = await analyzer._exec_single_chunk_universal(
|
||||
@@ -116,6 +129,18 @@ async def exec_single_chunk(
|
||||
)
|
||||
return chunk_result
|
||||
|
||||
if analyzer.video_processing == "agentic":
|
||||
return await run_agentic_chunk_conversation(
|
||||
analyzer,
|
||||
media,
|
||||
current_start,
|
||||
current_end,
|
||||
start_time,
|
||||
end_time,
|
||||
specific_query,
|
||||
lease_owner,
|
||||
)
|
||||
|
||||
return await _run_native_chunk_conversation(
|
||||
analyzer,
|
||||
media,
|
||||
@@ -134,6 +159,22 @@ async def exec_single_chunk(
|
||||
# stream output from the failed attempt is discarded, never
|
||||
# surfaced as a complete answer.
|
||||
failure = classify_video_failure(e)
|
||||
if attempt_mode == "agentic" and is_agentic_rejection(failure, e):
|
||||
# Switch to static and let the coordinator resize the interval.
|
||||
_degrade_agentic_to_static(analyzer, e, failure)
|
||||
return await _finish_failed_chunk(
|
||||
analyzer,
|
||||
media,
|
||||
current_start,
|
||||
current_end,
|
||||
specific_query,
|
||||
lease_owner,
|
||||
AgenticVideoDegraded(
|
||||
f"Agentic video processing rejected ({e}); re-plan"
|
||||
f" {current_start:.1f}s-{current_end:.1f}s at the static chunk size"
|
||||
),
|
||||
retryable=False,
|
||||
)
|
||||
attempts_so_far = attempt + 1
|
||||
if not failure.retryable:
|
||||
logger.warning(
|
||||
@@ -181,12 +222,51 @@ async def exec_single_chunk(
|
||||
source="video_sub_agent",
|
||||
)
|
||||
await asyncio.sleep(delay)
|
||||
attempt += 1
|
||||
|
||||
raise RuntimeError(
|
||||
f"Video sub-agent exhausted retries for {current_start:.1f}s-{current_end:.1f}s"
|
||||
)
|
||||
|
||||
|
||||
def _degrade_agentic_to_static(analyzer, error: Exception, failure) -> None:
|
||||
"""Switches the rest of this run to static video understanding (idempotent)."""
|
||||
if analyzer.video_processing != "agentic":
|
||||
return
|
||||
logger.warning(
|
||||
f"Agentic video processing rejected ({error}); the rest of this run"
|
||||
" analyzes video in static mode"
|
||||
)
|
||||
_va._record_llm_event(
|
||||
"llm_fallback",
|
||||
{
|
||||
"reason": "agentic_video_rejected",
|
||||
"category": failure.category.value,
|
||||
"error": str(error)[:500],
|
||||
},
|
||||
)
|
||||
analyzer.video_processing = "static"
|
||||
analyzer.chunk_size_seconds = analyzer.static_chunk_size_seconds
|
||||
|
||||
|
||||
def _ensure_fits_static_ceiling(analyzer, current_start: float, current_end: float) -> None:
|
||||
"""Refuses to analyze a clip sized for the agentic ceiling once the run is static.
|
||||
|
||||
Raises :class:`AgenticVideoDegraded`, which the attempt loop treats as a
|
||||
non-retryable failure: the lease is released via ``fail_segment`` and the
|
||||
coordinator re-plans the interval into static-sized pieces.
|
||||
"""
|
||||
if analyzer.video_processing == "agentic":
|
||||
return
|
||||
if current_end - current_start <= analyzer.chunk_size_seconds + 1e-3:
|
||||
return
|
||||
raise AgenticVideoDegraded(
|
||||
f"Run degraded to static video processing while {current_start:.1f}s-{current_end:.1f}s"
|
||||
f" was pending; re-plan it at the static chunk size"
|
||||
f" ({analyzer.chunk_size_seconds:.0f}s)"
|
||||
)
|
||||
|
||||
|
||||
async def _finish_failed_chunk(
|
||||
analyzer,
|
||||
media: _ChunkMedia,
|
||||
|
||||
@@ -97,6 +97,18 @@ async def cleanup_abandoned_gemini_files(client) -> None:
|
||||
logger.warning(f"Routine cloud maintenance skipped: {e}")
|
||||
|
||||
|
||||
async def delete_cloud_file(
|
||||
client, file_name: str, cloud_files_to_cleanup: set, *, timeout: float = 30.0
|
||||
) -> None:
|
||||
"""Best-effort deletion of an uploaded file; failures are logged, never raised."""
|
||||
logger.info(f"Cleaning up cloud file {file_name}...")
|
||||
try:
|
||||
await asyncio.wait_for(client.aio.files.delete(name=file_name), timeout=timeout)
|
||||
cloud_files_to_cleanup.discard(file_name)
|
||||
except Exception as ce:
|
||||
logger.error(f"Failed to delete cloud file {file_name}: {ce}")
|
||||
|
||||
|
||||
async def upload_and_poll_file(client, compressed_path: Path, cloud_files_to_cleanup: set) -> any:
|
||||
"""Upload a media file to the Gemini File API and poll until it is ACTIVE."""
|
||||
logger.info(f"Uploading {compressed_path} to Gemini File API...")
|
||||
|
||||
@@ -32,6 +32,7 @@ from artemis.agents.video_analyzer import video_analyzer as _va
|
||||
from artemis.agents.video_analyzer.reliability import classify_video_failure
|
||||
from artemis.constants import SAFETY_SETTINGS_BLOCK_NONE
|
||||
from artemis.data_engine.trace import CURRENT_TRACE_ID, TraceSpan
|
||||
from artemis.llm.google import normalize_usage
|
||||
from artemis.utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
@@ -349,24 +350,9 @@ async def _consume_main_stream(
|
||||
function_calls = []
|
||||
|
||||
async for chunk in stream:
|
||||
if hasattr(chunk, "usage_metadata") and chunk.usage_metadata:
|
||||
span.payload["usage_metadata"] = {
|
||||
"prompt_token_count": getattr(
|
||||
chunk.usage_metadata,
|
||||
"prompt_token_count",
|
||||
0,
|
||||
),
|
||||
"candidates_token_count": getattr(
|
||||
chunk.usage_metadata,
|
||||
"candidates_token_count",
|
||||
0,
|
||||
),
|
||||
"total_token_count": getattr(
|
||||
chunk.usage_metadata,
|
||||
"total_token_count",
|
||||
0,
|
||||
),
|
||||
}
|
||||
usage = normalize_usage(getattr(chunk, "usage_metadata", None))
|
||||
if usage:
|
||||
span.payload["usage_metadata"] = usage
|
||||
|
||||
chunk_text = chunk.text or ""
|
||||
text += chunk_text
|
||||
|
||||
@@ -53,10 +53,50 @@ class VideoFailure:
|
||||
|
||||
|
||||
_MEDIA_MARKERS = ("ffmpeg", "codec", "corrupt", "invalid video", "invalid audio", "media")
|
||||
_AGENTIC_REJECTION_MARKERS = ("processing", "agentic")
|
||||
|
||||
|
||||
class SubAgentAnswerExhausted(RuntimeError):
|
||||
"""The sub-agent exhausted its turns without a valid submit_answer.
|
||||
|
||||
The caller records a failed segment so a later claim can retry it. The shared
|
||||
classifier permits retries, splitting, and fallback for this failure.
|
||||
"""
|
||||
|
||||
def __init__(self, turns: int, reason: str):
|
||||
self.turns = turns
|
||||
self.reason = reason
|
||||
super().__init__(
|
||||
f"submit_answer was not accepted within {turns} turn(s); last rejection: {reason}"
|
||||
)
|
||||
|
||||
|
||||
class AgenticVideoDegraded(RuntimeError):
|
||||
"""Return an interval to the coordinator after switching to static mode.
|
||||
|
||||
The coordinator splits oversized clips and retries smaller clips as-is.
|
||||
"""
|
||||
|
||||
|
||||
def is_agentic_rejection(failure: VideoFailure, error: BaseException) -> bool:
|
||||
"""True when a bad request is the API refusing agentic video processing.
|
||||
|
||||
Only an HTTP 400 whose message mentions ``processing`` / ``agentic``
|
||||
qualifies; other bad requests (413 payload too large, 404, ...) keep the
|
||||
shared handling.
|
||||
"""
|
||||
if failure.category is not VideoFailureCategory.BAD_REQUEST:
|
||||
return False
|
||||
if extract_status_code(error) != 400:
|
||||
return False
|
||||
message = str(error).lower()
|
||||
return any(marker in message for marker in _AGENTIC_REJECTION_MARKERS)
|
||||
|
||||
|
||||
def classify_video_failure(error: BaseException) -> VideoFailure:
|
||||
"""Convert provider/transport/media exceptions into recovery decisions."""
|
||||
if isinstance(error, AgenticVideoDegraded):
|
||||
return VideoFailure(VideoFailureCategory.BAD_REQUEST, False, True, False)
|
||||
generic = classify_failure(error)
|
||||
message = str(error).lower()
|
||||
has_media_marker = any(marker in message for marker in _MEDIA_MARKERS)
|
||||
|
||||
@@ -36,7 +36,10 @@ from langchain_core.messages import (
|
||||
)
|
||||
|
||||
from artemis.agents.video_analyzer import video_analyzer as _va
|
||||
from artemis.agents.video_analyzer.reliability import classify_video_failure
|
||||
from artemis.agents.video_analyzer.reliability import (
|
||||
SubAgentAnswerExhausted,
|
||||
classify_video_failure,
|
||||
)
|
||||
from artemis.data_engine.trace import CURRENT_TRACE_ID, TraceSpan
|
||||
from artemis.utils.logger import get_logger
|
||||
|
||||
@@ -218,29 +221,33 @@ async def _invoke_universal_with_visual_retry(
|
||||
|
||||
|
||||
def _extract_submit_payload(response) -> tuple[str, str, list]:
|
||||
"""Pulls summary/analysis/timeline events from a universal model response."""
|
||||
final_summary = "No summary provided."
|
||||
final_analysis = "No analysis provided."
|
||||
timeline_events = []
|
||||
"""Extract a summary, analysis, and events from the model response.
|
||||
|
||||
if getattr(response, "tool_calls", None):
|
||||
for tc in response.tool_calls:
|
||||
if tc.get("name") == "submit_answer":
|
||||
args = tc.get("args", {})
|
||||
final_summary = (
|
||||
str(args.get("summary", "")).strip().replace("\n", " ") or final_summary
|
||||
)
|
||||
final_analysis = (
|
||||
str(args.get("analysis", "")).strip().replace("\n", " ") or final_analysis
|
||||
)
|
||||
timeline_events = args.get("timeline_events", [])
|
||||
break
|
||||
elif getattr(response, "content", None):
|
||||
text_content = str(response.content).strip()
|
||||
final_summary = text_content[:200].replace("\n", " ")
|
||||
final_analysis = text_content
|
||||
Use the first submit_answer call if its summary is non-empty. Otherwise,
|
||||
fall back to response text, using its first 200 characters as the summary
|
||||
and dropping any events from the rejected call. Raise SubAgentAnswerExhausted
|
||||
if neither source provides an answer.
|
||||
"""
|
||||
for tc in getattr(response, "tool_calls", None) or []:
|
||||
if tc.get("name") != "submit_answer":
|
||||
continue
|
||||
args = tc.get("args") or {}
|
||||
final_summary = str(args.get("summary", "")).strip().replace("\n", " ")
|
||||
if not final_summary:
|
||||
break
|
||||
final_analysis = (
|
||||
str(args.get("analysis", "")).strip().replace("\n", " ") or "No analysis provided."
|
||||
)
|
||||
return final_summary, final_analysis, list(args.get("timeline_events") or [])
|
||||
|
||||
return final_summary, final_analysis, timeline_events
|
||||
raw_content = getattr(response, "content", None)
|
||||
text_content = str(raw_content).strip() if raw_content else ""
|
||||
if text_content:
|
||||
return text_content[:200].replace("\n", " "), text_content, []
|
||||
|
||||
raise SubAgentAnswerExhausted(
|
||||
1, "the model neither called submit_answer with a summary nor replied with text"
|
||||
)
|
||||
|
||||
|
||||
async def _persist_universal_events(
|
||||
@@ -361,23 +368,9 @@ async def exec_analyze_audio_universal(
|
||||
label="Universal audio sub-agent",
|
||||
force_fallback=force_fallback,
|
||||
)
|
||||
final_summary = "No audio summary provided."
|
||||
final_analysis = "No audio analysis provided."
|
||||
|
||||
if getattr(response, "tool_calls", None):
|
||||
for tc in response.tool_calls:
|
||||
if tc.get("name") == "submit_answer":
|
||||
args = tc.get("args", {})
|
||||
final_summary = (
|
||||
str(args.get("summary", "")).strip().replace("\n", " ") or final_summary
|
||||
)
|
||||
final_analysis = (
|
||||
str(args.get("analysis", "")).strip().replace("\n", " ") or final_analysis
|
||||
)
|
||||
break
|
||||
elif getattr(response, "content", None):
|
||||
final_summary = str(response.content)[:200].replace("\n", " ")
|
||||
final_analysis = str(response.content)
|
||||
# Same resolution order as the video chunk (raises SubAgentAnswerExhausted
|
||||
# when the model produced nothing usable); audio has no timeline events.
|
||||
final_summary, final_analysis, _ = _extract_submit_payload(response)
|
||||
|
||||
end_val = end_time if end_time is not None else "unknown"
|
||||
analyzer._record_blackboard_entry(
|
||||
|
||||
@@ -53,6 +53,7 @@ from artemis.agents.video_analyzer.gemini_files import (
|
||||
cleanup_abandoned_gemini_files,
|
||||
)
|
||||
from artemis.agents.video_analyzer.reliability import (
|
||||
AgenticVideoDegraded,
|
||||
VideoCircuitBreaker,
|
||||
classify_video_failure,
|
||||
)
|
||||
@@ -67,6 +68,11 @@ from artemis.constants import SAFETY_SETTINGS_BLOCK_NONE
|
||||
from artemis.context import ArtemisContext
|
||||
from artemis.controllers.controller_factory import get_controller
|
||||
from artemis.data_engine.trace import CURRENT_TRACE_ID, TraceSpan, trace
|
||||
from artemis.llm.google import (
|
||||
is_gemini_model,
|
||||
resolve_video_processing,
|
||||
strip_provider_prefix,
|
||||
)
|
||||
from artemis.llm.reliability import retry_policy_for
|
||||
from artemis.services.llm import _record_llm_event, _record_llm_retry, get_llm
|
||||
from artemis.utils.logger import get_logger
|
||||
@@ -152,7 +158,19 @@ class VideoAnalyzer:
|
||||
"enable_ledger",
|
||||
getattr(agent_config, "enable_video_ledger", True),
|
||||
)
|
||||
self.chunk_size_seconds = float(getattr(self.video_config, "chunk_size_seconds", 60.0))
|
||||
self.static_chunk_size_seconds = float(
|
||||
getattr(self.video_config, "chunk_size_seconds", 60.0)
|
||||
)
|
||||
self.agentic_chunk_size_seconds = float(
|
||||
getattr(self.video_config, "agentic_chunk_size_seconds", 600.0)
|
||||
)
|
||||
self.agentic_call_timeout_seconds = float(
|
||||
getattr(self.video_config, "agentic_call_timeout_seconds", 300.0)
|
||||
)
|
||||
# Effective chunk ceiling; re-derived once the engine picks a
|
||||
# video-processing mode (agentic sub-agents take long clips).
|
||||
self.chunk_size_seconds = self.static_chunk_size_seconds
|
||||
self.video_processing = "static"
|
||||
self.min_chunk_seconds = float(getattr(self.video_config, "min_chunk_seconds", 4.0))
|
||||
self.max_split_depth = int(getattr(self.video_config, "max_split_depth", 4))
|
||||
self.action_window_seconds = float(getattr(self.video_config, "action_window_seconds", 2.0))
|
||||
@@ -399,25 +417,19 @@ class VideoAnalyzer:
|
||||
llm_config = getattr(ctx, "llm_config", None)
|
||||
utils_cfg = getattr(llm_config, "utils", None) if llm_config else None
|
||||
llm_cfg = getattr(utils_cfg, "video_analyzer", None) if utils_cfg else None
|
||||
model_str = (
|
||||
llm_cfg.model if (llm_cfg and hasattr(llm_cfg, "model")) else "gemini-3.8-flash"
|
||||
).lower()
|
||||
self.model_name = (
|
||||
self.model_name = strip_provider_prefix(
|
||||
llm_cfg.model if (llm_cfg and hasattr(llm_cfg, "model")) else "gemini-3.8-flash"
|
||||
)
|
||||
if "/" in self.model_name:
|
||||
self.model_name = self.model_name.split("/")[-1]
|
||||
|
||||
has_google_key = bool(
|
||||
settings.GOOGLE_API_KEY and settings.GOOGLE_API_KEY.get_secret_value()
|
||||
)
|
||||
is_gemini_model = "gemini" in model_str
|
||||
|
||||
# If client is already set on ctx or explicit Gemini configuration
|
||||
self.client = getattr(ctx, "_genai_client", None)
|
||||
if self.client is not None:
|
||||
self.use_native_gemini = True
|
||||
elif has_google_key and is_gemini_model:
|
||||
elif has_google_key and is_gemini_model(self.model_name):
|
||||
try:
|
||||
self.client = genai.Client(api_key=settings.GOOGLE_API_KEY.get_secret_value())
|
||||
ctx._genai_client = self.client
|
||||
@@ -430,6 +442,29 @@ class VideoAnalyzer:
|
||||
self.use_native_gemini = False
|
||||
else:
|
||||
self.use_native_gemini = False
|
||||
self._resolve_video_processing()
|
||||
|
||||
def _resolve_video_processing(self) -> None:
|
||||
"""Picks static vs agentic video understanding for the sub-agents.
|
||||
|
||||
Agentic processing (Gemini Interactions API) needs the native client
|
||||
and a supporting model; the configured knob (``auto`` / ``agentic`` /
|
||||
``static``) decides the rest. The chunk ceiling follows the mode
|
||||
because agentic sub-agents are built to take long clips.
|
||||
"""
|
||||
configured = getattr(self.video_config, "processing", "auto")
|
||||
mode = "static"
|
||||
if self.use_native_gemini:
|
||||
mode = resolve_video_processing(configured, self.model_name)
|
||||
self.video_processing = mode
|
||||
self.chunk_size_seconds = (
|
||||
self.agentic_chunk_size_seconds if mode == "agentic" else self.static_chunk_size_seconds
|
||||
)
|
||||
if mode == "agentic":
|
||||
logger.info(
|
||||
f"Video sub-agents use agentic video understanding on {self.model_name}"
|
||||
f" (chunk ceiling {self.chunk_size_seconds:.0f}s)"
|
||||
)
|
||||
|
||||
@property
|
||||
def blackboard_ledger(self) -> str:
|
||||
@@ -565,6 +600,18 @@ class VideoAnalyzer:
|
||||
self.local_dirs_to_cleanup.add(metadata_path.parent)
|
||||
return requested_start, requested_end, None
|
||||
|
||||
def _plan_chunks(self, intervals: list[tuple[float, float]]) -> list[tuple[float, float]]:
|
||||
"""Cuts intervals into consecutive chunks of at most ``chunk_size_seconds``."""
|
||||
target_chunk_size = self.chunk_size_seconds
|
||||
chunks: list[tuple[float, float]] = []
|
||||
for gap_start, gap_end in intervals:
|
||||
cursor = gap_start
|
||||
while cursor < gap_end:
|
||||
chunk_end = min(gap_end, cursor + target_chunk_size)
|
||||
chunks.append((round(cursor, 3), round(chunk_end, 3)))
|
||||
cursor = chunk_end
|
||||
return chunks
|
||||
|
||||
@trace(type="tool", name="spawn_sub_agent")
|
||||
async def exec_spawn_sub_agent(
|
||||
self, start_time: float, end_time: float | None, specific_query: str
|
||||
@@ -600,21 +647,14 @@ class VideoAnalyzer:
|
||||
)
|
||||
return "CACHED VIDEO ANALYSIS: " + " ".join(cached_results)
|
||||
|
||||
target_chunk_size = self.chunk_size_seconds
|
||||
chunks: list[tuple[float, float]] = []
|
||||
for gap_start, gap_end in missing:
|
||||
cursor = gap_start
|
||||
while cursor < gap_end:
|
||||
chunk_end = min(gap_end, cursor + target_chunk_size)
|
||||
chunks.append((round(cursor, 3), round(chunk_end, 3)))
|
||||
cursor = chunk_end
|
||||
chunks = self._plan_chunks(missing)
|
||||
logger.info(
|
||||
f"Video blackboard planned {len(chunks)} uncovered chunk(s) for "
|
||||
f"{requested_start:.1f}s-{requested_end:.1f}s"
|
||||
)
|
||||
|
||||
async def analyze_with_recovery(
|
||||
cs: float, ce: float, depth: int = 0
|
||||
cs: float, ce: float, depth: int = 0, *, retried_static: bool = False
|
||||
) -> tuple[list[str], list[str]]:
|
||||
try:
|
||||
async with API_SEMAPHORE:
|
||||
@@ -623,8 +663,40 @@ class VideoAnalyzer:
|
||||
except Exception as error:
|
||||
failure = classify_video_failure(error)
|
||||
duration = ce - cs
|
||||
fits_ceiling = duration <= self.chunk_size_seconds + 1e-3
|
||||
degraded = isinstance(error, AgenticVideoDegraded)
|
||||
if degraded and fits_ceiling and not retried_static:
|
||||
# Retry short intervals once in static mode without splitting.
|
||||
logger.warning(
|
||||
f"Retrying {cs:.1f}s-{ce:.1f}s in static mode: it already fits"
|
||||
f" the {self.chunk_size_seconds:.0f}s chunk ceiling"
|
||||
)
|
||||
return await analyze_with_recovery(cs, ce, depth, retried_static=True)
|
||||
pieces = (
|
||||
self._plan_chunks([(cs, ce)])
|
||||
if failure.should_split and not fits_ceiling
|
||||
else []
|
||||
)
|
||||
if len(pieces) > 1:
|
||||
# Split clips planned before a downgrade at the new ceiling.
|
||||
logger.warning(
|
||||
f"Re-planning failed {cs:.1f}s-{ce:.1f}s chunk into {len(pieces)}"
|
||||
f" piece(s) of at most {self.chunk_size_seconds:.0f}s"
|
||||
f" after {failure.category.value}"
|
||||
)
|
||||
parts = await asyncio.gather(
|
||||
*(analyze_with_recovery(ps, pe, depth) for ps, pe in pieces)
|
||||
)
|
||||
return (
|
||||
[item for values, _ in parts for item in values],
|
||||
[item for _, failures in parts for item in failures],
|
||||
)
|
||||
# Bisecting only helps genuine size/timeout failures; a
|
||||
# degraded interval that fits the ceiling has had its one
|
||||
# static retry and is terminal.
|
||||
can_split = (
|
||||
failure.should_split
|
||||
and not degraded
|
||||
and depth < self.max_split_depth
|
||||
and duration >= self.min_chunk_seconds * 2
|
||||
)
|
||||
@@ -767,9 +839,7 @@ class VideoAnalyzer:
|
||||
temperature = 0.2
|
||||
thinking_level = None
|
||||
if llm_cfg:
|
||||
self.model_name = llm_cfg.model
|
||||
if "/" in self.model_name:
|
||||
self.model_name = self.model_name.split("/")[-1]
|
||||
self.model_name = strip_provider_prefix(llm_cfg.model)
|
||||
if getattr(llm_cfg, "temperature", None) is not None:
|
||||
temperature = llm_cfg.temperature
|
||||
if getattr(llm_cfg, "thinking_level", None) is not None:
|
||||
|
||||
Reference in new issue
Block a user