This commit is contained in:
imrewce 2026-10-16 08:24:18 +00:00 • committed by GitHub
commit 10893ad898
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 5711 additions and 270 deletions

View file

@ -10,6 +10,10 @@ daily/<date>/interests.yaml
A host agent can use it to learn "what is worth proactive attention today," then decide whether to remind the user, ask
a follow-up question, recommend a next step, or produce a proactive insight.
Every topic and agenda item carries a `kind`, one of exactly four values — `reminder`, `follow_up`, `new_idea`, and
`interest_extend` (see "Proposal Kinds" below). All four stay at the level of a proposal: the refresh pipeline never
carries any of them out, it only records what is worth raising with the user.
`interests.yaml` is generated by the proactive refresh pipeline, scheduled by `proactive_refresh_cron` by default.
`proactive_read` only reads and exposes the result; Auto Dream is an independent daily-to-digest flow and does not read
or write proactive state.
@ -33,13 +37,34 @@ proactive_refresh:
llm_timeout_seconds: 300
max_chars_per_file: 60000
max_total_chars: 300000
profile_max_chars: 1500
capability_max_chars: 1200
extends_enabled: true
reminders_enabled: true
new_ideas_enabled: true
max_follow_ups: 5
max_extends: 5
max_new_ideas: 3
reminder_lead_days: 3
resources_enabled: true
resource_max_chars: 40000
max_notice_reminders: 3
refine_enabled: true
refine_timeout_seconds: 150
- backend: proactive_topics_step
known_threshold: 0.85
known_threshold_calibrated_for: text-embedding-v4@1024
min_push_confidence: 0.5
min_reminder_confidence: 0.3
max_topics: 10
schedule_retention_days: 180
max_schedules: 80
max_schedule_history: 8
- backend: proactive_plan_step
max_reminder_cards: 8
- backend: proactive_agenda_step
min_discovery_slots: 2
redelivery_cooldown_days: 3
- backend: proactive_finish_step
file_catalog: proactive
@ -70,9 +95,16 @@ proactive_read:
min_confidence:
type: number
default: 0.4
include_delivered:
type: boolean
default: false
delivery_cooldown_days:
type: integer
default: 3
steps:
- backend: proactive_step
min_confidence: 0.4
delivery_cooldown_days: 3
```
Parameters:
@ -83,22 +115,50 @@ Parameters:
| `include_content` | Whether to return the raw YAML in the answer and metadata. Defaults to `true`. |
| `horizon_days` | Read one day's exposure file, or use the truth source for a wider evidence horizon. Defaults to `1`. |
| `min_confidence` | Minimum topic confidence to return. Defaults to `0.4`; legacy topics use `0.5`. |
| `include_delivered` | Also return agenda items the host already reported as delivered. Defaults to `false` (withhold). |
| `delivery_cooldown_days` | Days an already-delivered item stays withheld; `0` withholds same-day deliveries only. Defaults to `3`. |
### Refresh cost, files, and opt-out
When no daily Markdown note changed, refresh exits before calling an LLM and does not create a new exposure file. With
changed material, extraction normally makes one LLM call and may retry once after an unusable reply. If push candidates
remain, planning makes one additional call; agenda generation makes one more when there are multiple candidates. A
refresh therefore makes at most four LLM calls with the default chain.
With no daily Markdown change, no new external notice, and no due schedule, refresh exits before calling an LLM and
does not create a new exposure file. When only the clock produced due reminders the chain still runs to completion with
zero LLM calls (`reminder_only=true`), so today's agenda gets its reminders. With changed material, stage-1 recall makes
one LLM call (at most one retry after an unusable reply) and stage 2 then makes one small call per kind that actually
produced candidates (at most four). If push candidates remain, planning makes one additional call; agenda generation
makes one more when there are multiple candidates. A refresh therefore makes at most nine LLM calls with the default
chain, and only stage 1 carries the material blob.
The refresh pipeline maintains the rebuildable `daily/_proactive.yaml` truth source, writes
`daily/<date>/interests.yaml`, and advances the independent `proactive` file catalog. Auto Dream does not read or write
any of those proactive artifacts.
The refresh pipeline maintains the rebuildable `daily/_proactive.yaml` truth source (open topics, resolved tombstones,
the long-lived `schedules` registry, and the `deliveries` ledger), writes `daily/<date>/interests.yaml`, and advances
the independent `proactive` file catalog. Auto Dream does not read or write any of those proactive artifacts.
To disable automatic refresh, use an explicit application config that omits the `proactive_refresh_cron` job. Keep the
local-only `proactive_refresh` job if you still want on-demand maintenance. Because it has `enable_serve: false`, it is
not exposed through HTTP or MCP.
## Proposal Kinds
Extraction makes a single recall call and asks for one sectioned reply covering four kinds. Each has a distinct job and
each is optional: a round may produce any subset of them, including none.
| Kind | Job | Scenario card | Per-round cap |
|-------------------|----------------------------------------------------------------------------------|----------------------------------|--------------------------------------------------------------|
| `reminder` | The user's own plans and appointments, plus notices and key events from external sources that the user must be told about | `prepare_upcoming` | Uncapped; undated notices are bounded by `max_notice_reminders` (3) |
| `follow_up` | Recent progress on something the user assigned but never closed, or keeps tracking | `resume_task` / `answer_pending` | `max_follow_ups` (5) |
| `new_idea` | Work the agent can delegate to the user, based on its own resources (data, skills, tools, APIs) | `propose_delegation` | `max_new_ideas` (3) |
| `interest_extend` | Refinements and extensions of the topics the user cares about (growing the wiki network) | `explore_interest` | `max_extends` (5) |
`reminder` outranks the other three: the agenda step **pins** due schedules to the top of today's agenda (nearest due
first), where they are never suppressed and never swapped out, while discovery items always keep their
`min_discovery_slots` (2) slots — so a fully booked day does not collapse into a plain calendar. Reminder candidates go
through a lower gate in the topics step (`min_reminder_confidence` 0.3) and are exempt from `min_confidence` in
`proactive_read`.
Reminders are computed against the real clock (the configured application timezone) rather than from today's daily note
alone: before any LLM decision, extraction scans the `schedules` registry and deterministically seeds the `overdue`,
`due_today`, `conflict`, and `due_soon` (within `reminder_lead_days`) triggers. External notices under
`resource/<date>/` travel the same clock path, so a new external source needs no change to the proactive chain.
## Input Contract
A current proactive-refresh file looks like this:
@ -152,6 +212,7 @@ metadata:
| `error` | Read or parse error. |
| `summary` | Short summary. |
| `agenda` | Today's proactive agenda (optional, v2 files only). |
| `delivered_ids` | Agenda ids withheld this round by the delivery cooldown (when `include_delivered=false`). |
When today's `interests.yaml` was produced by the proactive refresh chain with an agenda,
the answer also carries an `agenda` field: the ordered agenda items, each with `topic_id`,
@ -206,6 +267,35 @@ Skipped: interests file not found at daily/2026-06-20/interests.yaml
This lets a host agent treat "there is no proactive refresh result for today yet" as a normal empty state.
## Delivery Receipts
Refresh only decides what is worth raising; it cannot know what the host actually raised. `proactive_feedback` closes
that loop so an item the consumer already showed is not proposed again tomorrow:
```bash
reme proactive_feedback date=2026-06-22 items='[{"topic_id":"<id>","status":"shown","channel":"inbox","consumer":"<host label>"}]'
```
Each receipt accepts `topic_id` (the only required field), `status`, `channel`, `consumer`, `outcome`, `external_ref`,
and `agenda_date`. `status` is one of `shown` (default), `executed`, `dismissed`, `failed`; `channel` is one of
`in_conversation`, `inbox`, `notification`, `other` (default). Unrecognized values fall back to the default instead of
failing the call. Receipts are stored in the `deliveries` section of the `daily/_proactive.yaml` truth source and
trimmed by `retention_days` (180) and `max_deliveries` (500).
- **Identity**: `external_ref` wins when the host supplies one, otherwise `(topic_id, agenda_date, channel)`. Reporting
the same delivery twice updates the record in place, so a host can report `shown` first and `executed` later.
- **Status only moves forward**: a terminal record (`executed`, `dismissed`) is never downgraded by a late `shown`, and
`failed` does not block redelivery in the next round.
- **Two readers**: `proactive_step` withholds agenda items still inside `delivery_cooldown_days` (3), and
`proactive_agenda_step` deterministically suppresses those candidates and writes an auditable reason into
`suppressed`. `kind == "reminder"` is exempt from the cooldown on both sides — its `topic_id` derives from a stable
schedule title, so normal suppression would silence a recurring reminder for the whole window after one delivery.
- **Optional contract**: a host that never reports leaves `deliveries` empty and both readers behave exactly as before.
The step makes no LLM call and does not touch `open_topics`, `resolved`, or `schedules`.
Readers return the items they withheld as `delivered_ids`, in both the `answer` and the metadata, so a consumer can
reconcile them against its own local ledger.
## Running Proactive
### Start the Service and Read Topics

View file

@ -9,6 +9,9 @@ daily/<date>/interests.yaml
上层 Agent 可以用它获取“今天值得主动关注什么”,再决定是否提醒、追问、推荐下一步或生成主动洞察。
每个 topic 和议程条目都带有 `kind`,取值固定为四类之一——`reminder`、`follow_up`、`new_idea`、`interest_extend`
(见下文“提议类型”)。四类都停留在“提议”:refresh 链路不会执行其中任何一件,只记录“值得对用户提起什么”。
`interests.yaml` 由 proactive refresh writer 链路生成,默认通过 `proactive_refresh_cron` 定时执行;
`proactive_read` 只负责读取和暴露结果。Auto Dream 是独立的 daily-to-digest 流程,不读取或写入 proactive 状态。
@ -30,13 +33,34 @@ proactive_refresh:
llm_timeout_seconds: 300
max_chars_per_file: 60000
max_total_chars: 300000
profile_max_chars: 1500
capability_max_chars: 1200
extends_enabled: true
reminders_enabled: true
new_ideas_enabled: true
max_follow_ups: 5
max_extends: 5
max_new_ideas: 3
reminder_lead_days: 3
resources_enabled: true
resource_max_chars: 40000
max_notice_reminders: 3
refine_enabled: true
refine_timeout_seconds: 150
- backend: proactive_topics_step
known_threshold: 0.85
known_threshold_calibrated_for: text-embedding-v4@1024
min_push_confidence: 0.5
min_reminder_confidence: 0.3
max_topics: 10
schedule_retention_days: 180
max_schedules: 80
max_schedule_history: 8
- backend: proactive_plan_step
max_reminder_cards: 8
- backend: proactive_agenda_step
min_discovery_slots: 2
redelivery_cooldown_days: 3
- backend: proactive_finish_step
file_catalog: proactive
@ -67,9 +91,16 @@ proactive_read:
min_confidence:
type: number
default: 0.4
include_delivered:
type: boolean
default: false
delivery_cooldown_days:
type: integer
default: 3
steps:
- backend: proactive_step
min_confidence: 0.4
delivery_cooldown_days: 3
```
参数含义:
@ -80,19 +111,44 @@ proactive_read:
| `include_content` | 是否在 answer 和 metadata 中返回 YAML 原文,默认 `true`。 |
| `horizon_days` | 读取单日曝光文件,或在更宽时间窗下读取 truth source;默认 `1`。 |
| `min_confidence` | 返回 topic 的最低置信度,默认 `0.4`;旧版 topic 使用 `0.5`。 |
| `include_delivered` | 是否连宿主已上报送达的议程条目一起返回;默认 `false`(扣留)。 |
| `delivery_cooldown_days` | 已送达条目的扣留天数,`0` 表示只扣留当天的送达;默认 `3`。 |
### Refresh 成本、文件与关闭方式
没有 daily Markdown 发生变化时,refresh 会在调用 LLM 前结束,也不会生成新的曝光文件。有变化素材时,extract
通常调用一次 LLM;回复不可用时最多重试一次。存在 push candidates 时,plan 会再调用一次;候选数大于一个时,
agenda 会再调用一次。因此默认链路单轮最多调用四次 LLM。
没有 daily Markdown 变化、没有新的外部通知、且没有到期日程时,refresh 会在调用 LLM 前结束,也不会生成新的曝光
文件。只有时钟算出到期提醒时,链路仍会零 LLM 调用地走完(`reminder_only=true`),保证当日议程拿到提醒。有变化
素材时,阶段 1 召回调用一次 LLM(回复不可用时最多重试一次),阶段 2 再对每个真正产出了候选的类型各调用一次小请求
(至多四次);存在 push candidates 时 plan 会再调用一次,候选数大于一个时 agenda 会再调用一次。因此默认链路单轮
最多九次 LLM 调用,其中只有阶段 1 携带素材 blob。
Refresh 会维护可重建的 truth source `daily/_proactive.yaml`,写入 `daily/<date>/interests.yaml`,并推进独立的
Refresh 会维护可重建的 truth source `daily/_proactive.yaml`(open topics、resolved 墓碑,以及长生命周期的
`schedules` 日程注册表与 `deliveries` 投递记录),写入 `daily/<date>/interests.yaml`,并推进独立的
`proactive` file catalog。Auto Dream 不读取或写入这些 proactive 产物。
如需关闭自动 refresh,请使用不包含 `proactive_refresh_cron` job 的显式应用配置。若仍需按需维护,可保留
本地 one-shot 的 `proactive_refresh`;它设置了 `enable_serve: false`,不会暴露到 HTTP 或 MCP。
## 提议类型(Proposal Kinds)
extract 只发起一次召回调用,要求模型返回四个分节的提议。每类职责不同,且都不是必需项:单轮可以产出其中任意子集,
也可以一条都没有。
| 类型 | 职责 | 对应场景卡 | 单轮上限 |
|-------------------|-----------------------------------------------------------------|----------------------------------|------------------------------------------------------|
| `reminder` | 用户自己的计划与日程,以及外部信源中必须告知用户的通知与关键事件 | `prepare_upcoming` | 不设上限;无日期通知受 `max_notice_reminders`(3)约束 |
| `follow_up` | 用户指派但未闭环、或持续关心事项的最近进展 | `resume_task` / `answer_pending` | `max_follow_ups`(5) |
| `new_idea` | 基于 agent 自身资源(数据、skill、工具与 API)可委托给用户的工作 | `propose_delegation` | `max_new_ideas`(3) |
| `interest_extend` | 用户关心主题的细化与延伸内容(扩展 wiki 网络节点) | `explore_interest` | `max_extends`(5) |
`reminder` 优先级高于其余三类:agenda 步骤把到期日程**固定**在当日议程最前(按到期时间由近到远),既不会被压制也不会
被交换,发现类条目则始终保有 `min_discovery_slots`(2)个名额,因此日程排满的一天也不会退化成一张纯日历。提醒候选在
topics 步骤走更低门槛(`min_reminder_confidence` 0.3),并在 `proactive_read` 中豁免 `min_confidence`。
提醒是对照真实时钟(应用配置时区)算出来的,而不是只看当天的 daily note:extract 在任何 LLM 决策之前先扫描
`schedules` 注册表,确定性地预置 `overdue`、`due_today`、`conflict` 与 `due_soon`(`reminder_lead_days` 之内)四类
触发。`resource/<date>/` 下的外部通知走同一条时钟通道,因此接入新的外部信源不需要改 proactive 链路。
## 输入契约
当前 proactive refresh 生成的文件如下:
@ -144,6 +200,7 @@ suppressed: []
| `error` | 读取或解析异常。 |
| `summary` | 简短摘要。 |
| `agenda` | 当日主动议程(可选,仅 v2 文件携带时返回)。 |
| `delivered_ids` | 本轮因送达冷却被扣留的议程 id(`include_delivered=false` 时)。 |
当当天的 `interests.yaml` 由 proactive refresh 链路生成并携带议程时,answer 会附带
`agenda` 字段:按顺序排列的当日议程条目,每条包含 `topic_id`、`title`、`scenario_type`、
@ -197,6 +254,31 @@ Skipped: interests file not found at daily/2026-06-20/interests.yaml
这让上层 Agent 可以把“今天还没有 proactive refresh 结果”当作正常空状态处理。
## 送达回执(Delivery Receipts)
refresh 只决定“什么值得提”,它无法知道宿主究竟提了什么。`proactive_feedback` 用来闭合这个环,避免消费方已经展示
过的条目明天又被提议一遍:
```bash
reme proactive_feedback date=2026-06-22 items='[{"topic_id":"<id>","status":"shown","channel":"inbox","consumer":"<host label>"}]'
```
每条回执接受 `topic_id`(唯一必填)、`status`、`channel`、`consumer`、`outcome`、`external_ref` 与 `agenda_date`;
`status` 取 `shown`(默认)、`executed`、`dismissed`、`failed`,`channel` 取 `in_conversation`、`inbox`、
`notification`、`other`(默认)。无法识别的取值回落到默认值而不是让调用失败。回执写入 truth source
`daily/_proactive.yaml` 的 `deliveries` 段,并按 `retention_days`(180)与 `max_deliveries`(500)裁剪。
- **身份**:宿主给了 `external_ref` 时以它为准,否则用 `(topic_id, agenda_date, channel)`;同一次送达重复上报会原地
更新,因此可以先报 `shown`、之后再报 `executed`,不会产生第二条。
- **状态只前进**:已终结的记录(`executed`、`dismissed`)不会被迟到的 `shown` 降级回去;`failed` 不阻止下一轮重新投递。
- **两个读取方**:`proactive_step` 扣留仍在 `delivery_cooldown_days`(3)内的议程条目,`proactive_agenda_step` 则
确定性压制这些候选并把可审计的理由写进 `suppressed`。`kind == "reminder"` 在两侧都豁免冷却——它的 `topic_id` 由
稳定的日程标题派生,照常压制会让一条周期性提醒在投递一次之后就在整个窗口内静默。
- **可选契约**:从不上报的宿主会让 `deliveries` 保持为空,两个读取方的行为与之前完全一致。该步骤不调用 LLM,也不改
`open_topics`/`resolved`/`schedules`。
读取方把自己扣留的条目以 `delivered_ids` 返回(`answer` 与 `metadata` 里都有),消费方用它与自己的本地台账对账。
## 运行方式
### 启动服务并读取 topics

View file

@ -100,13 +100,34 @@ jobs:
llm_timeout_seconds: 300
max_chars_per_file: 60000
max_total_chars: 300000
profile_max_chars: 1500
capability_max_chars: 1200
extends_enabled: true
reminders_enabled: true
new_ideas_enabled: true
max_follow_ups: 5
max_extends: 5
max_new_ideas: 3
reminder_lead_days: 3
resources_enabled: true
resource_max_chars: 40000
max_notice_reminders: 3
refine_enabled: true
refine_timeout_seconds: 150
- backend: proactive_topics_step
known_threshold: 0.85
known_threshold_calibrated_for: text-embedding-v4@1024
min_push_confidence: 0.5
min_reminder_confidence: 0.3
max_topics: 10
schedule_retention_days: 180
max_schedules: 80
max_schedule_history: 8
- backend: proactive_plan_step
max_reminder_cards: 8
- backend: proactive_agenda_step
min_discovery_slots: 2
redelivery_cooldown_days: 3
- backend: proactive_finish_step
file_catalog: proactive
@ -122,13 +143,34 @@ jobs:
llm_timeout_seconds: 300
max_chars_per_file: 60000
max_total_chars: 300000
profile_max_chars: 1500
capability_max_chars: 1200
extends_enabled: true
reminders_enabled: true
new_ideas_enabled: true
max_follow_ups: 5
max_extends: 5
max_new_ideas: 3
reminder_lead_days: 3
resources_enabled: true
resource_max_chars: 40000
max_notice_reminders: 3
refine_enabled: true
refine_timeout_seconds: 150
- backend: proactive_topics_step
known_threshold: 0.85
known_threshold_calibrated_for: text-embedding-v4@1024
min_push_confidence: 0.5
min_reminder_confidence: 0.3
max_topics: 10
schedule_retention_days: 180
max_schedules: 80
max_schedule_history: 8
- backend: proactive_plan_step
max_reminder_cards: 8
- backend: proactive_agenda_step
min_discovery_slots: 2
redelivery_cooldown_days: 3
- backend: proactive_finish_step
file_catalog: proactive
@ -267,9 +309,40 @@ jobs:
type: number
description: "minimum topic confidence to return (default 0.4 sits safely below the 0.5 fallback; v1 topics fall back to 0.5)"
default: 0.4
include_delivered:
type: boolean
description: "also return agenda items the host already reported as delivered (default withholds them)"
default: false
delivery_cooldown_days:
type: integer
description: "days an already-delivered agenda item stays withheld; 0 withholds only same-day deliveries"
default: 3
steps:
- backend: proactive_step
min_confidence: 0.4
delivery_cooldown_days: 3
proactive_feedback:
backend: base
description: "Record which proactive agenda items a host agent already delivered or executed, so they are not repeated."
parameters:
type: object
properties:
date:
type: string
description: "YYYY-MM-DD the report belongs to; defaults to today in the application's timezone"
default: ""
items:
type: array
description: "delivery reports: [{topic_id, status, channel, consumer, outcome, external_ref, agenda_date}]"
items:
type: object
required:
- items
steps:
- backend: proactive_feedback_step
retention_days: 180
max_deliveries: 500
version:
backend: base

View file

