Files
Artemis-Eapil/artemis/agents/summarizer/summarizer.py
T

140 lines
5.6 KiB
Python

# 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.
"""Pro-graph step summarizer node (M2: visual-transition lens dispatch).
Per the history-module redesign §5 (single step-level lens, confirmed
2026-08-31) this node no longer generates a per-step text capsule with its own
LLM call. It dispatches the step to the shared
:class:`~artemis.memory.step_memory.StepMemoryService` visual-transition lens
(``ctx.step_memory``): pre/post screenshots are pulled from the DataEngine by
step id, the red action-overlay and the ``flash_summarizer.md`` neutral-wording
contract apply unchanged, and the result lands as a versioned
``source="visual_transition"`` summary write (M0 semantics).
The auxiliary agents' compiled view (``build_plan_and_history``) is untouched:
a step whose summary is not ready yet falls back to detailed rendering
(``task_tree.py`` semantics), so the transition is safe. The intent/strategy
capsule moves up to the chunk-level ``StepCapsuleLens`` in M3.
"""
import json
from typing import Any
from artemis.context import ArtemisContext
from artemis.data_engine.trace import trace
from artemis.graph.state import State
from artemis.graph.visibility import strict_state
from artemis.memory import ensure_step_memory
from artemis.utils.logger import get_logger
from artemis.utils.task_tree import format_result_clean
logger = get_logger(__name__)
class SummarizerNode:
def __init__(self, ctx: ArtemisContext):
self.ctx = ctx
@trace(type="agent", name="summarizer")
async def __call__(self, state: State):
state = strict_state(state, "summarizer")
if not (self.ctx.data_engine and state.current_step_id):
return {}
try:
self._dispatch_visual_summary(state)
except Exception as e:
logger.error(f"Failed to dispatch visual step summary: {e}")
return {}
def _dispatch_visual_summary(self, state: State) -> None:
engine = self.ctx.data_engine
step_id = state.current_step_id
step_number = None
try:
step_number = engine.get_step_number(step_id)
except Exception as e:
logger.warning(f"Could not resolve step number for {step_id}: {e}")
record = None
if isinstance(step_number, int):
try:
record = engine.get_step_record(step_number)
except Exception as e:
logger.warning(f"Could not load step record {step_number}: {e}")
# Graceful degradation: a missing pre/post image simply dispatches with
# None bytes — the lens renders a text-only transition account.
pre_bytes = self._read_step_image(step_number, "pre")
post_bytes = self._read_step_image(step_number, "post")
action_name, action_args = self._extract_action(record, state)
result = getattr(state, "last_execution_result", None)
# format_result_clean reports only errors/repairs; a clean run keeps
# the controller's bare status word.
exec_outcome = format_result_clean(result)
if not exec_outcome:
if isinstance(result, dict) and result.get("status"):
exec_outcome = str(result["status"])
else:
exec_outcome = "unknown"
service = ensure_step_memory(self.ctx)
service.dispatch(
step_number=step_number if isinstance(step_number, int) else 0,
action_name=action_name,
action_args=action_args,
pre_img_bytes=pre_bytes,
post_img_bytes=post_bytes,
exec_outcome=exec_outcome,
data_engine_step_id=step_id,
)
logger.info(
f"Dispatched visual transition summary for step {step_number} ({step_id})"
)
def _read_step_image(self, step_number: int | None, which: str) -> bytes | None:
if not isinstance(step_number, int):
return None
try:
path = self.ctx.data_engine.get_step_image_path(step_number, which)
if path is None:
return None
return path.read_bytes()
except Exception as e:
logger.warning(f"Could not read {which} image for step {step_number}: {e}")
return None
@staticmethod
def _extract_action(record: Any, state: State) -> tuple[str, dict]:
actions = getattr(record, "action_taken", None) if record is not None else None
if not actions:
decisions = getattr(state, "structured_decisions", None)
if decisions:
try:
actions = json.loads(decisions)
except Exception:
actions = None
if isinstance(actions, dict):
actions = [actions]
if not isinstance(actions, list) or not actions or not isinstance(actions[0], dict):
return "no_op", {}
first = actions[0]
action_name = first.get("action") or "unknown"
action_args = {k: v for k, v in first.items() if k != "action"}
if len(actions) > 1:
action_args["additional_actions"] = actions[1:]
return action_name, action_args