Introduce two-gate checker settlement, explicit graph visibility and checkpoints, structured LLM reliability, centralized action specifications, and corresponding tests and design documentation.
224 lines
9.4 KiB
Python
224 lines
9.4 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.
|
|
|
|
import asyncio
|
|
import json
|
|
from pathlib import Path
|
|
from uuid import UUID
|
|
|
|
from langchain_core.messages import (
|
|
HumanMessage,
|
|
SystemMessage,
|
|
)
|
|
|
|
from artemis.context import ArtemisContext
|
|
from artemis.data_engine.trace import CURRENT_TRACE_ID, trace
|
|
from artemis.graph.state import State
|
|
from artemis.graph.visibility import strict_state
|
|
from artemis.services.llm import acomplete, get_llm
|
|
from artemis.utils.coordinates import normalize_any_structure
|
|
from artemis.utils.logger import get_logger
|
|
from artemis.utils.task_tree import (
|
|
build_plan_and_history,
|
|
get_active_subgoal_hashes,
|
|
)
|
|
|
|
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")
|
|
# 1. Trigger background task for summary and data engine update
|
|
if self.ctx.data_engine and state.current_step_id:
|
|
step_id = UUID(state.current_step_id)
|
|
decisions = state.structured_decisions
|
|
operator_raw_thinking = getattr(state, "operator_raw_thinking", None)
|
|
operator_native_thinking = getattr(state, "operator_native_thinking", None)
|
|
last_execution_result = getattr(state, "last_execution_result", None)
|
|
|
|
# Spawn background task
|
|
trace_id = CURRENT_TRACE_ID.get()
|
|
task = asyncio.create_task(
|
|
self._generate_summary_and_update_engine(
|
|
step_id,
|
|
decisions,
|
|
operator_raw_thinking,
|
|
operator_native_thinking,
|
|
last_execution_result,
|
|
trace_id,
|
|
),
|
|
name=f"summarizer_step_{step_id}",
|
|
)
|
|
if hasattr(self.ctx, "background_tasks") and isinstance(
|
|
self.ctx.background_tasks, list
|
|
):
|
|
self.ctx.background_tasks.append(task)
|
|
|
|
return {}
|
|
|
|
async def _generate_summary_and_update_engine(
|
|
self,
|
|
step_id,
|
|
decisions,
|
|
operator_raw_thinking,
|
|
operator_native_thinking,
|
|
last_execution_result,
|
|
trace_id,
|
|
):
|
|
"""Background task to generate summary and update Data Engine with retries."""
|
|
|
|
max_retries = 3
|
|
retry_delay = 2
|
|
|
|
for attempt in range(max_retries):
|
|
try:
|
|
logger.info(f"Generating summary for step {step_id} (attempt {attempt + 1})")
|
|
|
|
# Prepare prompt for summary
|
|
llm = get_llm(ctx=self.ctx, name="summarizer")
|
|
|
|
prompt_path = Path(__file__).parent.joinpath("summarizer.md")
|
|
if not prompt_path.exists():
|
|
prompt = (
|
|
"You are a summarizer. Summarize the latest action and"
|
|
" its result in 1-2 concise sentences. Focus on: Action"
|
|
" taken -> Result -> Observation."
|
|
)
|
|
else:
|
|
prompt = prompt_path.read_text(encoding="utf-8")
|
|
|
|
# Normalize coordinates before presenting to the summarizer LLM
|
|
width = (
|
|
getattr(self.ctx.device, "device_width", 1080)
|
|
if self.ctx and self.ctx.device
|
|
else 1080
|
|
)
|
|
height = (
|
|
getattr(self.ctx.device, "device_height", 2400)
|
|
if self.ctx and self.ctx.device
|
|
else 2400
|
|
)
|
|
|
|
content = []
|
|
|
|
# Consistently use optimal build_plan_and_history to construct accurate context
|
|
plan_and_history_str = ""
|
|
if self.ctx.data_engine:
|
|
try:
|
|
friendly_steps = self.ctx.data_engine.get_agent_friendly_steps()
|
|
if isinstance(friendly_steps, list):
|
|
# 1. Trim history to only include steps up to the current step being summarized (inclusive)
|
|
current_steps = []
|
|
for s in friendly_steps:
|
|
current_steps.append(s)
|
|
if str(s.get("step_id")) == str(step_id):
|
|
break
|
|
|
|
# 2. Read task plan to provide goal orientation to the summarizer
|
|
task_plan = ""
|
|
base_dir = getattr(self.ctx.data_engine, "base_dir", None)
|
|
if base_dir and isinstance(base_dir, (str, Path)):
|
|
notes_dir = Path(base_dir) / "notes"
|
|
task_plan_path = notes_dir / "task_plan.md"
|
|
if task_plan_path.exists():
|
|
task_plan = task_plan_path.read_text(encoding="utf-8")
|
|
|
|
# 3. Unified rendering: concise for first N steps (with self-healing facts), detailed for current step (without redundant summary header)
|
|
|
|
subgoal_hash = "default"
|
|
if task_plan:
|
|
subgoal_hash, _ = get_active_subgoal_hashes(task_plan)
|
|
|
|
plan_and_history_str = build_plan_and_history(
|
|
task_plan=task_plan,
|
|
steps=current_steps,
|
|
current_subgoal_hash=subgoal_hash,
|
|
last_n_detailed=1,
|
|
min_summaries=10,
|
|
strict_milestone_pruning=False,
|
|
)
|
|
except Exception as e:
|
|
logger.error(f"Failed to build plan and history for Summarizer: {e}")
|
|
|
|
if plan_and_history_str:
|
|
content.append(plan_and_history_str)
|
|
else:
|
|
# Fallback to standard raw fields if chronological trace couldn't be generated
|
|
if decisions:
|
|
try:
|
|
dec_obj = json.loads(decisions)
|
|
dec_obj = normalize_any_structure(dec_obj, width, height)
|
|
content.append(
|
|
f"Decisions made: {json.dumps(dec_obj, ensure_ascii=False)}"
|
|
)
|
|
except Exception:
|
|
content.append(f"Decisions made: {decisions}")
|
|
|
|
if operator_raw_thinking:
|
|
content.append(f"Operator explicit thoughts:\n{operator_raw_thinking}")
|
|
|
|
if operator_native_thinking:
|
|
content.append(f"Operator native thoughts:\n{operator_native_thinking}")
|
|
|
|
messages = [
|
|
SystemMessage(content=prompt),
|
|
HumanMessage(content="\n\n".join(content)),
|
|
]
|
|
|
|
aggregated_chunk = await acomplete(llm, messages)
|
|
|
|
generated_text = ""
|
|
content = getattr(aggregated_chunk, "content", "") if aggregated_chunk else ""
|
|
if isinstance(content, str):
|
|
generated_text = content
|
|
elif isinstance(content, list):
|
|
for item in content:
|
|
if isinstance(item, dict) and item.get("type") == "text":
|
|
generated_text += item.get("text", "")
|
|
|
|
summary = generated_text.strip()
|
|
logger.info(f"Summary generated: {summary}")
|
|
|
|
if aggregated_chunk and getattr(aggregated_chunk, "usage_metadata", None):
|
|
usage = aggregated_chunk.usage_metadata
|
|
cached_tokens = usage.get("input_token_details", {}).get("cache_read", 0)
|
|
logger.info(
|
|
"Summarizer LLM usage:"
|
|
f" prompt_tokens={usage.get('input_tokens')}"
|
|
f" (cached={cached_tokens}),"
|
|
f" completion_tokens={usage.get('output_tokens')}"
|
|
)
|
|
|
|
# Update Data Engine
|
|
self.ctx.data_engine.update_step_summary(step_id, summary)
|
|
logger.info(f"Data Engine updated with summary for step {step_id}")
|
|
return # Success
|
|
|
|
except Exception as e:
|
|
logger.error(
|
|
f"Failed to generate summary or update engine (attempt {attempt + 1}): {e}"
|
|
)
|
|
if attempt < max_retries - 1:
|
|
await asyncio.sleep(retry_delay)
|
|
retry_delay *= 2 # Exponential backoff
|
|
else:
|
|
logger.error(
|
|
f"Max retries reached for step {step_id}. Summary generation failed."
|
|
)
|