@ -8,7 +8,9 @@ from .dream import (
IntegrateOutcome,
)
from .proactive import (
ProactiveDelivery,
ProactiveResult,
ProactiveSchedule,
ProactiveState,
ProactiveStateFile,
ProactiveTopic,
@ -41,7 +43,9 @@ __all__ = [
"GraphSnapshotNode",
"IntegrateOutcome",
"JobConfig",
"ProactiveDelivery",
"ProactiveResult",
"ProactiveSchedule",
"ProactiveState",
"ProactiveStateFile",
"ProactiveTopic",

View file

@ -6,9 +6,44 @@ validated structurally by ``parse_extract_reply`` instead of a model here.
See ``PROACTIVE_SPEC.md`` sections F1/A2 for the full contracts.
"""
import datetime as dt
from pydantic import BaseModel, ConfigDict, Field, field_validator
TOPIC_KINDS = ("follow_up", "interest_extend")
# Topic kinds, ordered by proactive priority: ``reminder`` outranks the rest
# and pins today's agenda, ``interest_extend`` is the lowest-priority
# discovery kind. ``KIND_PRIORITY`` is the single ordering source used by
# ``sort_topics`` and by the plan/agenda steps.
TOPIC_KINDS = ("reminder", "follow_up", "new_idea", "interest_extend")
KIND_PRIORITY = {kind: index for index, kind in enumerate(TOPIC_KINDS)}
DEFAULT_KIND = "interest_extend"
# Reminder registry (``daily/_proactive.yaml: schedules``) vocabularies. The
# registry outlives ``open_topics`` because a schedule mentioned weeks ago
# still has to fire on its due date.
SCHEDULE_ACTIONS = ("add", "reschedule", "confirm", "done", "cancel")
SCHEDULE_STATUSES = ("open", "done", "cancelled")
RECURRENCES = ("", "daily", "weekly", "monthly", "yearly")
REMINDER_TRIGGERS = ("overdue", "due_today", "due_soon", "conflict")
# Delivery feedback (``daily/_proactive.yaml: deliveries``) vocabularies.
# A host agent reports back which agenda items it already surfaced or ran, so
# the reader and the agenda step can stop repeating them. Reporting is
# optional: a framework that never calls ``proactive_feedback`` leaves the
# section empty and the whole chain behaves exactly as before.
DELIVERY_STATUSES = ("shown", "executed", "dismissed", "failed")
DELIVERY_CHANNELS = ("in_conversation", "inbox", "notification", "other")
DEFAULT_DELIVERY_STATUS = "shown"
DEFAULT_DELIVERY_CHANNEL = "other"
# Statuses that retire an agenda item for the whole cooldown window. ``failed``
# is deliberately absent: a failed run should be retryable on the next round.
TERMINAL_DELIVERY_STATUSES = ("executed", "dismissed")
# ``outcome`` is a host-written summary; cap it so a chatty consumer cannot
# inflate the truth source without bound.
MAX_DELIVERY_OUTCOME_CHARS = 500
# Keys inside the untyped ``history`` audit trail that hold a date/datetime.
HISTORY_WHEN_KEYS = ("at", "from", "to")
def clamp_confidence(value) -> float:
@ -19,6 +54,29 @@ def clamp_confidence(value) -> float:
return 0.5
def iso_text(value) -> str:
"""Coerce a YAML-native scalar into the ISO text the string fields expect.
``yaml.safe_load`` reads an unquoted ``2026-10-11`` as ``datetime.date`` and
``2026-10-11T20:00`` as ``datetime.datetime``. The truth source is a
hand-editable file and the schedule registry is meant to outlive topics by
months, so a bare date must never invalidate an entry: date and time
scalars are rendered back to ISO text, anything else falls back to ``str``.
"""
if value is None:
return ""
if isinstance(value, (dt.datetime, dt.date, dt.time)):
return value.isoformat()
if isinstance(value, str):
return value
return str(value)
def kind_priority(kind) -> int:
"""Priority rank of a topic kind; unknown kinds sort last."""
return KIND_PRIORITY.get(str(kind or "").strip(), len(TOPIC_KINDS))
class ProactiveTopic(BaseModel):
"""One proactive topic; every field has a default so v1 files parse seamlessly.
@ -31,24 +89,33 @@ class ProactiveTopic(BaseModel):
id: str = ""
title: str = ""
reason: str = ""
kind: str = "interest_extend"
kind: str = DEFAULT_KIND
confidence: float = 0.5
first_seen: str = ""
last_evidence_at: str = ""
evidence: str = ""
paths: list[str] = Field(default_factory=list)
# Reminder-only links: ``schedule_id`` points at the long-lived registry
# entry, ``due_at`` is the ISO date/datetime the reminder is about.
schedule_id: str = ""
due_at: str = ""
@field_validator("kind", mode="before")
@classmethod
def _fallback_kind(cls, value):
text = str(value or "").strip()
return text if text in TOPIC_KINDS else "interest_extend"
return text if text in TOPIC_KINDS else DEFAULT_KIND
@field_validator("confidence", mode="before")
@classmethod
def _fallback_confidence(cls, value):
return clamp_confidence(value)
@field_validator("first_seen", "last_evidence_at", "due_at", mode="before")
@classmethod
def _iso_when(cls, value):
return iso_text(value)
@field_validator("paths", mode="before")
@classmethod
def _clean_str_list(cls, value):
@ -57,6 +124,137 @@ class ProactiveTopic(BaseModel):
return [str(item).strip() for item in value if str(item).strip()]
class ProactiveSchedule(BaseModel):
"""One personal schedule/appointment in the long-lived reminder registry.
The registry is a separate truth-source section from ``open_topics``: a
schedule survives topic trimming (``carry_forward_days``) until its due
date has passed by ``schedule_retention_days``, and every change the
pipeline observes is appended to ``history`` so a reschedule or a
cancellation stays auditable long after the originating daily note left
the extract scan window.
"""
model_config = ConfigDict(extra="ignore")
id: str = ""
title: str = ""
detail: str = ""
due_at: str = ""
due_end_at: str = ""
recurrence: str = ""
# 0 = "not specified": the reminder scan then applies the step-level
# ``reminder_lead_days`` default instead of a per-schedule value.
lead_days: int = 0
status: str = "open"
confidence: float = 0.5
first_seen: str = ""
last_seen_at: str = ""
evidence: str = ""
paths: list[str] = Field(default_factory=list)
history: list[dict] = Field(default_factory=list)
@field_validator("status", mode="before")
@classmethod
def _fallback_status(cls, value):
text = str(value or "").strip()
return text if text in SCHEDULE_STATUSES else "open"
@field_validator("recurrence", mode="before")
@classmethod
def _fallback_recurrence(cls, value):
text = str(value or "").strip().lower()
return text if text in RECURRENCES else ""
@field_validator("due_at", "due_end_at", "first_seen", "last_seen_at", mode="before")
@classmethod
def _iso_when(cls, value):
return iso_text(value)
@field_validator("confidence", mode="before")
@classmethod
def _fallback_confidence(cls, value):
return clamp_confidence(value)
@field_validator("lead_days", mode="before")
@classmethod
def _fallback_lead_days(cls, value):
try:
return max(0, min(int(value), 365))
except (TypeError, ValueError):
return 0
@field_validator("paths", mode="before")
@classmethod
def _clean_str_list(cls, value):
if not isinstance(value, list):
return []
return [str(item).strip() for item in value if str(item).strip()]
@field_validator("history", mode="before")
@classmethod
def _clean_history(cls, value):
if not isinstance(value, list):
return []
cleaned = []
for item in value:
if not isinstance(item, dict):
continue
cleaned.append({k: iso_text(v) if k in HISTORY_WHEN_KEYS else v for k, v in item.items()})
return cleaned
class ProactiveDelivery(BaseModel):
"""One host-side report that an agenda item was surfaced or executed.
The identity of a record is ``(topic_id, external_ref)`` when the host
supplies a reference (an inbox event id, a session id), and
``(topic_id, agenda_date, channel)`` otherwise, so repeated reports of the
same delivery update in place instead of piling up. ``status`` only moves
forward: ``shown`` may become ``executed``/``dismissed``/``failed``, but a
terminal record is never downgraded back to ``shown``.
"""
model_config = ConfigDict(extra="ignore")
topic_id: str = ""
agenda_date: str = ""
delivered_at: str = ""
channel: str = DEFAULT_DELIVERY_CHANNEL
consumer: str = ""
status: str = DEFAULT_DELIVERY_STATUS
outcome: str = ""
external_ref: str = ""
@field_validator("status", mode="before")
@classmethod
def _fallback_status(cls, value):
text = str(value or "").strip().lower()
return text if text in DELIVERY_STATUSES else DEFAULT_DELIVERY_STATUS
@field_validator("channel", mode="before")
@classmethod
def _fallback_channel(cls, value):
text = str(value or "").strip().lower()
return text if text in DELIVERY_CHANNELS else DEFAULT_DELIVERY_CHANNEL
@field_validator("agenda_date", "delivered_at", mode="before")
@classmethod
def _iso_when(cls, value):
return iso_text(value)
@field_validator("outcome", mode="before")
@classmethod
def _truncate_outcome(cls, value):
return str(value or "")[:MAX_DELIVERY_OUTCOME_CHARS]
def identity(self) -> tuple[str, str]:
"""Idempotency key: explicit host reference wins over the natural key."""
if self.external_ref:
return (self.topic_id, f"ref:{self.external_ref}")
return (self.topic_id, f"day:{self.agenda_date}|{self.channel}")
class ProactiveState(BaseModel):
"""Chain-shared proactive context state (``context['proactive']``).
@ -77,25 +275,56 @@ class ProactiveState(BaseModel):
carry_forward_days: int = 14
changed_paths: list[str] = Field(default_factory=list)
changed_mtimes: dict[str, float] = Field(default_factory=dict)
# External source records (``resource/``) scanned for user-facing notices.
# They feed the reminder branch only, but share ``changed_mtimes`` and the
# proactive catalog watermark with the daily material set, so a notice that
# lands on a day without any conversation still triggers a round.
resource_scanned: int = 0
resource_skipped: int = 0
resource_changed: list[str] = Field(default_factory=list)
carry_forward_count: int = 0
carry_forward_prompt: list[ProactiveTopic] = Field(default_factory=list)
llm_calls: int = 0
follow_ups: list[dict] = Field(default_factory=list)
extends: list[dict] = Field(default_factory=list)
reminders: list[dict] = Field(default_factory=list)
new_ideas: list[dict] = Field(default_factory=list)
updates: list[dict] = Field(default_factory=list)
schedule_updates: list[dict] = Field(default_factory=list)
# Stage-2 adjudication (precision pass over stage-1 candidates). Every
# failure is fail-open, so ``refine_degraded`` counts the kinds whose
# stage-1 candidates were kept untouched.
refine_calls: int = 0
refine_degraded: int = 0
refine_dropped: list[dict] = Field(default_factory=list)
refine_moved: list[dict] = Field(default_factory=list)
early_exit: str = ""
# ``reminder_only`` marks a clock-driven round with no new material: the
# chain stays alive (no ``early_exit``) but made zero LLM calls.
reminder_only: bool = False
due_schedules: list[dict] = Field(default_factory=list)
updates_applied: int = 0
updates_resolved: int = 0
schedules_added: int = 0
schedules_updated: int = 0
schedules_closed: int = 0
# Reminders whose published due value was re-read from the registry because
# this round's ``schedule_updates`` moved it after the clock scan.
reminders_reanchored: int = 0
schedules_out: list[dict] = Field(default_factory=list)
candidates_in: int = 0
candidates: list[dict] = Field(default_factory=list)
dropped_missing: int = 0
dropped_duplicate: int = 0
dropped_known: int = 0
dropped_over_cap: int = 0
topics_out: list[dict] = Field(default_factory=list)
push_candidates: list[dict] = Field(default_factory=list)
scenario_cards: list[dict] = Field(default_factory=list)
agenda: list[dict] = Field(default_factory=list)
suppressed: list[dict] = Field(default_factory=list)
# Candidates withheld because the host already reported them delivered.
suppressed_delivered: int = 0
plan_llm_calls: int = 0
push: bool = False
file_skip_reason: str = ""
@ -103,6 +332,11 @@ class ProactiveState(BaseModel):
interests_written: bool = False
checkpoint_paths: list[str] = Field(default_factory=list)
duration_ms: int = 0
# Delivery-feedback round counters (metadata/log only).
deliveries_in: int = 0
deliveries_added: int = 0
deliveries_updated: int = 0
deliveries_dropped: int = 0
class ProactiveStateFile(BaseModel):
@ -110,6 +344,10 @@ class ProactiveStateFile(BaseModel):
``resolved`` tombstones carry ``first_seen`` so a resurrected topic can
keep its original age anchor (F2.4 reopen channel).
``schedules`` is the long-lived reminder registry; it is optional so
files written before the reminder branch existed still load.
``deliveries`` is the optional host-feedback log; it is likewise absent
from files written before delivery reporting existed.
"""
model_config = ConfigDict(extra="ignore")
@ -117,6 +355,8 @@ class ProactiveStateFile(BaseModel):
version: int = 1
open_topics: list[ProactiveTopic] = Field(default_factory=list)
resolved: list[dict] = Field(default_factory=list)
schedules: list[ProactiveSchedule] = Field(default_factory=list)
deliveries: list[ProactiveDelivery] = Field(default_factory=list)
class ProactiveResult(BaseModel):
@ -132,3 +372,6 @@ class ProactiveResult(BaseModel):
push: bool | None = None
generated_at: str = ""
agenda: list[dict] = Field(default_factory=list)
# Agenda topic_ids withheld because the host already reported them as
# delivered; kept so a caller can see the suppression instead of guessing.
delivered_ids: list[str] = Field(default_factory=list)

View file

@ -2,6 +2,7 @@
from .agenda import ProactiveAgendaStep
from .extract import ProactiveExtractStep
from .feedback import ProactiveFeedbackStep
from .finish import ProactiveFinishStep
from .plan import ProactivePlanStep
from .proactive import ProactiveStep
@ -10,6 +11,7 @@ from .topics import ProactiveTopicsStep
__all__ = [
"ProactiveAgendaStep",
"ProactiveExtractStep",
"ProactiveFeedbackStep",
"ProactiveFinishStep",
"ProactivePlanStep",
"ProactiveStep",

View file

@ -14,6 +14,19 @@ call; LLM absent/timed out/unusable -> freshness/confidence order fallback;
after validation the agenda is never empty while candidates exist, so the push
semantics computed by the topics step never flip. The step also owns the final
interests.yaml render, extending the v2 shape with ``agenda``/``suppressed``.
Reminder priority is enforced here, not negotiated: every reminder card is
pinned to the front of the agenda (nearest due date first) before the editor
LLM sees anything, and only the slots the reminders leave are up for
selection. Pinned items are never suppressed and never counted as "silence".
Delivery feedback is enforced even earlier: a candidate the host already
reported as surfaced/executed inside ``redelivery_cooldown_days`` leaves the
candidate set before pinning and before the editor call, with an auditable
``already delivered ...`` suppression reason. This is the write-side twin of
the reader's filter, so a proposal cannot reappear tomorrow merely because
the host reported late. Hosts that never report keep an empty ``deliveries``
section and see no change.
"""
import asyncio
@ -29,14 +42,51 @@ from .utils import (
current_now,
interests_path_for,
load_personal_profile_block,
load_state,
norm_path,
parse_agenda_reply,
render_interests,
resolve_agent_wrapper,
suppressed_delivery_ids,
write_interests_if_changed,
)
FALLBACK_ORDER_REASON = "deterministic fallback: freshness/confidence order"
PINNED_REMINDER_REASONS = {
"due": "reminder pinned: a due schedule always enters today's agenda",
"new": "reminder pinned: a new reminder always enters today's agenda",
"open": "reminder pinned: an open reminder always enters today's agenda",
}
def _pin_reason(candidate: dict, day: str = "") -> str:
"""Order reason for a pinned reminder, naming the due date when known.
Every reminder is pinned, but not for the same reason: the audit trail
distinguishes a schedule the clock shows as due/overdue from one this round
merely found (whose due date may still be weeks away), so a reader can tell
a calendar fact from a freshness rule.
"""
due = str(candidate.get("due_at") or "").strip()
due_day, day = due[:10], str(day or "").strip()[:10]
first_seen = str(candidate.get("first_seen") or "").strip()[:10]
if due_day and day and due_day <= day:
cause = "due"
elif first_seen and day and first_seen >= day:
cause = "new"
else:
cause = "open"
base = PINNED_REMINDER_REASONS[cause]
return f"{base} (due {due})" if due else base
def _delivered_reason(record) -> str:
"""Suppression reason naming when, where and how the item was already used."""
if record is None:
return "already delivered (within the redelivery cooldown)"
when = str(record.agenda_date or "") or str(record.delivered_at or "")[:10]
where = f" via {record.channel}" if record.channel else ""
return f"already delivered on {when}{where} ({record.status})"
@R.register("proactive_agenda_step")
@ -51,17 +101,21 @@ class ProactiveAgendaStep(BaseStep):
def __init__(
self,
max_agenda_items: int = 6,
min_discovery_slots: int = 2,
llm_timeout_seconds: float = 120,
profile_rel_path: str = "profile.md",
profile_max_chars: int = 2500,
redelivery_cooldown_days: int = 3,
skip_key: str = "proactive_skip",
**kwargs,
):
super().__init__(**kwargs)
self.max_agenda_items = max(int(max_agenda_items), 1)
self.min_discovery_slots = max(int(min_discovery_slots), 0)
self.llm_timeout_seconds = float(llm_timeout_seconds)
self.profile_rel_path = str(profile_rel_path or "")
self.profile_max_chars = max(int(profile_max_chars), 0)
self.redelivery_cooldown_days = max(int(redelivery_cooldown_days), 0)
self.skip_key = skip_key
async def execute(self):
@ -91,27 +145,93 @@ class ProactiveAgendaStep(BaseStep):
cards_by_id = {str(c.get("topic_id") or ""): c for c in state.scenario_cards if isinstance(c, dict)}
carded = [c for c in candidates if str(c.get("id")) in cards_by_id]
over_budget = [c for c in candidates if str(c.get("id")) not in cards_by_id]
# Delivery feedback outranks every other selection rule: drop what the
# host already surfaced before pinning reminders or calling the editor,
# so the cooldown cannot be argued away by narrative coherence.
daily = state.daily_dir or daily_dir(self)
blocked = self._suppressed_deliveries(ws, daily, state.date)
# Reminders are the one exemption: a due schedule has to be announced
# every time it comes due, and reminder ids derive from a stable title,
# so blocking them would silence a recurring reminder for the whole
# cooldown window after a single delivery.
reminder_ids = {str(c.get("id")) for c in candidates if str(c.get("kind")) == "reminder"}
blocked = {
blocked_id: record
for blocked_id, record in blocked.items()
if blocked_id not in reminder_ids
}
delivered = [c for c in candidates if str(c.get("id")) in blocked]
if delivered:
carded = [c for c in carded if str(c.get("id")) not in blocked]
over_budget = [c for c in over_budget if str(c.get("id")) not in blocked]
state.suppressed_delivered = len(delivered)
# Reminders are pinned: every reminder - a due schedule or a freshly
# found one - always reaches today's agenda and is never traded away for
# narrative coherence. What the editor LLM gets
# to choose is the remaining (discovery) part of the agenda, inside the
# slots the pinned reminders leave - but never fewer than
# ``min_discovery_slots``, so a reminder-heavy day still surfaces the
# discovery kinds instead of collapsing into a plain calendar. On such a
# day the agenda may exceed ``max_agenda_items``: pinned reminders are
# additive by design, and dropping either side would break a guarantee.
pinned = [c for c in carded if str(c.get("kind")) == "reminder"]
optional = [c for c in carded if str(c.get("kind")) != "reminder"]
pinned_ids = [str(c.get("id")) for c in pinned]
slots = max(self.max_agenda_items - len(pinned_ids), self.min_discovery_slots)
self.logger.info(
f"[{self.name}] start candidates={len(candidates)} carded={len(carded)} "
f"over_budget={len(over_budget)} max_agenda_items={self.max_agenda_items}",
f"over_budget={len(over_budget)} already_delivered={len(delivered)} "
f"pinned_reminders={len(pinned_ids)} slots={slots} "
f"max_agenda_items={self.max_agenda_items}",
)
if not carded:
agenda_ids, order_reasons, suppressed_reasons = [], {}, {}
elif len(carded) == 1:
agenda_ids = [str(carded[0].get("id"))]
order_reasons = {agenda_ids[0]: "single push candidate; auto-agenda without LLM"}
if not optional:
selected_ids, order_reasons, suppressed_reasons = [], {}, {}
elif slots == 0:
selected_ids, order_reasons = [], {}
suppressed_reasons = {
str(c.get("id")): f"agenda capacity reserved for {len(pinned_ids)} reminder(s)" for c in optional
}
elif len(optional) == 1:
selected_ids = [str(optional[0].get("id"))]
order_reasons = {selected_ids[0]: "single push candidate; auto-agenda without LLM"}
suppressed_reasons = {}
else:
agenda_ids, order_reasons, suppressed_reasons = await self._llm_agenda(state, ws, carded, cards_by_id)
selected_ids, order_reasons, suppressed_reasons = await self._llm_agenda(
state,
ws,
optional,
cards_by_id,
slots,
)
for cid in pinned_ids:
order_reasons.setdefault(
cid,
_pin_reason(next((c for c in pinned if str(c.get("id")) == cid), {}), state.date),
)
agenda_ids = pinned_ids + [cid for cid in selected_ids if cid not in pinned_ids]
suppressed: list[dict] = []
for candidate in over_budget:
for candidate in delivered:
record = blocked.get(str(candidate.get("id")))
suppressed.append(
{
"topic_id": str(candidate.get("id")),
"title": str(candidate.get("title") or ""),
"reason": "over plan budget: not expanded into a scenario card",
"reason": _delivered_reason(record),
},
)
for candidate in over_budget:
is_reminder = str(candidate.get("kind")) == "reminder"
suppressed.append(
{
"topic_id": str(candidate.get("id")),
"title": str(candidate.get("title") or ""),
"reason": (
"over reminder card budget: not expanded into a scenario card"
if is_reminder
else "over plan budget: not expanded into a scenario card"
),
},
)
for candidate in carded:
@ -136,12 +256,15 @@ class ProactiveAgendaStep(BaseStep):
{
"topic_id": cid,
"title": str(candidate.get("title") or ""),
"kind": str(candidate.get("kind") or ""),
"scenario_type": str(card.get("scenario_type") or ""),
"opener": str(card.get("opener") or ""),
"next_action": str(card.get("next_action") or ""),
"preconditions": list(card.get("preconditions") or []),
"delivery": str(card.get("delivery") or "in_conversation"),
"linked_memory": list(card.get("linked_memory") or []),
**({"due_at": str(candidate.get("due_at"))} if candidate.get("due_at") else {}),
**({"schedule_id": str(candidate.get("schedule_id"))} if candidate.get("schedule_id") else {}),
"order_reason": order_reasons.get(cid) or "",
},
)
@ -168,8 +291,8 @@ class ProactiveAgendaStep(BaseStep):
state.duration_ms = state.duration_ms + int((time.monotonic() - started) * 1000)
self._store(state)
answer = (
f"Agenda: {len(agenda)} item(s), suppressed={len(suppressed)}, push={state.push}, "
f"written={written} to {rel_path}"
f"Agenda: {len(agenda)} item(s), suppressed={len(suppressed)} "
f"(already delivered={len(delivered)}), push={state.push}, written={written} to {rel_path}"
)
self.context.response.success = True
self.context.response.answer = answer
@ -182,13 +305,19 @@ class ProactiveAgendaStep(BaseStep):
ws,
carded: list[dict],
cards_by_id: dict[str, dict],
slots: int,
) -> tuple[list[str], dict[str, str], dict[str, str]]:
"""One list-wise LLM call; returns (agenda ids, order reasons, suppressed reasons)."""
"""One list-wise LLM call over the non-pinned cards.
Returns ``(agenda ids, order reasons, suppressed reasons)``. ``slots``
is what the pinned reminders left of ``max_agenda_items``, and it is
both the cap told to the model and the hard truncation limit.
"""
carded_ids = [str(c.get("id")) for c in carded]
wrapper = resolve_agent_wrapper(self)
if wrapper is None:
self.logger.warning(f"[{self.name}] no agent_wrapper available; deterministic fallback agenda")
return self._fallback_agenda(carded_ids)
return self._fallback_agenda(carded_ids, slots)
cards_json = json.dumps(
[
@ -207,12 +336,12 @@ class ProactiveAgendaStep(BaseStep):
)
user_message = self.prompt_format(
"agenda_user_message",
max_agenda_items=self.max_agenda_items,
max_agenda_items=slots,
cards_json=cards_json,
changed_files_json=json.dumps(list(dict.fromkeys(state.changed_paths)), ensure_ascii=False),
profile_block=self._profile_block(ws),
)
system_prompt = self.prompt_format("agenda_system_prompt", max_agenda_items=self.max_agenda_items)
system_prompt = self.prompt_format("agenda_system_prompt", max_agenda_items=slots)
state.plan_llm_calls += 1
try:
result = await asyncio.wait_for(
@ -221,12 +350,12 @@ class ProactiveAgendaStep(BaseStep):
)
except asyncio.TimeoutError:
self.logger.warning(f"[{self.name}] LLM reply timed out after {self.llm_timeout_seconds}s")
return self._fallback_agenda(carded_ids)
return self._fallback_agenda(carded_ids, slots)
except Exception as e: # noqa: BLE001 - network/provider errors degrade to fallback agenda
self.logger.warning(
f"[{self.name}] LLM reply failed ({type(e).__name__}: {e}); using fallback agenda",
)
return self._fallback_agenda(carded_ids)
return self._fallback_agenda(carded_ids, slots)
raw = agent_reply_result_text(result)
agenda_raw, suppressed_raw = parse_agenda_reply(raw)
@ -236,7 +365,7 @@ class ProactiveAgendaStep(BaseStep):
cid = str(item.get("topic_id") or "").strip()
if cid in allowed and cid not in agenda_ids:
agenda_ids.append(cid)
if len(agenda_ids) >= self.max_agenda_items:
if len(agenda_ids) >= slots:
break
order_reasons = {
str(item.get("topic_id") or "").strip(): str(item.get("order_reason") or "").strip()
@ -250,16 +379,23 @@ class ProactiveAgendaStep(BaseStep):
}
if not agenda_ids:
self.logger.warning(f"[{self.name}] agenda reply unusable; raw={raw[:200]!r}")
return self._fallback_agenda(carded_ids)
return self._fallback_agenda(carded_ids, slots)
return agenda_ids, order_reasons, suppressed_reasons
def _fallback_agenda(self, carded_ids: list[str]) -> tuple[list[str], dict[str, str], dict[str, str]]:
def _fallback_agenda(self, carded_ids: list[str], slots: int) -> tuple[list[str], dict[str, str], dict[str, str]]:
"""Freshness/confidence head when generation is unavailable (cards keep sort order)."""
agenda_ids = carded_ids[: self.max_agenda_items]
agenda_ids = carded_ids[: max(int(slots), 0)]
order_reasons = {cid: FALLBACK_ORDER_REASON for cid in agenda_ids}
suppressed_reasons = {cid: "capacity (deterministic fallback)" for cid in carded_ids if cid not in agenda_ids}
return agenda_ids, order_reasons, suppressed_reasons
def _suppressed_deliveries(self, ws, daily: str, day: str) -> dict:
"""topic_id -> blocking delivery record reported by the host."""
state_file, _needs_bootstrap = load_state(ws, daily)
if not state_file.deliveries:
return {}
return suppressed_delivery_ids(state_file, day, self.redelivery_cooldown_days)
def _profile_block(self, ws) -> str:
"""Digest-personal profile first, legacy single profile file as fallback."""
digest_dir = str(self.config_value("digest_dir"))

View file

@ -4,10 +4,16 @@ agenda_system_prompt: |
candidates), the files that changed today (a proxy for what the user is
focused on right now) and an optional user profile.
Reminder cards are NOT in your input: a due schedule is pinned to today's
agenda by the pipeline and cannot be traded away. You are editing the
remaining discovery slots.
Decide which cards enter today's agenda, in which order, and which are
held back. The number of agenda items is YOUR call: judge it from content
richness and redundancy - anything from 1 up to at most {max_agenda_items}.
Fewer, stronger items beat many weak ones.
When {max_agenda_items} is 0 the pinned reminders already fill today's
agenda: suppress every card with that reason instead of forcing one in.
## Editorial rules
@ -21,6 +27,10 @@ agenda_system_prompt: |
- Auditable silence: EVERY candidate topic_id must appear exactly once,
either in `agenda` or in `suppressed` (with a short reason). Never drop
a candidate without a word.
- Quote every YAML string containing punctuation such as `:` - an unquoted
`order_reason` with a colon in it makes the whole reply unparseable, and
the agenda then degrades to a deterministic order. Keep both reasons to one
short sentence.
## Output format
@ -38,9 +48,14 @@ agenda_system_prompt_zh: |
你是个人记忆助手当日主动议程的编辑。输入是场景卡(由推送候选扩展而来)、
今天发生变化的文件(代表用户当前关注点)以及可选的用户画像。
提醒类(reminder)场景卡不在你的输入里:到期日程已由流程固定进入当日议程,
不可被替换或压制。你编辑的是剩下的发现类名额。
决定哪些卡片进入今日议程、按什么顺序,哪些暂缓。议程条数由你自行裁决:
依据内容丰富度和重复程度决定,最少 1 条,至多不超过 {max_agenda_items} 条。
少而强胜过多而弱。
当 {max_agenda_items} 为 0 时,说明固定提醒已占满今日议程:请把每张卡都
放入 suppressed 并写明该原因,不要硬塞。
## 编辑规则
@ -52,6 +67,9 @@ agenda_system_prompt_zh: |
压制它并在 reason 中说明。
- 沉默可审计:每个候选 topic_id 必须恰好出现一次,要么在 agenda,要么在
suppressed(附简短理由)。禁止不声不响地丢弃任何候选。
- 包含 `:` 等标点的 YAML 字符串请加引号——`order_reason` 里出现未加引号的
冒号会让整份回复无法解析,议程随之退化为确定性排序。两类理由都请控制在一
句话以内。
## 输出格式

View file

@ -1,4 +1,25 @@
"""Proactive refresh extract step: material scan + follow_ups/extends/updates (F2.0-F2.3)."""
"""Proactive refresh extract step: material scan + four proposal kinds (F2.0-F2.3).
The step owns discovery. It reads the changed daily notes plus three cheap
read-only views - the carry-forward topics, the long-lived schedule registry
and the digest capability inventory - together with the current business
clock, and asks for one sectioned reply covering four proposal kinds:
``follow_ups`` open loops: propose fetching the latest status and reporting;
``extends`` knowledge-base recommendations: propose a wiki node to expand;
``reminders`` personal schedule review: propose due/conflicting reminders,
plus must-tell notices found in changed ``resource/`` records;
``new_ideas`` delegatable work the user has not thought of: propose only.
Only the reminder branch reads the ``resource/`` layer, which is the reserved
hook for external source interfaces: whatever lands there becomes eligible for
the schedule review without any further change to the chain.
Reminders additionally have a deterministic, clock-driven path
(``reminder_candidates_from_view``) so a due schedule still reaches today's
agenda when there is no new material, no agent wrapper, or an unusable reply.
Nothing here executes a proposal; the output stays a proposal end to end.
"""
import asyncio
import json
@ -9,25 +30,55 @@ 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 .refine import apply_verdicts, candidate_payload, candidates_by_kind, parse_refine_reply
from .schedules import (
DUE_TRIGGERS,
cap_notice_reminders,
clean_reminder,
clean_schedule_update,
merge_reminders,
reminder_candidates_from_view,
schedule_prompt_entries,
schedule_view,
)
from .utils import (
clean_candidate,
current_now,
load_capability_block,
load_carry_forward,
load_personal_profile_block,
load_state,
parse_extract_reply,
resolve_agent_wrapper,
scan_material_daily,
scan_material_resource,
)
DUE_SCHEDULE_KEYS = (
"id",
"title",
"status",
"due_at",
"next_due",
"due_in_days",
"trigger",
"conflict_with",
)
@R.register("proactive_extract_step")
class ProactiveExtractStep(BaseStep):
"""Scan the proactive material set and extract follow-ups, extends and updates.
"""Scan the proactive material set and extract the four proposal kinds.
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).
budget/timeout per F4.3; early-exits with zero LLM calls when no new
evidence exists (F2.0) - unless the clock produced due reminders, which
are delivered without any LLM call.
Per-kind ceilings keep discovery proportional (``max_follow_ups``,
``max_extends``, ``max_new_ideas``); reminders are deliberately uncapped
because they are bounded by the registry itself and pin today's agenda.
"""
def __init__(
@ -39,7 +90,21 @@ class ProactiveExtractStep(BaseStep):
max_chars_per_file: int = 60000,
max_total_chars: int = 300000,
profile_max_chars: int = 1500,
capability_max_chars: int = 1200,
extends_enabled: bool = True,
reminders_enabled: bool = True,
new_ideas_enabled: bool = True,
max_follow_ups: int = 5,
max_extends: int = 5,
max_new_ideas: int = 3,
reminder_lead_days: int = 3,
schedule_closed_lookback_days: int = 7,
resources_enabled: bool = True,
resource_scan_days: int = 0,
resource_max_chars: int = 40000,
max_notice_reminders: int = 3,
refine_enabled: bool = True,
refine_timeout_seconds: float = 90,
skip_key: str = "proactive_skip",
**kwargs,
):
@ -51,9 +116,26 @@ class ProactiveExtractStep(BaseStep):
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.capability_max_chars = max(int(capability_max_chars), 0)
self.extends_enabled = bool(extends_enabled)
self.reminders_enabled = bool(reminders_enabled)
self.new_ideas_enabled = bool(new_ideas_enabled)
self.max_follow_ups = max(int(max_follow_ups), 0)
self.max_extends = max(int(max_extends), 0)
self.max_new_ideas = max(int(max_new_ideas), 0)
self.reminder_lead_days = max(int(reminder_lead_days), 0)
self.schedule_closed_lookback_days = max(int(schedule_closed_lookback_days), 0)
self.resources_enabled = bool(resources_enabled)
# 0 inherits ``scan_days``: the notice window normally matches the
# conversation window, but can be widened on its own.
self.resource_scan_days = max(int(resource_scan_days), 0)
self.resource_max_chars = max(int(resource_max_chars), 0)
self.max_notice_reminders = max(int(max_notice_reminders), 0)
self.refine_enabled = bool(refine_enabled)
self.refine_timeout_seconds = float(refine_timeout_seconds)
self.skip_key = skip_key
# pylint: disable=too-many-return-statements
async def execute(self):
assert self.context is not None
if self.context.get(self.skip_key):
@ -71,10 +153,27 @@ class ProactiveExtractStep(BaseStep):
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}")
self.logger.info(
f"[{self.name}] start date={day} scan_days={self.scan_days} extends={self.extends_enabled} "
f"reminders={self.reminders_enabled} new_ideas={self.new_ideas_enabled}",
)
# 1) Material set M (F2.0) - all cheap, before any LLM work.
# 1) Material set M (F2.0) - all cheap, before any LLM work. Daily
# notes feed every branch. The ``resource/`` layer feeds the reminder
# branch only (external notices the user must be told about), so it is
# scanned only when reminders are active.
m_daily = scan_material_daily(ws, day, daily, self.scan_days)
notices = self.reminders_enabled and self.resources_enabled
if notices:
m_resource, resource_skipped = scan_material_resource(
ws,
day,
self.resource_scan_days or self.scan_days,
str(self.config_value("resource_dir")),
)
else:
m_resource, resource_skipped = [], 0
state.resource_scanned, state.resource_skipped = len(m_resource), resource_skipped
# 2) Truth source + carry-forward (F1.3/F1.4).
state_file, needs_bootstrap = load_state(ws, daily)
@ -90,57 +189,120 @@ class ProactiveExtractStep(BaseStep):
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.
# 3) Change detection against the proactive catalog watermark. Both
# material sets share one watermark, so an external notice arriving on
# a day without any conversation still triggers a round.
existing = {}
for rel in m_daily:
for rel in (*m_daily, *m_resource):
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]
changed = [rel for rel, mt in existing.items() if indexed.get(rel) != mt]
daily_set = set(m_daily)
state.changed_paths = [rel for rel in changed if rel in daily_set]
state.resource_changed = [rel for rel in changed if rel not in daily_set]
# 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}
state.changed_mtimes = {rel: existing[rel] for rel in changed}
self.logger.info(
f"[{self.name}] material daily={len(m_daily)} "
f"changed={len(state.changed_paths)} carry_forward={len(carry_all)}",
f"[{self.name}] material daily={len(m_daily)} resource={len(m_resource)} "
f"resource_skipped={resource_skipped} changed={len(state.changed_paths)} "
f"notices_changed={len(state.resource_changed)} carry_forward={len(carry_all)}",
)
# 4) Zero-consumption early exit (A4 row 1).
if not state.changed_paths:
# 4) Clock + schedule registry scan (branch C). Deterministic and free,
# so it runs before any LLM decision: a due schedule must reach today's
# agenda even when there is no new material, no agent wrapper, or an
# unusable reply.
now_dt = current_now(self)
view = schedule_view(state_file.schedules, now_dt, self.reminder_lead_days) if self.reminders_enabled else []
schedules_by_id = {str(entry.get("id") or ""): entry for entry in view if entry.get("id")}
seeded = reminder_candidates_from_view(view, day)
state.due_schedules = [
{key: entry.get(key) for key in DUE_SCHEDULE_KEYS} for entry in view if entry.get("trigger") in DUE_TRIGGERS
]
if seeded:
self.logger.info(
f"[{self.name}] clock scan: registry={len(state_file.schedules)} "
f"due={len(state.due_schedules)} seeded_reminders={len(seeded)} now={now_dt.isoformat()}",
)
# 5) Zero-consumption early exit (A4 row 1) - unless the clock alone
# already produced reminders, which need neither material nor an LLM.
if not state.changed_paths and not state.resource_changed:
if seeded:
return self._clock_only(state, started, seeded, "Skipped LLM: no new evidence")
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
# 6) 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:
if seeded:
return self._clock_only(state, started, seeded, "Degraded: no agent_wrapper configured")
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)
# 7) One LLM call with sectioned output (A3); retry once on parse failure.
# meta is None when this round produced no usable output (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, failure = await self._extract_with_retry(wrapper, state, ws, day, daily, now_dt, view)
if meta is None:
if seeded:
return self._clock_only(state, started, seeded, f"Degraded: {failure}")
self.context[self.skip_key] = {"reason": failure}
self._store(state)
return passthrough_response(self, self.skip_key)
self._clean_output(state, meta, set(m_daily), day)
self._clean_output(state, meta, set(m_daily), day, schedules_by_id, set(m_resource))
if self.refine_enabled:
await self._refine_stage(state, wrapper, ws, day, now_dt, view, set(m_resource))
state.reminders = merge_reminders(seeded, state.reminders)
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)"
f"{len(state.reminders)} reminder(s), {len(state.new_ideas)} idea(s), "
f"{len(state.updates)} update(s), {len(state.schedule_updates)} schedule update(s) "
f"from {len(state.changed_paths)} changed file(s) "
f"and {len(state.resource_changed)} external notice file(s)"
)
if state.refine_calls:
answer += (
f"; refine: {state.refine_calls} pass(es), dropped {len(state.refine_dropped)}, "
f"moved {len(state.refine_moved)}, degraded {state.refine_degraded}"
)
return self._finish(state, started, answer)
def _build_messages(self, state: ProactiveState, ws, day: str, daily: str, material_blob: str) -> tuple[str, str]:
def _clock_only(self, state: ProactiveState, started: float, seeded: list[dict], reason: str):
"""Deliver clock-driven reminders without consuming this round's material.
``changed_paths`` is cleared so the finish step checkpoints nothing and
the unread material stays "changed" for the next round; the chain still
runs, so today's agenda gets its reminders.
"""
state.changed_paths, state.resource_changed, state.changed_mtimes = [], [], {}
state.reminders = seeded
state.reminder_only = True
self.logger.warning(f"[{self.name}] {reason}; delivering {len(seeded)} clock-driven reminder(s)")
return self._finish(state, started, f"{reason}; {len(seeded)} clock-driven reminder(s), 0 LLM output used")
def _build_messages(
self,
state: ProactiveState,
ws,
day: str,
daily: str,
material_blob: str,
notice_blob: str,
now_dt,
view: list[dict],
) -> tuple[str, str]:
carry_forward_json = json.dumps(
[
{
@ -157,20 +319,43 @@ class ProactiveExtractStep(BaseStep):
ensure_ascii=False,
)
changed = list(dict.fromkeys(state.changed_paths))
notices = list(dict.fromkeys(state.resource_changed))
schedules_json = json.dumps(
schedule_prompt_entries(view, self.schedule_closed_lookback_days, day),
ensure_ascii=False,
)
user_message = self.prompt_format(
"extract_user_message",
date=day,
clock=f"{now_dt.isoformat(timespec='minutes')} ({now_dt.strftime('%A')})",
resource_dir=str(self.config_value("resource_dir")),
changed_paths_json=json.dumps(changed, ensure_ascii=False),
carry_forward_json=carry_forward_json,
schedules_json=schedules_json,
material_blob=material_blob,
profile_block=self._profile_block(ws),
capability_block=self._capability_block(ws),
resource_paths_json=json.dumps(notices, ensure_ascii=False),
resource_block=notice_blob or "(no external notices in this round)",
extends=self.extends_enabled,
reminders=self.reminders_enabled,
ideas=self.new_ideas_enabled,
resources=self.reminders_enabled and self.resources_enabled,
)
system_prompt = self.prompt_format(
"extract_system_prompt",
workspace_dir=str(ws),
daily_dir=daily,
resource_dir=str(self.config_value("resource_dir")),
output_shape=self._output_shape(),
max_follow_ups=self.max_follow_ups,
max_extends=self.max_extends,
max_new_ideas=self.max_new_ideas,
max_notice_reminders=self.max_notice_reminders,
extends=self.extends_enabled,
reminders=self.reminders_enabled,
ideas=self.new_ideas_enabled,
resources=self.reminders_enabled and self.resources_enabled,
)
return user_message, system_prompt
@ -179,8 +364,41 @@ class ProactiveExtractStep(BaseStep):
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."""
def _capability_block(self, ws) -> str:
"""Digest procedure/wiki inventory; grounds the ``new_ideas`` branch."""
digest_dir = str(self.config_value("digest_dir"))
return load_capability_block(ws, digest_dir, self.capability_max_chars)
def _output_shape(self) -> str:
"""YAML sample listing exactly the active sections.
Composed from per-section snippets rather than inline ``[flag]`` lines:
the flag filter left-strips kept lines, which would flatten the
indentation of a nested YAML example.
"""
names = ["follow_ups"]
if self.extends_enabled:
names.append("extends")
if self.reminders_enabled:
names.append("reminders")
if self.new_ideas_enabled:
names.append("new_ideas")
names.append("updates")
if self.reminders_enabled:
names.append("schedule_updates")
return "\n".join(self.get_prompt(f"extract_shape_{name}") for name in names)
async def _extract_with_retry(
self,
wrapper,
state,
ws,
day: str,
daily: str,
now_dt,
view,
) -> tuple[dict | None, str]:
"""Returns ``(meta, failure_reason)``; meta None means no usable output."""
# 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]
@ -190,48 +408,111 @@ class ProactiveExtractStep(BaseStep):
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)
# External notices are packed under their own, smaller budget so a
# large upload can neither crowd out the conversation material nor be
# crowded out by it.
notice_blob = pack_paths(
ws,
list(dict.fromkeys(state.resource_changed))[::-1],
limit_per_file=self.max_chars_per_file,
max_total_chars=self.resource_max_chars or None,
)
user_message, system_prompt = self._build_messages(
state,
ws,
day,
daily,
material_blob,
notice_blob,
now_dt,
view,
)
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
return None, "llm_timeout"
meta = parse_extract_reply(raw)
if meta:
return 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}",
f"[{self.name}] reply unusable after 2 attempts; keeping material un-checkpointed; raw={raw[:200]!r}",
)
self.context[self.skip_key] = {"reason": "extract_parse_failed"}
return None
return None, "extract_parse_failed"
async def _reply(self, wrapper, state, user_message, system_prompt):
async def _reply(self, wrapper, state, user_message, system_prompt, timeout: float | None = None):
"""One timeout-wrapped reply; returns raw text or None on timeout."""
state.llm_calls += 1
limit = self.llm_timeout_seconds if timeout is None else float(timeout)
try:
result = await asyncio.wait_for(
wrapper.reply(user_message, system_prompt=system_prompt),
timeout=self.llm_timeout_seconds,
timeout=limit,
)
except asyncio.TimeoutError:
self.logger.warning(f"[{self.name}] LLM reply timed out after {self.llm_timeout_seconds}s")
self.logger.warning(f"[{self.name}] LLM reply timed out after {limit}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
def _clean_output(
self,
state: ProactiveState,
meta: dict,
allowed: set[str],
day: str,
schedules_by_id: dict,
notice_allowed: set[str] | None = None,
) -> None:
# Only the reminder branch may cite the ``resource/`` layer: the other
# three kinds reason about the user's own conversations, and letting
# them quote an external upload would blur that boundary.
reminder_allowed = set(allowed) | set(notice_allowed or ())
self._append_kind(
state.follow_ups,
meta.get("follow_ups"),
allowed,
"follow_up",
day,
self.max_follow_ups,
state,
)
if self.extends_enabled:
for raw in meta.get("extends") or []:
if candidate := clean_candidate(raw, allowed, "interest_extend", day):
state.extends.append(candidate)
self._append_kind(
state.extends,
meta.get("extends"),
allowed,
"interest_extend",
day,
self.max_extends,
state,
)
if self.reminders_enabled:
cleaned: list[dict] = []
for raw in meta.get("reminders") or []:
if candidate := clean_reminder(raw, reminder_allowed, day, schedules_by_id, notice_allowed):
cleaned.append(candidate)
else:
state.dropped_missing += 1
# Dateless notice/key-event reminders are bounded; clock-anchored
# ones are not (they are facts, and they pin today's agenda).
state.reminders, over_cap = cap_notice_reminders(cleaned, self.max_notice_reminders)
state.dropped_over_cap += over_cap
for raw in meta.get("schedule_updates") or []:
if update := clean_schedule_update(raw, reminder_allowed, day):
state.schedule_updates.append(update)
else:
state.dropped_missing += 1
if self.new_ideas_enabled:
self._append_kind(
state.new_ideas,
meta.get("new_ideas"),
allowed,
"new_idea",
day,
self.max_new_ideas,
state,
)
for raw in meta.get("updates") or []:
if not isinstance(raw, dict):
continue
@ -251,6 +532,142 @@ class ProactiveExtractStep(BaseStep):
},
)
def _append_kind(
self,
bucket: list[dict],
raw_items,
allowed: set[str],
kind: str,
day: str,
cap: int,
state: ProactiveState,
) -> None:
"""Clean one discovery section into ``bucket``, honouring its ceiling.
``cap <= 0`` means unlimited. Over-cap entries are counted separately
from invalid ones so a noisy round stays diagnosable in metadata.
"""
for raw in raw_items or []:
if cap and len(bucket) >= cap:
state.dropped_over_cap += 1
continue
if candidate := clean_candidate(raw, allowed, kind, day):
bucket.append(candidate)
else:
state.dropped_missing += 1
# ------------------------------------------------------------------
# Stage 2: per-kind adjudication (precision pass)
# ------------------------------------------------------------------
def _enabled_kinds(self) -> set[str]:
"""Kinds this round may hold candidates in (the legal ``move`` targets)."""
return {
kind
for kind, active in (
("follow_up", True),
("interest_extend", self.extends_enabled),
("reminder", self.reminders_enabled),
("new_idea", self.new_ideas_enabled),
)
if active
}
def _kind_caps(self) -> dict[str, int]:
"""Per-kind ceilings re-applied after stage-2 moves (0 = uncapped)."""
return {
"follow_up": self.max_follow_ups,
"interest_extend": self.max_extends,
"new_idea": self.max_new_ideas,
"reminder": 0,
}
def _cap_label(self, kind: str) -> str:
"""Ceiling as the refine prompt should state it."""
if kind != "reminder":
return str(self._kind_caps()[kind])
return f"uncapped, but at most {self.max_notice_reminders} dateless notice/key-event reminder(s)"
async def _refine_stage(self, state, wrapper, ws, day: str, now_dt, view, notice_paths: set[str]) -> None:
"""One small adjudication call per kind that produced candidates.
Stage 2 never sees the material blob: it receives that kind's candidates
plus the only context slice the kind needs, which keeps the pass cheap
and its judgement focused. Each pass is independently fail-open, so a
timeout or an unusable reply keeps the stage-1 candidates of that kind.
"""
verdicts: list[dict] = []
for kind, bucket in candidates_by_kind(state):
known = {str(candidate.get("id") or "") for candidate in bucket}
user_message = self.prompt_format(
"refine_user_message",
kind=kind,
cap=self._cap_label(kind),
checks=self.get_prompt(f"refine_checks_{kind}"),
candidates_json=json.dumps(candidate_payload(bucket), ensure_ascii=False),
context_block=self._refine_context(kind, state, ws, view, day, now_dt),
)
system_prompt = self.prompt_format("refine_system_prompt", kind=kind)
raw = await self._reply(wrapper, state, user_message, system_prompt, self.refine_timeout_seconds)
state.refine_calls += 1
if raw is None:
state.refine_degraded += 1
self.logger.warning(f"[{self.name}] refine({kind}) timed out; keeping stage-1 candidates")
continue
parsed = parse_refine_reply(raw)
if parsed is None:
state.refine_degraded += 1
self.logger.warning(f"[{self.name}] refine({kind}) reply unusable; raw={raw[:160]!r}")
continue
# A pass may not reach into another kind's bucket: ids outside it
# are ignored rather than trusted.
verdicts.extend(verdict for verdict in parsed if verdict["id"] in known)
if not verdicts:
# Nothing to adjudicate: every pass answered "keep everything".
# The caps only need re-applying when a move could have crossed a
# kind boundary, but the summary is worth logging either way.
self.logger.info(
f"[{self.name}] refine passes={state.refine_calls} dropped=0 moved=0 "
f"degraded={state.refine_degraded} no verdicts; kept: "
f"reminders={len(state.reminders)} follow_ups={len(state.follow_ups)} "
f"ideas={len(state.new_ideas)} extends={len(state.extends)}",
)
return
apply_verdicts(state, verdicts, self._kind_caps(), self._enabled_kinds(), notice_paths)
state.reminders, over_cap = cap_notice_reminders(state.reminders, self.max_notice_reminders)
state.dropped_over_cap += over_cap
self.logger.info(
f"[{self.name}] refine passes={state.refine_calls} dropped={len(state.refine_dropped)} "
f"moved={len(state.refine_moved)} degraded={state.refine_degraded} kept: "
f"reminders={len(state.reminders)} follow_ups={len(state.follow_ups)} "
f"ideas={len(state.new_ideas)} extends={len(state.extends)}",
)
def _refine_context(self, kind: str, state, ws, view, day: str, now_dt) -> str:
"""The single context slice this kind's adjudication needs (no material)."""
if kind == "reminder":
entries = schedule_prompt_entries(view, self.schedule_closed_lookback_days, day)
return (
f"now: {now_dt.isoformat(timespec='minutes')} ({now_dt.strftime('%A')})\n"
"schedules (registry, triggers already computed from the clock):\n"
f"{json.dumps(entries, ensure_ascii=False)}"
)
if kind == "follow_up":
carried = [
{"id": t.id, "title": t.title, "kind": t.kind, "last_evidence_at": t.last_evidence_at}
for t in state.carry_forward_prompt
]
return (
"carry_forward_topics (already tracked; a candidate that restates one of these "
f"must be dropped):\n{json.dumps(carried, ensure_ascii=False)}"
)
if kind == "new_idea":
return f"capability_inventory (what the agent can actually do):\n{self._capability_block(ws)}"
return (
"knowledge_base inventory (existing wiki nodes; a candidate that merely restates one "
f"of these must be dropped):\n{self._capability_block(ws)}"
)
def _store(self, state: ProactiveState) -> None:
assert self.context is not None
data = state.model_dump()

View file

@ -1,27 +1,111 @@
extract_system_prompt: |
You are the proactive discovery agent for a personal memory workspace.
You read recently changed conversation notes and maintain a small set of
user-interest topics. You never browse the web,
never write files, and never collect material yourself.
You read recently changed conversation notes, the user's schedule registry
and the current clock, and you propose what the assistant should bring up
next. You never browse the web, never write files, never collect material
yourself, and never execute anything: every entry you emit is a PROPOSAL
that the host agent may raise with the user.
workspace_dir: {workspace_dir}
## Branch A - follow_ups (open loops)
## The four kinds and their division of labour
Find unresolved matters the user may still want to close:
- questions that were asked but never answered;
- tasks that were started, interrupted, or postponed;
- commitments or plans mentioned without any follow-through yet.
Each branch below has its own section, its own job and its own ceiling.
Every branch is optional; a quiet day produces empty lists. Never let one
branch borrow another's job (a due schedule is a reminder, not a follow-up;
a wiki node worth expanding is an extend, not an idea).
## Branch A - follow_ups (kind: follow_up)
Scope: matters the user assigned, or keeps caring about, that are NOT closed
yet - tasks started/interrupted/postponed, questions asked but never
answered, commitments mentioned without follow-through. Carry-forward topics
already track the long-lived ones, so prefer `updates` for those and open a
`follow_ups` entry only for a matter no carry-forward topic covers.
Job: state the latest progress visible in the material, then propose going
and fetching the current status so the assistant can report back to the user
on its own initiative ("let me check where X stands and tell you"). The
deliverable is a report, never a silent completion of the work.
Emit each as a `follow_ups` entry.
[extends]## Branch B - extends (interest boundary)
[extends]## Branch B - extends (kind: interest_extend)
[extends]
[extends]Infer topics the user has NOT focused on yet, but that are plausibly
[extends]relevant to their recent work, using the conversation notes. These
[extends]guide future knowledge-source expansion; you only describe them, you
[extends]never collect anything.
[extends]Scope: content recommendation for the user's personal knowledge
[extends]base. Starting from a topic the user already cares about (see the
[extends]wiki inventory in the user message), name one refinement or
[extends]extension of it that is worth adding as a new wiki node: a
[extends]sub-topic, an adjacent concept, a comparison, a missing prerequisite.
[extends]Job: propose introducing and walking the user through that content.
[extends]You only describe the node to expand; you never collect or write it.
[extends]Emit each as an `extends` entry.
[reminders]## Branch C - reminders (kind: reminder) + schedule_updates
[reminders]
[reminders]Scope: the user's personal plans and calendar. You are given
[reminders]`schedules` (a long-lived registry that may hold items mentioned
[reminders]weeks ago) and `now` (the current clock). Your jobs:
[reminders]1. Review - compare the registry and the material against the
[reminders] clock, then propose a reminder for what is due soon, due today,
[reminders] already overdue, or clashing with another entry. Warn about a
[reminders] possible conflict explicitly and say which entries collide.
[reminders] Also propose the upcoming todos the user would want to be
[reminders] nudged about. Propose only: never claim a calendar was changed.
[reminders]2. Maintain - whenever the material adds, moves, confirms,
[reminders] completes or cancels a schedule, emit a `schedule_updates`
[reminders] entry. The registry is the only structure that lives long
[reminders] enough to fire on a due date mentioned long ago, so a schedule
[reminders] you do not register will never be reminded.
[reminders] Title an entry by the matter alone, never by its date or time:
[reminders] the id is derived from the title, so a date in it mints a brand
[reminders] new registry entry every time that schedule moves, and the user
[reminders] ends up reminded twice about one appointment.
[resources]3. Notices and key events - scan `resource_notices` (external
[resources] source records that changed this round: whatever an interface
[resources] dropped into `{resource_dir}/` - mail, feeds, exports,
[resources] documents) and pick out only what the user MUST be told today.
[resources] Two shapes count:
[resources] a) a direct notice about the user's own affairs - a deadline or
[resources] date that moved, a cancellation, a bill or payment falling
[resources] due, a confirmation that needs action;
[resources] b) a key event that materially affects the user even though
[resources] nobody addressed it to them - a policy or rule change, an
[resources] incident/recall/security advisory, a development in a matter
[resources] they track, a market or supply shift they are exposed to.
[resources] Emit each as a `reminders` entry citing that resource path. Give
[resources] `due_at` whenever the item has a date, and register that dated
[resources] commitment via `schedule_updates` so it keeps firing from the
[resources] registry. A key event with no date may omit `due_at`; at most
[resources] {max_notice_reminders} such dateless entries survive a round, so
[resources] make them count. Merely interesting reading material is neither
[resources] a notice nor a key event; leave it out.
[reminders]Traceability: a reminder about a registered schedule cites it via
[reminders]`schedule_id` and may leave `paths` empty (its evidence is the
[reminders]clock). A reminder about a brand-new schedule must cite
[reminders]changed_paths and carry `due_at`.
[resources]A notice-driven reminder cites its resource_paths entry instead.
[reminders]Reminders are the highest-priority kind: every reminder you emit
[reminders]enters today's agenda, so they are not capped - but never invent a
[reminders]schedule to justify one, and never repeat a schedule the registry
[reminders]already shows as done or cancelled.
[reminders]Emit reminders as `reminders` entries and registry maintenance as
[reminders]`schedule_updates` entries.
[ideas]## Branch D - new_ideas (kind: new_idea)
[ideas]
[ideas]Scope: work the user probably does not realise can be delegated.
[ideas]Combine what the agent recently executed, the data it collected and
[ideas]the experience/skills/tools/APIs it already has (the capability
[ideas]inventory in the user message) with what the material shows the user
[ideas]is working on, then propose one concrete piece of work worth handing
[ideas]over - the kind of task a user would never think to ask for.
[ideas]Job: describe the deliverable, why it is valuable right now, and what
[ideas]existing resource makes it feasible. Stop at the proposal: never
[ideas]execute it and never imply it is already done.
[ideas]Ground every idea in a capability or a dataset that actually appears
[ideas]in the inventory or the material; an idea needing resources nobody
[ideas]has is noise.
[ideas]Emit each as a `new_ideas` entry.
## updates (carried-forward topics)
For every topic in carry_forward_topics, decide exactly one action:
@ -41,6 +125,7 @@ extract_system_prompt: |
## Confidence rubric (fixed; do not freestyle scores)
- 0.9: explicit unfinished action with a time or commitment;
[reminders]- 0.8: a registered schedule the clock shows as due, overdue or clashing;
- 0.7: the user stated intent explicitly but no concrete action yet;
- 0.5: inferential link from several weak signals;
- 0.3: weak association only;
@ -50,9 +135,21 @@ extract_system_prompt: |
- Empty lists are a normal, expected output. Never invent topics to fill
space, and never promote a one-off weak mention into a topic.
- Every topic must be traceable to this round's material: `paths` may only
contain values from changed_paths (paths look like `{daily_dir}/<date>/...`);
entries violating this are discarded.
- Ceilings are not targets, and entries over a ceiling are discarded
downstream: at most {max_follow_ups} follow_ups.
[extends]- Ceiling: at most {max_extends} extends.
[ideas]- Ceiling: at most {max_new_ideas} new_ideas.
[reminders]- Reminders are uncapped, but each one must be a real finding of
[reminders]the schedule review - never invent a schedule to justify one.
- Every topic must be traceable: `paths` may only contain values from
changed_paths (paths look like `{daily_dir}/<date>/...`); entries violating
this are discarded.
[reminders]- Traceability exception: a `reminders` entry citing a registered
[reminders]`schedule_id` may take its paths from the registry instead.
[resources]- Traceability exception: `reminders` and `schedule_updates` may also
[resources]cite resource_paths (`{resource_dir}/...`). No other section may.
- user_profile and the capability inventory are background knowledge: use
them to judge relevance, never cite them in paths/evidence.
- Do not restate topics already listed in carry_forward_topics; use
`updates` to change their status instead.
- Same-matter rule: if a finding is really a carry_forward topic in
@ -69,45 +166,93 @@ extract_system_prompt: |
Return only one YAML fenced block with this exact shape:
```yaml
follow_ups:
- title: <specific unresolved matter>
reason: <why it is still open>
confidence: 0.7
paths: [<changed path>, ...]
[extends]extends:
[extends] - title: <related-but-unfocused topic>
[extends] reason: <why it matters, grounded in the material>
[extends] confidence: 0.5
[extends] paths: [<changed path>, ...]
updates:
- id: <echoed carry-forward id>
action: keep|update|resolve
evidence: <path or path#anchor>
reason: <short justification>
confidence: <re-scored per rubric; emit for update, omit otherwise>
{output_shape}
```
extract_system_prompt_zh: |
你是个人记忆 workspace 的 proactive 发现 agent。你阅读近期变化的对话笔记
,维护一小组用户兴趣主题。你绝不联网、
绝不写文件、绝不自行收集资料。
你是个人记忆 workspace 的 proactive 发现 agent。你阅读近期变化的对话笔记、
用户日程注册表与当前时钟,产出"下一步值得主动提起什么"的提议。你绝不联网、
绝不写文件、绝不自行收集资料、绝不执行任何动作:你输出的每一条都是提议
(proposal),由宿主 agent 决定是否向用户提起。
workspace_dir: {workspace_dir}
## 分支 A - follow_ups(未决事项 / open loop)
## 四类标签及其职责边界
找出用户可能仍想关闭的未决事项:
- 提出过但没有得到回答的问题;
- 开始过但被中断或搁置的任务;
- 提到过但没有后续动作的承诺或计划。
下面每个分支有自己的 section、职责与条数上限。每个分支都是可选的,平静的
一天就应该输出空列表。禁止串味:到期的日程属于 reminder 而不是 follow_up;
值得展开的知识节点属于 interest_extend 而不是 new_idea。
## 分支 A - follow_ups(kind: follow_up)
范围:用户指派过、或持续关心但尚未关闭的事项——开始过却被中断/搁置的任务、
提出过但没有回答的问题、提到过却没有后续动作的承诺。carry_forward_topics
已经跟踪长期未决事项,这类请优先用 `updates` 处置,只有当没有任何
carry-forward 主题覆盖时,才新开 `follow_ups` 条目。
职责:先说清素材里能看到的最新进展,然后提议去检索该事项的最新情况,以便
助手主动向用户汇报("我去确认一下 X 现在到哪一步了,再告诉你")。交付物是
一次汇报,绝不是悄悄替用户把事做完。
每条作为一个 `follow_ups` 条目输出。
[extends]## 分支 B - extends(兴趣边界扩展)
[extends]## 分支 B - extends(kind: interest_extend)
[extends]
[extends]推断用户尚未关注、但与其近期工作大概率相关的主题,依据是对话笔记
[extends]。它们用于指引未来的知识源扩展;你只描述主题,不收集任何资料。
[extends]范围:面向个人知识库的内容推荐。从用户已经关心的主题出发(见用户
[extends]消息中的 wiki 节点清单),指出一个值得作为新 wiki 节点补上的细化或
[extends]延伸方向:子话题、相邻概念、横向对比、缺失的前置知识。
[extends]职责:提议向用户介绍并展示这部分内容。你只描述要扩展哪个节点,
[extends]绝不自行收集或撰写内容。
[extends]每条作为一个 `extends` 条目输出。
[reminders]## 分支 C - reminders(kind: reminder)+ schedule_updates
[reminders]
[reminders]范围:用户的个人计划与日程。输入包含 `schedules`(长生命周期
[reminders]注册表,可能有几周前提到的条目)与 `now`(当前时钟)。职责如下:
[reminders]1. 检查回顾——把注册表与素材对照时钟检查一遍,对即将到期、今天
[reminders] 到期、已经逾期、或与其它日程冲突的安排提出提醒;冲突要明确
[reminders] 预警并指出是哪几条相撞;同时提议提醒即将到来的 todos。
[reminders] 只做提议:绝不声称已经改动了任何日历。
[reminders]2. 维护注册表——素材中出现新增、改期、确认、完成、取消日程时,
[reminders] 必须输出 `schedule_updates` 条目。注册表是唯一能活到"很久以
[reminders] 前提到的到期日"那天的结构;不登记的日程永远不会被提醒。
[reminders] 条目标题只写事项本身,绝不要带日期或时间:id 由标题推导,标题
[reminders] 里含日期会让这条日程每次改期都在注册表里新增一条,同一个约定
[reminders] 就会被提醒两次。
[resources]3. 通知与关键事件——通读 `resource_notices`(本轮发生变化的外部
[resources] 信源记录,即外部信源接口投递到 `{resource_dir}/` 的邮件、订阅
[resources] 源、导出件、文档等),只挑出"今天必须告知用户"的内容。两种形态
[resources] 都算:
[resources] a) 直接通知——与用户自身事务相关的:时间/截止日被改动、被取消、
[resources] 账单或付款到期、需要动作的确认件;
[resources] b) 关键事件——虽不是发给用户本人、但对其有实质影响的:政策或规则
[resources] 变化、事故/召回/安全公告、其持续跟踪事项的重要进展、其所暴露
[resources] 的市场或供应变化。
[resources] 每条作为 `reminders` 输出并引用该 resource 路径。带日期的一律给出
[resources] `due_at`,并用 `schedule_updates` 把该承诺登记进注册表,使其在素材
[resources] 离开扫描窗口后仍能按到期日触发。没有日期的关键事件可以省略
[resources] `due_at`;这类无日期条目每轮最多保留 {max_notice_reminders} 条,
[resources] 请挑真正重要的。仅仅是"有点意思"的阅读材料既不是通知也不是关键
[resources] 事件,不要输出。
[reminders]可追溯性:针对已登记日程的提醒用 `schedule_id` 引用,`paths`
[reminders]可以为空(它的证据是时钟);针对全新日程的提醒必须引用
[reminders]changed_paths 并给出 `due_at`。
[resources]来自外部通知的提醒改为引用 resource_paths 中的路径。
[reminders]reminder 是四类中优先级最高的:你输出的每条 reminder 都会进入
[reminders]当日 agenda,因此不设条数上限——但绝不为了凑提醒编造日程,也
[reminders]绝不重复注册表中已完成/已取消的条目。
[reminders]提醒输出为 `reminders` 条目,注册表维护输出为 `schedule_updates`。
[ideas]## 分支 D - new_ideas(kind: new_idea)
[ideas]
[ideas]范围:用户根本没想到"这事可以委托 AI 办"的工作事项。把 agent 最近
[ideas]执行的结果、收集的数据、已积累的经验/skill/工具/API(见用户消息中
[ideas]的能力清单)与素材里用户正在做的事情结合起来,提议一件具体、值得
[ideas]交出去做的工作。
[ideas]职责:说清交付物是什么、为什么现在有价值、依靠哪个已有资源才可行。
[ideas]停留在提议:绝不执行,也绝不暗示已经做完。
[ideas]每个想法必须落在能力清单或素材里真实存在的能力/数据上;需要谁都没有
[ideas]的资源才能完成的想法属于噪音。
[ideas]每条作为一个 `new_ideas` 条目输出。
## updates(carry-forward 主题处置)
对 carry_forward_topics 中的每个主题,恰好选择一个动作:
@ -125,6 +270,7 @@ extract_system_prompt_zh: |
## confidence 评分规则(固化,禁止自由打分)
- 0.9:有明确未完成动作且带时间/承诺;
[reminders]- 0.8:注册表中已登记、且时钟显示即将到期/已逾期/存在冲突的日程;
- 0.7:用户显式表达意图但无明确动作;
- 0.5:推断性关联(多条弱信号);
- 0.3:弱联想;
@ -134,8 +280,20 @@ extract_system_prompt_zh: |
- 空列表是正常且预期的输出。不得为凑数编造主题,不得把一次性弱提及
拔高为主题。
- 每个主题必须能追溯到本轮素材:`paths` 只能使用 changed_paths 中的值
(路径形如 `{daily_dir}/<date>/...`),越界条目会被丢弃。
- 上限是天花板而不是目标,超出上限的条目会在下游被丢弃:follow_ups 至多
{max_follow_ups} 条。
[extends]- 上限:extends 至多 {max_extends} 条。
[ideas]- 上限:new_ideas 至多 {max_new_ideas} 条。
[reminders]- reminders 不设上限,但每条都必须来自真实的日程检查结论,
[reminders]绝不为了凑提醒编造日程。
- 每个主题必须可追溯:`paths` 只能使用 changed_paths 中的值(路径形如
`{daily_dir}/<date>/...`),越界条目会被丢弃。
[reminders]- 可追溯性例外:引用了已登记 `schedule_id` 的 `reminders` 条目,
[reminders]其 paths 可以来自注册表。
[resources]- 可追溯性例外:`reminders` 与 `schedule_updates` 还可以引用
[resources]resource_paths(`{resource_dir}/...`);其他 section 不可以。
- user_profile 与能力清单属于背景知识:只用于判断相关度,禁止引用进
paths/evidence。
- 不要复述 carry_forward_topics 中已 open 的主题;要改变其状态请用
`updates`。
- 同一件事规则:若新发现其实只是某个 carry_forward 主题的换种说法,
@ -151,61 +309,427 @@ extract_system_prompt_zh: |
只返回一个 YAML fenced block,结构必须严格如下:
```yaml
{output_shape}
```
extract_user_message: |
date: {date}
now (current clock in the business timezone; the reference for every due
judgement): {clock}
changed_paths (the ONLY allowed values for `paths`):
{changed_paths_json}
[resources]resource_paths (external source records changed this round; also
[resources]allowed in `paths`, but ONLY for reminders/schedule_updates):
[resources]{resource_paths_json}
carry_forward_topics (already open; only act on them via updates):
{carry_forward_json}
[reminders]schedules (long-lived registry; `next_due`, `due_in_days`,
[reminders]`trigger` and `conflict_with` are already computed from the clock
[reminders]above; recently closed entries are shown so you do not re-add
[reminders]them):
[reminders]{schedules_json}
user_profile (background knowledge about the user's identity, preferences
and constraints; use it ONLY to judge relevance and confidence - it is NOT
material, never cite it in paths/evidence):
{profile_block}
[ideas]capability_inventory (reusable experience/skills and knowledge nodes
[ideas]the agent already has; background for new_ideas only - it is NOT
[ideas]material, never cite it in paths/evidence):
[ideas]{capability_block}
# Material
{material_blob}
[resources]# External notices ({resource_dir}/, reminder branch only)
[resources]
[resources]{resource_block}
Emit the sections your active branches define, per the contract:
- follow_ups and updates
[extends]- extends
[reminders]- reminders and schedule_updates
[ideas]- new_ideas
Remember: every section is optional, empty lists are fine, never invent
topics, and never execute a proposal.
extract_user_message_zh: |
日期:{date}
当前时钟(业务时区;所有"是否到期"判断的基准):{clock}
changed_paths(`paths` 唯一允许的取值):
{changed_paths_json}
[resources]resource_paths(本轮变化的外部信源记录;也允许写进 `paths`,
[resources]但仅限 reminders/schedule_updates):
[resources]{resource_paths_json}
carry_forward_topics(已 open 的主题;只能通过 updates 处置):
{carry_forward_json}
[reminders]schedules(长生命周期日程注册表;其中 `next_due`、`due_in_days`、
[reminders]`trigger`、`conflict_with` 已按上面的时钟算好;近期已关闭的条目
[reminders]也会列出,避免你重复登记):
[reminders]{schedules_json}
用户画像(用户身份、偏好与约束的背景知识;仅用于判断相关度与
confidence——它不是素材,禁止引用进 paths/evidence):
{profile_block}
[ideas]能力清单(agent 已经具备的可复用经验/skill 与已有知识节点;仅供
[ideas]new_ideas 参考——它不是素材,禁止引用进 paths/evidence):
[ideas]{capability_block}
# 本轮素材
{material_blob}
[resources]# 外部通知({resource_dir}/,仅供 reminder 分支使用)
[resources]
[resources]{resource_block}
按契约输出当前启用分支对应的 section:
- follow_ups 与 updates
[extends]- extends
[reminders]- reminders 与 schedule_updates
[ideas]- new_ideas
记住:每个 section 都是可选的,空列表是正常输出,绝不编造主题,也绝不
执行任何提议。
# ---------------------------------------------------------------------------
# Output-shape snippets, composed by ``_output_shape`` for the active branches
# only. They are separate keys (not ``[flag]`` lines) so the flag filter's
# left-strip cannot flatten the YAML indentation of the example.
# ---------------------------------------------------------------------------
extract_shape_follow_ups: |
follow_ups:
- title: <specific unresolved matter>
reason: <latest progress seen in the material, and what to go fetch and report>
confidence: 0.7
paths:
- <changed path>
extract_shape_extends: |
extends:
- title: <related-but-unfocused topic>
reason: <which cared-about topic it refines or extends, and the wiki node it would add>
confidence: 0.5
paths:
- <changed path>
extract_shape_reminders: |
reminders:
- title: <the scheduled matter>
reason: <why now: due soon / due today / overdue / clashes with what>
due_at: <YYYY-MM-DD or YYYY-MM-DDTHH:MM>
schedule_id: <registry id; omit only for a brand-new schedule>
conflict_with: [<title or schedule_id it clashes with>]
confidence: 0.8
paths:
- <changed path; may be empty when schedule_id is given>
extract_shape_new_ideas: |
new_ideas:
- title: <a concrete piece of work worth delegating>
reason: <the deliverable, why now, and which existing skill/tool/data makes it feasible>
confidence: 0.5
paths:
- <changed path>
extract_shape_updates: |
updates:
- id: <echoed carry-forward id>
action: keep|update|resolve
evidence: <path or path#anchor>
reason: <short justification>
confidence: <re-scored per rubric; emit for update, omit otherwise>
extract_shape_schedule_updates: |
schedule_updates:
- id: <registry id; omit for action=add>
action: add|reschedule|confirm|done|cancel
title: <required for add; the matter alone, never its date or time>
due_at: <YYYY-MM-DD or YYYY-MM-DDTHH:MM; required for add and reschedule>
due_end_at: <optional end of a range>
recurrence: <omit or one of daily|weekly|monthly|yearly>
lead_days: <optional; how many days ahead to start reminding>
detail: <optional short note>
evidence: <path from changed_paths, or the notice path; required, it anchors the change>
reason: <short justification>
extract_shape_follow_ups_zh: |
follow_ups:
- title: <具体的未决事项>
reason: <为什么仍未关闭>
reason: <素材中看到的最新进展,以及要去检索并汇报什么>
confidence: 0.7
paths: [<素材路径>, ...]
[extends]extends:
[extends] - title: <相关但未被关注的主题>
[extends] reason: <为什么重要,基于素材>
[extends] confidence: 0.5
[extends] paths: [<素材路径>, ...]
paths:
- <素材路径>
extract_shape_extends_zh: |
extends:
- title: <相关但尚未被关注的主题>
reason: <它细化/延伸了哪个已关心主题,会补上哪个 wiki 节点>
confidence: 0.5
paths:
- <素材路径>
extract_shape_reminders_zh: |
reminders:
- title: <对应的日程事项>
reason: <为什么是现在:即将到期 / 今天到期 / 已逾期 / 与哪条冲突>
due_at: <YYYY-MM-DD 或 YYYY-MM-DDTHH:MM>
schedule_id: <注册表 id;仅全新日程可省略>
conflict_with: [<与之冲突的标题或 schedule_id>]
confidence: 0.8
paths:
- <素材路径;给了 schedule_id 时可以为空>
extract_shape_new_ideas_zh: |
new_ideas:
- title: <一件具体、值得委托出去的工作>
reason: <交付物是什么、为什么是现在、依靠哪个已有 skill/工具/数据才可行>
confidence: 0.5
paths:
- <素材路径>
extract_shape_updates_zh: |
updates:
- id: <回显的 carry-forward id>
action: keep|update|resolve
evidence: <路径或 路径#锚点>
reason: <简短理由>
confidence: <按 rubric 重新打分;action=update 时给出,其余可省略>
extract_shape_schedule_updates_zh: |
schedule_updates:
- id: <注册表 id;action=add 时可省略>
action: add|reschedule|confirm|done|cancel
title: <add 时必填;只写事项本身,不要带日期或时间>
due_at: <YYYY-MM-DD 或 YYYY-MM-DDTHH:MM;add 与 reschedule 必填>
due_end_at: <可选的时间段结束点>
recurrence: <可省略,或 daily|weekly|monthly|yearly 之一>
lead_days: <可选;提前多少天开始提醒>
detail: <可选的简短备注>
evidence: <changed_paths 中的路径,或通知所来自的外部信源路径;必填,它是证据锚点>
reason: <简短理由>
# ---------------------------------------------------------------------------
# Stage 2: per-kind adjudication prompts (``_refine_stage``). One small call per
# kind that produced candidates. The material blob is never re-sent: a pass gets
# its own candidates plus the single context slice that kind needs.
# ---------------------------------------------------------------------------
refine_system_prompt: |
You are the quality gate of the proactive discovery stage of a personal
memory system. An earlier pass already read today's material and proposed
candidates of kind `{kind}`. You do NOT re-read the material, and you do NOT
write new candidates: you adjudicate the list you are given.
For each candidate decide exactly one of:
- `keep`: it belongs to this kind, it is grounded, and it is worth raising;
- `drop`: reject it, and say why in one short clause;
- `move`: it is unmistakably another kind's job - name `to_kind`.
You may also rescore `confidence` with the fixed rubric when the proposer
clearly over- or under-rated it. Never rewrite `title` or `reason`: the
wording the user will see has already been produced.
## Rubric (fixed; do not freestyle scores)
- 0.9: explicit unfinished action with a time or commitment;
- 0.8: a registered schedule the clock shows as due, overdue or clashing;
- 0.7: the user stated intent explicitly but no concrete action yet;
- 0.5: inferential link from several weak signals;
- 0.3: weak association only;
- below 0.3: drop the candidate instead of scoring it.
## Hard rules
- Echo ids exactly; an id that is not in the candidate list is discarded.
- A candidate you do not mention is kept unchanged, so mention only the ones
you want to drop, move or rescore.
- Dropping is for real defects, not for taste: when in doubt, keep.
- The context block is background knowledge, never evidence; do not cite it.
- These stay proposals: never word a verdict as if work had been done.
- Quote YAML strings containing punctuation such as `:`.
## Output format
Return only one YAML fenced block:
```yaml
verdicts:
- id: <echoed id>
decision: keep|drop|move
to_kind: <only for move: reminder|follow_up|new_idea|interest_extend>
confidence: <optional rescore per the rubric>
reason: <required for drop; one short clause>
```
extract_user_message: |
date: {date}
refine_system_prompt_zh: |
你是个人记忆系统 proactive 发现阶段的质量闸门。前一遍已经读过今天的素材,
并产出了 kind 为 `{kind}` 的候选。你**不重读素材**,也**不新增候选**:
你只裁决给你的这份列表。
changed_paths (the ONLY allowed values for `paths`):
{changed_paths_json}
对每个候选恰好给出一个结论:
- `keep`:属于该 kind、有依据、值得提起;
- `drop`:驳回,并用一小句说明原因;
- `move`:它明显属于另一类职责——给出 `to_kind`。
carry_forward_topics (already open; only act on them via updates):
{carry_forward_json}
当 proposer 明显打高或打低时,你可以按固定评分规则重打 `confidence`。
绝不改写 `title` 或 `reason`:给用户看的措辞已经产出了。
user_profile (background knowledge about the user's identity, preferences
and constraints; use it ONLY to judge interest relevance and confidence -
it is NOT material, never cite it in paths/evidence):
{profile_block}
## 评分规则(固化,禁止自由打分)
# Material
- 0.9:有明确未完成动作且带时间/承诺;
- 0.8:注册表中已登记、且时钟显示即将到期/已逾期/存在冲突的日程;
- 0.7:用户显式表达意图但无明确动作;
- 0.5:推断性关联(多条弱信号);
- 0.3:仅弱关联;
- 低于 0.3:直接 drop,不要打分。
{material_blob}
## 硬约束
Extract follow_ups[extends] / extends / updates per the contract. Remember:
empty lists are fine; never invent topics.
- id 必须原样回显;不在候选列表中的 id 会被丢弃。
- 未被提及的候选按原样保留,所以只写你要 drop / move / 重打分的那几条。
- drop 只用于真实缺陷,不用于口味差异:拿不准就 keep。
- 上下文块是背景知识,不是证据,禁止引用。
- 这些仍然是提议:任何结论都不得写成"已经做完了"。
- 包含 `:` 等标点的 YAML 字符串请加引号。
extract_user_message_zh: |
日期:{date}
## 输出格式
changed_paths(`paths` 唯一允许的取值):
{changed_paths_json}
只返回一个 YAML fenced block:
```yaml
verdicts:
- id: <回显的 id>
decision: keep|drop|move
to_kind: <仅 move 时给出:reminder|follow_up|new_idea|interest_extend>
confidence: <可选,按评分规则重打>
reason: <drop 时必填;一小句>
```
carry_forward_topics(已 open 的主题;只能通过 updates 处置):
{carry_forward_json}
refine_user_message: |
kind under review: {kind}
ceiling for this kind: {cap}
用户画像(用户身份、偏好与约束的背景知识;仅用于判断兴趣相关度与
confidence——它不是素材,禁止引用进 paths/evidence):
{profile_block}
## What to reject for this kind
# 本轮素材
{checks}
{material_blob}
## Context for this kind only (background knowledge, never evidence)
按契约抽取 follow_ups[extends] / extends / updates。记住:空列表是正常
输出,绝不编造主题。
{context_block}
## Candidates to adjudicate
{candidates_json}
Return one YAML fenced block with a `verdicts` list, mentioning only the
candidates you want to drop, move or rescore.
refine_user_message_zh: |
本次裁决的 kind:{kind}
该类上限:{cap}
## 该类应当驳回什么
{checks}
## 仅供该类使用的上下文(背景知识,不是证据)
{context_block}
## 待裁决候选
{candidates_json}
只返回一个 YAML fenced block,其中 `verdicts` 列表只写你要 drop / move /
重打分的候选。
refine_checks_reminder: |
- Drop a reminder whose schedule the registry already shows as done or
cancelled, or whose due value contradicts the registry entry it cites.
- Drop one that is really an open loop on the user's own work (follow_up) or
a topic worth reading about (interest_extend) - but never one anchored on
the external source layer, which no other kind is allowed to quote.
- Keep an external notice or key event even when it also reads as interesting
background: "the user must be told" is exactly this kind's job, and a key
event that materially affects them is not merely reading material.
- Keep a dated reminder even when it looks minor: a clock fact about the
user's own calendar is exactly what this kind exists for.
- A dateless reminder must be an external notice or a key event the user has
to be told today; drop one that is merely interesting background, and
remember only a few dateless entries survive the ceiling.
- Move to follow_up only when the candidate tracks progress on a matter the
user assigned and has no schedule attached.
refine_checks_reminder_zh: |
- 若注册表已显示该日程完成或取消,或候选给出的到期值与其引用的注册表条目
矛盾,drop。
- 若它其实是用户自己工作的未闭环事项(follow_up),或只是值得阅读的主题
(interest_extend),drop;但锚定在外部信源上的候选例外——其他任何类都不
允许引用外部信源。
- 外部通知或关键事件即使同时读起来像"有意思的背景资料",也要 keep:"必须
告知用户"正是这一类的职责,实质影响用户的关键事件不只是阅读材料。
- 带日期的提醒即使看起来很小也要 keep:关于用户自己日程的时钟事实正是这一
类存在的意义。
- 无日期的提醒必须是"今天必须告知"的外部通知或关键事件;仅仅是有意思的背景
信息就 drop,并记住无日期条目受上限约束、只能留下少数几条。
- 仅当候选跟踪的是用户指派事项的进展且不带任何日程时,才 move 到 follow_up。
refine_checks_follow_up: |
- Drop a candidate that restates a carry_forward topic from the context:
long-lived open loops are maintained through updates, never re-proposed.
- Drop one with no visible open loop (nothing was assigned, asked, or left
unfinished); a general interest is interest_extend.
- Drop one whose "latest progress" is asserted rather than visible in its own
reason.
- Keep a candidate that is really a dated commitment instead of dropping it:
stage 2 cannot attach a due date, so the calendar stays the reminder
branch's job.
refine_checks_follow_up_zh: |
- 若候选只是复述上下文中的 carry_forward 主题,drop:长期未闭环事项通过
updates 维护,不应被重新提议。
- 若看不到任何未闭环动作(没有被指派、没有被提问、没有被搁置的事项),drop;
泛泛的兴趣属于 interest_extend。
- 若所谓"最新进展"只是断言、在其自身 reason 里看不到依据,drop。
- 若候选其实是一个带日期的承诺,keep 而不要 drop:第二阶段无法补上到期日,
日历始终由 reminder 分支负责。
refine_checks_new_idea: |
- Drop an idea that needs a capability, dataset, tool or API absent from the
capability inventory: it would be an offer nobody can fulfil.
- Drop one that is really a task the user already assigned (follow_up) or a
topic to read about (interest_extend).
- Drop one worded as a report of work already done; these stay proposals.
- Keep only ideas naming a concrete deliverable, and rescore vague ones down.
refine_checks_new_idea_zh: |
- 若想法依赖能力清单中不存在的经验/数据/工具/API,drop:那是没人能兑现的邀约。
- 若它其实是用户已经指派的任务(follow_up)或只是可读的主题(interest_extend),
drop。
- 若措辞像"已经做完了的汇报",drop;这些只能是提议。
- 只保留说清了具体交付物的想法;含糊的把 confidence 打低。
refine_checks_interest_extend: |
- Drop a candidate that merely restates a wiki node that already exists in the
inventory: an extend must add a refinement or a new adjacent node.
- Drop one the user has no visible interest in, so there is nothing to extend.
- Drop one that is a to-do or a schedule rather than content worth introducing.
- Move to new_idea only when the candidate is a piece of work to delegate
rather than content to explain.
refine_checks_interest_extend_zh: |
- 若候选只是复述清单里已存在的 wiki 节点,drop:extend 必须带来细化或新的
相邻节点。
- 若看不到用户的相关兴趣、无从延伸,drop。
- 若它其实是一条待办或日程,而不是值得介绍的内容,drop。
- 仅当候选是"可委托的一件工作"而不是"可讲解的内容"时,才 move 到 new_idea。

