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>
246 lines
10 KiB
Python
246 lines
10 KiB
Python
"""Proactive plan step: expand push candidates into scenario cards (F2.5).
|
||
|
||
Runs between the topics and agenda steps. Only topics that qualify for today's
|
||
push (``first_seen == today`` and ``confidence >= min_push_confidence``,
|
||
computed by the topics step into ``state.push_candidates``) are expanded, so
|
||
the LLM cost stays proportional to what will actually be surfaced. One batched
|
||
LLM call per round; on any LLM failure every selected candidate still gets a
|
||
deterministic fallback card so the agenda step never sees an empty input.
|
||
|
||
Card contract: ``scenario_type`` (resume_task | answer_pending |
|
||
explore_interest | prepare_upcoming), ``opener`` (a casual, friend-like
|
||
conversation opener that already names the minimal next action - never a
|
||
"based on your records" notification tone), ``next_action``, ``preconditions``
|
||
and ``delivery`` (in_conversation | notification | agenda_item).
|
||
``linked_memory`` is derived from the topic's paths/evidence by code, never by
|
||
the LLM.
|
||
"""
|
||
|
||
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 workspace_dir
|
||
from .utils import load_personal_profile_block, parse_plan_reply, resolve_agent_wrapper
|
||
|
||
SCENARIO_TYPES = ("resume_task", "answer_pending", "explore_interest", "prepare_upcoming")
|
||
DELIVERY_MODES = ("in_conversation", "notification", "agenda_item")
|
||
DEFAULT_SCENARIO_BY_KIND = {"follow_up": "resume_task", "interest_extend": "explore_interest"}
|
||
MAX_OPENER_CHARS = 300
|
||
MAX_NEXT_ACTION_CHARS = 200
|
||
MAX_PRECONDITIONS = 5
|
||
MAX_PRECONDITION_CHARS = 80
|
||
|
||
|
||
def linked_memory(candidate: dict) -> list[str]:
|
||
"""Workspace paths related to a topic, derived from paths + evidence."""
|
||
out: list[str] = []
|
||
raw = list(candidate.get("paths") or [])
|
||
evidence = str(candidate.get("evidence") or "").strip()
|
||
if evidence:
|
||
raw.append(evidence)
|
||
for path in raw:
|
||
rel = str(path or "").strip().split("#", 1)[0]
|
||
if rel and rel not in out:
|
||
out.append(rel)
|
||
return out
|
||
|
||
|
||
def fallback_card(candidate: dict) -> dict:
|
||
"""Deterministic card used when the LLM is absent or its output is unusable."""
|
||
kind = str(candidate.get("kind") or "interest_extend")
|
||
title = str(candidate.get("title") or "")
|
||
scenario = DEFAULT_SCENARIO_BY_KIND.get(kind, "explore_interest")
|
||
if kind == "follow_up":
|
||
opener = f"上次聊到「{title}」,好像还没收尾——要不要趁现在花几分钟往前推一步?"
|
||
next_action = "先回顾相关记录,确认卡点,然后给出下一步动作"
|
||
else:
|
||
opener = f"感觉你最近可能会想看看「{title}」,有空的话可以先从相关材料扫一眼。"
|
||
next_action = "快速浏览相关材料,判断值不值得深入"
|
||
memory = linked_memory(candidate)
|
||
if memory:
|
||
next_action = f"{next_action}(材料:{memory[0]})"
|
||
return {
|
||
"scenario_type": scenario,
|
||
"opener": opener,
|
||
"next_action": next_action,
|
||
"preconditions": [],
|
||
"delivery": "in_conversation",
|
||
}
|
||
|
||
|
||
def _clean_card(raw: dict, candidate: dict) -> dict:
|
||
"""Validate one LLM card; per-field fallback keeps the card always usable."""
|
||
base = fallback_card(candidate)
|
||
scenario = str(raw.get("scenario_type") or "").strip()
|
||
if scenario not in SCENARIO_TYPES:
|
||
scenario = base["scenario_type"]
|
||
opener = str(raw.get("opener") or "").strip()[:MAX_OPENER_CHARS]
|
||
if not opener:
|
||
opener = base["opener"]
|
||
next_action = str(raw.get("next_action") or "").strip()[:MAX_NEXT_ACTION_CHARS]
|
||
if not next_action:
|
||
next_action = base["next_action"]
|
||
preconditions = raw.get("preconditions")
|
||
if not isinstance(preconditions, list):
|
||
preconditions = []
|
||
preconditions = [str(item).strip()[:MAX_PRECONDITION_CHARS] for item in preconditions if str(item).strip()]
|
||
delivery = str(raw.get("delivery") or "").strip()
|
||
if delivery not in DELIVERY_MODES:
|
||
delivery = base["delivery"]
|
||
return {
|
||
"scenario_type": scenario,
|
||
"opener": opener,
|
||
"next_action": next_action,
|
||
"preconditions": preconditions[:MAX_PRECONDITIONS],
|
||
"delivery": delivery,
|
||
}
|
||
|
||
|
||
@R.register("proactive_plan_step")
|
||
class ProactivePlanStep(BaseStep):
|
||
"""Expand today's push candidates into scenario cards.
|
||
|
||
Zero LLM calls when there are no push candidates. At most
|
||
``max_plan_topics`` candidates are expanded; the overflow stays card-less
|
||
and the agenda step suppresses it with an explicit reason.
|
||
"""
|
||
|
||
def __init__(
|
||
self,
|
||
max_plan_topics: int = 6,
|
||
llm_timeout_seconds: float = 120,
|
||
profile_max_chars: int = 800,
|
||
skip_key: str = "proactive_skip",
|
||
**kwargs,
|
||
):
|
||
super().__init__(**kwargs)
|
||
self.max_plan_topics = max(int(max_plan_topics), 1)
|
||
self.llm_timeout_seconds = float(llm_timeout_seconds)
|
||
self.profile_max_chars = max(int(profile_max_chars), 0)
|
||
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()
|
||
raw_state = self.context.get("proactive")
|
||
if not raw_state:
|
||
self.context.response.success = True
|
||
self.context.response.answer = "Skipped plan: no proactive extract state in context"
|
||
return self.context.response
|
||
state = ProactiveState.model_validate(raw_state)
|
||
if state.early_exit:
|
||
self.context.response.success = True
|
||
self.context.response.answer = f"Skipped plan: {state.early_exit}"
|
||
return self.context.response
|
||
|
||
candidates = [c for c in state.push_candidates if isinstance(c, dict) and c.get("id")]
|
||
if not candidates:
|
||
state.scenario_cards = []
|
||
self._store(state)
|
||
self.context.response.success = True
|
||
self.context.response.answer = "Plan: no push candidates, 0 cards, 0 LLM calls"
|
||
self.logger.info(f"[{self.name}] no push candidates; skipping")
|
||
return self.context.response
|
||
|
||
selected = candidates[: self.max_plan_topics]
|
||
self.logger.info(
|
||
f"[{self.name}] start candidates={len(candidates)} selected={len(selected)} "
|
||
f"max_plan_topics={self.max_plan_topics}",
|
||
)
|
||
cards_by_id = await self._llm_cards(state, selected)
|
||
|
||
cards: list[dict] = []
|
||
fallbacks = 0
|
||
for candidate in selected:
|
||
cid = str(candidate.get("id"))
|
||
raw_card = cards_by_id.get(cid)
|
||
if raw_card is None:
|
||
fallbacks += 1
|
||
card = _clean_card(raw_card, candidate) if raw_card is not None else fallback_card(candidate)
|
||
card = {
|
||
"topic_id": cid,
|
||
"title": str(candidate.get("title") or ""),
|
||
"kind": str(candidate.get("kind") or "interest_extend"),
|
||
**card,
|
||
"linked_memory": linked_memory(candidate),
|
||
}
|
||
cards.append(card)
|
||
|
||
state.scenario_cards = cards
|
||
state.duration_ms = state.duration_ms + int((time.monotonic() - started) * 1000)
|
||
self._store(state)
|
||
answer = f"Plan: {len(cards)} scenario card(s), fallback={fallbacks}, llm_calls={state.plan_llm_calls}"
|
||
self.context.response.success = True
|
||
self.context.response.answer = answer
|
||
self.logger.info(f"[{self.name}] finish {answer}")
|
||
return self.context.response
|
||
|
||
async def _llm_cards(self, state: ProactiveState, selected: list[dict]) -> dict[str, dict]:
|
||
"""One batched LLM call; returns candidate-id -> raw card dict."""
|
||
wrapper = resolve_agent_wrapper(self)
|
||
if wrapper is None:
|
||
self.logger.warning(f"[{self.name}] no agent_wrapper available; using fallback cards")
|
||
return {}
|
||
selected_ids = {str(c.get("id")) for c in selected}
|
||
ws = workspace_dir(self)
|
||
user_message = self.prompt_format(
|
||
"plan_user_message",
|
||
profile_block=self._profile_block(ws),
|
||
candidates_json=json.dumps(
|
||
[
|
||
{
|
||
"id": c.get("id"),
|
||
"title": c.get("title"),
|
||
"kind": c.get("kind"),
|
||
"reason": c.get("reason"),
|
||
"confidence": c.get("confidence"),
|
||
"first_seen": c.get("first_seen"),
|
||
"evidence": c.get("evidence"),
|
||
}
|
||
for c in selected
|
||
],
|
||
ensure_ascii=False,
|
||
),
|
||
)
|
||
system_prompt = self.prompt_format("plan_system_prompt")
|
||
state.plan_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 {}
|
||
except Exception as e: # noqa: BLE001 - network/provider errors degrade to fallback cards
|
||
self.logger.warning(
|
||
f"[{self.name}] LLM reply failed ({type(e).__name__}: {e}); using fallback cards",
|
||
)
|
||
return {}
|
||
raw = agent_reply_result_text(result)
|
||
cards_by_id: dict[str, dict] = {}
|
||
for card in parse_plan_reply(raw):
|
||
cid = str(card.get("topic_id") or "").strip()
|
||
if cid in selected_ids and cid not in cards_by_id:
|
||
cards_by_id[cid] = card
|
||
if not cards_by_id:
|
||
self.logger.warning(f"[{self.name}] plan reply unusable; raw={raw[:200]!r}")
|
||
return cards_by_id
|
||
|
||
def _profile_block(self, ws) -> str:
|
||
"""Digest-personal profile sketch so openers respect user preferences."""
|
||
digest_dir = str(self.config_value("digest_dir"))
|
||
return load_personal_profile_block(ws, digest_dir, self.profile_max_chars)
|
||
|
||
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
|