mirror of
https://github.com/agentscope-ai/ReMe.git
synced 2026-09-15 23:31:05 +00:00
* refractor(proactive): upgrade proactive feature with disentangled job and steps * refactor(proactive): apply audit fixes - rename read-side job 'proactive' -> 'proactive_read' (less confusing vs the refresh pipeline) - drop dedicated agent_wrapper.proactive; extraction reuses the default wrapper - simplify schema: remove unused ProactiveExtractOutput/TopicUpdate, drop resource_paths - extract no longer scans resource/ directly (daily notes already carry resource content) - update tests and docs accordingly * feat(proactive): strict extract-output gate and prompt total budget - parse_extract_reply now requires a contract section (follow_ups/extends/updates as a list); non-empty replies with misspelled section names trigger the existing one-shot retry instead of silently checkpointing changed files - pack_paths gains max_total_chars; extract packs newest daily material first, keeps the first file on overflow, and records omitted files in a trailer (default budget 300000 chars, configurable via max_total_chars) - tests: schema gate unit, schema-error retry e2e, budget unit + e2e * feat(proactive): add scenario-card plan step and generative agenda step * feat(proactive): digest-personal profile personalization and leaner LLM contract - extract/plan/agenda now draw a user profile block from <digest_dir>/personal/*.md (frontmatter description + body excerpt, per-file budget, profile.md fallback) - all daily access honours the configured daily_dir (prompt paths parameterized, config-driven fallbacks) so workspaces using e.g. memory/ work unchanged - schema trim: drop dead fields errors/material_paths, carry_forward_all -> count - shrink LLM output contract: new topics emit title/reason/confidence/paths only; keywords removed end-to-end, evidence derived from paths[0] (updates keep it) * fix(proactive): skip checkpoint when extract reply stays unusable after retry Two consecutive unparseable replies now short-circuit the round without checkpointing, so the same material is retried next round instead of being silently consumed (closes the residual audit #1 gap: the structural gate detected schema-wrong output but a double failure still checkpointed). * fix(proactive): replace running bool with reference-counted job activity tracker for the idle gate * refactor(proactive): remove job activity tracking and idle gate, restore job tree to upstream * fix(proactive): address second audit round (readonly reader, mtime checkpoint, wider fallbacks, profile containment, horizon content, expiry boundary) * refactor(dream): strip interests.yaml ownership from dream, proactive is now the sole writer * refactor(dream): separate proactive topic generation * ci: update renamed auto dream smoke test * fix(proactive): complete refresh migration and docs --------- Co-authored-by: jinli.yl <jinli.yl@alibaba-inc.com>
266 lines
11 KiB
Python
266 lines
11 KiB
Python
"""Proactive refresh extract step: material scan + follow_ups/extends/updates (F2.0-F2.3)."""
|
|
|
|
import asyncio
|
|
import json
|
|
import time
|
|
|
|
from ...base_step import BaseStep
|
|
from .._evolve import agent_reply_result_text, passthrough_response
|
|
from ....components import R
|
|
from ....schema import ProactiveState
|
|
from ..dream.utils import daily_dir, pack_paths, today, workspace_dir
|
|
from .utils import (
|
|
clean_candidate,
|
|
load_carry_forward,
|
|
load_personal_profile_block,
|
|
load_state,
|
|
parse_extract_reply,
|
|
resolve_agent_wrapper,
|
|
scan_material_daily,
|
|
)
|
|
|
|
|
|
@R.register("proactive_extract_step")
|
|
class ProactiveExtractStep(BaseStep):
|
|
"""Scan the proactive material set and extract follow-ups, extends and updates.
|
|
|
|
Owns the ``proactive`` catalog watermark (never touches the dream catalog)
|
|
and the daily LLM budget. Short-circuits via ``context[skip_key]`` on
|
|
busy/budget/timeout per F4.3; early-exits with zero LLM calls when no new
|
|
evidence exists (F2.0).
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
scan_days: int = 2,
|
|
carry_forward_days: int = 14,
|
|
max_carry_forward_topics: int = 20,
|
|
llm_timeout_seconds: float = 300,
|
|
max_chars_per_file: int = 60000,
|
|
max_total_chars: int = 300000,
|
|
profile_max_chars: int = 1500,
|
|
extends_enabled: bool = True,
|
|
skip_key: str = "proactive_skip",
|
|
**kwargs,
|
|
):
|
|
super().__init__(**kwargs)
|
|
self.scan_days = max(int(scan_days), 1)
|
|
self.carry_forward_days = max(int(carry_forward_days), 1)
|
|
self.max_carry_forward_topics = max(int(max_carry_forward_topics), 0)
|
|
self.llm_timeout_seconds = float(llm_timeout_seconds)
|
|
self.max_chars_per_file = max(int(max_chars_per_file), 1000)
|
|
self.max_total_chars = max(int(max_total_chars), 0)
|
|
self.profile_max_chars = max(int(profile_max_chars), 0)
|
|
self.extends_enabled = bool(extends_enabled)
|
|
self.skip_key = skip_key
|
|
|
|
async def execute(self):
|
|
assert self.context is not None
|
|
if self.context.get(self.skip_key):
|
|
return passthrough_response(self, self.skip_key)
|
|
started = time.monotonic()
|
|
day = today(self, str(self.context.get("date", "") or ""))
|
|
ws = workspace_dir(self)
|
|
daily = daily_dir(self)
|
|
if self.file_catalog is None:
|
|
raise RuntimeError("proactive_extract_step requires file_catalog")
|
|
state = ProactiveState(
|
|
date=day,
|
|
daily_dir=daily,
|
|
workspace=str(ws),
|
|
scan_days=self.scan_days,
|
|
carry_forward_days=self.carry_forward_days,
|
|
)
|
|
self.logger.info(f"[{self.name}] start date={day} scan_days={self.scan_days} extends={self.extends_enabled}")
|
|
|
|
# 1) Material set M (F2.0) - all cheap, before any LLM work.
|
|
m_daily = scan_material_daily(ws, day, daily, self.scan_days)
|
|
|
|
# 2) Truth source + carry-forward (F1.3/F1.4).
|
|
state_file, needs_bootstrap = load_state(ws, daily)
|
|
carry_all, carry_prompt = await load_carry_forward(
|
|
ws,
|
|
state_file,
|
|
day,
|
|
self.carry_forward_days,
|
|
self.max_carry_forward_topics,
|
|
daily,
|
|
needs_bootstrap,
|
|
)
|
|
state.carry_forward_count = len(carry_all)
|
|
state.carry_forward_prompt = carry_prompt
|
|
|
|
# 3) Change detection against the proactive catalog watermark.
|
|
# Only daily notes are material: fresh resource uploads already flow
|
|
# into daily notes via auto_resource, so no separate resource scan.
|
|
existing = {}
|
|
for rel in m_daily:
|
|
try:
|
|
existing[rel] = (ws / rel).stat().st_mtime
|
|
except OSError as e:
|
|
self.logger.error(f"[{self.name}] stat failed on {rel}: {e}")
|
|
nodes = await self.file_catalog.get_nodes()
|
|
indexed = {n.path: n.st_mtime for n in nodes}
|
|
state.changed_paths = [rel for rel, mt in existing.items() if indexed.get(rel) != mt]
|
|
# mtime snapshot of what this round actually read; finish only
|
|
# checkpoints paths whose mtime still matches, so files modified while
|
|
# the LLM calls run stay "changed" for the next round (audit item 3).
|
|
state.changed_mtimes = {rel: existing[rel] for rel in state.changed_paths}
|
|
self.logger.info(
|
|
f"[{self.name}] material daily={len(m_daily)} "
|
|
f"changed={len(state.changed_paths)} carry_forward={len(carry_all)}",
|
|
)
|
|
|
|
# 4) Zero-consumption early exit (A4 row 1).
|
|
if not state.changed_paths:
|
|
state.early_exit = "no_new_evidence"
|
|
return self._finish(state, started, "Skipped: no new evidence; 0 LLM calls")
|
|
|
|
# 5) LLM channel (F4.4): structurally one reply per round plus at most
|
|
# one parse-failure retry, so no persistent budget is needed (v5 R5).
|
|
wrapper = resolve_agent_wrapper(self)
|
|
if wrapper is None:
|
|
state.early_exit = "no_agent_wrapper"
|
|
self.logger.warning(f"[{self.name}] no agent_wrapper available; skipping round")
|
|
return self._finish(state, started, "Skipped: no agent_wrapper configured")
|
|
|
|
# 6) One LLM call with sectioned output (A3); retry once on parse failure.
|
|
# meta is None when this round must NOT checkpoint (timeout, or two
|
|
# unusable replies): the material stays "changed" for the next round
|
|
# instead of being silently consumed on output that can never yield
|
|
# topics (audit #1).
|
|
meta = await self._extract_with_retry(wrapper, state, ws, day, daily)
|
|
if meta is None:
|
|
self._store(state)
|
|
return passthrough_response(self, self.skip_key)
|
|
self._clean_output(state, meta, set(m_daily), day)
|
|
answer = (
|
|
f"Extracted {len(state.follow_ups)} follow_up(s), {len(state.extends)} extend(s), "
|
|
f"{len(state.updates)} update(s) from {len(state.changed_paths)} changed file(s)"
|
|
)
|
|
return self._finish(state, started, answer)
|
|
|
|
def _build_messages(self, state: ProactiveState, ws, day: str, daily: str, material_blob: str) -> tuple[str, str]:
|
|
carry_forward_json = json.dumps(
|
|
[
|
|
{
|
|
"id": t.id,
|
|
"title": t.title,
|
|
"kind": t.kind,
|
|
"confidence": t.confidence,
|
|
"reason": t.reason,
|
|
"last_evidence_at": t.last_evidence_at,
|
|
"evidence": t.evidence,
|
|
}
|
|
for t in state.carry_forward_prompt
|
|
],
|
|
ensure_ascii=False,
|
|
)
|
|
changed = list(dict.fromkeys(state.changed_paths))
|
|
user_message = self.prompt_format(
|
|
"extract_user_message",
|
|
date=day,
|
|
changed_paths_json=json.dumps(changed, ensure_ascii=False),
|
|
carry_forward_json=carry_forward_json,
|
|
material_blob=material_blob,
|
|
profile_block=self._profile_block(ws),
|
|
extends=self.extends_enabled,
|
|
)
|
|
system_prompt = self.prompt_format(
|
|
"extract_system_prompt",
|
|
workspace_dir=str(ws),
|
|
daily_dir=daily,
|
|
extends=self.extends_enabled,
|
|
)
|
|
return user_message, system_prompt
|
|
|
|
def _profile_block(self, ws) -> str:
|
|
"""Digest-personal profile sketch; background knowledge, never material."""
|
|
digest_dir = str(self.config_value("digest_dir"))
|
|
return load_personal_profile_block(ws, digest_dir, self.profile_max_chars)
|
|
|
|
async def _extract_with_retry(self, wrapper, state, ws, day: str, daily: str) -> dict | None:
|
|
"""Returns parsed meta; None means skip without checkpointing."""
|
|
# Newest daily material first: when the total budget bites, the
|
|
# freshest evidence survives and the oldest files are omitted.
|
|
ordered = list(dict.fromkeys(state.changed_paths))[::-1]
|
|
material_blob = pack_paths(
|
|
ws,
|
|
ordered,
|
|
limit_per_file=self.max_chars_per_file,
|
|
max_total_chars=self.max_total_chars or None,
|
|
)
|
|
user_message, system_prompt = self._build_messages(state, ws, day, daily, material_blob)
|
|
raw = ""
|
|
for attempt in (1, 2):
|
|
raw = await self._reply(wrapper, state, user_message, system_prompt)
|
|
if raw is None:
|
|
self.context[self.skip_key] = {"reason": "llm_timeout"}
|
|
return None
|
|
meta = parse_extract_reply(raw)
|
|
if meta:
|
|
return meta
|
|
self.logger.warning(f"[{self.name}] parse failed on attempt {attempt}; raw={raw[:200]!r}")
|
|
self.logger.error(
|
|
f"[{self.name}] reply unusable after 2 attempts; keeping material un-checkpointed; " f"raw={raw[:200]!r}",
|
|
)
|
|
self.context[self.skip_key] = {"reason": "extract_parse_failed"}
|
|
return None
|
|
|
|
async def _reply(self, wrapper, state, user_message, system_prompt):
|
|
"""One timeout-wrapped reply; returns raw text or None on timeout."""
|
|
state.llm_calls += 1
|
|
try:
|
|
result = await asyncio.wait_for(
|
|
wrapper.reply(user_message, system_prompt=system_prompt),
|
|
timeout=self.llm_timeout_seconds,
|
|
)
|
|
except asyncio.TimeoutError:
|
|
self.logger.warning(f"[{self.name}] LLM reply timed out after {self.llm_timeout_seconds}s")
|
|
return None
|
|
return agent_reply_result_text(result)
|
|
|
|
def _clean_output(self, state: ProactiveState, meta: dict, allowed: set[str], day: str) -> None:
|
|
for raw in meta.get("follow_ups") or []:
|
|
if candidate := clean_candidate(raw, allowed, "follow_up", day):
|
|
state.follow_ups.append(candidate)
|
|
else:
|
|
state.dropped_missing += 1
|
|
if self.extends_enabled:
|
|
for raw in meta.get("extends") or []:
|
|
if candidate := clean_candidate(raw, allowed, "interest_extend", day):
|
|
state.extends.append(candidate)
|
|
else:
|
|
state.dropped_missing += 1
|
|
for raw in meta.get("updates") or []:
|
|
if not isinstance(raw, dict):
|
|
continue
|
|
topic_id = str(raw.get("id") or "").strip()
|
|
if not topic_id:
|
|
continue
|
|
action = str(raw.get("action") or "keep").strip()
|
|
if action not in ("keep", "update", "resolve"):
|
|
action = "keep"
|
|
state.updates.append(
|
|
{
|
|
"id": topic_id,
|
|
"action": action,
|
|
"evidence": str(raw.get("evidence") or "").strip()[:120],
|
|
"reason": str(raw.get("reason") or "").strip(),
|
|
"confidence": raw.get("confidence"),
|
|
},
|
|
)
|
|
|
|
def _store(self, state: ProactiveState) -> None:
|
|
assert self.context is not None
|
|
data = state.model_dump()
|
|
self.context["proactive"] = data
|
|
self.context.response.metadata["proactive"] = data
|
|
|
|
def _finish(self, state: ProactiveState, started: float, answer: str):
|
|
state.duration_ms = int((time.monotonic() - started) * 1000)
|
|
self._store(state)
|
|
self.context.response.success = True
|
|
self.context.response.answer = answer
|
|
self.logger.info(f"[{self.name}] finish answer={answer!r}")
|
|
return self.context.response
|