View file

@ -0,0 +1,106 @@
"""Proactive delivery feedback step: record what a host agent already surfaced.
The refresh chain decides what is worth raising; it cannot know what the host
actually raised. This step closes that loop by accepting a small report from
the consumer and appending it to the ``deliveries`` section of the truth source
``daily/_proactive.yaml``. Two readers benefit:
- ``proactive_step`` withholds agenda items still inside the cooldown window,
so the same proposal is not surfaced twice;
- ``proactive_agenda_step`` suppresses those candidates deterministically and
records an auditable reason instead of re-editing them into today's agenda.
Reporting is optional. A framework that never calls this step leaves
``deliveries`` empty and both readers behave exactly as they did before, which
keeps the contract additive rather than a behavioural change for every host.
The step is deliberately narrow: it never calls an LLM, never touches
``open_topics``/``resolved``/``schedules``, never rewrites ``interests.yaml``
and never advances a file catalog. It only appends feedback.
"""
import time
from ...base_step import BaseStep
from ....components import R
from ..dream.utils import daily_dir, today, workspace_dir
from .utils import (
current_now,
load_state,
save_state,
suppressed_delivery_ids,
trim_deliveries,
upsert_deliveries,
)
@R.register("proactive_feedback_step")
class ProactiveFeedbackStep(BaseStep):
"""Upsert host-reported agenda deliveries into the truth source.
Records are idempotent on ``(topic_id, external_ref)`` when the host
supplies a reference and on ``(topic_id, agenda_date, channel)``
otherwise, so a retry of the same report updates in place. A terminal
status (``executed``/``dismissed``) is never downgraded by a later
``shown`` report; ``failed`` never blocks re-delivery.
"""
def __init__(
self,
retention_days: int = 180,
max_deliveries: int = 500,
**kwargs,
):
super().__init__(**kwargs)
self.retention_days = max(int(retention_days), 1)
self.max_deliveries = max(int(max_deliveries), 1)
async def execute(self):
assert self.context is not None
started = time.monotonic()
raw_items = self.context.get("items")
items = list(raw_items) if isinstance(raw_items, (list, tuple)) else []
day = today(self, str(self.context.get("date", "") or ""))
daily = daily_dir(self)
ws = workspace_dir(self)
self.logger.info(f"[{self.name}] start day={day} items={len(items)}")
if not items:
self.context.response.success = True
self.context.response.answer = "Feedback: no delivery items reported"
self.context.response.metadata["proactive_feedback"] = {
"date": day,
"received": 0,
"added": 0,
"updated": 0,
"dropped": 0,
"trimmed": 0,
}
return self.context.response
state_file, _needs_bootstrap = load_state(ws, daily)
counts = upsert_deliveries(state_file, items, day, current_now(self))
trimmed = trim_deliveries(state_file, day, self.retention_days, self.max_deliveries)
await save_state(ws, state_file, daily)
suppressed = suppressed_delivery_ids(state_file, day, self.retention_days)
payload = {
"date": day,
"received": len(items),
"added": counts["added"],
"updated": counts["updated"],
"dropped": counts["dropped"],
"trimmed": trimmed,
"total": len(state_file.deliveries),
"suppressed_topics": sorted(suppressed),
}
self.context.response.success = True
self.context.response.answer = (
f"Feedback: added {counts['added']}, updated {counts['updated']}, "
f"dropped {counts['dropped']}, trimmed {trimmed}; "
f"{len(state_file.deliveries)} record(s) on file"
)
self.context.response.metadata["proactive_feedback"] = payload
self.context.response.metadata["duration_ms"] = int((time.monotonic() - started) * 1000)
self.logger.info(f"[{self.name}] finish {payload}")
return self.context.response

