182 lines
7.2 KiB
Python
182 lines
7.2 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.
|
|
|
|
"""History Analyzer: answers natural-language questions about the session history.
|
|
|
|
Mounts the shared history tools (``search_history`` / ``replay_steps`` /
|
|
``get_step_screenshot``) plus the read-only note tools; tool results enter the
|
|
conversation through ``tool_result_messages`` so a fetched screenshot travels
|
|
in the carrier the model's provider accepts.
|
|
"""
|
|
|
|
from pathlib import Path
|
|
|
|
from langchain_core.messages import BaseMessage, HumanMessage, SystemMessage
|
|
from langchain_core.tools import BaseTool
|
|
|
|
from artemis.context import ArtemisContext
|
|
from artemis.data_engine.trace import TraceSpan, trace
|
|
from artemis.memory.context_policy import build_history_for
|
|
from artemis.services.llm import acomplete, get_llm, invoke_llm_with_timeout_message
|
|
from artemis.tools.history import get_history_tools
|
|
from artemis.tools.scratchpad import (
|
|
get_list_notes_tool_pure,
|
|
get_read_note_tool_pure,
|
|
)
|
|
from artemis.tools.tool_wrapper import (
|
|
invoke_tool_with_injection,
|
|
split_multimodal_result,
|
|
tool_result_messages,
|
|
)
|
|
from artemis.utils.logger import get_logger
|
|
from artemis.utils.notes import get_note_file_path
|
|
from artemis.utils.task_tree import get_active_subgoal_hashes
|
|
|
|
logger = get_logger(__name__)
|
|
|
|
_FALLBACK_SYSTEM_PROMPT = (
|
|
"You are a History Analyzer. Your role is to analyze the execution history"
|
|
" of a session and answer user queries in natural language.\nIf you need"
|
|
" the full record of specific steps, use the `replay_steps` tool; use"
|
|
" `search_history` to locate steps by keyword and `get_step_screenshot` to"
|
|
" look at a step's screen."
|
|
)
|
|
|
|
|
|
class HistoryAnalyzer:
|
|
def __init__(self, ctx: ArtemisContext):
|
|
self.ctx = ctx
|
|
|
|
def _build_tools(self) -> list[BaseTool]:
|
|
return [
|
|
*get_history_tools(self.ctx),
|
|
get_list_notes_tool_pure(self.ctx),
|
|
get_read_note_tool_pure(self.ctx),
|
|
]
|
|
|
|
@trace(type="agent", name="history_analyzer")
|
|
async def run(self, query: str) -> str:
|
|
if not self.ctx.data_engine:
|
|
return "Error: DataEngine is not available to retrieve history."
|
|
|
|
# 1. Fetch all steps from Data Engine
|
|
history_steps = self.ctx.data_engine.get_agent_friendly_steps()
|
|
if not history_steps:
|
|
return "No history recorded for this session yet."
|
|
|
|
# 2. Build the plan and history or step list
|
|
plan_and_history = ""
|
|
try:
|
|
current_plan = ""
|
|
current_path = get_note_file_path(self.ctx.data_engine.base_dir, "task_plan")
|
|
if current_path.exists():
|
|
current_plan = current_path.read_text(encoding="utf-8")
|
|
|
|
if current_plan:
|
|
active_subgoal_hash, _ = get_active_subgoal_hashes(current_plan)
|
|
plan_and_history = build_history_for(
|
|
"history_analyzer",
|
|
current_plan,
|
|
history_steps,
|
|
active_subgoal_hash,
|
|
engine=self.ctx.data_engine,
|
|
)
|
|
except Exception as e:
|
|
logger.error(f"Failed to build plan and history in HistoryAnalyzer: {e}")
|
|
|
|
if not plan_and_history:
|
|
# Fallback to a simple list
|
|
plan_and_history = "\n".join(
|
|
[
|
|
f"- Step {s.get('step_number')} ({s.get('relative_time')}):"
|
|
f" {s.get('summary') or 'No summary'}"
|
|
for s in history_steps
|
|
]
|
|
)
|
|
|
|
# 3. Load system prompt
|
|
prompt_path = Path(__file__).parent.joinpath("history_analyzer.md")
|
|
if prompt_path.exists():
|
|
system_prompt_template = prompt_path.read_text(encoding="utf-8")
|
|
else:
|
|
system_prompt_template = _FALLBACK_SYSTEM_PROMPT
|
|
|
|
system_message_content = (
|
|
system_prompt_template + f"\n\n### Task Plan and Execution History:\n{plan_and_history}"
|
|
)
|
|
|
|
# 4. Prepare LLM and bind tools
|
|
llm = get_llm(ctx=self.ctx, name="history_analyzer")
|
|
tools = self._build_tools()
|
|
llm = llm.bind_tools(tools=tools)
|
|
|
|
# 5. ReAct Loop
|
|
messages: list[BaseMessage] = [
|
|
SystemMessage(content=system_message_content),
|
|
HumanMessage(content=query),
|
|
]
|
|
|
|
max_turns = 5
|
|
for turn in range(max_turns):
|
|
logger.info(f"HistoryAnalyzer ReAct turn {turn + 1}")
|
|
|
|
response = await invoke_llm_with_timeout_message(acomplete(llm, messages))
|
|
messages.append(response)
|
|
|
|
if not response.tool_calls:
|
|
return (
|
|
response.content.strip()
|
|
if isinstance(response.content, str)
|
|
else str(response.content)
|
|
)
|
|
|
|
# Process tool calls
|
|
for tc in response.tool_calls:
|
|
tool_name = tc["name"].split(":")[-1] if ":" in tc["name"] else tc["name"]
|
|
args = dict(tc.get("args") or {})
|
|
tool = next((t for t in tools if t.name == tool_name), None)
|
|
|
|
with TraceSpan(name=tool_name, ctx=self.ctx) as span:
|
|
span.payload = {"args": args}
|
|
if tool is None:
|
|
result = f"Error: Tool {tool_name} is not supported."
|
|
status = "error"
|
|
span.status = "failed"
|
|
span.error = result
|
|
else:
|
|
logger.info(f"HistoryAnalyzer executing tool {tool_name} with args: {args}")
|
|
try:
|
|
result = await invoke_tool_with_injection(
|
|
tool=tool, args=args, tool_call_id=tc["id"]
|
|
)
|
|
text, _ = split_multimodal_result(result)
|
|
status = "error" if text.startswith("Error") else "success"
|
|
span.result = text
|
|
if status == "error":
|
|
span.status = "failed"
|
|
span.error = text
|
|
except Exception as e:
|
|
span.status = "failed"
|
|
span.error = str(e)
|
|
result = f"Error running {tool_name}: {e}"
|
|
status = "error"
|
|
|
|
# A step screenshot travels in the carrier the model's provider
|
|
# accepts; text results stay a single ToolMessage.
|
|
messages.extend(
|
|
tool_result_messages(tc["id"], result, name=tool_name, status=status, llm=llm)
|
|
)
|
|
|
|
return "Error: HistoryAnalyzer failed to resolve the query within maximum turns."
|