140 lines
5.6 KiB
Python
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
|