View file

@ -42,7 +42,7 @@ class ProactiveFinishStep(BaseStep):
return self.context.response
ws = workspace_dir(self)
checkpoint = [rel for rel in state.changed_paths if (ws / rel).is_file()]
checkpoint = [rel for rel in (*state.changed_paths, *state.resource_changed) if (ws / rel).is_file()]
snapshot = state.changed_mtimes
nodes: list[FileNode] = []
deferred = 0

View file

@ -1,22 +1,27 @@
"""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.
push (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.
explore_interest | prepare_upcoming | propose_delegation), ``opener`` (a casual,
friend-like conversation opener that already names the minimal next action -
never a "based on your records" notification tone, and never a stock lead-in
worn into a template), ``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.
Reminders are expanded first and are never squeezed out by ``max_plan_topics``
(they are bounded by ``max_reminder_cards`` instead), because the agenda step
pins every reminder card it receives.
"""
import asyncio
import hashlib
import json
import time
@ -24,17 +29,50 @@ from ...base_step import BaseStep
from .._evolve import agent_reply_result_text, passthrough_response
from ....components import R
from ....schema import ProactiveState
from ....schema.proactive import DEFAULT_KIND
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")
SCENARIO_TYPES = ("resume_task", "answer_pending", "explore_interest", "prepare_upcoming", "propose_delegation")
DELIVERY_MODES = ("in_conversation", "notification", "agenda_item")
DEFAULT_SCENARIO_BY_KIND = {"follow_up": "resume_task", "interest_extend": "explore_interest"}
DEFAULT_SCENARIO_BY_KIND = {
"follow_up": "resume_task",
"interest_extend": "explore_interest",
"reminder": "prepare_upcoming",
"new_idea": "propose_delegation",
}
MAX_OPENER_CHARS = 300
MAX_NEXT_ACTION_CHARS = 200
MAX_PRECONDITIONS = 5
MAX_PRECONDITION_CHARS = 80
# Deterministic openers, several per kind, so a batch of fallback cards does not
# open every line the same way - one repeated lead-in reads as a script. ``{when}``
# is the parenthesised due date (empty when unknown) and only reminder variants
# carry it, because a reminder may not hide when it is due.
FALLBACK_OPENERS = {
"reminder": (
"「{title}」{when}就快到了,要不要现在把准备事项先过一遍?",
"时间快到了:「{title}」{when},先确认下会不会和别的安排撞上?",
"有件事排在前面了:「{title}」{when},现在动手准备最从容。",
),
"new_idea": (
"有件活儿我觉得可以替你办:「{title}」,要不要我先出个方案你看看?",
"「{title}」这件事其实可以直接交给我,要不要我先出一版草案给你审?",
"这活儿不必你自己来:「{title}」,我先跑个初稿你看看值不值?",
),
"follow_up": (
"上次聊到「{title}」,好像还没收尾——要不要趁现在花几分钟往前推一步?",
"「{title}」还挂着,要不要我先去查一下最新进展,再回来跟你说下一步?",
"那件「{title}」好像只差临门一脚了,现在顺手推一下?",
),
"interest_extend": (
"感觉你最近可能会想看看「{title}」,有空的话可以先从相关材料扫一眼。",
"「{title}」这块还能再往深挖一层,要不要我挑两处给你展开讲讲?",
"顺着「{title}」又翻到些延伸内容,值得的话我带你过一遍?",
),
}
def linked_memory(candidate: dict) -> list[str]:
"""Workspace paths related to a topic, derived from paths + evidence."""
@ -51,15 +89,22 @@ def linked_memory(candidate: dict) -> list[str]:
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")
"""Deterministic card used when the LLM is absent or its output is unusable.
Its opener is picked per topic out of ``FALLBACK_OPENERS``, so the
no-LLM path honours the same "no repeated lead-in" rule the prompt states.
"""
kind = str(candidate.get("kind") or DEFAULT_KIND)
title = str(candidate.get("title") or "")
scenario = DEFAULT_SCENARIO_BY_KIND.get(kind, "explore_interest")
if kind == "follow_up":
opener = f"上次聊到「{title}」,好像还没收尾——要不要趁现在花几分钟往前推一步?"
scenario = DEFAULT_SCENARIO_BY_KIND.get(kind, DEFAULT_SCENARIO_BY_KIND[DEFAULT_KIND])
opener = _fallback_opener(kind, candidate, title)
if kind == "reminder":
next_action = "确认时间、所需材料与是否和其它安排冲突,需要改期就现在说"
elif kind == "new_idea":
next_action = "确认值不值得委托;值得的话由 agent 先产出一版草案供你审阅"
elif kind == "follow_up":
next_action = "先回顾相关记录,确认卡点,然后给出下一步动作"
else:
opener = f"感觉你最近可能会想看看「{title}」,有空的话可以先从相关材料扫一眼。"
next_action = "快速浏览相关材料,判断值不值得深入"
memory = linked_memory(candidate)
if memory:
@ -73,6 +118,20 @@ def fallback_card(candidate: dict) -> dict:
}
def _fallback_opener(kind: str, candidate: dict, title: str) -> str:
"""Pick one opener variant for this kind, seeded by the topic identity.
The seed is the topic id, so one topic keeps one opener across rounds
instead of flip-flopping, while different topics in the same round land on
different variants and the agenda does not read as one repeated sentence.
"""
variants = FALLBACK_OPENERS.get(kind) or FALLBACK_OPENERS[DEFAULT_KIND]
seed = str(candidate.get("id") or title)
picked = variants[int(hashlib.sha1(seed.encode("utf-8")).hexdigest(), 16) % len(variants)]
due = str(candidate.get("due_at") or "").strip()
return picked.format(title=title, when=f"({due})" if due else "")
def _clean_card(raw: dict, candidate: dict) -> dict:
"""Validate one LLM card; per-field fallback keeps the card always usable."""
base = fallback_card(candidate)
@ -105,14 +164,19 @@ def _clean_card(raw: dict, candidate: dict) -> dict:
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.
Zero LLM calls when there are no push candidates. The two budgets are
independent: at most ``max_reminder_cards`` reminders plus
``max_plan_topics`` discovery candidates are expanded. A reminder-heavy day
therefore cannot starve the discovery kinds of cards, because reminders are
pinned into the agenda later and would otherwise take every slot with them.
The overflow stays card-less and the agenda step suppresses it with an
explicit reason.
"""
def __init__(
self,
max_plan_topics: int = 6,
max_reminder_cards: int = 8,
llm_timeout_seconds: float = 120,
profile_max_chars: int = 800,
skip_key: str = "proactive_skip",
@ -120,6 +184,7 @@ class ProactivePlanStep(BaseStep):
):
super().__init__(**kwargs)
self.max_plan_topics = max(int(max_plan_topics), 1)
self.max_reminder_cards = max(int(max_reminder_cards), 1)
self.llm_timeout_seconds = float(llm_timeout_seconds)
self.profile_max_chars = max(int(profile_max_chars), 0)
self.skip_key = skip_key
@ -149,10 +214,15 @@ class ProactivePlanStep(BaseStep):
self.logger.info(f"[{self.name}] no push candidates; skipping")
return self.context.response
selected = candidates[: self.max_plan_topics]
# Reminders first and outside the discovery budget: they pin today's
# agenda, so starving them here would silently break that guarantee.
reminders = [c for c in candidates if str(c.get("kind")) == "reminder"][: self.max_reminder_cards]
others = [c for c in candidates if str(c.get("kind")) != "reminder"]
selected = reminders + others[: 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}",
f"reminders={len(reminders)} max_plan_topics={self.max_plan_topics} "
f"max_reminder_cards={self.max_reminder_cards}",
)
cards_by_id = await self._llm_cards(state, selected)
@ -167,7 +237,7 @@ class ProactivePlanStep(BaseStep):
card = {
"topic_id": cid,
"title": str(candidate.get("title") or ""),
"kind": str(candidate.get("kind") or "interest_extend"),
"kind": str(candidate.get("kind") or DEFAULT_KIND),
**card,
"linked_memory": linked_memory(candidate),
}
@ -203,6 +273,8 @@ class ProactivePlanStep(BaseStep):
"confidence": c.get("confidence"),
"first_seen": c.get("first_seen"),
"evidence": c.get("evidence"),
**({"due_at": c.get("due_at")} if c.get("due_at") else {}),
**({"schedule_id": c.get("schedule_id")} if c.get("schedule_id") else {}),
}
for c in selected
],

View file

@ -12,25 +12,48 @@ plan_system_prompt: |
- `resume_task`: a task that was started but interrupted/postponed;
- `answer_pending`: a question that was asked but never answered;
- `explore_interest`: an interest the user has not dived into yet;
- `prepare_upcoming`: something with a future time anchor to prepare for.
follow_up topics become resume_task / answer_pending / prepare_upcoming;
interest_extend topics become explore_interest / prepare_upcoming. Pick by
semantics; use a time anchor whenever the topic carries one.
- `opener`: one casual spoken sentence, like a friend happening to mention
it ("by the way..."). STRICTLY forbidden: notification tone such as
"based on your previous records", "the system reminds you", "as you
mentioned on <date>". The opener must already point at the minimal
executable next action, so it invites doing, not just knowing.
- `prepare_upcoming`: something with a future time anchor to prepare for;
- `propose_delegation`: a piece of work worth handing over to the agent.
Map by kind first, then refine by semantics:
- `follow_up` -> resume_task / answer_pending; the card offers to fetch the
latest status and report back to the user;
- `interest_extend` -> explore_interest; the card offers to walk the user
through a refinement or extension of a topic they care about;
- `reminder` -> prepare_upcoming, naming the due date (and the clash to
resolve when the topic mentions one);
- `new_idea` -> propose_delegation, naming the deliverable the agent would
produce - an offer, never a report of work already done.
- `opener`: one casual spoken sentence a friend would actually say. Two
failure modes are STRICTLY forbidden:
1. Notification tone: "based on your previous records", "the system
reminds you", "as you mentioned on <date>".
2. A stock lead-in worn into a template. Never open with "by the way..."
or any other fixed filler ("just a reminder...", "quick note..."):
the same first words every day read as a script, not as a person.
Start from the substance instead and change the angle card by card - the
date or the clash, what moved since last time, the deliverable the agent
would produce, a direct question, or whatever the user is working on right
now. Inside one batch no two openers may share their opening words.
The opener must already point at the minimal executable next action, so it
invites doing, not just knowing.
A reminder opener still sounds like a person, but it may not hide the date:
the user has to hear when it is due.
- `next_action`: the smallest concrete step the user can start right now.
- `preconditions`: list of things that must hold first; empty list is fine.
- `delivery`: `in_conversation` (weave into talk), `notification`
(standalone nudge) or `agenda_item` (belongs on today's agenda).
Reminders with a near or clashing due date belong on `notification` or
`agenda_item`, not buried in conversation.
## Hard rules
- Exactly one card per input topic, no extras.
- Ground everything in the given reason/evidence; never invent facts.
- Proposals stay proposals: never word a card as if the work were done.
- Openers vary: one lead-in formula per batch is a defect, not a style.
- Quote YAML strings containing punctuation such as `:`.
- Write `opener` and `next_action` in the language the user's own material is
written in, so the assistant never switches tongues mid-conversation.
## Output format
@ -38,8 +61,8 @@ plan_system_prompt: |
```yaml
cards:
- topic_id: <echoed id>
scenario_type: resume_task|answer_pending|explore_interest|prepare_upcoming
opener: <one casual sentence pointing at the next action>
scenario_type: resume_task|answer_pending|explore_interest|prepare_upcoming|propose_delegation
opener: <one casual sentence, substance first, pointing at the next action>
next_action: <smallest executable step>
preconditions: [<condition>, ...]
delivery: in_conversation|notification|agenda_item
@ -53,27 +76,47 @@ plan_system_prompt_zh: |
## 每张卡的字段
- `topic_id`:原样回显给定 id。
- `scenario_type`:四选一
- `scenario_type`:五选一
- `resume_task`:开始过但被中断/搁置的任务;
- `answer_pending`:提出过但没有得到回答的问题;
- `explore_interest`:用户尚未深入的兴趣方向;
- `prepare_upcoming`:带未来时间点、需要提前准备的事项。
follow_up 主题对应 resume_task / answer_pending / prepare_upcoming;
interest_extend 主题对应 explore_interest / prepare_upcoming。按语义选择;
主题带时间线索时优先 prepare_upcoming。
- `opener`:一句口语化的开场,像朋友随口提起("对了,上次那个……")。
严禁通知腔:"根据您之前的记录"、"系统提醒您"、"您在某天提到过"等。
- `prepare_upcoming`:带未来时间点、需要提前准备的事项;
- `propose_delegation`:一件值得委托给 agent 去办的工作。
先按 kind 映射,再按语义细化:
- `follow_up` -> resume_task / answer_pending(卡片要表达"我去查最新进展
再向你汇报");
- `interest_extend` -> explore_interest(卡片要表达"带你看看这个主题的
细化/延伸内容");
- `reminder` -> prepare_upcoming,必须点明到期时间,若主题提到冲突还要
点明要先协调哪一件事;
- `new_idea` -> propose_delegation,说清 agent 会交付什么——是邀约,
绝不能写成已经做完的汇报。
- `opener`:一句朋友真会说出口的口语化开场。以下两种写法都严禁:
1. 通知腔:"根据您之前的记录"、"系统提醒您"、"您在某天提到过"等;
2. 把某个口头禅当模板反复套用。不要一律以"对了……"开头,也不要换成
"顺便说一下"、"提醒您一下"之类别的固定引子——每天都是同一句开头,
就成了脚本,不像人。
直接从内容本身切入,并且逐卡换角度:先说时间点或撞期、先说上次之后有
什么变化、先说 agent 会交付什么、直接抛一个问题,或者从用户眼下正在做
的事接过去。同一批卡片里,任意两张开场的开头用词都不得相同。
开场必须顺势给出最小可执行的下一步,让人愿意动手,而不只是知道。
reminder 的开场同样要像人话,但不能藏时间:用户必须听到"什么时候到期"。
- `next_action`:用户现在就能开始的最小具体动作。
- `preconditions`:需要先满足的条件列表,可以为空。
- `delivery`:`in_conversation`(对话中自然带出)/ `notification`
(单独提醒)/ `agenda_item`(列入当日议程)。
临近到期或存在冲突的 reminder 应用 `notification` 或 `agenda_item`,
不要埋在闲聊里。
## 硬约束
- 每个输入主题恰好一张卡,不得多给。
- 所有内容必须基于给定的 reason/evidence,禁止编造事实。
- 提议就是提议:任何卡片都不得写成工作已经完成。
- 开场白必须各不相同:整批共用一个引子是缺陷,不是风格。
- 包含 `:` 等标点的 YAML 字符串请加引号。
- `opener` 与 `next_action` 使用用户素材本身所用的语言书写,避免助手在对话
中途切换语言。
## 输出格式
@ -81,8 +124,8 @@ plan_system_prompt_zh: |
```yaml
cards:
- topic_id: <回显的 id>
scenario_type: resume_task|answer_pending|explore_interest|prepare_upcoming
opener: <一句口语化开场,顺势给出下一步动作>
scenario_type: resume_task|answer_pending|explore_interest|prepare_upcoming|propose_delegation
opener: <一句口语化开场,直接从内容切入,顺势给出下一步动作>
next_action: <最小可执行步骤>
preconditions: [<条件>, ...]
delivery: in_conversation|notification|agenda_item

View file

@ -6,6 +6,11 @@ fallback) and ``horizon_days``: horizon=1 keeps the legacy single-day file
read (v1 compatible); horizon>1 reads the truth source directly, filtering by
``last_evidence_at`` recency (v5 R4 - the truth source already carries the
cross-day merge, so the reader no longer re-scans exposure products).
Agenda items the host already reported through ``proactive_feedback`` are
withheld while they sit inside ``delivery_cooldown_days``, so a consumer that
reads this job daily does not resurface the same proposal. Feedback is
optional: with an empty ``deliveries`` section nothing is withheld.
"""
import datetime as dt
@ -23,10 +28,27 @@ from .utils import (
parse_interests_topics,
sort_topics,
state_file_path,
suppressed_delivery_ids,
topic_id,
)
def _above_floor(topic, min_confidence: float) -> bool:
"""Confidence floor check; clock-anchored reminders are exempt.
A reminder carrying a due value is a clock fact about the user's own
schedule, not a discovery guess, and the refresh chain already applied the
(lower) ``min_reminder_confidence`` when it pinned it to the agenda.
Filtering it again here would silently drop today's agenda item. A dateless
reminder - an external notice or a key event - is a judgement call, so it
still has to clear the reader's floor.
"""
get = topic.get if isinstance(topic, dict) else (lambda key: getattr(topic, key, ""))
if str(get("kind") or "") == "reminder" and str(get("due_at") or "").strip():
return True
return float(get("confidence") or 0.0) >= min_confidence - 1e-9
@R.register("proactive_step")
class ProactiveStep(BaseStep):
"""Read ``daily/<date>/interests.yaml`` (optionally merged over a horizon).
@ -37,11 +59,21 @@ class ProactiveStep(BaseStep):
- ``horizon_days>1`` reads the truth source and filters by evidence recency.
"""
def __init__(self, include_content: bool = True, horizon_days: int = 1, min_confidence: float = 0.4, **kwargs):
def __init__(
self,
include_content: bool = True,
horizon_days: int = 1,
min_confidence: float = 0.4,
include_delivered: bool = False,
delivery_cooldown_days: int = 3,
**kwargs,
):
super().__init__(**kwargs)
self.include_content = include_content
self.horizon_days = max(int(horizon_days), 1)
self.min_confidence = float(min_confidence)
self.include_delivered = include_delivered
self.delivery_cooldown_days = max(int(delivery_cooldown_days), 0)
async def execute(self):
assert self.context is not None
@ -54,27 +86,37 @@ class ProactiveStep(BaseStep):
min_confidence = float(raw_min_confidence)
except (TypeError, ValueError):
min_confidence = self.min_confidence
include_delivered = bool(self.context.get("include_delivered", self.include_delivered))
raw_cooldown = self.context.get("delivery_cooldown_days", self.delivery_cooldown_days)
try:
cooldown = max(int(raw_cooldown), 0)
except (TypeError, ValueError):
cooldown = self.delivery_cooldown_days
daily = self.config_value("daily_dir")
ws = workspace_dir(self)
blocked = {} if include_delivered else self._suppressed_deliveries(ws, daily, day, cooldown)
default_rel = f"{daily}/{day}/interests.yaml"
result = ProactiveResult(date=day, path=default_rel)
self.logger.info(
f"[{self.name}] start date={day} path={default_rel} include_content={include_content} "
f"horizon_days={horizon} min_confidence={min_confidence}",
f"horizon_days={horizon} min_confidence={min_confidence} "
f"include_delivered={include_delivered} delivery_cooldown_days={cooldown} blocked={len(blocked)}",
)
if horizon == 1:
outcome = self._read_single(ws, daily, day, include_content, min_confidence, result)
outcome = self._read_single(ws, daily, day, include_content, min_confidence, result, blocked)
if outcome is not None:
return outcome
# The horizon view reads open topics from the truth source and carries
# no agenda, so delivery withholding does not apply to it.
return self._read_horizon(ws, daily, day, horizon, include_content, min_confidence, result)
# ------------------------------------------------------------------
# Single-day read (legacy-compatible path)
# ------------------------------------------------------------------
def _read_single(self, ws, daily, day, include_content, min_confidence, result: ProactiveResult):
def _read_single(self, ws, daily, day, include_content, min_confidence, result: ProactiveResult, blocked=None):
rel_path = f"{daily}/{day}/interests.yaml"
abs_path = ws / daily / day / "interests.yaml"
if not abs_path.is_file():
@ -119,19 +161,20 @@ class ProactiveStep(BaseStep):
kept = [
dump_topic(t)
for t in sort_topics(topics)
if t.id not in resolved_ids and t.confidence >= min_confidence - 1e-9
if t.id not in resolved_ids and _above_floor(t, min_confidence)
]
result.topics = kept
agenda_raw = data.get("agenda")
if isinstance(agenda_raw, list):
kept_ids = {str(t.get("id") or "") for t in kept}
result.agenda = [
item
for item in agenda_raw
if isinstance(item, dict) and str(item.get("topic_id") or "") in kept_ids
]
result.agenda, result.delivered_ids = self._select_agenda(agenda_raw, kept_ids, blocked)
result.summary = f"Read {len(result.topics)} proactive topic(s) from {rel_path}"
self.logger.info(f"[{self.name}] read done path={rel_path} topics={len(result.topics)}")
if result.delivered_ids:
result.summary += f"; withheld {len(result.delivered_ids)} already-delivered agenda item(s)"
self.logger.info(
f"[{self.name}] read done path={rel_path} topics={len(result.topics)} "
f"agenda={len(result.agenda)} withheld={len(result.delivered_ids)}",
)
return self._finish(True, result, include_content=include_content)
except Exception as e: # noqa: BLE001
result.error, result.summary = f"{type(e).__name__}: {e}", ""
@ -142,7 +185,16 @@ class ProactiveStep(BaseStep):
# Multi-day merge (horizon_days > 1, or fallback target)
# ------------------------------------------------------------------
def _read_horizon(self, ws, daily, day, horizon, include_content, min_confidence, result: ProactiveResult):
def _read_horizon(
self,
ws,
daily,
day,
horizon,
include_content,
min_confidence,
result: ProactiveResult,
):
"""Truth-source view (v5 R4): open topics with evidence inside the horizon.
The truth source already carries the cross-day merge (carry-forward),
@ -171,7 +223,7 @@ class ProactiveStep(BaseStep):
for t in state_file.open_topics
if (
(not cutoff or cutoff <= str(t.last_evidence_at or "") <= upper_bound)
and t.confidence >= min_confidence - 1e-9
and _above_floor(t, min_confidence)
)
]
kept = sort_topics(kept)
@ -185,6 +237,37 @@ class ProactiveStep(BaseStep):
state_file, _needs_bootstrap = load_state(ws, daily)
return {str(r.get("id") or "") for r in state_file.resolved if isinstance(r, dict) and r.get("id")}
def _suppressed_deliveries(self, ws, daily: str, day: str, cooldown: int) -> dict:
"""topic_id -> blocking delivery record; empty when nothing was reported."""
state_file, _needs_bootstrap = load_state(ws, daily)
if not state_file.deliveries:
return {}
return suppressed_delivery_ids(state_file, day, cooldown)
@staticmethod
def _select_agenda(agenda_raw: list, kept_ids: set, blocked: dict | None) -> tuple[list, list]:
"""Keep agenda items whose topic survived filtering and was not delivered.
The agenda order coming out of the refresh chain *is* the priority
order (reminders pinned first), so this only ever removes items and
never re-sorts them.
"""
blocked = blocked or {}
agenda, withheld = [], []
for item in agenda_raw:
if not isinstance(item, dict):
continue
item_id = str(item.get("topic_id") or "")
if item_id not in kept_ids:
continue
# Reminders stay exempt from the cooldown, matching the agenda
# step: a due schedule is announced every time it comes due.
if item_id in blocked and str(item.get("kind") or "") != "reminder":
withheld.append(item_id)
continue
agenda.append(item)
return agenda, withheld
def _finish(self, success: bool, result: ProactiveResult, *, include_content: bool):
assert self.context is not None
self.context.response.success = success
@ -197,6 +280,7 @@ class ProactiveStep(BaseStep):
"summary": result.summary,
"topics": result.topics,
**({"agenda": result.agenda} if result.agenda else {}),
**({"delivered_ids": result.delivered_ids} if result.delivered_ids else {}),
**({"content": result.content} if include_content else {}),
}
self.context.response.metadata.update(result.model_dump())

View file

@ -0,0 +1,196 @@
"""Stage-2 per-kind adjudication for proactive extraction (precision pass).
Stage 1 owns recall: one batched reply over the changed material produces the
candidates of all four kinds. Stage 2 owns precision: every enabled kind that
actually produced candidates gets one small, material-free call that re-checks
those candidates against the kind's own contract plus the only context slice
that kind needs (registry and clock for reminders, carry-forward topics for
follow-ups, capability/wiki inventory for ideas and extends).
A pass may keep, drop or move a candidate and rescore its confidence. It never
rewrites wording - stage 1 owns the user-facing text - and it never invents a
candidate. Every failure mode is fail-open: a timeout or an unusable reply keeps
the stage-1 candidates untouched, because losing a real finding costs more than
keeping a borderline one.
"""
from ....schema.proactive import TOPIC_KINDS, clamp_confidence
from .utils import parse_fenced_yaml
# Refine order = proactive priority order, which keeps the audit log readable:
# the highest-priority kind is adjudicated first.
REFINE_ORDER = ("reminder", "follow_up", "new_idea", "interest_extend")
DECISIONS = ("keep", "drop", "move")
# Fields only a reminder candidate carries. A move out of ``reminder`` has to
# drop them, otherwise the receiving kind publishes a ``schedule_id`` pointing
# at a registry entry that was never created plus a due date nothing fires on.
REMINDER_ONLY_FIELDS = ("schedule_id", "due_at", "next_due", "trigger", "conflict_with")
def _notice_anchored(item: dict, notice_paths: set[str]) -> bool:
"""True when the candidate cites the external source (``resource/``) layer."""
return bool({str(p) for p in (item.get("paths") or [])} & notice_paths)
def _reminder_contract_met(item: dict, notice_paths: set[str]) -> bool:
"""``clean_reminder``'s traceability rule: a due value or a notice anchor."""
return bool(str(item.get("due_at") or "").strip()) or _notice_anchored(item, notice_paths)
def candidates_by_kind(state) -> list[tuple[str, list[dict]]]:
"""``(kind, bucket)`` pairs in refine order, skipping empty buckets."""
buckets = kind_buckets(state)
return [(kind, buckets[kind]) for kind in REFINE_ORDER if buckets[kind]]
def kind_buckets(state) -> dict[str, list[dict]]:
"""Map each kind to the ``ProactiveState`` bucket holding its candidates."""
return {
"reminder": list(state.reminders),
"follow_up": list(state.follow_ups),
"new_idea": list(state.new_ideas),
"interest_extend": list(state.extends),
}
def candidate_payload(bucket: list[dict]) -> list[dict]:
"""Candidate fields worth adjudicating; no material, paths trimmed."""
out = []
for item in bucket:
payload = {
"id": str(item.get("id") or ""),
"title": str(item.get("title") or ""),
"reason": str(item.get("reason") or ""),
"confidence": clamp_confidence(item.get("confidence")),
"paths": [str(p) for p in (item.get("paths") or [])][:3],
}
for key in ("due_at", "schedule_id", "trigger"):
if str(item.get(key) or "").strip():
payload[key] = str(item.get(key))
conflicts = item.get("conflict_with")
if isinstance(conflicts, list) and conflicts:
payload["conflict_with"] = [str(c) for c in conflicts][:3]
out.append(payload)
return out
def parse_refine_reply(text: str) -> list[dict] | None:
"""Verdict list from a refine reply; ``None`` when the reply is unusable.
An empty list is a valid reply ("keep everything"); only a missing or
non-list ``verdicts`` key counts as unusable, which lets the caller keep
the stage-1 candidates instead of silently dropping a whole kind.
"""
payload = parse_fenced_yaml(text)
if "verdicts" not in payload:
return None
verdicts = payload["verdicts"]
if verdicts is None:
# `verdicts:` with nothing beneath it is how YAML spells the empty list,
# and an unmentioned candidate is kept - so this is the documented
# "nothing to change" reply, not a broken one. Counting it as unusable
# would flag the most common clean round as a degrade and hide a real
# stage-2 failure behind the noise.
return []
if not isinstance(verdicts, list):
return None
out = []
for raw in verdicts:
if not isinstance(raw, dict):
continue
cid = str(raw.get("id") or "").strip()
if not cid:
continue
decision = str(raw.get("decision") or "keep").strip().lower()
verdict = {
"id": cid,
"decision": decision if decision in DECISIONS else "keep",
"to_kind": str(raw.get("to_kind") or "").strip(),
"reason": str(raw.get("reason") or "").strip()[:200],
}
if raw.get("confidence") is not None:
verdict["confidence"] = clamp_confidence(raw.get("confidence"))
out.append(verdict)
return out
def apply_verdicts(
state,
verdicts: list[dict],
caps: dict[str, int],
enabled: set[str],
notice_paths: set[str] | None = None,
) -> None:
"""Rebuild the four buckets from the stage-2 verdicts, in place on ``state``.
A missing verdict means keep (fail-open), an unknown id is ignored, and a
move to an unknown or disabled kind is ignored rather than fatal. Caps are
re-applied afterwards in stage-1 order, so a move can never push a kind over
its ceiling.
Two moves are refused rather than applied, because stage 1 owns the
path/kind invariants and stage 2 must not break them:
* out of ``reminder`` for a candidate anchored on the external notice
layer - only the reminder branch may cite ``resource/``, so demoting it
would publish an external upload under a kind that may not quote one;
* into ``reminder`` for a candidate with neither a due value nor a notice
anchor - stage 2 cannot attach a date, so it could never satisfy
``clean_reminder``'s contract for a dateless reminder.
Any other successful move out of ``reminder`` strips the reminder-only
fields so no dangling registry link survives the reclassification.
"""
notices = {str(p) for p in (notice_paths or set())}
buckets = kind_buckets(state)
index: dict[str, tuple[str, dict]] = {}
for kind, bucket in buckets.items():
for item in bucket:
index.setdefault(str(item.get("id") or ""), (kind, item))
for verdict in verdicts:
entry = index.get(verdict["id"])
if entry is None:
continue
kind, item = entry
if verdict["decision"] == "drop":
buckets[kind] = [c for c in buckets[kind] if c is not item]
state.refine_dropped.append(_audit(verdict, kind, item))
elif verdict["decision"] == "move":
target = verdict["to_kind"]
if target not in TOPIC_KINDS or target not in enabled or target == kind:
continue
if kind == "reminder" and _notice_anchored(item, notices):
continue
if target == "reminder" and not _reminder_contract_met(item, notices):
continue
buckets[kind] = [c for c in buckets[kind] if c is not item]
item["kind"] = target
if kind == "reminder":
for field in REMINDER_ONLY_FIELDS:
item.pop(field, None)
buckets[target].append(item)
index[verdict["id"]] = (target, item)
state.refine_moved.append({**_audit(verdict, kind, item), "to_kind": target})
if "confidence" in verdict:
item["confidence"] = verdict["confidence"]
for kind, bucket in buckets.items():
cap = int(caps.get(kind, 0))
if cap and len(bucket) > cap:
state.dropped_over_cap += len(bucket) - cap
buckets[kind] = bucket[:cap]
state.reminders = buckets["reminder"]
state.follow_ups = buckets["follow_up"]
state.new_ideas = buckets["new_idea"]
state.extends = buckets["interest_extend"]
def _audit(verdict: dict, kind: str, item: dict) -> dict:
"""One audit record so a refine decision stays diagnosable in metadata."""
return {
"id": verdict["id"],
"kind": kind,
"title": str(item.get("title") or "")[:80],
"decision": verdict["decision"],
"reason": verdict["reason"],
}

File diff suppressed because it is too large Load diff

View file

@ -23,8 +23,18 @@ from .._evolve import passthrough_response
from ....components import R
from ....enumeration import ComponentEnum
from ....schema import ProactiveState, ProactiveStateFile, ProactiveTopic
from ....schema.proactive import clamp_confidence
from ....schema.proactive import DEFAULT_KIND, clamp_confidence
from ..dream.utils import daily_dir, previous_dates, today, workspace_dir
from .schedules import (
apply_schedule_updates,
effective_due,
refresh_reminder_dues,
slim_schedule,
sort_reminders,
topic_from_candidate,
trim_schedules,
upsert_schedule_from_reminder,
)
from .utils import (
current_now,
dump_topic,
@ -61,6 +71,10 @@ class ProactiveTopicsStep(BaseStep):
dedup_lookback_days: int = 7,
digest_compare_limit: int = 500,
known_threshold_calibrated_for: str = "text-embedding-v4@1024",
min_reminder_confidence: float = 0.3,
schedule_retention_days: int = 180,
max_schedules: int = 80,
max_schedule_history: int = 8,
skip_key: str = "proactive_skip",
**kwargs,
):
@ -71,6 +85,10 @@ class ProactiveTopicsStep(BaseStep):
self.max_topics = max(int(max_topics), 1)
self.dedup_lookback_days = max(int(dedup_lookback_days), 0)
self.digest_compare_limit = max(int(digest_compare_limit), 0)
self.min_reminder_confidence = float(min_reminder_confidence)
self.schedule_retention_days = max(int(schedule_retention_days), 1)
self.max_schedules = max(int(max_schedules), 0)
self.max_schedule_history = max(int(max_schedule_history), 0)
self.skip_key = skip_key
# pylint: disable=too-many-statements
@ -93,7 +111,9 @@ class ProactiveTopicsStep(BaseStep):
daily = state.daily_dir or daily_dir(self)
self.logger.info(
f"[{self.name}] start date={day} follow_ups={len(state.follow_ups)} extends={len(state.extends)} "
f"updates={len(state.updates)} carry_forward={state.carry_forward_count}",
f"reminders={len(state.reminders)} new_ideas={len(state.new_ideas)} "
f"updates={len(state.updates)} schedule_updates={len(state.schedule_updates)} "
f"carry_forward={state.carry_forward_count}",
)
state_file, _needs_bootstrap = load_state(ws, daily)
@ -105,6 +125,13 @@ class ProactiveTopicsStep(BaseStep):
# check, same shape as INV-8); otherwise it degrades to keep so stale
# re-reads cannot rewrite evidence or refresh the freshness sort key.
new_material = {norm_path(str(path).split("#", 1)[0]) for path in state.changed_paths}
# The reminder branch is the only one allowed to cite the ``resource/``
# layer and ``clean_schedule_update`` already accepts those paths, so
# the registry anchor has to agree with it: without the notice paths a
# dated external notice could never be registered through the explicit
# channel and would only reach the registry via the reminder safety
# net, losing its detail/end-time/recurrence and its own reason.
schedule_material = new_material | {norm_path(str(path).split("#", 1)[0]) for path in state.resource_changed}
for update in state.updates:
topic = open_by_id.get(str(update.get("id") or ""))
if topic is None:
@ -138,8 +165,49 @@ class ProactiveTopicsStep(BaseStep):
)
state.updates_resolved += 1
# 1b) Schedule registry (branch C). Applied before the candidates so a
# reminder always sees the registry it just helped maintain. The
# registry has its own, much longer lifecycle than open_topics: a
# schedule mentioned weeks ago must still fire on its due date.
counts, touched_schedules = apply_schedule_updates(state_file, state.schedule_updates, day, schedule_material)
state.schedules_added += counts["added"]
state.schedules_updated += counts["updated"]
state.schedules_closed += counts["closed"]
# The clock scan read the registry before those updates landed, so a
# seeded reminder may still advertise the previous due value; a reader
# sorts and notifies by it, so re-anchor it on the registry.
state.reminders_reanchored = refresh_reminder_dues(
state.reminders,
state_file,
touched_schedules,
current_now(self),
)
adopted_schedules = 0
for candidate in state.reminders:
outcome = upsert_schedule_from_reminder(state_file, candidate, day, touched_schedules)
if outcome == "added":
state.schedules_added += 1
elif outcome == "updated":
state.schedules_updated += 1
elif outcome == "adopted":
adopted_schedules += 1
dropped_schedules = trim_schedules(
state_file,
day,
self.schedule_retention_days,
self.max_schedules,
self.max_schedule_history,
)
state.schedules_out = [slim_schedule(s) for s in state_file.schedules if s.status == "open"]
self.logger.info(
f"[{self.name}] schedule registry added={state.schedules_added} updated={state.schedules_updated} "
f"closed={state.schedules_closed} dropped={dropped_schedules} open={len(state.schedules_out)} "
f"skipped_updates={counts['skipped']} reminders_reanchored={state.reminders_reanchored} "
f"adopted={adopted_schedules}",
)
# 2) Candidates: same-id merge, tombstone resurrect, dedup the rest.
raw_candidates = list(state.follow_ups) + list(state.extends)
raw_candidates = list(state.reminders) + list(state.follow_ups) + list(state.extends) + list(state.new_ideas)
state.candidates_in = len(raw_candidates)
merged: list[dict] = []
resurrected: list[dict] = []
@ -196,6 +264,13 @@ class ProactiveTopicsStep(BaseStep):
if normalized in seen_normalized:
dropped_duplicate += 1
continue
if str(candidate.get("kind") or "") == "reminder":
# A due schedule must fire even when the same words were
# published recently: the historical "known" gate filters
# discovery noise, it is not a calendar.
seen_normalized.add(normalized)
survivors.append(candidate)
continue
if embedder is None:
if normalized in comparison_norms:
dropped_duplicate += 1
@ -230,7 +305,7 @@ class ProactiveTopicsStep(BaseStep):
# 3) Truth source: add new topics, prune, single atomic write (F1.3).
for candidate in survivors:
state_file.open_topics.append(ProactiveTopic.model_validate(candidate))
state_file.open_topics.append(topic_from_candidate(candidate))
trim_state_file(state_file, day, max(state.carry_forward_days, 1))
await save_state(ws, state_file, daily)
@ -239,9 +314,24 @@ class ProactiveTopicsStep(BaseStep):
# rounds because such topics persist in the truth source once added.
# The candidate list (sorted deterministically) feeds the plan/agenda
# steps; ``push`` is exactly "at least one candidate".
push_candidates = sort_topics(
[t for t in state_file.open_topics if t.first_seen == day and t.confidence >= self.min_push_confidence],
)
# Reminders are the exception on both counts: one refreshed today pushes
# regardless of ``first_seen`` (a due schedule recurs daily until it is
# closed) and only has to clear the lower ``min_reminder_confidence``.
reminders_today = [
t
for t in state_file.open_topics
if t.kind == "reminder"
and day in (t.first_seen, t.last_evidence_at)
and t.confidence >= self.min_reminder_confidence
]
others_today = [
t
for t in state_file.open_topics
if t.kind != "reminder" and t.first_seen == day and t.confidence >= self.min_push_confidence
]
push_candidates = sort_reminders([dump_topic(t) for t in reminders_today]) + [
dump_topic(t) for t in sort_topics(others_today)
]
push = bool(push_candidates)
if push:
file_skip_reason = ""
@ -286,6 +376,13 @@ class ProactiveTopicsStep(BaseStep):
existing.confidence = clamp_confidence(candidate.get("confidence"))
if candidate.get("paths"):
existing.paths = list(candidate["paths"])
if existing.kind == "reminder":
# Keep the published due date and registry link current so a
# recurring reminder shows its next occurrence, not the anchor.
if due := effective_due(candidate):
existing.due_at = due
if candidate.get("schedule_id"):
existing.schedule_id = str(candidate["schedule_id"])
@staticmethod
def _resurrect(tombstone: dict, candidate: dict, day: str) -> ProactiveTopic:
@ -294,16 +391,19 @@ class ProactiveTopicsStep(BaseStep):
The over-age trim still applies to the original ``first_seen``, so a
resurrection only extends a lifetime that has not fully elapsed.
"""
kind = str(candidate.get("kind") or DEFAULT_KIND)
return ProactiveTopic(
id=str(tombstone.get("id") or candidate.get("id") or ""),
title=str(candidate.get("title") or tombstone.get("title") or ""),
reason=str(candidate.get("reason") or ""),
kind=str(candidate.get("kind") or "interest_extend"),
kind=kind,
confidence=clamp_confidence(candidate.get("confidence")),
first_seen=str(tombstone.get("first_seen") or day),
last_evidence_at=day,
evidence=str(candidate.get("evidence") or "")[:120],
paths=candidate.get("paths") or [],
schedule_id=str(candidate.get("schedule_id") or ""),
due_at=effective_due(candidate) if kind == "reminder" else "",
)
async def _comparison_texts(

View file

@ -17,8 +17,16 @@ from pathlib import Path
import yaml
from ....enumeration import ComponentEnum
from ....schema import ProactiveStateFile, ProactiveTopic
from ....schema.proactive import clamp_confidence
from ....schema import ProactiveDelivery, ProactiveSchedule, ProactiveStateFile, ProactiveTopic
from ....schema.proactive import (
DEFAULT_DELIVERY_CHANNEL,
DEFAULT_DELIVERY_STATUS,
DEFAULT_KIND,
TERMINAL_DELIVERY_STATUSES,
TOPIC_KINDS,
clamp_confidence,
kind_priority,
)
from ....utils import get_logger
from ...file_io._file_io import get_path_lock
from .._evolve import now
@ -26,9 +34,17 @@ from ..dream.utils import clean_paths, recent_dates, scan_day_files
logger = get_logger(log_to_file=False)
PROACTIVE_STATE_NAME = "_proactive.yaml"
INTERESTS_NAME = "interests.yaml"
EXTRACT_SECTIONS = ("follow_ups", "extends", "updates")
EXTRACT_SECTIONS = ("follow_ups", "extends", "reminders", "new_ideas", "updates", "schedule_updates")
# Registry triggers that deserve a reminder; ``later``/``unscheduled``/``closed``
# stay in the prompt view but never become candidates.
def load_yaml_topics(path: Path, *, strict: bool = False) -> list[dict]:
@ -101,8 +117,6 @@ def _clean_legacy_topic(raw: dict) -> dict:
# ---------------------------------------------------------------------------
# Frozen identity contract (A7 / INV-4)
# ---------------------------------------------------------------------------
def normalize_topic(title: str) -> str:
@ -121,8 +135,6 @@ def topic_id(title: str) -> str:
# ---------------------------------------------------------------------------
# Paths and material set M (F2.0)
# ---------------------------------------------------------------------------
def state_file_path(ws: Path, daily: str = "daily") -> Path:
@ -158,8 +170,75 @@ def scan_material_daily(ws: Path, day: str, daily: str, scan_days: int) -> list[
return sorted(out)
# ---------------------------------------------------------------------------
# Truth-source state file daily/_proactive.yaml (F1.3)
# External source records the reminder branch may read directly. Binary sources
# are deliberately absent: ``auto_resource`` interprets them into daily cards,
# which already reach extraction through ``scan_material_daily``.
RESOURCE_NOTICE_SUFFIXES = (
".md",
".markdown",
".txt",
".json",
".yaml",
".yml",
".csv",
".tsv",
".html",
".eml",
".ics",
)
def _mtime_day(path: Path) -> str:
"""Local date a file last changed, or ``""`` when it cannot be stat'ed."""
try:
return dt.date.fromtimestamp(path.stat().st_mtime).isoformat()
except OSError:
return ""
def scan_material_resource(
ws: Path,
day: str,
scan_days: int,
resource: str = "resource",
) -> tuple[list[str], int]:
"""M_resource: external source records that may carry notices for the user.
Mirrors the ``resource/`` entry-point semantics of Auto Resource: a dated
directory ``resource/<YYYY-MM-DD>/...`` belongs to that date, while a file
directly under ``resource/`` (or under a non-dated folder) belongs to the
day it landed, i.e. its mtime. Returns ``(paths, skipped_binary)``; only
text-readable suffixes are returned, because a binary source has to be
interpreted by ``auto_resource`` before anyone can quote it.
This is the reserved hook for future external source interfaces: whatever
writes into ``resource/`` becomes eligible for the reminder review without
any further change to the proactive chain.
"""
window = set(recent_dates(day, scan_days))
root = ws / resource
if not window or not root.is_dir():
return [], 0
out: list[str] = []
skipped = 0
for path in sorted(root.rglob("*")):
if not path.is_file():
continue
rel = norm_path(path.relative_to(ws).as_posix())
parts = rel.split("/")
if any(part.startswith("_") or part.startswith(".") for part in parts[1:]):
continue
owner = parts[1] if len(parts) > 2 and safe_date(parts[1]) else _mtime_day(path)
if owner not in window:
continue
if path.suffix.lower() not in RESOURCE_NOTICE_SUFFIXES:
skipped += 1
continue
if rel not in out:
out.append(rel)
return sorted(out), skipped
# ---------------------------------------------------------------------------
@ -187,11 +266,56 @@ def load_state(ws: Path, daily: str = "daily") -> tuple[ProactiveStateFile, bool
try:
state = ProactiveStateFile.model_validate(data)
except Exception as e: # noqa: BLE001
logger.warning(f"proactive state file invalid, rebuilding empty: {path} ({e})")
return ProactiveStateFile(), False
# Salvage entry by entry instead of discarding the file: the schedule
# registry is designed to outlive topics by months, so one malformed
# hand-edited entry must not silently delete every long-lived reminder.
# Still no bootstrap - the file exists, so this is not a first run.
state, dropped = _salvage_state(data)
logger.warning(
f"proactive state file invalid, salvaged entry by entry: {path} "
f"(dropped {dropped} unreadable entr(ies); kept open_topics={len(state.open_topics)} "
f"schedules={len(state.schedules)} deliveries={len(state.deliveries)}; {e})",
)
return state, False
return state, needs_bootstrap
# Truth-source sections whose entries are validated one by one when the whole
# file fails to parse. ``resolved`` is a plain dict list, so it only needs the
# non-mapping items filtered out.
_STATE_SECTIONS = (("open_topics", ProactiveTopic), ("schedules", ProactiveSchedule), ("deliveries", ProactiveDelivery))
def _salvage_state(data: dict) -> tuple[ProactiveStateFile, int]:
"""Rebuild a partly-unreadable truth source, dropping only the bad entries.
Returns the salvaged model and the number of entries that had to be
dropped. Never raises: a section that is not even a list is treated as
absent, exactly like a file written before that section existed.
"""
sections: dict[str, list] = {}
dropped = 0
for key, model in _STATE_SECTIONS:
raw = data.get(key)
raw = raw if isinstance(raw, list) else []
kept = []
for item in raw:
try:
kept.append(model.model_validate(item))
except Exception: # noqa: BLE001
dropped += 1
sections[key] = kept
raw_resolved = data.get("resolved")
resolved = [item for item in (raw_resolved if isinstance(raw_resolved, list) else []) if isinstance(item, dict)]
version = data.get("version")
state = ProactiveStateFile(
version=version if isinstance(version, int) else 1,
resolved=resolved,
**sections,
)
return state, dropped
async def save_state(ws: Path, state_file: ProactiveStateFile, daily: str = "daily") -> None:
"""Atomically persist the truth source (path lock + tmp file + os.replace)."""
path = state_file_path(ws, daily)
@ -210,7 +334,8 @@ async def save_state(ws: Path, state_file: ProactiveStateFile, daily: str = "dai
raise
def _safe_date(text: str) -> dt.date | None:
def safe_date(text: str) -> dt.date | None:
"""Parse an ISO ``YYYY-MM-DD`` string, returning None when it is not one."""
try:
return dt.date.fromisoformat(str(text or "").strip())
except ValueError:
@ -237,13 +362,13 @@ async def load_carry_forward(
state_file.open_topics = _bootstrap_from_history(ws, day, days, daily)
await save_state(ws, state_file, daily)
resolved_ids = {str(r.get("id") or "") for r in state_file.resolved if isinstance(r, dict)}
base = _safe_date(day)
base = safe_date(day)
open_topics: list[ProactiveTopic] = []
expired = 0
for topic in state_file.open_topics:
if topic.id and topic.id in resolved_ids:
continue
first_seen = _safe_date(topic.first_seen)
first_seen = safe_date(topic.first_seen)
# Boundary aligned with trim_state_file/_expiry_cutoff: age >= days is
# over-age everywhere, so a topic never enters the prompt in the same
# round it gets pruned from the truth source (audit item 9).
@ -259,7 +384,7 @@ async def load_carry_forward(
def _bootstrap_from_history(ws: Path, day: str, days: int, daily: str) -> list[ProactiveTopic]:
"""One-time bootstrap: newest record per id wins, first_seen takes the min (F1.4)."""
if _safe_date(day) is None:
if safe_date(day) is None:
return []
records: dict[str, ProactiveTopic] = {}
first_seen: dict[str, str] = {}
@ -283,7 +408,7 @@ def _bootstrap_from_history(ws: Path, day: str, days: int, daily: str) -> list[P
def trim_state_file(state_file: ProactiveStateFile, day: str, days: int) -> None:
"""Prune budget/exposure/resolved windows and over-age/resolved open topics."""
base = _safe_date(day)
base = safe_date(day)
if base is None:
return
cutoff = (base - dt.timedelta(days=max(int(days), 0))).isoformat()
@ -295,7 +420,7 @@ def trim_state_file(state_file: ProactiveStateFile, day: str, days: int) -> None
for topic in state_file.open_topics:
if topic.id and topic.id in resolved_ids:
continue
first_seen = _safe_date(topic.first_seen)
first_seen = safe_date(topic.first_seen)
if first_seen is not None and first_seen.isoformat() <= cutoff:
continue
kept.append(topic)
@ -303,8 +428,157 @@ def trim_state_file(state_file: ProactiveStateFile, day: str, days: int) -> None
# ---------------------------------------------------------------------------
# interests.yaml read/render (F1.2 / A2 / A4)
# ---------------------------------------------------------------------------
# Delivery feedback (host -> ReMe). Optional by design: a framework that never
# reports leaves ``deliveries`` empty and every helper below degrades to a
# no-op, so the chain behaves exactly as it did before feedback existed.
# ---------------------------------------------------------------------------
def _delivery_date(text: str) -> str:
"""``YYYY-MM-DD`` prefix of an ISO timestamp; '' when unparseable."""
raw = str(text or "").strip()
parsed = safe_date(raw[:10])
return parsed.isoformat() if parsed is not None else ""
def normalize_delivery(raw, day: str, now_dt: dt.datetime) -> ProactiveDelivery | None:
"""Coerce one host-reported item into a ``ProactiveDelivery``.
``topic_id`` is the only mandatory field; a record without one can never
match an agenda item, so it is dropped instead of being stored. Missing
``agenda_date`` defaults to the round's day and missing ``delivered_at``
to the business clock, which keeps a minimal host report
(``{"topic_id": ..., "status": ...}``) sufficient.
"""
if not isinstance(raw, dict):
return None
topic = str(raw.get("topic_id") or "").strip()
if not topic:
return None
stamp = str(raw.get("delivered_at") or "").strip()
if not _delivery_date(stamp):
stamp = now_dt.isoformat(timespec="seconds")
return ProactiveDelivery(
topic_id=topic,
agenda_date=str(raw.get("agenda_date") or day or "")[:10],
delivered_at=stamp,
channel=str(raw.get("channel") or DEFAULT_DELIVERY_CHANNEL),
consumer=str(raw.get("consumer") or ""),
status=str(raw.get("status") or DEFAULT_DELIVERY_STATUS),
outcome=str(raw.get("outcome") or ""),
external_ref=str(raw.get("external_ref") or ""),
)
def delivery_index(state_file: ProactiveStateFile) -> dict[str, list[ProactiveDelivery]]:
"""Group delivery records by topic_id, newest first.
The list is kept (not collapsed to one record) because a topic can be
surfaced through several channels; suppression only needs the most recent
relevant record, which is the head after sorting.
"""
grouped: dict[str, list[ProactiveDelivery]] = {}
for record in state_file.deliveries:
if not record.topic_id:
continue
grouped.setdefault(record.topic_id, []).append(record)
for records in grouped.values():
records.sort(key=lambda r: (str(r.delivered_at or ""), str(r.external_ref or "")), reverse=True)
return grouped
def is_delivery_suppressed(record: ProactiveDelivery | None, day: str, cooldown_days: int) -> bool:
"""Whether a delivery record still blocks re-proposing its topic.
``failed`` never blocks: a run that did not complete should be retryable
on the next round. Every other status blocks while it is younger than
``cooldown_days``, so a surfaced-but-ignored item can come back later
while an executed or dismissed one does not repeat tomorrow.
"""
if record is None:
return False
if str(record.status or "") == "failed":
return False
base = safe_date(day)
delivered = safe_date(_delivery_date(record.delivered_at))
if base is None or delivered is None:
return False
window = max(int(cooldown_days), 0)
return (base - delivered).days <= window
def suppressed_delivery_ids(
state_file: ProactiveStateFile,
day: str,
cooldown_days: int,
) -> dict[str, ProactiveDelivery]:
"""topic_id -> blocking record, for the reader and the agenda step."""
out: dict[str, ProactiveDelivery] = {}
for item_id, records in delivery_index(state_file).items():
for record in records:
if is_delivery_suppressed(record, day, cooldown_days):
out[item_id] = record
break
return out
def upsert_deliveries(
state_file: ProactiveStateFile,
items: list,
day: str,
now_dt: dt.datetime,
) -> dict[str, int]:
"""Merge host-reported deliveries into the truth source (idempotent).
Identity is ``ProactiveDelivery.identity()``: an explicit ``external_ref``
wins, otherwise ``(topic_id, agenda_date, channel)``. Status never moves
backwards out of a terminal value, so a late ``shown`` report cannot undo
an ``executed`` one.
"""
counts = {"added": 0, "updated": 0, "dropped": 0}
by_identity = {record.identity(): record for record in state_file.deliveries}
for raw in items:
record = normalize_delivery(raw, day, now_dt)
if record is None:
counts["dropped"] += 1
continue
key = record.identity()
existing = by_identity.get(key)
if existing is None:
state_file.deliveries.append(record)
by_identity[key] = record
counts["added"] += 1
continue
if existing.status in TERMINAL_DELIVERY_STATUSES and record.status not in TERMINAL_DELIVERY_STATUSES:
logger.info(f"delivery for {existing.topic_id} kept terminal status {existing.status!r}")
counts["dropped"] += 1
continue
existing.agenda_date = record.agenda_date or existing.agenda_date
existing.delivered_at = record.delivered_at
existing.channel = record.channel
existing.consumer = record.consumer or existing.consumer
existing.status = record.status
if record.outcome:
existing.outcome = record.outcome
counts["updated"] += 1
return counts
def trim_deliveries(state_file: ProactiveStateFile, day: str, retention_days: int, max_deliveries: int) -> int:
"""Prune the delivery log by age then by size; returns the dropped count."""
base = safe_date(day)
before = len(state_file.deliveries)
if base is not None:
cutoff = (base - dt.timedelta(days=max(int(retention_days), 0))).isoformat()
state_file.deliveries = [r for r in state_file.deliveries if _delivery_date(r.delivered_at) >= cutoff]
cap = max(int(max_deliveries), 0)
if len(state_file.deliveries) > cap:
state_file.deliveries.sort(key=lambda r: str(r.delivered_at or ""), reverse=True)
del state_file.deliveries[cap:]
return before - len(state_file.deliveries)
def quarantine_interests(path: Path, error: Exception) -> None:
@ -363,23 +637,28 @@ def parse_interests_topics(data: dict, file_date: str) -> tuple[list[ProactiveTo
id=str(raw.get("id") or "").strip() or topic_id(title),
title=title,
reason=reason,
kind=raw.get("kind", "interest_extend"),
kind=raw.get("kind", DEFAULT_KIND),
confidence=raw.get("confidence", 0.5),
first_seen=str(raw.get("first_seen") or "").strip() or file_date,
last_evidence_at=str(raw.get("last_evidence_at") or "").strip() or file_date,
evidence=str(raw.get("evidence") or "").strip()[:120],
paths=raw.get("paths") or [],
schedule_id=str(raw.get("schedule_id") or "").strip(),
due_at=str(raw.get("due_at") or "").strip(),
),
)
return topics, is_v1, push
def sort_topics(topics: list) -> list:
"""Order: last_evidence_at desc -> follow_up first -> confidence desc -> id asc.
"""Order: last_evidence_at desc -> kind priority -> confidence desc -> id asc.
Freshness is the primary key (v5 aging fix): stale topics sink below newly
evidenced ones of any kind, so long-lived follow_ups cannot permanently
crowd out new discoveries.
Kind priority is ``KIND_PRIORITY`` (reminder > follow_up > new_idea >
interest_extend), so a reminder always outranks discovery kinds inside the
same freshness band and survives the ``max_topics`` render cap.
"""
def get(topic, key):
@ -387,7 +666,7 @@ def sort_topics(topics: list) -> list:
out = sorted(topics, key=lambda t: str(get(t, "id") or ""))
out.sort(key=lambda t: clamp_confidence(get(t, "confidence")), reverse=True)
out.sort(key=lambda t: 0 if get(t, "kind") == "follow_up" else 1)
out.sort(key=lambda t: kind_priority(get(t, "kind")))
out.sort(key=lambda t: str(get(t, "last_evidence_at") or ""), reverse=True)
return out
@ -395,17 +674,24 @@ def sort_topics(topics: list) -> list:
def dump_topic(topic) -> dict:
"""Render one topic as an A2-ordered dict for interests.yaml v2."""
get = (lambda k: getattr(topic, k)) if not isinstance(topic, dict) else topic.get
return {
dumped = {
"id": str(get("id") or ""),
"title": str(get("title") or ""),
"reason": str(get("reason") or ""),
"kind": str(get("kind") or "interest_extend"),
"kind": str(get("kind") or DEFAULT_KIND),
"confidence": clamp_confidence(get("confidence")),
"first_seen": str(get("first_seen") or ""),
"last_evidence_at": str(get("last_evidence_at") or ""),
"evidence": str(get("evidence") or "")[:120],
"paths": [str(p) for p in (get("paths") or [])],
}
# Reminder links are emitted only when present so the v2 topic shape of
# every other kind stays byte-identical.
if schedule_id := str(get("schedule_id") or ""):
dumped["schedule_id"] = schedule_id
if due_at := str(get("due_at") or ""):
dumped["due_at"] = due_at
return dumped
def render_interests(
@ -474,8 +760,6 @@ def write_interests_if_changed(ws: Path, path: Path, rendered: dict) -> bool: #
# ---------------------------------------------------------------------------
# Candidate cleaning (extract side, A3)
# ---------------------------------------------------------------------------
def clean_candidate(raw, allowed_paths: set[str], kind: str, day: str) -> dict:
@ -491,7 +775,7 @@ def clean_candidate(raw, allowed_paths: set[str], kind: str, day: str) -> dict:
"id": topic_id(title),
"title": title,
"reason": reason,
"kind": kind,
"kind": kind if kind in TOPIC_KINDS else DEFAULT_KIND,
"confidence": clamp_confidence(raw.get("confidence")),
"first_seen": day,
"last_evidence_at": day,
@ -631,6 +915,57 @@ def load_personal_profile_block(
return "(no user profile)"
CAPABILITY_SECTIONS = (
("procedure", "procedure (reusable experience, skills, tool/API know-how the agent already has)"),
("wiki", "wiki (existing knowledge nodes of the personal knowledge base)"),
)
def load_capability_block(ws: Path, digest_dir: str, max_chars: int) -> str:
"""Inventory of what the agent can already do, used by the ``new_ideas`` branch.
Reads only rebuildable digest nodes: ``<digest_dir>/procedure/*.md``
(accumulated experience, skills, tool/API know-how) and
``<digest_dir>/wiki/*.md`` (knowledge nodes an interest can extend).
Each node contributes its frontmatter ``description`` so proposals stay
grounded in resources that actually exist instead of invented capabilities.
"""
max_chars = int(max_chars)
if max_chars <= 0:
return "(no capability inventory)"
per_section = max(max_chars // max(len(CAPABILITY_SECTIONS), 1), 200)
sections: list[str] = []
for subdirectory, heading in CAPABILITY_SECTIONS:
lines = _digest_inventory_lines(ws / digest_dir / subdirectory, per_section)
if lines:
sections.append(f"### {heading}\n" + "\n".join(lines))
if not sections:
return "(no capability inventory)"
return "\n\n".join(sections)[:max_chars]
def _digest_inventory_lines(directory: Path, budget: int) -> list[str]:
"""``- <title>: <description>`` lines for one digest subdirectory, budget-bounded."""
if not directory.is_dir():
return []
lines: list[str] = []
used = 0
for path in sorted(p for p in directory.glob("*.md") if p.is_file()):
try:
raw = path.read_text(encoding="utf-8")
except OSError:
continue
meta, _body = strip_frontmatter(raw)
title = str(meta.get("name") or path.stem)
description = " ".join(str(meta.get("description") or "").split())
line = f"- {title}" + (f": {description[:160]}" if description else "")
if used + len(line) > budget:
break
lines.append(line)
used += len(line) + 1
return lines
def parse_fenced_yaml(text: str) -> dict:
"""Parse the first fenced YAML/JSON block (or the bare text) into a dict.

View file

@ -0,0 +1,714 @@
"""Unit tests for proactive delivery feedback (host -> ReMe reporting).
Covers the ``deliveries`` truth-source section, the ``proactive_feedback``
step, the reader-side withholding in ``proactive_step`` and the write-side
suppression in ``proactive_agenda_step``. Feedback is optional by contract, so
every test that asserts new behaviour is paired with one asserting that an
empty ``deliveries`` section changes nothing.
"""
import asyncio
import datetime as dt
from unittest.mock import patch
import yaml
from reme.components.application_context import ApplicationContext
from reme.components.runtime_context import RuntimeContext
from reme.schema import ProactiveDelivery, ProactiveStateFile
from reme.schema.proactive import MAX_DELIVERY_OUTCOME_CHARS
from reme.steps.evolve.proactive.feedback import ProactiveFeedbackStep
from reme.steps.evolve.proactive.proactive import ProactiveStep
from reme.steps.evolve.proactive.utils import (
is_delivery_suppressed,
load_state,
suppressed_delivery_ids,
topic_id,
trim_deliveries,
upsert_deliveries,
)
from .test_proactive_refresh import (
DAY,
EXTRACT_CLOCK,
_FileStore,
_interests,
_reply,
_reminder,
_run_full_chain,
_state_topic,
_topic,
_touch,
_write_state,
)
NOW = dt.datetime(2026, 8, 13, 18, 0, tzinfo=dt.timezone(dt.timedelta(hours=8)))
def _write_state_with_deliveries(ws, deliveries, open_topics=None):
"""Seed the truth source with open topics plus a delivery log."""
data = {
"version": 1,
"open_topics": open_topics or [],
"resolved": [],
"schedules": [],
"deliveries": deliveries,
}
_touch(ws / "daily" / "_proactive.yaml", yaml.safe_dump(data, allow_unicode=True, sort_keys=False))
def _write_interests_with_agenda(ws, topics, agenda, day=DAY, push=True):
"""Write a v2 exposure file carrying an agenda, as the agenda step would."""
payload = {
"version": 2,
"date": day,
"generated_at": f"{day}T18:00:00+08:00",
"push": push,
"topics": topics,
"agenda": agenda,
"suppressed": [],
}
_touch(_interests(ws, day), yaml.safe_dump(payload, allow_unicode=True, sort_keys=False))
def _agenda_item(topic_id_value, title, kind="follow_up", **extra):
item = {
"topic_id": topic_id_value,
"title": title,
"kind": kind,
"scenario_type": "resume_task",
"opener": f"对了,{title}还差一步。",
"next_action": "跑一下下一步",
"preconditions": [],
"delivery": "in_conversation",
"linked_memory": [],
"order_reason": "seeded",
}
item.update(extra)
return item
# ---------------------------------------------------------------------------
# Schema and pure helpers
# ---------------------------------------------------------------------------
def test_delivery_model_fallbacks():
"""Illegal status/channel fall back; outcome is capped; identity is stable."""
record = ProactiveDelivery(topic_id="t", status="BOGUS", channel="Inbox", outcome="x" * 900, external_ref="e1")
assert record.status == "shown"
assert record.channel == "inbox"
assert len(record.outcome) == MAX_DELIVERY_OUTCOME_CHARS
assert record.identity() == ("t", "ref:e1")
bare = ProactiveDelivery(topic_id="t", agenda_date=DAY, channel="notification")
assert bare.identity() == ("t", f"day:{DAY}|notification")
assert ProactiveDelivery(topic_id="t", channel="weird").channel == "other"
def test_upsert_deliveries_is_idempotent():
"""Re-reporting the same delivery updates in place instead of appending."""
state_file = ProactiveStateFile()
items = [
{"topic_id": "a", "status": "shown", "channel": "inbox", "external_ref": "ev1"},
{"topic_id": "b", "status": "executed", "channel": "in_conversation", "outcome": "done"},
{"topic_id": "", "status": "shown"},
"garbage",
]
assert upsert_deliveries(state_file, items, DAY, NOW) == {"added": 2, "updated": 0, "dropped": 2}
assert upsert_deliveries(state_file, items, DAY, NOW) == {"added": 0, "updated": 2, "dropped": 2}
assert len(state_file.deliveries) == 2
def test_upsert_deliveries_never_downgrades_terminal_status():
"""A late ``shown`` report cannot undo an ``executed`` one."""
state_file = ProactiveStateFile()
upsert_deliveries(state_file, [{"topic_id": "a", "status": "executed", "channel": "inbox"}], DAY, NOW)
counts = upsert_deliveries(state_file, [{"topic_id": "a", "status": "shown", "channel": "inbox"}], DAY, NOW)
assert counts == {"added": 0, "updated": 0, "dropped": 1}
assert state_file.deliveries[0].status == "executed"
def test_upsert_deliveries_defaults_missing_clock_fields():
"""A minimal report still gets an agenda_date and a delivered_at."""
state_file = ProactiveStateFile()
upsert_deliveries(state_file, [{"topic_id": "a"}], DAY, NOW)
record = state_file.deliveries[0]
assert record.agenda_date == DAY
assert record.delivered_at.startswith(DAY)
assert record.status == "shown" and record.channel == "other"
def test_is_delivery_suppressed_window():
"""``failed`` never blocks; other statuses block inside the cooldown only."""
def record(status, delivered_at):
return ProactiveDelivery(topic_id="a", status=status, delivered_at=delivered_at)
assert is_delivery_suppressed(None, DAY, 3) is False
assert is_delivery_suppressed(record("failed", f"{DAY}T18:00:00+08:00"), DAY, 3) is False
assert is_delivery_suppressed(record("shown", f"{DAY}T18:00:00+08:00"), DAY, 3) is True
assert is_delivery_suppressed(record("executed", "2026-08-10T18:00:00+08:00"), DAY, 3) is True
assert is_delivery_suppressed(record("shown", "2026-08-09T18:00:00+08:00"), DAY, 3) is False
assert is_delivery_suppressed(record("shown", "2026-08-09T18:00:00+08:00"), DAY, 0) is False
assert is_delivery_suppressed(record("shown", "not-a-date"), DAY, 3) is False
def test_trim_deliveries_by_age_then_size():
"""Old records go first, then the newest ``max_deliveries`` survive."""
state_file = ProactiveStateFile()
for index, day in enumerate(["2026-01-01", "2026-08-11", "2026-08-12", "2026-08-13"]):
state_file.deliveries.append(
ProactiveDelivery(topic_id=f"t{index}", delivered_at=f"{day}T18:00:00+08:00", external_ref=f"e{index}"),
)
assert trim_deliveries(state_file, DAY, 180, 2) == 2
assert [r.topic_id for r in state_file.deliveries] == ["t3", "t2"]
# retention 1 day keeps 08-12 and 08-13; retention 0 keeps only today.
assert trim_deliveries(state_file, DAY, 1, 10) == 0
assert trim_deliveries(state_file, DAY, 0, 10) == 1
assert [r.topic_id for r in state_file.deliveries] == ["t3"]
def test_suppressed_delivery_ids_prefers_the_blocking_record():
"""The newest blocking record wins; a failed run does not mask it."""
state_file = ProactiveStateFile()
upsert_deliveries(
state_file,
[
{"topic_id": "a", "status": "failed", "channel": "inbox", "external_ref": "e1"},
{"topic_id": "a", "status": "shown", "channel": "notification", "external_ref": "e2"},
{"topic_id": "b", "status": "failed", "channel": "inbox", "external_ref": "e3"},
],
DAY,
NOW,
)
blocked = suppressed_delivery_ids(state_file, DAY, 3)
assert set(blocked) == {"a"}
assert blocked["a"].status == "shown"
# ---------------------------------------------------------------------------
# proactive_feedback_step
# ---------------------------------------------------------------------------
def test_feedback_step_records_and_persists(tmp_path):
"""One report lands in the truth source and is summarized in the response."""
async def run():
ws = tmp_path
_write_state(ws, open_topics=[_state_topic("Topic A")])
response = await _async_feedback(
ws,
[
{
"topic_id": "a1",
"status": "executed",
"channel": "in_conversation",
"outcome": "已完成",
"external_ref": "s1",
},
{"topic_id": "a2", "status": "shown", "channel": "inbox", "external_ref": "ev1"},
],
)
assert response.success is True
assert "added 2" in response.answer
payload = response.metadata["proactive_feedback"]
assert payload["added"] == 2 and payload["updated"] == 0 and payload["total"] == 2
assert payload["suppressed_topics"] == ["a1", "a2"]
state_file, _ = load_state(ws)
assert {r.topic_id: r.status for r in state_file.deliveries} == {"a1": "executed", "a2": "shown"}
# The step must not disturb the other truth-source sections.
assert [t.title for t in state_file.open_topics] == ["Topic A"]
assert state_file.resolved == [] and state_file.schedules == []
asyncio.run(run())
async def _async_feedback(ws, items, day=DAY, **kwargs):
step = ProactiveFeedbackStep(app_context=ApplicationContext(workspace_dir=str(ws)), **kwargs)
return await step(RuntimeContext(date=day, items=items, file_store=_FileStore(ws)))
def test_feedback_step_creates_missing_truth_source(tmp_path):
"""Reporting into a fresh workspace bootstraps an otherwise empty state file."""
async def run():
response = await _async_feedback(tmp_path, [{"topic_id": "a1", "status": "shown"}])
assert response.success is True
state_file, _ = load_state(tmp_path)
assert [r.topic_id for r in state_file.deliveries] == ["a1"]
assert state_file.open_topics == []
asyncio.run(run())
def test_feedback_step_without_items_is_a_noop(tmp_path):
"""An empty report succeeds, writes nothing and says so."""
async def run():
_write_state(tmp_path, open_topics=[_state_topic("Topic A")])
before = (tmp_path / "daily" / "_proactive.yaml").read_text(encoding="utf-8")
response = await _async_feedback(tmp_path, [])
assert response.success is True
assert response.answer == "Feedback: no delivery items reported"
assert response.metadata["proactive_feedback"]["received"] == 0
assert (tmp_path / "daily" / "_proactive.yaml").read_text(encoding="utf-8") == before
asyncio.run(run())
def test_feedback_step_is_idempotent_across_runs(tmp_path):
"""Retrying the same report does not duplicate records."""
async def run():
_write_state(tmp_path)
items = [{"topic_id": "a1", "status": "shown", "channel": "inbox", "external_ref": "ev1"}]
await _async_feedback(tmp_path, items)
response = await _async_feedback(tmp_path, items)
assert "updated 1" in response.answer
state_file, _ = load_state(tmp_path)
assert len(state_file.deliveries) == 1
asyncio.run(run())
def test_feedback_step_trims_by_retention(tmp_path):
"""Records older than the retention window are pruned on write."""
async def run():
ws = tmp_path
_write_state_with_deliveries(
ws,
[{"topic_id": "old", "delivered_at": "2026-01-01T18:00:00+08:00", "status": "shown", "external_ref": "e"}],
)
response = await _async_feedback(ws, [{"topic_id": "new", "status": "shown"}], retention_days=30)
assert response.metadata["proactive_feedback"]["trimmed"] == 1
state_file, _ = load_state(ws)
assert [r.topic_id for r in state_file.deliveries] == ["new"]
asyncio.run(run())
# ---------------------------------------------------------------------------
# proactive_step (reader)
# ---------------------------------------------------------------------------
def _seed_reader(ws, fid, eid):
"""Two open topics, both on today's agenda; ``eid`` already delivered."""
follow = _state_topic("记忆检索回归", topic_id_value=fid)
extend = _state_topic("多跳 RAG 综述", topic_id_value=eid, kind="interest_extend")
_write_state_with_deliveries(
ws,
[
{
"topic_id": eid,
"agenda_date": DAY,
"delivered_at": f"{DAY}T09:00:00+08:00",
"channel": "inbox",
"status": "shown",
"external_ref": "ev1",
},
],
open_topics=[follow, extend],
)
_write_interests_with_agenda(
ws,
[follow, extend],
[_agenda_item(fid, "记忆检索回归"), _agenda_item(eid, "多跳 RAG 综述", kind="interest_extend")],
)
def test_reader_withholds_delivered_agenda_items(tmp_path):
"""A delivered agenda item disappears from the read but stays auditable."""
async def run():
ws = tmp_path
fid, eid = topic_id("记忆检索回归"), topic_id("多跳 RAG 综述")
_seed_reader(ws, fid, eid)
step = ProactiveStep(app_context=ApplicationContext(workspace_dir=str(ws)))
response = await step(RuntimeContext(date=DAY, include_content=False, file_store=_FileStore(ws)))
assert response.success is True
assert [i["topic_id"] for i in response.answer["agenda"]] == [fid]
assert response.answer["delivered_ids"] == [eid]
assert "withheld 1" in response.answer["summary"]
# Topics are material, not delivery units: they stay unfiltered.
assert {t["id"] for t in response.answer["topics"]} == {fid, eid}
assert response.metadata["delivered_ids"] == [eid]
asyncio.run(run())
def test_reader_include_delivered_returns_everything(tmp_path):
"""``include_delivered`` is the escape hatch for hosts doing their own dedup."""
async def run():
ws = tmp_path
fid, eid = topic_id("记忆检索回归"), topic_id("多跳 RAG 综述")
_seed_reader(ws, fid, eid)
step = ProactiveStep(app_context=ApplicationContext(workspace_dir=str(ws)))
response = await step(
RuntimeContext(date=DAY, include_content=False, include_delivered=True, file_store=_FileStore(ws)),
)
assert [i["topic_id"] for i in response.answer["agenda"]] == [fid, eid]
assert "delivered_ids" not in response.answer
asyncio.run(run())
def test_reader_cooldown_expiry_lets_an_item_back(tmp_path):
"""A merely-shown item returns once the cooldown has passed."""
async def run():
ws = tmp_path
fid, eid = topic_id("记忆检索回归"), topic_id("多跳 RAG 综述")
follow = _state_topic("记忆检索回归", topic_id_value=fid)
extend = _state_topic("多跳 RAG 综述", topic_id_value=eid, kind="interest_extend")
# The single-day exposure is read at DAY, so the delivery is backdated
# instead: shown a week ago, default cooldown is three days.
_write_state_with_deliveries(
ws,
[
{
"topic_id": eid,
"agenda_date": "2026-08-06",
"delivered_at": "2026-08-06T09:00:00+08:00",
"channel": "inbox",
"status": "shown",
},
],
open_topics=[follow, extend],
)
_write_interests_with_agenda(
ws,
[follow, extend],
[_agenda_item(fid, "记忆检索回归"), _agenda_item(eid, "多跳 RAG 综述", kind="interest_extend")],
)
step = ProactiveStep(app_context=ApplicationContext(workspace_dir=str(ws)))
response = await step(RuntimeContext(date=DAY, include_content=False, file_store=_FileStore(ws)))
assert [i["topic_id"] for i in response.answer["agenda"]] == [fid, eid]
assert "delivered_ids" not in response.answer
asyncio.run(run())
def test_reader_without_feedback_is_unchanged(tmp_path):
"""No ``deliveries`` section means no withholding (backwards compatible)."""
async def run():
ws = tmp_path
fid, eid = topic_id("记忆检索回归"), topic_id("多跳 RAG 综述")
follow = _state_topic("记忆检索回归", topic_id_value=fid)
extend = _state_topic("多跳 RAG 综述", topic_id_value=eid, kind="interest_extend")
_write_state(ws, open_topics=[follow, extend])
_write_interests_with_agenda(
ws,
[follow, extend],
[_agenda_item(fid, "记忆检索回归"), _agenda_item(eid, "多跳 RAG 综述", kind="interest_extend")],
)
step = ProactiveStep(app_context=ApplicationContext(workspace_dir=str(ws)))
response = await step(RuntimeContext(date=DAY, include_content=False, file_store=_FileStore(ws)))
assert [i["topic_id"] for i in response.answer["agenda"]] == [fid, eid]
assert "delivered_ids" not in response.answer
assert "withheld" not in response.answer["summary"]
asyncio.run(run())
def test_reader_reminder_still_outranks_and_survives(tmp_path):
"""Withholding a discovery item never reorders or drops a pinned reminder."""
async def run():
ws = tmp_path
rid, eid = topic_id("牙医预约"), topic_id("多跳 RAG 综述")
reminder = _state_topic("牙医预约", topic_id_value=rid, kind="reminder", confidence=0.9)
extend = _state_topic("多跳 RAG 综述", topic_id_value=eid, kind="interest_extend")
_write_state_with_deliveries(
ws,
[
{
"topic_id": eid,
"agenda_date": DAY,
"delivered_at": f"{DAY}T09:00:00+08:00",
"channel": "inbox",
"status": "shown",
},
],
open_topics=[reminder, extend],
)
_write_interests_with_agenda(
ws,
[reminder, extend],
[
_agenda_item(rid, "牙医预约", kind="reminder", scenario_type="prepare_upcoming"),
_agenda_item(eid, "多跳 RAG 综述", kind="interest_extend"),
],
)
step = ProactiveStep(app_context=ApplicationContext(workspace_dir=str(ws)))
response = await step(RuntimeContext(date=DAY, include_content=False, file_store=_FileStore(ws)))
assert [i["topic_id"] for i in response.answer["agenda"]] == [rid]
assert response.answer["agenda"][0]["kind"] == "reminder"
asyncio.run(run())
def test_reader_reminder_is_exempt_from_the_cooldown(tmp_path):
"""A reminder delivered inside the window is served again: due means due.
Reminder ids derive from a stable schedule title, so withholding them like
discovery items would silence a recurring reminder for the whole cooldown
after a single delivery.
"""
async def run():
ws = tmp_path
rid = topic_id("牙医预约")
reminder = _state_topic("牙医预约", topic_id_value=rid, kind="reminder", confidence=0.9)
_write_state_with_deliveries(
ws,
[
{
"topic_id": rid,
"agenda_date": DAY,
"delivered_at": f"{DAY}T09:00:00+08:00",
"channel": "inbox",
"status": "shown",
},
],
open_topics=[reminder],
)
_write_interests_with_agenda(
ws,
[reminder],
[_agenda_item(rid, "牙医预约", kind="reminder", scenario_type="prepare_upcoming")],
)
step = ProactiveStep(app_context=ApplicationContext(workspace_dir=str(ws)))
response = await step(RuntimeContext(date=DAY, include_content=False, file_store=_FileStore(ws)))
assert [i["topic_id"] for i in response.answer["agenda"]] == [rid]
assert response.answer["agenda"][0]["kind"] == "reminder"
assert "delivered_ids" not in response.answer
assert "withheld" not in response.answer["summary"]
asyncio.run(run())
# ---------------------------------------------------------------------------
# proactive_agenda_step (writer)
# ---------------------------------------------------------------------------
def test_agenda_step_suppresses_delivered_candidate(tmp_path):
"""A reported candidate leaves the agenda with an auditable reason."""
async def run():
ws = tmp_path
_touch(ws / "daily" / DAY / "session.md", "we discussed the eval plan")
_write_state_with_deliveries(
ws,
[
{
"topic_id": topic_id("任务一"),
"agenda_date": DAY,
"delivered_at": f"{DAY}T09:00:00+08:00",
"channel": "in_conversation",
"status": "executed",
"external_ref": "s1",
},
],
)
extract_reply = _reply(
follow_ups=[
_topic("任务一", confidence=0.9, paths=[f"daily/{DAY}/session.md"]),
_topic("任务二", confidence=0.8, paths=[f"daily/{DAY}/session.md"]),
],
)
_, _, _, context, resps = await _run_full_chain(ws, [extract_reply])
assert all(r.success for r in resps)
state = context.get("proactive")
assert state["suppressed_delivered"] == 1
data = yaml.safe_load(_interests(ws).read_text(encoding="utf-8"))
assert [i["title"] for i in data["agenda"]] == ["任务二"]
suppressed = {s["topic_id"]: s["reason"] for s in data["suppressed"]}
reason = suppressed[topic_id("任务一")]
assert reason.startswith("already delivered on")
assert "in_conversation" in reason and "executed" in reason
asyncio.run(run())
def test_agenda_step_reminder_is_exempt_from_the_cooldown(tmp_path):
"""A reported reminder is pinned again while a reported discovery item is not."""
async def run():
ws = tmp_path
_touch(ws / "daily" / DAY / "session.md", "牙医复诊在即,评审材料还差一版")
path = f"daily/{DAY}/session.md"
rid = topic_id("牙医复诊")
_write_state_with_deliveries(
ws,
[
{
"topic_id": rid,
"agenda_date": DAY,
"delivered_at": f"{DAY}T09:00:00+08:00",
"channel": "inbox",
"status": "shown",
},
{
"topic_id": topic_id("任务一"),
"agenda_date": DAY,
"delivered_at": f"{DAY}T09:00:00+08:00",
"channel": "inbox",
"status": "shown",
},
],
)
extract_reply = _reply(
reminders=[_reminder("牙医复诊", due_at="2026-08-14T15:00", paths=[path])],
follow_ups=[
_topic("任务一", confidence=0.9, paths=[path]),
_topic("任务二", confidence=0.8, paths=[path]),
],
)
with patch(EXTRACT_CLOCK, return_value=NOW):
_, _, _, context, resps = await _run_full_chain(ws, [extract_reply])
assert all(r.success for r in resps)
# only the discovery candidate counts as suppressed by the cooldown
assert context.get("proactive")["suppressed_delivered"] == 1
data = yaml.safe_load(_interests(ws).read_text(encoding="utf-8"))
titles = [i["title"] for i in data["agenda"]]
assert "牙医复诊" in titles
assert "任务一" not in titles
assert data["agenda"][0]["kind"] == "reminder" # still pinned first
suppressed = {s["topic_id"] for s in data["suppressed"]}
assert rid not in suppressed
asyncio.run(run())
def test_agenda_step_without_feedback_is_unchanged(tmp_path):
"""No delivery log means the deterministic fallback agenda stays intact."""
async def run():
ws = tmp_path
_touch(ws / "daily" / DAY / "session.md", "material")
extract_reply = _reply(
follow_ups=[
_topic("任务一", confidence=0.9, paths=[f"daily/{DAY}/session.md"]),
_topic("任务二", confidence=0.7, paths=[f"daily/{DAY}/session.md"]),
],
)
_, _, _, context, resps = await _run_full_chain(ws, [extract_reply])
assert all(r.success for r in resps)
assert context.get("proactive")["suppressed_delivered"] == 0
data = yaml.safe_load(_interests(ws).read_text(encoding="utf-8"))
assert [i["title"] for i in data["agenda"]] == ["任务一", "任务二"]
assert data["suppressed"] == []
asyncio.run(run())
def test_agenda_step_cooldown_zero_only_blocks_same_day(tmp_path):
"""``redelivery_cooldown_days=0`` still blocks a delivery reported today."""
async def run():
ws = tmp_path
_touch(ws / "daily" / DAY / "session.md", "material")
_write_state_with_deliveries(
ws,
[
{
"topic_id": topic_id("任务一"),
"agenda_date": DAY,
"delivered_at": f"{DAY}T09:00:00+08:00",
"channel": "inbox",
"status": "shown",
},
],
)
extract_reply = _reply(follow_ups=[_topic("任务一", confidence=0.9, paths=[f"daily/{DAY}/session.md"])])
_, _, _, context, resps = await _run_full_chain(
ws,
[extract_reply],
agenda_kwargs={"redelivery_cooldown_days": 0},
)
assert all(r.success for r in resps)
assert context.get("proactive")["suppressed_delivered"] == 1
data = yaml.safe_load(_interests(ws).read_text(encoding="utf-8"))
assert data["agenda"] == []
assert data["push"] is False
asyncio.run(run())
# ---------------------------------------------------------------------------
# Host interop: the report shape a consumer actually sends
# ---------------------------------------------------------------------------
def test_consumer_report_shape_round_trips(tmp_path):
"""A host report in the wire shape the consumer sends is stored verbatim and withholds.
Locks the interop contract on three points that are easy to break silently:
every field of the report survives (an unknown ``consumer`` label must not be
dropped, a known ``channel`` must not be coerced to ``other``), a UTC
``delivered_at`` still suppresses on a later local business day, and the
reader publishes ``delivered_ids`` in metadata for the host to count.
"""
async def run():
ws = tmp_path
fid, eid = topic_id("记忆检索回归"), topic_id("多跳 RAG 综述")
follow = _state_topic("记忆检索回归", topic_id_value=fid)
extend = _state_topic("多跳 RAG 综述", topic_id_value=eid, kind="interest_extend")
_write_state(ws, open_topics=[follow, extend])
_write_interests_with_agenda(
ws,
[follow, extend],
[_agenda_item(fid, "记忆检索回归"), _agenda_item(eid, "多跳 RAG 综述", kind="interest_extend")],
)
# 23:30Z on the previous UTC day is 07:30 on DAY in +08:00: the consumer
# stamps UTC, the business clock is local, so the date prefix lags.
report = {
"topic_id": eid,
"agenda_date": DAY,
"delivered_at": "2026-08-12T23:30:00.123456+00:00",
"channel": "in_conversation",
"consumer": "qwenpaw",
"status": "shown",
"outcome": "",
"external_ref": "session-7f3a",
}
response = await _async_feedback(ws, [report])
assert response.success is True and "added 1" in response.answer
state_file, _ = load_state(ws)
assert len(state_file.deliveries) == 1
stored = state_file.deliveries[0]
assert stored.consumer == "qwenpaw" # a free-form host label is kept as sent
assert stored.channel == "in_conversation" # in the vocabulary, so not coerced
assert stored.external_ref == "session-7f3a"
assert stored.agenda_date == DAY
assert stored.delivered_at.startswith("2026-08-12T23:30:00")
assert is_delivery_suppressed(stored, DAY, 3) is True
# Re-reporting the same delivery updates in place instead of piling up.
again = await _async_feedback(ws, [dict(report, status="executed")])
assert again.success is True
state_file, _ = load_state(ws)
assert len(state_file.deliveries) == 1
assert state_file.deliveries[0].status == "executed"
reader = ProactiveStep(app_context=ApplicationContext(workspace_dir=str(ws)))
read = await reader(RuntimeContext(date=DAY, include_content=False, file_store=_FileStore(ws)))
assert read.success is True
assert [item["topic_id"] for item in read.answer["agenda"]] == [fid]
assert read.metadata["delivered_ids"] == [eid]
asyncio.run(run())

File diff suppressed because it is too large Load diff