From 4fd4b04e820a84c14103f5832b74936d330ff699 Mon Sep 17 00:00:00 2001 From: Frey <370805886@qq.com> Date: Tue, 22 Sep 2026 14:10:30 +0800 Subject: [PATCH 01/48] docs(rfc): define multi-artifact Dream expansion --- docs/en/rfcs/0000_multi_artifact_dreaming.md | 133 ++++++ docs/zh/rfcs/0000_multi_artifact_dreaming.md | 405 +++++++++++++++++++ 2 files changed, 538 insertions(+) create mode 100644 docs/en/rfcs/0000_multi_artifact_dreaming.md create mode 100644 docs/zh/rfcs/0000_multi_artifact_dreaming.md diff --git a/docs/en/rfcs/0000_multi_artifact_dreaming.md b/docs/en/rfcs/0000_multi_artifact_dreaming.md new file mode 100644 index 0000000000..b787c7e715 --- /dev/null +++ b/docs/en/rfcs/0000_multi_artifact_dreaming.md @@ -0,0 +1,133 @@ +- Proposal Name: `multi_artifact_dreaming` +- Start Date: 2026-09-22 +- Related RFCs: [Artifact Dreaming](1510-artifact-dreaming.md), [Candidate and Review Inbox](0050_artifact_candidate_review_inbox.md), [Profile](1485_profile_artifact.md), [Topic Memory](1417_topic_memory.md), [Prompt management](1468_scope_owned_prompt_management.md), [Tags](1467_artifact_tags.md), and [Processing Supervisor](1515_artifact_processing_supervisor.md) + +# Summary + +Extend explicit Artifact Dreaming beyond `refine_experience` and `derive_skill` with bounded revisions of Memory entries, Profile, Topic Memory, Handoff, managed Skill, and Prompt, plus a separate proposal for Tag changes. Every new Dream operation produces a reviewable candidate or an explicit `no_change`/`needs_evidence` result. A Dream candidate cannot change the current Artifact or Tag set before an authorized reviewer approves its exact version against the current target. Existing automatic Source-window generation retains its current activation and review policy; adding Dream to a Family does not turn automatic generation into a review flow or let an automatic policy bypass Dream review. + +Each run addresses one selected target using exact, same-Scope evidence. The existing Dream run, Candidate, Review, Family writer, and Processing Supervisor machinery remains the foundation. A0/A1 introduce no new business tables; later evaluation and Tag storage is justified and introduced only when those stages ship. Existing `/v1` interfaces are extended in place, with coordinated Client upgrades for new closed enum and proposal types. No periodic discovery or unattended publication is required. + +# Motivation + +Existing revisions become inaccurate as later observations accumulate: a Memory entry can describe an outdated fact, Profile may mistake a short trip for a permanent move, a Topic may retain a disproved hypothesis, Handoff may omit a completed task, and Skill or Prompt may repeat a known failure. An Experience proposal cannot directly repair every affected Family. Today each such correction requires the application to gather evidence, manage retries and concurrency, choose the correct writer, and retain an auditable decision. + +The Families do not share one write model. Memory text lives in Entry Versions and an owning manifest; Profile generation may hold a Source-window cursor and a policy pending pointer; Topic Memory publishes the current revision and complete retrieval projection atomically; Handoff has separate commit, activation, and receiver checks; Prompt is live generation configuration; Tag is mutable catalog metadata rather than an Artifact. A shared run envelope must preserve those distinct commit boundaries. More candidates or longer summaries are not evidence of better task outcomes. + +# Guide-level explanation + +## User model and improvement evidence + +A user or authorized integration selects the current exact target, relevant sources or approved revisions, and one operation. The worker resolves the evidence, asks the model for a complete proposed change, checks the typed output and citations, and returns either `proposed`, `no_change`, or `needs_evidence`. The model must identify what is wrong or missing in the target, the exact evidence supporting the change, the proposed replacement, and any counterevidence or uncertainty. There is no universal improvement score. A successful run is not approval or proof of later task improvement. + +`proposed` creates a pending Candidate. Review rechecks permission, exact candidate version, evidence availability, policy, and target baseline, then invokes the Family's transactional writer. Target changes cause a conflict rather than an automatic rebase. Rejection never changes the formal target. Review approval of Skill does not install or execute it; approval of Handoff does not activate it or acknowledge it for the receiver. + +For example, a user's Profile says “permanently based in Shanghai.” “In Shenzhen this week, returning on the 15th” is insufficient evidence of a permanent move. A later statement, “I have moved to Shenzhen and will be based here,” can support a bounded Memory revision. The Profile needs a separate run after the Memory result becomes formal; neither operation silently updates the other. The original Source and historical Entry Version remain inspectable. + +| Operation | Exact target and output | Review effect | +| --- | --- | --- | +| `refine_experience` | New or replacement Experience Candidate | Existing Experience review | +| `derive_skill` | New instruction Skill Candidate from approved Experience | Existing Skill review and separate use/export | +| `revise_memory` | Current active entries in one Memory Artifact, up to 20 | Atomically create Entry Versions, manifest, and Memory Revision | +| `revise_profile` | Current complete Profile | Replace the head without consuming a Source window | +| `revise_topic_memory` | Current Topic identity and complete content | Publish a new revision with its complete retrieval projection | +| `refresh_handoff` | Current Handoff Revision | Commit a new revision; activation and receiver checks remain explicit | +| `revise_skill` | Existing instruction-only managed Skill | Commit a validated standard package after trusted evaluation | +| `revise_prompt` | Existing registered custom-capable Prompt key | Publish a new configuration revision after trusted evaluation | +| `revise_tags` | One TagTarget with an ETag and content basis | Replace the Tag set conditionally without an Artifact Revision | + +New revision operations require an existing target. They do not create empty Profiles, Topics, Handoffs, or Prompt keys. They do not merge Memory entries, split Topics, run business tools, perform package scripts, or change cross-Scope permissions. An event can justify submitting a request, but the event itself is not evidence; the request carries inspectable citations. Periodic evidence discovery is a separate future policy. + +## Outcomes and review + +The run lifecycle remains `queued/running/succeeded/failed`; a successful run returns `proposed`, `no_change`, or `needs_evidence`. Missing or revoked evidence, target conflict, and exhausted budget are failures, not no-change. A `proposed` result points to an exact Candidate ID and version. The reviewer can inspect, revise, approve, or reject that version. A substantive candidate edit invalidates its old evaluation records. + +# Reference-level explanation + +## Operation registry and admission + +Add a server-owned `DreamOperationSpec` registry declaring an operation name and version, target kind, allowed evidence roles, proposal schema and validator, processing Family and canonical binding, review and commit adapters, effect, and evaluation policy. Requests cannot name Python paths, tools, SQL, or arbitrary code. Expose an operation only when resolution, generation, review, commit, and worker execution are all implemented and configured. Family read support alone is insufficient. + +The current worker dispatch assumes a Skill run or an Experience run. Replace that binary assumption with a binding-to-operation registry and claim one accepted run in deterministic request-generation, acceptance-time, and run-ID order. Reuse one Supervisor binding per Family. Do not create another scheduler or clear ordinary Source progress when a Dream run finishes. Keep explicit work from starving ordinary Source work: after at most four consecutive Dream attempts on one binding, perform one eligible Source pass before continuing, with the counter durable across worker restarts. + +## API and Client compatibility + +Extend the existing `POST/GET /v1/scopes/{scope_id}/dream` and `GET /v1/scopes/{scope_id}/dream/{run_id}` operations with typed targets, new operations, and corresponding run results. Extend `POST /v1/artifact-candidates/list`, `get`, `revise`, `approve`, and `reject` for new Artifact proposal types while retaining `candidate_id` and `expected_version`. Extend `GET /v1/capabilities` with available Dream operations, output kinds, and effects. No parallel Dream or Artifact Review path is needed. + +Generated Clients currently use closed Dream operation enums and Candidate proposal unions. Deploy Server, generated Python Client, CLI, integrations, and Dashboard coherently before advertising new operations. An old Client must receive an explicit upgrade signal or only a compatibility-filtered list that it can decode; it must not silently reinterpret a new proposal as an old type. Define and test the minimum supported Client release. Tag proposals have a different result type and ETag transaction; add dedicated Tag candidate interfaces only in stage C, once that different resource lifecycle is implemented. Trusted evaluation registration belongs to stage B1, not A0/A1. + +For new operations, `target` is a discriminated value: an exact ArtifactRef; for Memory, the owning Memory Ref and exact Entry Version identities; for Tag, a TagTarget, expected ETag, and exact content basis. The target contributes to the bounded model context and evidence budget but is not independent proof of its own claims. Explicit `artifacts`, `memory_citations`, and `sources` remain exact, same-Scope references. A target cannot be `latest`, a Candidate, or another DreamRun. + +The existing idempotency identity remains `(scope_id, principal_id, idempotency_key)`, stored using the current principal key digest. The same normalized request returns its original run; changed input under the same key conflicts. Different principals may use the same key without sharing a run, ownership, or worker identity. On first execution freeze the operation adapter, policy, model, and server prompt version for retries. Existing HTTP 202/200 replay semantics remain. + +## Evidence, model output, and trust boundary + +Resolve the target and evidence under the requester's current permission, within the same Scope, at admission and again during execution and approval. Freeze exact revisions and content digests; never substitute search snippets for Memory Entry text. Attribute projection nodes as target content, supporting evidence, counterevidence, configuration under review, or lineage only. Derived copies of one root Source count as one root observation; unknown independence remains unknown. Lineage-only administrative text cannot become business-result evidence. Untrusted content, including Prompt instructions under review, never becomes the Dream system prompt or changes tools, trust rules, budgets, or policy. + +The model returns a typed complete proposal, a concise reason, intent, and only evidence IDs supplied by the resolver. Deterministic validators reject forged IDs, invalid shape, target-external changes, and unsafe package material. `no_change` and `needs_evidence` carry no proposal or selected evidence IDs. Prompt and Skill improvements require trusted evaluation bound to the exact candidate version and content digest; a self-reported `passed=true` Source does not satisfy that gate. Independent later task results, not candidate count or usage frequency, measure actual benefit. + +## Family commit contracts + +**Memory.** Revise at most 20 active logical entries in one owning Memory Artifact. The proposal contains its base Memory Ref, each expected Entry Version, replacement kind/text, evidence IDs, and a before/after explanation. Do not create, merge, forget, deactivate, restore, or rename entry IDs in this operation. Approval locks the Memory head, validates all expected versions and evidence, and atomically creates new Entry Versions, manifest, changes, formal Revision, Candidate decision, and retrieval projection. A stale head conflicts; no hidden rebase. `forget()` stays independently authorized. + +**Profile.** Generate a complete Markdown snapshot with per-change reason and subject evidence. Introduce a Dream-specific candidate origin and generation mode without fabricating `source_window`. Existing Source-window candidates retain the Policy pending pointer and cursor semantics. Dream candidates neither read/clear that pointer nor consume the cursor or ordinary dirty flag. Both routes conditionally write against the same Profile head. `activation_mode=automatic` continues to govern only the existing automatic flow; Dream always requires review. Use the established Profile lock order: Policy, Candidate, Source Cursor only for Source-window decisions, then head validation and conditional write. All HTTP and SDK decision paths follow that order. + +**Topic Memory.** Revise one Topic identity with complete title, summary, and detail; preserve the distinction between confirmed results, hypotheses, ruled-out causes, open questions, and next steps. Prepare the complete FTS, configured vector, and detail-chunk projection for the exact candidate version and digest outside the publishing transaction. On approval, recheck candidate, head, evidence, permission, and retrieval configuration; atomically publish the new Revision, head, full active projection, publication time, and Candidate decision through the existing Topic publication contract. A failed embedding or changed candidate leaves the old complete head and projection in place. Do not introduce a “new head, index pending” state or bypass startup consistency checks. Dream never advances the Topic Source cursor. + +**Handoff.** Require the exact current revision and evidence for every claimed change in completed, pending, blocked, or next work. Reuse existing prepare/commit validation through a transaction-safe writer, but approval only commits a non-activated revision. Existing activation tokens, live-state, capability, authorization, and receiver acknowledgement remain separate. A plan does not prove its action happened. + +**Skill.** `revise_skill` targets a managed Skill with an exact package snapshot. A package with only root `SKILL.md` is an instruction-only Skill and is supported, including existing generated Skills. Legacy instruction content without a package follows the existing one-file package normalization. A package containing additional assets, scripts, or dependencies is rejected as an unsupported target in this stage. Keep authorized metadata and frontmatter, produce a new valid standard package, and bind evaluation to the exact target and candidate; never edit cached instructions while retaining the old package digest. Approval does not install, run, or automatically distribute the Skill. + +**Prompt.** Target only an existing registered, custom-capable key. Propose full `mode/instructions/demonstrations` content after comparing verified errors and their original evidence with human-labelled or otherwise trusted expected outputs. The Dream system prompts and review/evaluation rules cannot be targets. Validate demonstrations against that key's schemas. Keep training roots separate from held-out evaluation roots. Approval requires review and Prompt write authority plus a trusted, current evaluation record; it publishes a new Prompt Revision for future inferences. In-flight inference keeps its frozen prior revision; rollback writes a higher revision. + +**Tag.** Tag is catalog metadata, not an Artifact Family. A Catalog Change Candidate captures one current TagTarget, expected ETag, exact Artifact or Memory Entry content basis, before/after complete Tag sets, evidence, and reason. Review checks both ETag and current content basis. Approval atomically replaces the Tag set and records its decision, without creating an Artifact Revision or changing its content digest or embeddings. It must not be squeezed into the Artifact Candidate result shape, where approved implies `result_artifact`. + +## Review transaction, storage, and implementation stages + +A common review path validates current authority, reads the candidate to choose the server-registered Family/origin adapter, performs any external preparation, then lets that adapter acquire all locks in the Family's established order. The common layer must not lock Candidate before the Profile adapter locks Policy. Inside the transaction, revalidate exact candidate version and digest, target baseline, evidence availability, policy, and required evaluation. The Family writer and Candidate decision commit together. Stale or invalid candidates remain pending with an actionable conflict; rejected Dream candidates never alter formal content or a Source cursor. + +A0/A1 reuse `pc_dream_runs`, `pc_artifact_candidate_heads`, `pc_artifact_candidate_versions`, and Family storage. Prefer existing typed payloads for operation version, target, origin, proposal, and result; add columns or indexes only for proven query, uniqueness, or transactional needs. No new A0/A1 business table, generic index-outbox table, or second Dream scheduler is needed. Preserve the current `(scope_id, principal_key, idempotency_key)` constraint. Stage B1 may add a dedicated trusted evaluation table if existing trusted storage cannot bind evaluator identity, candidate version/digest, test set and policy versions, result, and usage. Stage C may add Tag candidate and candidate-version storage justified by its distinct ETag and result lifecycle. Neither is a prerequisite for A0/A1 startup or migration. Apply any schema change to SQLite and OceanBase. + +| Stage | Deliverable | Enablement gate | +| --- | --- | --- | +| A0 | Operation registry, typed targets/evidence roles, `/v1` schema extension, shared review adapters | Current operations regressions pass; Client compatibility and lock ordering are tested; incomplete operations stay disabled | +| A1 | Explicit Memory, Profile, and Topic Memory revision | Atomic Memory write, Profile cursor isolation, and Topic complete-projection publication pass on SQLite and OceanBase | +| B1 | Handoff refresh, one-file Skill revision, trusted evaluation | Non-activation boundary and version-bound evaluation gate pass | +| B2 | Prompt revision | Same-key schemas, held-out tests, Prompt write authority, and rollback pass | +| C | Single-target Tag governance | Both ETag and content baseline, distinct result type, and decision history pass | + +Per-run defaults remain at most 20 explicitly selected target/artifact/entry references, at most 32 projected evidence items including Sources, 64 KiB model-visible evidence, two model calls, 4,096 output tokens per call, and 120 seconds from first execution. A Scope has at most 32 pending/running runs. Root traversal retains its 128-node, 256-edge, 8-level budget. Complete snapshots that exceed budget fail; do not truncate them and claim a complete revision. + +The idempotency key handles request replay. Candidate suppression may additionally fingerprint operation, complete target baseline, root evidence digest, and policy within one Scope and one principal. Before reusing a pending Candidate, verify its current version, actual content and evidence, and permission; editing the proposal invalidates an outdated fingerprint. Do not reuse across principals in the first release. A rejected proposal with unchanged evidence can yield `no_change` and a readable reference to the earlier decision, but cannot conceal new counterevidence or a changed target. + +Unregistered operation or invalid target yields a stable validation error; unavailable capability rejects before creating a run; stale target and Tag ETag have distinct conflicts; permission or evidence revocation blocks generation or approval; invalid model output and exhausted budget fail the run. Approval without current trusted evaluation returns an actionable conflict. Record bounded operation/family timings, outcome, usage, candidate reuse, review conflicts, and Topic projection preparation/publication time without recording raw evidence or identifiers in low-cardinality metrics. + +## Validation + +Acceptance scenarios cover temporary travel versus an explicit move; a single Memory entry revision without changing other entries; revoked or superseded evidence; simultaneous Dream and Source-window Profile candidates; Topic embedding failure, worker restart, stale projection and competing fusion; Handoff plans without execution evidence; supported one-file versus unsupported multi-file Skill packages; stale evaluation after candidate edit; Prompt held-out-set leakage and rollback; Tag content-basis changes while ETag stays fixed; same-root evidence copies; per-principal idempotency; a shared lock order across HTTP and SDK review paths; worker lease takeover; and preservation of ordinary Source progress while Dream runs execute. Use an isolated real model chain for quality evaluation and compare no Dream, normal regeneration, and this proposal without presupposing an improvement percentage. + +# Drawbacks + +Each Family still needs its own validator and transaction-safe writer. Full Memory-head CAS may conflict in active Scopes. Complete Profile/Topic snapshots can exceed model budgets. Human review and trusted evaluation add latency and operational cost. Closed Client types require coordinated upgrades. Tag's distinct lifecycle may ultimately justify its own candidate storage and interfaces. + +# Rationale and alternatives + +A generic `replace` writer would be smaller superficially but would bypass Memory Entry Versions, Profile cursor policy, Topic projection invariants, Handoff activation, and Tag ETags. Converting every result into an Experience would leave the underlying inaccurate content in place. One Dream service per Family duplicates idempotency, budgeting, evidence, and recovery. One common run with Family-owned validation and commits preserves the proven runtime while respecting each write boundary. A universal nightly scan and automatic overwrite is postponed because discovery quality, cost, and rollback evidence are not established. In-place `/v1` extension avoids a parallel route tree; it requires a deliberate minimum Client version and compatibility strategy. + +# Prior art + +This proposal builds on [Artifact Dreaming](1510-artifact-dreaming.md) for exact evidence and bounded runs, [Candidate and Review Inbox](0050_artifact_candidate_review_inbox.md) for immutable proposal versions, [Memory](0014_memory_layer_design.md) for Entry Version writes, [Profile](1485_profile_artifact.md) for Source-window policy, [Topic Memory](1417_topic_memory.md) for complete current projections, [Handoff](0048_handoff_artifact.md) for explicit commit and receiver checks, and [Prompt](1468_scope_owned_prompt_management.md) and [Tag](1467_artifact_tags.md) for their distinct configuration and catalog lifecycles. + +# Unresolved questions + +1. Define the minimum coordinated Client release and explicit upgrade or compatibility-filtering behavior for old closed enums and proposal unions. +2. Define the first trusted held-out evaluation sets, versioned quality thresholds, and owners for Skill and each supported Prompt key. +3. Extract the transaction-safe, non-activating Handoff writer without bypassing existing tokens or receiver checks. +4. Choose the Stage C Tag Inbox pagination strategy while preserving its separate lifecycle. +5. Decide which application observes post-publication quality and requests explicit rollback; no automatic causal attribution is claimed. + +Stage A0/A1 can be developed independently; an operation in a later stage remains unavailable until its own contract, storage, evaluation, and concurrency gate is complete. + +# Future possibilities + +A later discovery policy could select new root evidence, counterexamples, and corrections with quiet periods and candidate deduplication. Future governance may merge Memory entries, split or merge Topics, support signed multi-file Skill package diffs, or enable separately authorized cross-Scope sharing. Automatic approval and rollback would need another RFC establishing reversibility, evidence thresholds, auditability, and independent task-outcome evaluation. diff --git a/docs/zh/rfcs/0000_multi_artifact_dreaming.md b/docs/zh/rfcs/0000_multi_artifact_dreaming.md new file mode 100644 index 0000000000..a0713b88d6 --- /dev/null +++ b/docs/zh/rfcs/0000_multi_artifact_dreaming.md @@ -0,0 +1,405 @@ +- Proposal Name: `multi_artifact_dreaming` +- Start Date: 2026-09-22 +- Related RFCs: [Artifact Dreaming(1510)](https://github.com/oceanbase/powercontext/blob/master/docs/zh/rfcs/1510-artifact-dreaming.md)、[Candidate 与 Review Inbox(0050)](https://github.com/oceanbase/powercontext/blob/master/docs/zh/rfcs/0050_artifact_candidate_review_inbox.md)、[Profile(1485)](https://github.com/oceanbase/powercontext/blob/master/docs/zh/rfcs/1485_profile_artifact.md)、[Topic Memory(1417)](https://github.com/oceanbase/powercontext/blob/master/docs/zh/rfcs/1417_topic_memory.md)、[Prompt(1468)](https://github.com/oceanbase/powercontext/blob/master/docs/zh/rfcs/1468_scope_owned_prompt_management.md)、[Tag(1467)](https://github.com/oceanbase/powercontext/blob/master/docs/zh/rfcs/1467_artifact_tags.md)、[Processing Supervisor(1515)](https://github.com/oceanbase/powercontext/blob/master/docs/zh/rfcs/1515_artifact_processing_supervisor.md) + + +# Summary +将 Dream 从现有的 Experience 提炼与 Skill 派生,扩展为按制品生命周期执行的受控复盘能力。新增 Memory 条目修订、Profile 画像校正、Topic Memory 主题校正、Handoff 交接刷新、现有 Skill 修订和 Prompt 改进,并为 Tag 提供独立的分类变更提案。所有操作沿用“精确证据、后台运行、待审候选、条件提交、完整来源”的原则,一次运行只处理一个目标,不直接覆盖正式状态。所有新增 Dream 操作都必须先写入待审核 Candidate;只有 Reviewer 批准当前候选版本并通过目标当前基准校验后,变更才能生效:Artifact 由所属 Family 的写入器提交新 Revision、推进 head,Tag 按其 ETag 条件替换标签集。Dream 生成成功或候选入库本身不改变正式状态。 + +Dream 负责提出可解释的变化,Family adapter 负责语义校验和提交,Reviewer 负责批准,应用或评测服务负责实际业务验证。Source 保持原始证据身份,不被 Dream 改写;Tag 保持 catalog 属性身份,不伪装成 Artifact。第一阶段交付 Memory、Profile、Topic Memory,后续阶段扩展 Handoff、Skill、Prompt 和 Tag。自动发现、全量夜间扫描、多目标原子合并及无人值守发布不作为本提案的交付前提。 + +# Motivation +## 现有能力与缺口 +当前 `DreamOperation` 只有 `refine_experience` 和 `derive_skill`。前者读取精确 Memory 条目和 Experience,后者读取 Experience,均可补充合规 Source,输出最多一个候选。DreamRun 已包含幂等、后台执行、证据 manifest、预算和运行结果。 + +其他制品也会因证据增加而失准:Memory 中旧事实未校正,Profile 把临时出差当成常驻变化,Topic Memory 没有吸收新排查结论,Handoff 落后于真实进度,Skill 的适用范围缺少反例,Prompt 持续产生同类提取错误,Tag 出现重复或错误分类。这些问题无法全部通过新增 Experience 解决。 + +扩展不能只是放宽一个 family 枚举。当前实现至少存在以下差异: + +| 当前实现 | 扩展必须解决的问题 | +| --- | --- | +| Memory 正文位于 Entry Version,Artifact 保存 manifest | 不能通过替换 manifest JSON 绕过条目写入、hash 与索引维护 | +| Profile 已有 Candidate 审核,但绑定 Source window、Policy pending 指针和游标推进 | Dream 选取离散证据,不能冒充连续消费窗口 | +| Topic Memory 有自身生成、融合和检索投影 | 手动修订不能被旧后台结果覆盖,旧 chunk 不得与新 head 混用 | +| Handoff 有 prepare、commit/activate、接收方核验等语义 | 生成文本不能替代交接激活或接收确认 | +| Prompt 是运行配置,正式 head 会影响后续推理选择 | 发布不是纯内容编辑,需要评测门槛、明确权限及回滚 | +| Tag 使用独立的可变集合和 ETag | 标签变化不应创建 Artifact Revision | +| Candidate 的 MemoryCitation 当前限制为 Experience | 新 family 必须显式增加合法引用与审核校验,不能全局取消约束 | + + +## 目标与非目标 +目标是降低过时背景、错误概括和不完整交接造成的后续任务错误,并使每次变化可核验、可拒绝、可追溯。候选数量、摘要长度或版本增长均不代表效果提升。 + +本提案不执行业务工具,不扩张现有权限,不修改 Source,不自动忘记用户信息,不自动合并不同主体,不自动发布 Skill 包,也不从缺失使用记录推断某条记忆没有起效。跨 Scope 证据正文读取和跨目标事务不在本期范围。 + +# Guide-level explanation +## 1. 用户模型 +用户在一个 Scope 内选择目标的精确版本和相关证据,选择对应 Dream 操作。系统后台比较现状与证据,给出完整候选、差异、理由、逐项引用和证据不足说明。Reviewer 批准后,目标按自身生命周期产生新状态。其他制品需要更新时,用户再提交独立运行,不自动串联发布。 + +```latex +选定目标、精确证据和问题 + | + v +DreamRun:解析来源、冻结证据、生成与类型校验 + | + +------+----------------+ + | | + 有充分改进依据 无实质变化 / 证据不足 + | | + v v +版本化候选及差异 no_change / needs_evidence + | + v +审核当前候选版本、证据、权限和目标基准 + | + +-- Artifact:Family writer 提交新 Revision + +-- Tag:以 ETag 条件替换当前标签集合 + | + v +应用显式使用;实际结果作为新 Source 留存 +``` + +## 2. 操作矩阵 +| operation | 目标与输出 | 复盘内容 | 生效边界 | +| --- | --- | --- | --- | +| `refine_experience`,已有 | 新 Experience 或现有 Experience 的修订候选 | 情境、行动、结果、反例及适用范围 | 沿用现有审核 | +| `derive_skill`,已有 | 新 Skill 候选 | 从 Experience 派生指令 | 沿用现有审核和显式导出 | +| `revise_memory`,新增 | 一个 Memory Artifact 内指定条目的修订候选 | 事实校正、时效表述、偏好和约束冲突 | 新 Entry Version 与 Memory Revision 原子提交 | +| `revise_profile`,新增 | 当前 Profile 的完整快照候选 | 主体归属、稳定偏好、临时安排与明确变更 | 人工审核后替换 head,不消费 Source window | +| `revise_topic_memory`,新增 | 一个 Topic Memory 的完整内容候选 | 新进展、已排除假设、未决问题及来源 | 保持 topic identity,提交新版本与检索更新意图 | +| `refresh_handoff`,新增 | 当前 Handoff 的完整修订候选 | 目标、已完成事项、阻塞、下一步 | 批准只形成正式版本,仍需现有显式激活与接收核验 | +| `revise_skill`,新增 | 当前 managed Skill 的替换候选 | 使用反例、步骤、前置条件与检查方法 | 通过内容校验、评测和审核后发布,安装执行仍独立 | +| `revise_prompt`,新增 | 已注册 Prompt key 的完整配置候选 | instructions、demonstrations 的失败模式 | 评测与配置写权限通过后发布,后续推理解析新 head | +| `revise_tags`,新增 | 单一逻辑 target 的标签集合变更候选 | 错误分类、同义重复、缺失分类 | ETag 条件替换,不创建 Artifact Revision | + + +所有新增操作要求目标已存在,第一期不通过 Dream 创建空 Profile、空主题、空交接或新 Prompt key。条目合并、主题拆分、批量重命名标签作为后续治理能力单独定义。 + +## 3. 客服场景:同一组原文如何驱动不同复盘 +用户 U1 的个人 Scope 为 `S_U1`,订单工单 Scope 为 `S_TICKET_2048`。应用通过已有主体绑定分别投递属于该范围的 Source;不因同一客户而自动跨 Scope 读取。以下两个表分别展示各自 Scope 内的模拟证据。 + +**个人背景证据:** + +| Source | 原文与时间 | 语义 | +| --- | --- | --- | +| S01 | 9/01:“我长期在上海工作,联系我尽量在下午。” | 原常驻地与明确联系偏好 | +| S02 | 9/08:“这周去深圳出差,15 号回上海。” | 带期限的临时安排 | +| S03 | 9/21:“计划变了,已经搬到深圳,以后常驻这里,仍然下午联系。” | 明确长期变更,偏好未变 | + + +**工单处理证据:** + +| Source | 原文或实际结果 | 语义 | +| --- | --- | --- | +| S11 | 客户:“页面显示已签收,但我没有收到。” | 待核验投诉 | +| S12 | 客服:“猜测是门卫代收,尚未核对凭证。” | 猜测,不是事实 | +| S13 | 核验记录:“签收照片并非客户地址,已提交物流复核。” | 已核验观察与下一步 | +| S14 | 任务结果:“物流确认投递错误,补发已创建;物流单号尚未回传。” | 已完成与待办分别记录 | + + +这些证据形成以下可独立审核的变化: + +| 制品 | 复盘前 | 候选示例 | 不允许的推断 | +| --- | --- | --- | --- | +| Memory,S_U1 | E_CITY@v1:“长期在上海工作” | E_CITY@v2:“9/21 起常驻深圳,依据 S03” | 仅凭 S02 就认定搬家 | +| Profile,S_U1 | 常驻上海,下午联系 | 常驻深圳,下午联系;保留未受影响背景 | 根据客户投诉推断性格或收入 | +| Topic Memory,S_TICKET_2048 | “疑似门卫代收,待查” | “已确认投递错误;补发已创建,单号待回传” | 将未完成补发写成客户已收到 | +| Handoff,同工单 Scope | “下一步核对签收凭证” | “凭证已核验、补发已创建;接手确认单号并通知客户” | 代替接手方确认权限和当前物流状态 | +| Experience,同工单 Scope | 无该任务的复盘 | “签收争议先核对签收人与地址凭证,再决定处置” | 单个案例足以证明普遍有效 | +| Skill,同 Scope 且有合法使用反馈 | “未收到货则直接补发” | 增加凭证核验、适用条件、人工升级点和结果检查 | 自动补发、退款或执行脚本 | +| Prompt,S_U1 | 把短期出差提取成常驻变更 | 加入临时安排反例和明确搬家的正例 | 覆盖服务端 trust rules 或增加工具权限 | +| Tag,工单主题 | `[物流, 门卫代收]` | `[物流, 投递错误, 单号待回传]` | 标签等于根因证明或资源授权 | + + +操作彼此独立。Memory 修订后不会暗中重写 Profile,Topic 更新后也不会自动激活 Handoff。若需要协调,应用在前一步正式发布后读取精确结果,另行提交下一步 Dream,并承担协调与失败处理。 + +## 4. 触发与结果 +新增能力先使用显式触发:用户点击复盘,或 integration 在有授权的任务结束、明确纠正、使用反馈到达时提交请求。事件只是触发理由,不是事实证据,必须携带可核验引用。 + +Dream 保留 `queued/running/succeeded/failed` 状态及 `proposed/no_change/needs_evidence` 结果。`succeeded` 不表示候选获批,也不表示业务效果提升。证据不存在、权限撤回、目标冲突和预算耗尽是失败,不能降格为无变化。 + +周期性选择证据需要后续独立的 discovery policy;不能将已有 Source-window 周期生成描述成自动扫描所有制品的 Dream。新增操作的审核要求只约束 Dream 发起的变更;同一 Family 已有的自动生成流程继续沿用原有触发、审核策略和生效逻辑,不因接入 Dream 被强制改为待审,也不能借其自动发布配置绕过 Dream 审核。 + +# Reference-level explanation +## 1. 扩展架构 +在既有 DreamService、Evidence Resolver、ReviewService、Family writer 和 Processing Supervisor 上引入内部 `DreamOperationSpec` 注册表。每个 spec 显式声明: + +| 字段 | 责任 | +| --- | --- | +| operation / spec_version | 稳定操作名和操作规范版本 | +| target_kind / allowed_evidence | 合法目标、正文输入及引用角色 | +| proposal_schema / validator | 类型化输出与确定性业务约束 | +| processing_family / binding | 所属执行资源与现有 Supervisor 绑定 | +| review_adapter / commit_adapter | 审核校验、锁顺序和原子提交 | +| effect / evaluation_policy | 审核后的实际影响及必须满足的评测要求 | + + +注册表是服务端固定配置,不接收请求内 Python 路径、任意工具名、SQL 或自定义执行代码。Family registry 可发现读取能力,不等于已经支持 Dream。只有解析、生成、审核、提交和后台执行能力全部就绪时,operation 才能对外启用。 + +现有 `DreamService.execute()` 按 Skill 与 Experience 二分 operation,需要改成按所属 binding 查询它支持的 operation 集合,再按 accepted_at、run_id 稳定排序领取一个 Run。不得通过“非 Skill 就当 Experience”的默认分支执行新增操作。 + +## 2. 协议与兼容性 + +扩展现有 `/v1` Dream、Artifact Candidate 与 capabilities 接口,不新增平行的 v2 Dream/Review 路径。现有请求与响应字段保持原义;新 operation 使用类型化目标、证据和候选 proposal。`openapi/powercontext.yaml` 是源契约,Client 与服务端生成代码同步更新。 + +| 现有资源 | 扩展内容 | +| --- | --- | +| `POST/GET /v1/scopes/{scope_id}/dream`、`GET /v1/scopes/{scope_id}/dream/{run_id}` | 提交、列出、读取新增操作;沿用状态、预算、幂等与 run_id | +| `POST /v1/artifact-candidates/list`、`get` | 读取新增 Artifact Family 的类型化 Candidate | +| `POST /v1/artifact-candidates/revise`、`approve`、`reject` | 复用 candidate_id、expected_version 和现有审核权限;由 Family adapter 校验并提交 | +| `GET /v1/capabilities` | 增加 `artifact_dreaming_operations`,声明操作、可用性、输出种类与生效方式 | + +当前生成 Client 将 Dream operation 和 Candidate proposal 解析为封闭枚举/联合类型。新操作上线前必须协调升级服务端、Python Client、CLI、集成插件和 Dashboard;旧 Client 不得在不理解新类型的情况下读取新 Run 或 Candidate。不能以忽略未知字段、伪造旧 operation 或返回空 proposal 掩盖不兼容。部署验收须明确最低 Client 版本;若生产环境无法协调升级,应先提供按能力过滤的兼容读取策略,再启用新 operation,而不是让旧 Client 在 list/get 上解析失败。 + +Tag 不是 Artifact;C 阶段的 Catalog Change Candidate 与 Artifact Candidate 的结果类型不同。只在该阶段证明现有接口无法准确表达 ETag 与结果类型后,才增加独立的 Tag 候选接口。可信评测登记接口同样归 B1 阶段,不成为 A0/A1 前置条件。 + +新增操作请求沿用 `operation`、`artifacts`、`memory_citations`、`sources` 和 `idempotency_key`;`target` 扩展为按 `kind` 区分的类型化值:普通制品为精确 ArtifactRef,Memory 为 Memory Ref 加目标 Entry Version 集合,Tag 为 TagTarget、expected_etag 和正文基准。普通制品目标自动进入待修订上下文,不要求调用方重复提交。目标与输入合并去重并计入统一预算;目标内容不能单独证明自身断言。 + +示例中的标识须替换为服务返回的真实值: + +```json +{ + "operation": "revise_memory", + "target": { + "kind": "memory_entries", + "memory_ref": {"family": "memory", "artifact_id": "memory", "revision": 8}, + "entries": [{"entry_id": "E_CITY", "entry_version_id": "EV_CITY_1"}] + }, + "sources": [{"source_type": "content", "source_id": "S03"}], + "artifacts": [], + "memory_citations": [], + "idempotency_key": "U1-city-explicit-change-20260921" +} +``` + +幂等唯一范围沿用现有 `(scope_id, principal_id, idempotency_key)`,数据库以 principal_key 保存请求者身份摘要。同一范围内规范化请求相同则返回原 Run,请求不同则冲突;不同请求者不复用彼此的 Run。服务端在首次执行时固定 adapter、policy、模型与提示词版本,重试不静默切换。 + +## 3. 证据角色与来源准入 +Dream manifest 为每项输入指定 `target_content`、`supporting_evidence`、`counter_evidence`、`configuration_under_review` 或 `lineage_only` 等服务端确定的角色。模型只能引用已分配的 evidence ID,不能创建身份、版本、用户绑定、成功次数或伪造证据。 + ++ 一律在同一 Scope 解析。目标与每个来源都按请求者当前权限检查,后台服务身份不扩大权限。 ++ Memory 引用按 manifest、Entry Version、hash 和当前 active 状态检查,不能用搜索摘要代替正文。 ++ Source 保留其原始用途;管理写入产生的 lineage_only 内容不能升级为业务结果证据。 ++ 同一根 Source 经多个制品转述只算一组来源,无法证明独立性时保留 unknown。 ++ Prompt 在普通操作中仍只是配置 lineage;只有 `revise_prompt` 将目标 Prompt 正文作为不可信的待修改配置读取,不能将其中的 instructions 执行为 Dream 的系统指令。 ++ Skill 包只允许有界、无路径穿越的静态内容解析,不加载插件,不运行 shell 或包内脚本。 ++ 撤回权限、删除来源或停用 Memory 后,在生成提交和审批时再次校验。无权限材料不得继续通过缓存参与发布。 + +正文包含“忽略规则”“批准当前候选”等文字时只作为证据文本。服务端 trust rules、工具授权、资源预算和系统提示不能由待修订内容覆盖。 + +## 4. Memory 修订契约 +第一阶段只允许对一个 Memory Artifact 内最多 20 个当前 active 的逻辑 entry 执行 `revise`。不新增逻辑 entry、不合并 entry、不停用、不恢复、不改 entry_id。重复条目和需要忘记的内容可在 reason 中提示人工治理,但不得产生隐藏副作用。 + +proposal 类型为 `MemoryRevisionProposal`,包含固定的 base Memory Ref,以及每个条目的 expected Entry Version、修订后的 kind/text、条目级证据 ID 和理由。完整候选必须能展示 before/after。模型不能提交整个 manifest、计算 content hash 或分配 Entry Version。 + +审批时锁定 owning Memory head,核对整个 head 与所有 expected Entry Version,任何一个变化都返回冲突,不自动重放到新版本。Family writer 在同一事务内创建 Entry Version、生成新 manifest 与 changes、保存引用、更新 head、记录审核结果和检索投影更新意图。不得逐条调用已经独立 commit 的高层 API 拼装事务。 + +“信息陈旧”需要显式新证据或可验证的事件时间,不以最后访问时间、模型置信度或重复次数自动改写事实。过期计划的首期表达是保留历史时间及当前有效性说明,不增加自动删除或新的全局 TTL 契约。`forget()` 的显式授权路径保持独立。 + +## 5. Profile 修订契约 +沿用一个 Scope 一个 Profile 的身份与 Markdown 快照。模型输出完整 content,同时生成逐段变化理由、主体依据和证据引用。保持当前生成策略对范围、主体和允许信息的限制,排队或审核期间 Policy 改变应使候选失效并要求重新生成。 + +新增 `ProfileDreamCandidateProposal` 和可区分的 `generation.mode=dream_review_approved`,附精确 dream_run_id 和 policy revision/digest。Dream 模式不允许 `source_window`,也不伪造 after/through。 + +当前 `decide_profile()` 依赖 Policy.pending_candidate_id,并在批准或拒绝时推进 Source Cursor。新路径必须按 candidate origin 分派,只有 Source-window 候选继续走原逻辑;Dream 候选不读取或修改该 pending 指针,不推进游标、不清除普通 Source dirty。两种候选都按同一 Profile head CAS 提交。任一先发布,另一候选保持 pending 并显示冲突,不能覆盖新 head。 + +`activation_mode=automatic` 继续按原有逻辑控制 Source-window 周期生成及其直接生效路径;`review_required` 继续沿用原有候选审核路径。两者都不决定 Dream 的审核策略:Profile Dream 一律先生成 Candidate,批准后才按 Profile head 的当前版本条件提交。发布新 Profile 后,PreparedContext 是否读取它仍取决于原有显式 assembly 配置。 + +## 6. Topic Memory 修订契约 +Family 名称使用现有 `topic-memory`。候选保持 `title/summary/detail` 完整结构,按证据区分已确认事实、猜测、已排除项、未决问题和下一步。修订一个 topic identity,不在本阶段拆分或合并主题,不修改 topic routing identity。 + +审核与后台融合都以同一 head 为条件提交,并复用 Topic Memory 既有的完整投影原子发布契约。收到批准请求后,先针对精确 candidate_id、candidate_version 和 proposal digest,在发布事务外准备完整的 FTS、当前检索配置所需的 vector 及 detail chunk 投影;此时不推进 head,也不将投影加入正式检索。投影准备失败时返回错误,候选保持 pending,现有正式版本及检索结果继续可用。 + +投影准备完成后,由 Family adapter 按统一锁顺序进入批准事务,重新核对候选版本与 digest、目标当前 head、证据、权限和检索配置,调用既有 publish_revision 等事务内发布逻辑,原子提交新 Topic Revision、head、完整检索投影、发布时间及 Candidate approved/result。任何条件变化或提交失败都不产生部分发布。候选被修订后必须重新准备对应投影,不能复用旧正文的投影;外部模型或 embedding 调用不得占用发布事务的数据库锁。 + +本操作不增加“head 已更新但索引待重建”的中间状态,不新增 Topic 索引 outbox、待完成状态或专用恢复流程。启动时继续执行既有 head、active projection 和 chunk 一致性检查。 + +Dream 不推进 Topic Source Cursor。后续正常融合以新 head 作为基准处理新增 Source,不从旧快照重建后覆盖审核结论。相关后台 CAS 与投影一致性验收是开启该 operation 的前置条件。 + +## 7. Handoff 刷新契约 +只针对精确 Handoff Revision,保持 work identity、目标任务与原有结构化内容约束。对已完成、未完成、阻塞和下一步的每一项变更提供结果来源。没有新的状态证据时返回 no_change 或 needs_evidence,不能仅凭时间流逝宣称任务完成。 + +候选生成不改变当前激活交接,不写 accepted acknowledgement,不变更任务授权。批准形成正式 Handoff Revision,应用随后按现有接口显式激活该版本,接收方仍核对 live state、capability 和 authorization。 + +当前 prepare/commit 已校验的 token、basis、引用和激活条件不能因增加 Candidate 被旁路。实现需把可复用的内容校验和非激活提交提取到事务内 writer,再由 Dream 审核 adapter 调用;不得伪造接收方核验记录。已被接收的历史 Revision 保持不变,新版本不改写旧接收记录。 + +## 8. Skill 修订与评测契约 +`derive_skill` 保持原语义,`revise_skill` 明确指向一个已有 managed Skill。首期支持 instruction-only Skill,按包内实际文件判断支持范围,不以 package 字段是否存在判断。现有纯指令内容也会规范化为仅含根目录 SKILL.md 的标准包;这种单文件包属于支持范围。对于尚无 package 的旧纯指令内容,沿用既有规范化流程生成单文件标准包。 + +服务端读取目标精确 package digest 对应的包快照,只有包内仅含根目录 SKILL.md 时才允许本期修订;包含额外附件、脚本或依赖文件的包返回明确的 unsupported_target,不默默丢弃文件。修订保留未授权改变的 frontmatter 和元数据,生成新的标准包,经过既有包校验、评测与审核后提交精确 package 引用;不得只修改缓存的 instructions 字段而保留旧 package 引用。多文件包的路径、签名和资产替换另行设计。 + +输入需要与目标版本关联的任务结果、使用反馈或已批准 Experience。输出保留适用范围、前置条件、指令与验证要求,不推断“被选中”就是“被执行”或“有效”。负面用例不删除,不以新增步骤数作为质量指标。 + +审核要求可信评测记录固定到 candidate_id、candidate_version、proposal_digest、目标版本、评测集版本和评测器版本。评测服务由部署者注册,单独鉴权提交。普通用户随手填写 `passed=true` 或上传自述 Source 不能满足门槛。评测在隔离环境运行,无生产凭据和任意外部副作用,预算独立计量。 + +确定性 schema、来源和禁止越权检查必须全部通过;业务门槛由版本化 evaluation policy 固定,失败和 unknown 都不能当成通过。Reviewer 修改实质内容或证据后,旧评测记录失效。批准不安装、不执行、不自动分发 Skill,原有显式使用路径保持。 + +## 9. Prompt 改进与生效契约 +仅支持已注册且允许自定义的 Prompt key,例如 `memory.extract`。输入包含当前精确 Prompt、已核验的错误输出及原始 Source、期望输出与独立保留测试集。错误输出作为被诊断对象,不作为事实来源;expected output 需要人工标注或其他可信来源。 + +候选只修改现有 `mode/instructions/demonstrations`,所有 demonstration 按该 Prompt Definition 的输入输出 schema 校验。首期输出固定为 custom 模式,不修改 Prompt key、模型配置、trust rules、工具集合或资源上限。 + +训练示例与保留测试集必须按根 Source 分离,不能把保留答案写进 demonstrations 后宣称改进。评分对比同一输入和固定执行配置下的 baseline 与 candidate,检查误提取、漏提取、主体混淆、时态混淆、token 成本和 trust-rule 回归。只有已注册的评测器可以登记评测通过记录。 + +当前 Prompt head 在后续 inference 中解析,因此批准该候选就是配置发布。审批 UI 和响应必须明确显示这一影响,Reviewer 同时需要 review authority 与现有 Prompt 写权限。先保存一个“已批准但不生效”的 head 会与现有语义冲突,本期不增加该中间状态。 + +在途推理继续使用已冻结的旧 Prompt Revision;发布后启动的新推理解析新 head。回滚读取旧内容,再写成新的单调递增 Revision,不回退版本号。Dream 自身的服务端提示、Review 校验及评测评分器不能成为 `revise_prompt` 的目标,避免修改自身审核标准。 + +## 10. Tag 变更契约 +Tag 不是 Artifact Family。新增 `CatalogChangeCandidate`,复用 Review Inbox 的列表展示、比较与审批交互,但使用独立 schema、存储和提交 adapter。其状态为 pending/approved/rejected,批准结果为 target、完整标签集及新 tag_digest/ETag,不填充 `result_artifact`。 + +每次仅更新一个现有 TagTarget,保存 expected_etag、basis_ref/basis_citation、完整 before/after tags、证据和理由。沿用现有 NFC/casefold 规范化、数量及长度限制。标签增删都需要审核,不能给 target 以外的资源自动迁移标签。 + +审批时同时核对标签 ETag 与正文基准。正文 head 或条目版本改变,即使 ETag 未变也要求重新提案,防止按旧内容打标。按既有权限读取 basis;ETag 冲突返回 412,正文基准冲突返回 409。事务内替换标签集合并标记候选 approved,Artifact Revision、digest、embedding 均不改变。 + +统一 Inbox 在服务层聚合两种候选,不创建 `family=tag`,不把新 Tag 审批混进要求 approved 必须有 result_artifact 的旧 DTO。首期不批量改名、不定义全局别名、不检查或修改外部系统自动化。涉及业务规则的标签需要业务方确认其影响,PowerContext 标签本身始终不授予权限。 + +## 11. 候选模型与审批事务 +对新增 Artifact family 注册专门 proposal 类型和审核 adapter,复用已有候选版本、pending/approved/rejected 及 result_artifact 语义。所有 Dream-origin Artifact 变更,无论该 Family 的既有自动生成是否需要审核,都只能先进入 pending Candidate;批准时重新核对候选版本、证据、权限和目标当前 head,条件提交新 Revision 并原子标记 approved。审核前以及冲突时不得让 Dream 结果进入正式 head 或检索生效状态。既有非 Dream 自动生成路径保持原有策略,不以 Family 级开关统一改变其行为。MemoryCitation 从“仅 Experience”改为逐 operation/family 的白名单校验,而不是无条件放开。 + +新增 Candidate 保存 origin、operation、精确 target、proposal digest、evidence manifest 引用、validation policy digest 和可选 evaluation refs。需要模型生成的只有内容计划、已给定 evidence ID 及说明,目标身份、CAS token、事务结果和 generation 元数据均由服务端填写。 + +审批共同步骤:校验身份及当前权限;读取候选以确定服务端注册的 Family/origin adapter;完成所需的事务外准备;由 adapter 从事务开始决定完整锁顺序并锁定候选 expected_version;核对目标基准;复核证据及当前 active 状态;核对 schema、评测记录和 policy;调用事务内 Family writer;原子保存正式结果和审核状态。通用层不得先锁 Candidate 再交给 adapter 补锁其他资源;预读的候选版本、origin 和内容必须在事务内重新校验。 + +同一 Family 的 HTTP、SDK 及其他审核入口共用相同的事务与锁顺序。Profile 保持 Policy → Candidate → Source Cursor(仅 Source-window 候选)→ Profile head 校验与条件写入;Dream Profile 不读取或推进 Source Cursor。approve/revise/reject 中涉及相同资源的操作也必须遵守该顺序,不允许某一入口改成 Candidate → Policy。其他 Family 按既有写入流程确定锁顺序,并通过并发验收验证。 + +发生冲突或校验失败时,候选保留 pending 并返回可操作原因,不自动 rebase。拒绝 Dream 候选不会修改 Artifact/Tag,也不会推进普通 Source cursor;既有 Source-window 候选仍按原有规则处理游标。修订不能换 target,目标变化需要新候选。候选正文修改会产生新 version,旧版本和其评测记录继续可追溯。 + +## 12. 持久化、索引与后台执行 +A0/A1 复用 `pc_dream_runs`、`pc_artifact_candidate_heads`、`pc_artifact_candidate_versions` 和各 Family 的既有存储,不新增业务表。operation spec version、类型化 target、result reference 及 proposal/origin 优先使用已有类型化 payload;只有查询、唯一约束或事务校验确实需要时才增加必要列和索引。Dream 的数据库唯一约束和请求查询沿用 `(scope_id, principal_key, idempotency_key)`,principal_key 沿用既有请求者身份编码。 + +可信评测记录存储归属 B1,记录主体、候选 digest、评测集版本、policy 版本、结果与用量,不复制用户凭据或完整训练集;仅在无法用既有可信存储满足不可伪造记录与版本绑定时新增专用表。Tag 候选及版本存储归属 C,以其独立结果类型和 ETag 事务语义论证所需新表,不复用 Artifact head 表。两者的接口、表结构与迁移均随所属阶段交付,不作为 A0/A1 的启动、迁移或能力启用前置条件。 + +所有 schema 变化同时覆盖 SQLite 与 OceanBase。新增 Profile generation mode 和 Candidate proposal 在对应 operation 启用前,必须使持久化 reader、HTTP serializer、Client 和 Dashboard 同步识别。上线后不理解新增封闭类型的旧 Client 应收到明确的升级提示或使用兼容过滤,不得伪造旧 mode、空 Source window 或成功的空结果。 + +Supervisor 保持一个 processing family 一个 canonical binding,不为 Dream 另建常驻 scheduler。Memory、Profile、Topic、Experience、Skill 复用各自资源归属;Handoff、Prompt 等尚无完整后台 Dream 能力的 Family,须补齐规范 binding、Worker 配置与能力声明后才能启用。Tag 作业路由到其 owning Artifact family binding,并在注册表区分 operation,不能为 `tag` 伪造 Family。 + +同一 binding 内显式 Run 按固定顺序执行。为避免普通 Source 处理饥饿,新增轮转策略:最多连续处理 4 个显式 Run 后,若存在可执行的 Source 工作则执行一次 Source pass,再继续 Dream。计数持久化到调用状态,不因 Worker 重启重置。没有 Source 工作时不人为阻塞 Dream;现有 v1 Run 的语义与预算保持。 + +Run 的候选落库、结果记录、租约 fence 检查、调用确认和后继调度意图在同一事务提交。Family 审核复用各自已有的投影发布契约,不统一引入异步 outbox;Topic Memory 按第 6 节在事务外准备完整投影,在批准事务内原子发布。只有既有 Family 已采用异步投影时才沿用其 processing intent,不为本次扩展新增通用索引任务表。审批等待不占 Dream Worker。查询只读取,不因 prepare_context 或 search 自动启动 Dream。 + +## 13. 预算与重复提案抑制 +第一阶段沿用现有默认 Run 上限:20 项显式目标/制品/条目引用,包含补充 Source 后至多 32 项模型可见证据,64 KiB 总证据投影,每次最多 4096 输出 token,最多 2 次模型调用,首次执行起 120 秒;同 Scope 最多 32 个未完成 Run。来源遍历沿用 128 节点、256 边、8 层上限。 + +完整快照必须在预算内,超限失败并提示缩小目标或证据,不能截断后声称完整修订。部署者可收紧预算,调用方不能放宽。 + +idempotency_key 按第 2 节包含请求者身份的唯一范围处理相同请求重放。额外的 proposal fingerprint 在 `(scope_id, principal_id)` 内使用 operation、完整 target baseline(含选中条目及其版本)、实际根证据摘要和 policy digest,防止同一请求者对同一目标和证据生成多个未审候选;首期不跨请求者复用 Candidate,也不转移候选归属或后台执行身份。命中 pending 候选时须重新核对当前候选版本、内容、证据与当前请求的匹配关系及可读权限,再关联精确 candidate_id/version 并记录 reuse;已被修订而不再匹配的候选不能沿用旧 fingerprint。命中已拒绝且证据未变的提案返回 no_change,并在具备读取权限时提供现有候选引用。新反例、目标版本或 policy 变化可以重新提案。抑制依据可解释,不把“模型换种措辞”当作新增价值。 + +## 14. 错误、可观测性与安全 +| 情况 | 可观察行为 | +| --- | --- | +| operation 未注册、输入 family 不合法、仅 Source 无合法目标 | 422,稳定原因码 | +| capability 未就绪 | 503,不创建运行 | +| 目标已不是 current head | 409 target_conflict | +| 标签 ETag 过期 | 412 tag_precondition_failed | +| 引用越权或不可见 | 沿用既有 403/404 策略,不泄漏正文 | +| 生成期间目标改变、证据撤回 | Run failed,无候选或正式写入 | +| 内容太大、预算耗尽 | Run failed,明确 evidence_limit_exceeded/budget_exceeded | +| 模型输出伪造引用、错误类型或目标外变更 | failed: invalid_generation_output | +| 审批缺少有效评测 | 409 evaluation_required/stale_evaluation,保留 pending | +| policy 改变或候选 expected_version 不匹配 | 409,要求重新检查 | +| Topic 投影准备失败或准备期间候选、head、检索配置改变 | 批准失败,Candidate 保持 pending,正式 head 和检索投影均不变;重新准备后再审核 | + + +记录 operation/family、等待与执行时长、模型调用和 token、三类结果、失败原因、候选复用、审批冲突和投影准备/发布耗时。Metrics 标签使用低基数维度,不放正文、用户 ID、Source ID 或标签原文。Trace 默认不输出 Prompt、Memory、Profile 和原始聊天内容;受权限控制的精确读取用于调查。 + +量化效果要记录后续任务是否实际获得并使用了对应版本。仅有召回、没有注入记录时标记 unknown;仅有任务成功、没有相关核验时不能归因于 Dream。不得因为一份用户画像被频繁使用,就把它判为真实或有效。 + +## 15. 实现阶段与验收 +| 阶段 | 交付 | 启用条件 | +| --- | --- | --- | +| A0 | Operation registry、现有 v1 契约扩展、证据角色、typed target、共用审核扩展;复用现有 Run/Candidate 表 | Client 与 Server 协调升级,旧操作回归、请求者幂等隔离与统一锁顺序通过,未注册操作关闭 | +| A1 | Memory、Profile、Topic Memory 显式复盘;不依赖 Tag 或可信评测存储 | 条目原子提交、Profile 游标隔离、Topic 完整投影原子发布及启动一致性通过 | +| B1 | Handoff 刷新、仅含 SKILL.md 的纯指令 Skill 包修订、可信评测接口与存储 | 激活边界、包内容准入与评测门槛通过,真实任务端到端验证完成 | +| B2 | Prompt 改进 | 同 key schema、保留测试集、配置发布权限及回滚通过 | +| C | Tag 单 target 分类治理、独立候选接口与存储、统一 Inbox | ETag/正文双基准、独立结果类型、历史记录通过,新表必要性明确 | + + +代码分工:`builtin/dream` 承载 operation spec 和运行编排;`builtin/evidence` 增加严格 Family resolver;`builtin/review` 和 Family adapter 完成候选及事务提交;`builtin/persistence`、SQLite/OceanBase 实现记录与 CAS;`builtin/runtime` 接入 Supervisor 和投影更新。`openapi/powercontext.yaml` 是 HTTP 源契约,更新后运行 `make api-generate` 和 `make contract-test`,禁止手写 `_generated` 代码。 + +测试验证公开行为和实际事务,不固化内部函数调用次数。新增公共契约、持久化与并发逻辑需完成相关单元/集成测试、`make check`、`make test`;涉及支持的 Python 版本时执行 tox。重要验收矩阵: + +| 场景 | 必须观察到的结果 | +| --- | --- | +| 出差 S02 与明确搬家 S03 | 前者不改长期画像,后者生成可追溯修订 | +| 两个用户的相似聊天 | 不跨主体归并或生成画像 | +| 只修订一个 Memory 条目 | 未选中条目保持不变,旧 citation 仍指向旧内容 | +| Memory 在生成/审核时被修订或停用 | CAS 或证据校验失败,不覆盖、不复活 | +| Profile Dream 与 Source-window 候选并发 | 只有当前基准可批准,Dream 不推进游标或清除 pending 指针 | +| Topic Dream 与 fusion 并发 | 只对当前 head 原子发布完整投影;竞争失败不改变正式状态 | +| Topic embedding 失败、准备期间候选被修订、发布事务失败 | Candidate 保持 pending,旧 head 与完整投影继续可用,无部分发布 | +| Topic 批准提交前后服务重启 | 读取到完整旧版本或完整新版本,既有启动一致性检查通过 | +| Handoff 只有计划,没有执行结果 | 不把计划改成完成,不产生 accepted | +| Skill 仅含 SKILL.md 的标准包(含现有纯指令生成结果) | 可以生成修订候选,审核通过后提交新的合法标准包 | +| Skill 包含额外文件 | 明确拒绝,不能悄悄删附件、脚本或依赖文件 | +| 候选编辑后复用旧评测 | 审核拒绝,要求重测 | +| 普通调用方提交自述 passed=true | 不能获得可信评测通过记录 | +| Prompt 保留测试集进入 demonstrations | 隔离检查失败,不能报告有效改进 | +| Prompt 发布与回滚 | 新推理使用新 head,在途推理固定旧版本,回滚产生更高 Revision | +| Tag 标签未变但正文已变 | 正文基准冲突,不按旧内容修改分类 | +| Tag 审核成功 | 只改变标签集,不创建 Artifact Revision | +| 同根 Source 被多次转述 | 不增加独立证据数量 | +| 相同幂等请求、响应丢失、Worker 接管 | 同一请求者和协议版本内至多一个正式候选/结果,旧任期不能提交 | +| 同 Scope 不同请求者使用相同 idempotency_key 或相同证据 | 不误报幂等冲突,不复用对方 Run/Candidate,归属与执行身份各自保持 | +| HTTP/SDK 并发审核同一 Source-window Profile 候选 | 各入口使用一致的 Policy 优先锁顺序,按版本条件完成或返回冲突,不产生反向锁等待 | +| A0/A1 部署未配置 B1/C 存储与接口 | 可以启动并启用已完成的首期操作,后续阶段能力保持关闭 | +| 缺证据、无变化、执行失败 | 三者可区分,不产生虚构正式制品 | +| 连续 Dream 请求与普通 Source 工作 | 两类工作均取得进展,普通游标仅由自身流程推进 | +| 旧 Client 与扩展后的 v1 Server | 未升级时收到明确升级提示或只读取其可解析类型;升级后可读取新增 Run 与 Candidate | + + +SQLite 与 OceanBase 都执行并发冲突、租约隔离和原子失败恢复验收。使用隔离数据完成一条真实链路:原始会话写入、生成初始制品、追加纠正证据、Dream 提案、人工审核、后续任务使用、结果回查。模型质量评测使用时间隔离的后续任务,对比“无 Dream”“普通重新生成”“本提案”,报告错误更新率、遗漏率、审核修改量、后续任务质量、延迟与总成本,不预设提升百分比。 + +# Drawbacks ++ 每种 Family 都需要专门 writer 和校验,统一入口不能消除条目、游标、激活和配置发布的语义差异。 ++ 全量 Memory head CAS 在活跃 Scope 中可能频繁冲突;采用更细粒度 CAS 会增加 manifest 合并复杂度,本期选择可解释的保守冲突。 ++ 完整 Profile/Topic 快照增加上下文和输出成本,预算可能使大型目标暂不可处理。 ++ 人工审核与 Skill/Prompt 评测增加等待和运营负担;错误的评测集仍可能选出错误配置。 ++ 扩展现有 v1 封闭枚举与 Profile generation 需要协调升级 Client;静默伪装成旧类型会更危险。 ++ Tag 的独立候选实现增加代码,但可避免把可变分类与不可变 Artifact Revision 混成一种生命周期。 + +# Rationale and alternatives +| 方案 | 优点 | 不采用的原因 | +| --- | --- | --- | +| 所有制品直接调用通用 replace | 改动小 | 绕过 Memory manifest、Profile 游标、Handoff 激活及 Tag ETag | +| 所有复盘结果都先转成 Experience | 可复用当前 Dream | 无法实际纠正画像、主题和操作配置,会留下错误的正式状态 | +| 每个 Family 单独实现一套 Dream 服务 | 局部实现直观 | 幂等、预算、证据与故障恢复重复,难以保持一致 | +| 统一外层 Run,内部注册 Family adapter | 共用运行能力,保留语义边界 | 采用,代价是显式注册与类型化协议 | +| 定时全量扫描、生成后自动覆盖 | 操作少 | 审核负担、成本和错误扩散难控制,尚无收益依据 | +| 一次改完 Memory、Profile、Topic、Tag | 表面一致 | 多目标事务与补偿复杂,本期只允许一个目标,协调另行定义 | +| Tag 强行创建 Artifact family | 可复用 result_artifact | 破坏已有 catalog 属性语义,产生无意义内容 Revision | + + +若不实施,仍可通过现有管理 API 人工修订,但证据收集、运行恢复、候选比较和权限复核分散在应用侧,无法形成一致的受控复盘流程。 + +# Prior art +本提案直接沿用 PowerContext 自身的设计,不以其他产品的宣传能力作为已验证依据。 + ++ [RFC 1510](https://github.com/oceanbase/powercontext/blob/master/docs/zh/rfcs/1510-artifact-dreaming.md):精确证据、单候选、后台预算、根来源去重。 ++ [RFC 0014](https://github.com/oceanbase/powercontext/blob/master/docs/zh/rfcs/0014_memory_layer_design.md):Memory 条目修订需显式证据,forget 独立授权。 ++ [RFC 1485](https://github.com/oceanbase/powercontext/blob/master/docs/zh/rfcs/1485_profile_artifact.md):Scope Profile 快照、Policy 与 Source-window 审核。 ++ [RFC 1417](https://github.com/oceanbase/powercontext/blob/master/docs/zh/rfcs/1417_topic_memory.md):主题生成、融合与渐进检索。 ++ [RFC 0048](https://github.com/oceanbase/powercontext/blob/master/docs/zh/rfcs/0048_handoff_artifact.md) 与 [1396](https://github.com/oceanbase/powercontext/blob/master/docs/zh/rfcs/1396_handoff_access_control.md):交接及权限边界。 ++ [RFC 1468](https://github.com/oceanbase/powercontext/blob/master/docs/zh/rfcs/1468_scope_owned_prompt_management.md) 与 [1467](https://github.com/oceanbase/powercontext/blob/master/docs/zh/rfcs/1467_artifact_tags.md):版本化配置与可变标签的不同语义。 ++ [RFC 1557](https://github.com/oceanbase/powercontext/blob/master/docs/zh/rfcs/1557_recurring_failure_repair.md):区分被选中、失败复发与实际效果的提案,不能从缺失观测作归因。该 RFC 的存在不代表其能力已实现。 + +本次现状核对的关键源码:`builtin/dream/models.py`、`builtin/dream/service.py`、`builtin/dream/bindings.py`、`builtin/review/models.py`、`builtin/review/service.py`、`builtin/artifacts/profile/models.py`、`builtin/artifacts/profile/review.py`、`builtin/artifacts/memory/models.py`、`builtin/artifacts/topic_memory/models.py` 和 `builtin/tags.py`,均位于 `src/powercontext/` 下。 + +# Unresolved questions +下列问题应在对应阶段启用前由维护者决策,不能委托模型临时决定: + +1. 扩展现有 v1 后的最低支持 Client 版本,以及旧 Client 的升级提示或兼容过滤策略。 +2. Skill 与各 Prompt key 的首组保留评测集、版本化质量门槛和评测责任人。本文规定必须有可信门槛,具体业务分数需实测确定。 +3. 现有 Handoff 非激活提交能力应如何从 prepare/commit 代码提取,确保与现有 token 和 receiver checks 完全一致。 +4. Catalog Change Candidate 的统一 Inbox 分页是否由服务端合并游标实现,还是先通过类型筛选页提供;无论 UI 方案如何,底层生命周期不混用。 +5. 发布后质量异常由哪个应用监控并发起显式回滚;本 RFC 不承诺自动因果归因或无人值守回滚。 + +这些问题不阻塞 A0/A1 的独立实现;涉及后续阶段的能力必须在问题解决、契约测试通过后才对外宣告可用。 + +# Future possibilities ++ 基于新增根证据、反例和纠正的周期性 discovery,设置静默期、候选去重和待审容量限制。 ++ Memory entry 合并、明确停用与恢复,Topic 拆分/合并,以及可解释的多目标原子变更组。 ++ 完整 Skill package 的静态差异、隔离测试和制品签名,保持执行与发布权限独立。 ++ 跨 Scope 的脱敏共享经验与独立授权读取,不从内容相似推导访问权。 ++ 对低风险确定性变更研究自动批准策略,但应另立 RFC,明确可逆性、质量阈值、审计和撤回机制。 ++ 用受控实验验证不同 Dream 策略对任务质量和成本的实际影响,再决定是否扩大自动化范围。 From 7cfdfc8091c7246d8e5430325f35093f2c7aabb4 Mon Sep 17 00:00:00 2001 From: Frey <370805886@qq.com> Date: Tue, 22 Sep 2026 14:28:43 +0800 Subject: [PATCH 02/48] feat(dream): review Profile revisions through existing flow --- openapi/powercontext.yaml | 19 ++++- .../builtin/artifacts/profile/models.py | 26 ++++-- .../builtin/artifacts/profile/review.py | 41 +++++++++- src/powercontext/builtin/dream/bindings.py | 34 ++++++-- src/powercontext/builtin/dream/generation.py | 5 +- src/powercontext/builtin/dream/models.py | 20 ++++- src/powercontext/builtin/dream/service.py | 47 ++++++++--- src/powercontext/builtin/evidence/models.py | 6 +- src/powercontext/builtin/evidence/resolver.py | 13 +++ .../builtin/evidence/selection.py | 2 +- src/powercontext/builtin/persistence/dream.py | 7 +- src/powercontext/builtin/review/models.py | 4 +- src/powercontext/builtin/review/service.py | 30 +++++++ .../builtin/runtime/composition.py | 7 +- .../builtin/runtime/family_processing.py | 10 +-- .../builtin/runtime/processing_registry.py | 8 +- src/powercontext/http/_generated/models.py | 9 ++- src/powercontext/http/_generated/schema.py | 39 +++++---- tests/e2e/dream_support.py | 8 +- tests/e2e/test_artifact_dreaming.py | 80 +++++++++++++++++++ 20 files changed, 345 insertions(+), 70 deletions(-) diff --git a/openapi/powercontext.yaml b/openapi/powercontext.yaml index df9235287d..1171aaff82 100644 --- a/openapi/powercontext.yaml +++ b/openapi/powercontext.yaml @@ -4540,7 +4540,7 @@ components: ProfileCandidateProposal: type: "object" additionalProperties: false - required: ["schema","content","source_window","generator_id","generator_version","created_at"] + required: ["schema","content","generator_id","generator_version","created_at"] properties: schema: type: "string" @@ -4550,6 +4550,14 @@ components: minLength: 1 source_window: $ref: "#/components/schemas/ProfileSourceWindow" + nullable: true + dream_run_id: + type: "string" + nullable: true + policy_version: + type: "integer" + minimum: 1 + nullable: true generator_id: type: "string" minLength: 1 @@ -5102,6 +5110,7 @@ components: enum: - refine_experience - derive_skill + - revise_profile DreamStatus: type: string enum: @@ -5121,12 +5130,14 @@ components: - source - experience - memory + - profile - unresolved DreamEvidenceRole: type: string enum: - root - derived + - target - lineage_only - unresolved DreamEvidenceIndependence: @@ -5199,8 +5210,8 @@ components: - operation - idempotency_key type: object - description: Select 1-20 exact Experience or Memory citations after deduplication, with at most 32 combined references - including Sources. Only refine_experience accepts Memory citations or a target. + description: Select 1-20 exact Artifact or Memory citations after deduplication, with at most 32 combined references + including Sources. A Profile revision requires its exact current Profile target and supporting evidence. DreamBudget: additionalProperties: false properties: @@ -5477,7 +5488,7 @@ components: type: array maxItems: 32 default: [] - description: Exact Memory entry provenance; non-empty only for Experience. Counted toward the combined evidence bound. + description: Exact Memory entry provenance for Experience or reviewed Profile Dream changes. Counted toward the combined evidence bound. items: $ref: "#/components/schemas/MemoryCitation" permissions: diff --git a/src/powercontext/builtin/artifacts/profile/models.py b/src/powercontext/builtin/artifacts/profile/models.py index 74a82a463d..f4a8130589 100644 --- a/src/powercontext/builtin/artifacts/profile/models.py +++ b/src/powercontext/builtin/artifacts/profile/models.py @@ -30,7 +30,9 @@ PROFILE_ARTIFACT_ID = "profile" PROFILE_SOURCE_WINDOW_BINDING = "profile-source-window" ProfileActivationMode = Literal["automatic", "review_required"] -ProfileGenerationMode = Literal["automatic", "manual_create", "manual_replace", "review_approved", "rollback"] +ProfileGenerationMode = Literal[ + "automatic", "manual_create", "manual_replace", "review_approved", "dream_review_approved", "rollback" +] class _ProfileValue(BaseModel): @@ -74,6 +76,7 @@ class ProfileGeneration(_ProfileValue): generator_id: str | None = None generator_version: str | None = None source_window: SourceWindow | None = None + dream_run_id: str | None = None restored_from_revision: int | None = Field(default=None, ge=1) @field_validator("created_at") @@ -85,9 +88,12 @@ def utc_timestamp(cls, value: datetime) -> datetime: @model_validator(mode="after") def valid_generation(self): - generated = self.mode in {"automatic", "review_approved"} - if generated != (self.source_window is not None): - raise ValueError("only generated profiles require a Source window") # noqa: TRY003 + windowed = self.mode in {"automatic", "review_approved"} + if windowed != (self.source_window is not None): + raise ValueError("only Source-window profiles require a Source window") # noqa: TRY003 + if (self.mode == "dream_review_approved") != (self.dream_run_id is not None): + raise ValueError("only Dream-approved profiles name a Dream run") # noqa: TRY003 + generated = windowed or self.mode == "dream_review_approved" if generated and (not self.generator_id or not self.generator_version): raise ValueError("generated profiles require generator identity") # noqa: TRY003 if not generated and (self.generator_id is not None or self.generator_version is not None): @@ -114,7 +120,9 @@ class ProfileCandidateProposal(_ProfileValue): default="powercontext.profile-candidate.v1", alias="schema" ) content: str - source_window: SourceWindow + source_window: SourceWindow | None = None + dream_run_id: str | None = None + policy_version: int | None = Field(default=None, ge=1) generator_id: str = Field(min_length=1) generator_version: str = Field(min_length=1) created_at: datetime @@ -129,6 +137,14 @@ def validate_markdown(cls, value: str) -> str: def utc_timestamp(cls, value: datetime) -> datetime: return ProfileGeneration.utc_timestamp(value) + @model_validator(mode="after") + def valid_origin(self): + if (self.source_window is None) == (self.dream_run_id is None): + raise ValueError("Profile Candidate requires exactly one generation origin") # noqa: TRY003 + if (self.dream_run_id is None) != (self.policy_version is None): + raise ValueError("Dream Profile Candidate requires a Policy version") # noqa: TRY003 + return self + class Profile(Artifact[ProfileContent]): family: ClassVar[str] = PROFILE_FAMILY diff --git a/src/powercontext/builtin/artifacts/profile/review.py b/src/powercontext/builtin/artifacts/profile/review.py index c38e1ead93..c75c4a1557 100644 --- a/src/powercontext/builtin/artifacts/profile/review.py +++ b/src/powercontext/builtin/artifacts/profile/review.py @@ -40,11 +40,13 @@ async def decide_profile(service, connection, candidate_id, expected_version, *, policies = ProfilePolicyRepository() policy = await policies.get(connection, service._scope_id, for_update=True) candidate = await service._candidates.lock_pending(connection, service._scope_id, candidate_id, expected_version) - if policy is None or policy.pending_candidate_id != candidate_id: - raise BaseValueConflictError("profile_candidate", (service._scope_id,)) proposal = candidate.proposal if not isinstance(proposal, ProfileCandidateProposal): raise InvalidCandidateError("family", "profile required") + if proposal.dream_run_id is not None: + return await _decide_dream_profile(service, connection, candidate, policy, reason=reason) + if policy is None or policy.pending_candidate_id != candidate_id: + raise BaseValueConflictError("profile_candidate", (service._scope_id,)) cursors = SourceCursorRepository() cursor = await cursors.load(connection, service._scope_id, PROFILE_SOURCE_WINDOW_BINDING, for_update=True) if (0 if cursor is None else cursor.cursor.sequence) != proposal.source_window.after: @@ -98,6 +100,41 @@ async def decide_profile(service, connection, candidate_id, expected_version, *, return result +async def _decide_dream_profile(service, connection, candidate, policy, *, reason=None): + proposal = candidate.proposal + if proposal.policy_version != (None if policy is None else policy.version): + raise BaseValueConflictError("profile_policy", (service._scope_id,)) + if reason is not None: + return await service._candidates.reject( + connection, service._scope_id, candidate.candidate_id, candidate.version, reason + ) + await service._validate_evidence( + connection, candidate.sources, candidate.artifacts, candidate.memory_citations + ) + current = await service._artifacts.latest(connection, service._scope_id, "profile", PROFILE_ARTIFACT_ID) + if current.as_ref() != candidate.target: + raise BaseValueConflictError("profile_head", (service._scope_id,)) + draft = ProfileDraft( + content=ProfileContent( + content=proposal.content, + generation=ProfileGeneration( + mode="dream_review_approved", + created_at=datetime.now(UTC), + generator_id=proposal.generator_id, + generator_version=proposal.generator_version, + dream_run_id=proposal.dream_run_id, + ), + ), + sources=candidate.sources, + artifacts=candidate.artifacts, + memory_citations=candidate.memory_citations, + ) + artifact = await service._artifacts.revise(connection, service._scope_id, current, draft) + return await service._candidates.mark_approved( + connection, service._scope_id, candidate.candidate_id, candidate.version, artifact.as_ref() + ) + + def revise_profile(current, proposal, sources, artifacts, target): if not isinstance(proposal, ProfileWriteContent) or proposal.restored_from_revision is not None: raise InvalidCandidateError("proposal", "only Markdown content is writable") diff --git a/src/powercontext/builtin/dream/bindings.py b/src/powercontext/builtin/dream/bindings.py index 9fd659a510..6790cd56e7 100644 --- a/src/powercontext/builtin/dream/bindings.py +++ b/src/powercontext/builtin/dream/bindings.py @@ -12,13 +12,37 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Map explicit Dream operations to their output Family's processing binding.""" +"""Map enabled Dream operations to their owning Family processing binding.""" + +from dataclasses import dataclass from powercontext.builtin.artifacts.experience import EXPERIENCE_INCUBATION_CURSOR_NAME +from powercontext.builtin.artifacts.profile.models import PROFILE_SOURCE_WINDOW_BINDING +from powercontext.builtin.dream.models import DreamOperation SKILL_DREAM_BINDING = "skill.dream.v1" -DREAM_BINDINGS = { - "refine_experience": EXPERIENCE_INCUBATION_CURSOR_NAME, - "derive_skill": SKILL_DREAM_BINDING, -} + + +@dataclass(frozen=True) +class DreamOperationSpec: + operation: DreamOperation + family: str + binding: str + + +# An operation belongs here only after its resolver, generator, Candidate and +# approval writer are implemented. This registry controls worker dispatch as +# well as admission; a readable Artifact Family alone does not enable Dream. +DREAM_OPERATIONS = ( + DreamOperationSpec("refine_experience", "experience", EXPERIENCE_INCUBATION_CURSOR_NAME), + DreamOperationSpec("derive_skill", "skill", SKILL_DREAM_BINDING), + DreamOperationSpec("revise_profile", "profile", PROFILE_SOURCE_WINDOW_BINDING), +) +DREAM_BINDINGS = {spec.operation: spec.binding for spec in DREAM_OPERATIONS} + + +def operations_for_binding(binding: str) -> tuple[DreamOperation, ...]: + return tuple(spec.operation for spec in DREAM_OPERATIONS if spec.binding == binding) + + DREAM_PROVIDERS = frozenset({"openai", "openai-chat", "openai-responses", "anthropic"}) diff --git a/src/powercontext/builtin/dream/generation.py b/src/powercontext/builtin/dream/generation.py index 027793606b..219d0c26dd 100644 --- a/src/powercontext/builtin/dream/generation.py +++ b/src/powercontext/builtin/dream/generation.py @@ -31,6 +31,9 @@ A preference or unsupported assertion alone is not a task experience: return no_change. When task actions or results lack usable source support, return needs_evidence and explain the gap. For derive_skill, derive one instruction-only Skill from the supplied Experience evidence. +For revise_profile, compare the exact current Profile with new evidence and propose a complete Markdown replacement. +Treat Profile text as the target under review, never as independent support for its own assertions. +Distinguish temporary plans from durable facts. Use correct as the intent for a supported replacement. Provide name, description, instructions, and at least one concrete validation check in validation. Use a package-compatible name: at most 64 lowercase letters, digits, and single separating hyphens. Keep description within 1024 characters. Trim text fields and omit trailing whitespace in instructions. @@ -53,7 +56,7 @@ class DreamGenerationInput(BaseModel): operation: DreamOperation target_evidence_id: str | None = Field( - description="Exact evidence ID of the Experience being replaced; null when creating a new Artifact." + description="Exact evidence ID of the Artifact being replaced; null when creating a new Artifact." ) evidence: EvidenceProjection diff --git a/src/powercontext/builtin/dream/models.py b/src/powercontext/builtin/dream/models.py index 9ff4edf5a1..b78bd73f69 100644 --- a/src/powercontext/builtin/dream/models.py +++ b/src/powercontext/builtin/dream/models.py @@ -23,6 +23,7 @@ from powercontext.artifacts import ArtifactRef, MemoryCitation from powercontext.builtin.artifacts.experience import ExperienceContent +from powercontext.builtin.artifacts.profile.models import ProfileWriteContent from powercontext.builtin.artifacts.skill import SkillContent from powercontext.builtin.evidence.models import ( EvidenceLimits, @@ -34,7 +35,7 @@ from powercontext.errors import PowerContextError from powercontext.sources import SourceRef -DreamOperation = Literal["refine_experience", "derive_skill"] +DreamOperation = Literal["refine_experience", "derive_skill", "revise_profile"] DreamStatus = Literal["queued", "running", "succeeded", "failed"] DreamOutcome = Literal["proposed", "no_change", "needs_evidence"] DreamIntent = Literal["create", "corroborate", "refine", "correct", "derive"] @@ -74,7 +75,8 @@ def validate_selection(self): selected = len(self.artifacts) + len(self.memory_citations) if not 1 <= selected <= 20 or selected + len(self.sources) > 32: raise DreamError("evidence_limit_exceeded") - if any(ref.family != "experience" for ref in self.artifacts): + allowed_families = {"experience"} if self.operation != "revise_profile" else {"experience", "profile"} + if any(ref.family not in allowed_families for ref in self.artifacts): raise DreamError("invalid_artifact_family") if any( ref.memory_ref.family != "memory" @@ -89,6 +91,12 @@ def validate_selection(self): self.memory_citations or self.target is not None or not self.artifacts ): raise DreamError("invalid_dream_operation") + if self.operation == "revise_profile" and ( + self.target is None + or self.target.family != "profile" + or any(ref.family == "profile" and ref != self.target for ref in self.artifacts) + ): + raise DreamError("invalid_dream_operation") return self def digest(self) -> str: @@ -172,7 +180,7 @@ class DreamPlan(BaseModel): outcome: DreamOutcome reason: str = Field(min_length=1, max_length=2000) intent: DreamIntent | None = None - proposal: ExperienceContent | SkillContent | None = None + proposal: ExperienceContent | SkillContent | ProfileWriteContent | None = None evidence_ids: tuple[str, ...] = Field(default=(), max_length=32) @model_validator(mode="after") @@ -193,6 +201,12 @@ def validate_operation(self, request: CreateDreamRunRequest) -> None: valid = ( isinstance(self.proposal, SkillContent) and self.intent == "derive" and self.proposal.package is None ) + elif request.operation == "revise_profile": + valid = ( + isinstance(self.proposal, ProfileWriteContent) + and self.proposal.restored_from_revision is None + and self.intent == "correct" + ) else: intents = {"create"} if request.target is None else {"corroborate", "refine", "correct"} valid = isinstance(self.proposal, ExperienceContent) and self.intent in intents diff --git a/src/powercontext/builtin/dream/service.py b/src/powercontext/builtin/dream/service.py index 4350959a61..f4f4d4d510 100644 --- a/src/powercontext/builtin/dream/service.py +++ b/src/powercontext/builtin/dream/service.py @@ -28,8 +28,9 @@ from powercontext.artifacts import ArtifactRef from powercontext.builtin.artifacts.experience import ExperienceContent +from powercontext.builtin.artifacts.profile.models import ProfileCandidateProposal, ProfileWriteContent from powercontext.builtin.artifacts.skill import SkillContent -from powercontext.builtin.dream.bindings import DREAM_BINDINGS +from powercontext.builtin.dream.bindings import DREAM_BINDINGS, operations_for_binding from powercontext.builtin.dream.generation import DreamGenerationInput, DreamGenerator from powercontext.builtin.dream.models import ( DREAM_PROMPT_VERSION, @@ -59,6 +60,7 @@ from powercontext.builtin.persistence.dream import DreamRepository, database_now from powercontext.builtin.persistence.errors import ArtifactProcessingLeadershipLostError from powercontext.builtin.persistence.processing_intents import ArtifactProcessingIntentRepository +from powercontext.builtin.persistence.profile import ProfilePolicyRepository from powercontext.builtin.persistence.tables import SCOPES_TABLE from powercontext.builtin.review.errors import ArtifactTargetConflictError from powercontext.builtin.review.generation import SkillGenerationOrigin, validate_skill_lineage @@ -165,6 +167,7 @@ async def create(self, scope_id: str, principal_id: str, request: CreateDreamRun sources=request.sources, artifacts=request.artifacts, memory_citations=request.memory_citations, + target=request.target, project=False, lock_memory=True, ) @@ -207,13 +210,13 @@ async def execute(self) -> bool: if self.processing is None: raise RuntimeError("Dream execution requires a Supervisor invocation") # noqa: TRY003 work = self.processing.assignment - operation: DreamOperation = "derive_skill" if work.artifact_family == "skill" else "refine_experience" - if DREAM_BINDINGS[operation] != work.binding_name: + operations = operations_for_binding(work.binding_name) + if not operations: raise ValueError("Dream operation and processing binding do not match") # noqa: TRY003 async with self._transaction() as connection: await self.processing.start(connection) record = await self.repository.next_pending( - connection, work.scope_id, operation, through_generation=work.claimed_request_generation + connection, work.scope_id, operations, through_generation=work.claimed_request_generation ) if record is None: return False @@ -221,7 +224,7 @@ async def execute(self) -> bool: connection, record, model_config_id=None if self.generator is None else self.generator.config_id ) if record.run.terminal: - await self._complete_invocation(connection, operation) + await self._complete_invocation(connection) return True try: async with self._transaction() as connection: @@ -238,12 +241,12 @@ async def execute(self) -> bool: await self._fail_or_retry(record, _error_code(error), retry=False) return True - async def _complete_invocation(self, connection: AsyncConnection, operation: DreamOperation) -> None: + async def _complete_invocation(self, connection: AsyncConnection) -> None: if self.processing is None: raise RuntimeError("Dream execution requires a Supervisor invocation") # noqa: TRY003 work = self.processing.assignment current = await self.processing.guard(connection) - remaining = await self.repository.next_pending(connection, work.scope_id, operation) + remaining = await self.repository.next_pending(connection, work.scope_id, operations_for_binding(work.binding_name)) if remaining is not None and current.requested_generation == work.claimed_request_generation: await self.intents.request(connection, work.scope_id, work.binding_name) # Dream never consumes or clears the Family's ordinary Source progress. @@ -309,6 +312,7 @@ async def _resolve(self, record: DreamRecord, connection: AsyncConnection | None sources=request.sources, artifacts=request.artifacts, memory_citations=request.memory_citations, + target=request.target, include_memory_text=request.operation != "derive_skill", pinned=record.run.input_manifest, lock_memory=connection is not None, @@ -323,7 +327,9 @@ async def _commit(self, record: DreamRecord, plan: DreamPlan) -> None: candidate = None if plan.outcome == "proposed": if self.attest_candidate is not None: - family = "skill" if run.operation == "derive_skill" else "experience" + family = {"derive_skill": "skill", "refine_experience": "experience", "revise_profile": "profile"}[ + run.operation + ] await self.attest_candidate(connection, record, _candidate_id(record), family) candidate = await self._propose(connection, record, plan, resolved) completed = run.model_copy( @@ -336,7 +342,7 @@ async def _commit(self, record: DreamRecord, plan: DreamPlan) -> None: } ) await self.repository.finish(connection, record, completed) - await self._complete_invocation(connection, run.operation) + await self._complete_invocation(connection) async def _propose( self, @@ -372,6 +378,27 @@ async def _propose( reason=plan.reason, candidate_id=_candidate_id(record), ) + elif isinstance(plan.proposal, ProfileWriteContent) and record.run.operation == "revise_profile": + policy = await ProfilePolicyRepository().get(connection, record.run.scope_id, for_update=True) + if policy is None or record.run.target is None or self.generator is None: + raise DreamError("capability_unavailable") + proposal = ProfileCandidateProposal( + content=plan.proposal.content, + dream_run_id=record.run.run_id, + policy_version=policy.version, + generator_id=self.generator.config_id, + generator_version=DREAM_PROMPT_VERSION, + created_at=await database_now(connection), + ) + candidate = await review.propose_profile_dream( + proposal, + sources=selected.sources, + artifacts=selected.artifacts, + target=record.run.target, + reason=plan.reason, + candidate_id=_candidate_id(record), + memory_citations=selected.memory_citations, + ) else: raise DreamError("invalid_generation_output") return DreamCandidateRef(candidate_id=candidate.candidate_id, version=candidate.version) @@ -420,7 +447,7 @@ async def _fail_or_retry(self, record: DreamRecord, code: str, *, retry: bool) - else: failed = current.run.model_copy(update={"status": "failed", "error": code, "completed_at": now}) await self.repository.finish(connection, current, failed) - await self._complete_invocation(connection, current.run.operation) + await self._complete_invocation(connection) except DreamError as error: if error.code != "attempt_conflict": raise diff --git a/src/powercontext/builtin/evidence/models.py b/src/powercontext/builtin/evidence/models.py index 5f54873592..1e8a73c970 100644 --- a/src/powercontext/builtin/evidence/models.py +++ b/src/powercontext/builtin/evidence/models.py @@ -52,12 +52,12 @@ class EvidenceNode(BaseModel): model_config = ConfigDict(frozen=True, extra="forbid") evidence_id: str - kind: Literal["source", "experience", "memory", "unresolved"] + kind: Literal["source", "experience", "memory", "profile", "unresolved"] digest: str source: SourceRef | None = None artifact: ArtifactRef | None = None memory_citations: tuple[MemoryCitation, ...] = () - role: Literal["root", "derived", "lineage_only", "unresolved"] + role: Literal["root", "derived", "target", "lineage_only", "unresolved"] historical: bool = False current_entry_version_id: str | None = None @@ -96,7 +96,7 @@ class ProjectedEvidence(BaseModel): """Untrusted content visible to the model for this operation only.""" evidence_id: str - kind: Literal["source", "experience", "memory"] + kind: Literal["source", "experience", "memory", "profile"] text: str historical: bool = False root_group_ids: tuple[str, ...] = () diff --git a/src/powercontext/builtin/evidence/resolver.py b/src/powercontext/builtin/evidence/resolver.py index 71035f3dc7..b9480e5943 100644 --- a/src/powercontext/builtin/evidence/resolver.py +++ b/src/powercontext/builtin/evidence/resolver.py @@ -28,6 +28,7 @@ from powercontext.builtin.artifacts.experience import Experience from powercontext.builtin.artifacts.memory import Memory, MemoryEntryVersion from powercontext.builtin.artifacts.memory.errors import InvalidMemoryCitationError, MemoryEntryNotFoundError +from powercontext.builtin.artifacts.profile.models import Profile from powercontext.builtin.evidence.models import ( EVIDENCE_TRANSFORM_VERSION, EvidenceEdge, @@ -181,6 +182,7 @@ async def resolve( sources: tuple[SourceRef, ...] = (), artifacts: tuple[ArtifactRef, ...] = (), memory_citations: tuple[MemoryCitation, ...] = (), + target: ArtifactRef | None = None, include_memory_text: bool = True, lock_memory: bool = False, pinned: EvidenceManifest | None = None, @@ -196,12 +198,15 @@ async def resolve( sources=sources, artifacts=artifacts, memory_citations=memory_citations, + target=target, project=False, ) owners = tuple(citation for node in observed.manifest.nodes for citation in node.memory_citations) await self._lock_memories(connection, owners) for ref in refs: await self._visit(connection, ref, traversal, depth=0, direct=True, lock_memory=lock_memory) + if target is not None and (target_id := evidence_id(target)) in traversal.nodes: + traversal.nodes[target_id] = traversal.nodes[target_id].model_copy(update={"role": "target"}) if pinned is not None: self._restore_snapshot(traversal, pinned) groups = self._groups(traversal) @@ -299,6 +304,14 @@ async def _read( return await self._read_memory(connection, ref, locked=locked) artifact = await self.artifacts.get(connection, self.scope_id, ref) digest = content_digest(artifact.model_dump_json().encode()) + if isinstance(artifact, Profile): + return ( + EvidenceNode( + evidence_id=evidence_id(ref), kind="profile", artifact=ref, digest=digest, role="derived" + ), + artifact.content.content, + (), + ) if ref.family == "prompt": return ( EvidenceNode( diff --git a/src/powercontext/builtin/evidence/selection.py b/src/powercontext/builtin/evidence/selection.py index 2893943673..74b45c73ee 100644 --- a/src/powercontext/builtin/evidence/selection.py +++ b/src/powercontext/builtin/evidence/selection.py @@ -40,7 +40,7 @@ def select_evidence( nodes = {node.evidence_id: node for node in manifest.nodes} for node_id in used: node = nodes.get(node_id) - if node is None or node.role in {"unresolved", "lineage_only"} or (skill and node.kind == "memory"): + if node is None or node.role in {"unresolved", "lineage_only", "target"} or (skill and node.kind == "memory"): raise EvidenceResolutionError("invalid_generation_output") chosen = set(used) origins = (*manifest.artifacts, *manifest.memory_citations) diff --git a/src/powercontext/builtin/persistence/dream.py b/src/powercontext/builtin/persistence/dream.py index 9701ccbefe..06eb4a0ca2 100644 --- a/src/powercontext/builtin/persistence/dream.py +++ b/src/powercontext/builtin/persistence/dream.py @@ -168,13 +168,16 @@ async def next_pending( self, connection: AsyncConnection, scope_id: str, - operation: str, + operation: str | tuple[str, ...], *, through_generation: int | None = None, ) -> DreamRecord | None: + operations = (operation,) if isinstance(operation, str) else operation + if not operations: + return None statement = select(RUNS.c.payload).where( RUNS.c.scope_id == scope_id, - RUNS.c.operation == operation, + RUNS.c.operation.in_(operations), RUNS.c.status.in_(("queued", "running")), ) if through_generation is not None: diff --git a/src/powercontext/builtin/review/models.py b/src/powercontext/builtin/review/models.py index 4a49ab6ff9..9520d29cdf 100644 --- a/src/powercontext/builtin/review/models.py +++ b/src/powercontext/builtin/review/models.py @@ -73,8 +73,8 @@ def validate_evidence(self): raise ValueError(f"Candidate evidence must not exceed {MAX_CANDIDATE_EVIDENCE} references") # noqa: TRY003 if self.target is not None and self.target.family != self.family: raise ValueError("Candidate target must belong to the proposed family") # noqa: TRY003 - if self.memory_citations and self.family != "experience": - raise ValueError("only Experience Candidates accept Memory citations") # noqa: TRY003 + if self.memory_citations and self.family not in {"experience", "profile"}: + raise ValueError("this Candidate Family does not accept Memory citations") # noqa: TRY003 return self @model_validator(mode="after") diff --git a/src/powercontext/builtin/review/service.py b/src/powercontext/builtin/review/service.py index 2cd5a9b902..3a331e1b5d 100644 --- a/src/powercontext/builtin/review/service.py +++ b/src/powercontext/builtin/review/service.py @@ -46,6 +46,7 @@ from powercontext.builtin.persistence.errors import RepositoryNotFoundError from powercontext.builtin.persistence.experience_index import ExperienceIndex from powercontext.builtin.persistence.generation_sources import GenerationSourceAccess +from powercontext.builtin.persistence.profile import ProfilePolicyRepository from powercontext.builtin.persistence.skill_packages import SkillPackageRepository from powercontext.builtin.persistence.sources import StoredSource from powercontext.builtin.review.errors import ( @@ -170,6 +171,32 @@ async def propose_skill( ) return _skill_candidate(candidate) + async def propose_profile_dream( + self, + proposal: ProfileCandidateProposal, + /, + *, + sources: tuple[SourceRef, ...], + artifacts: tuple[ArtifactRef, ...], + target: ArtifactRef, + reason: str, + candidate_id: str, + memory_citations: tuple[MemoryCitation, ...] = (), + ) -> ArtifactCandidate[ProfileCandidateProposal]: + if proposal.dream_run_id is None or proposal.source_window is not None: + raise InvalidCandidateError("proposal", "a Dream Profile Candidate requires a Dream run") + candidate = await self._propose( + "profile", + proposal, + sources=sources, + artifacts=artifacts, + target=target, + reason=reason, + candidate_id=candidate_id, + memory_citations=memory_citations, + ) + return ArtifactCandidate[ProfileCandidateProposal].model_validate(candidate.model_dump(mode="python")) + async def _propose( self, family: str, @@ -299,6 +326,9 @@ async def revise( canonical_artifacts = _unique_artifacts(artifacts) _validate_reason(reason) async with self._connection() as connection: + preview = await self._candidates.get(connection, self._scope_id, candidate_id) + if preview.family == "profile": + await ProfilePolicyRepository().get(connection, self._scope_id, for_update=True) current = await self._candidates.lock_pending( connection, self._scope_id, diff --git a/src/powercontext/builtin/runtime/composition.py b/src/powercontext/builtin/runtime/composition.py index 71c78b34a2..fee6723d56 100644 --- a/src/powercontext/builtin/runtime/composition.py +++ b/src/powercontext/builtin/runtime/composition.py @@ -59,6 +59,7 @@ topic_memory_stage_budget, validate_topic_memory_stage_capacity, ) +from powercontext.builtin.dream.bindings import DREAM_OPERATIONS from powercontext.builtin.dream.generation import ( DREAM_INSTRUCTIONS, DreamGenerationInput, @@ -459,9 +460,9 @@ async def run_profile(scope_id, high): if dream_generator is not None and config.runtime.dream_enabled: registered_families = {binding.artifact_family for binding in processing_bindings} configured_operations = tuple( - operation - for operation, family in (("refine_experience", "experience"), ("derive_skill", "skill")) - if family in registered_families + spec.operation + for spec in DREAM_OPERATIONS + if spec.family in registered_families ) topic_memory_processing_available = _topic_memory_processing_available(config, processing_bindings) runtime = await resources.enter_async_context( diff --git a/src/powercontext/builtin/runtime/family_processing.py b/src/powercontext/builtin/runtime/family_processing.py index ef7a0ee616..2073e1acdf 100644 --- a/src/powercontext/builtin/runtime/family_processing.py +++ b/src/powercontext/builtin/runtime/family_processing.py @@ -28,7 +28,7 @@ from powercontext._logging import log_safely from powercontext.builtin.artifacts.experience import EXPERIENCE_INCUBATION_CURSOR_NAME from powercontext.builtin.artifacts.profile.models import PROFILE_SOURCE_WINDOW_BINDING -from powercontext.builtin.dream.bindings import SKILL_DREAM_BINDING +from powercontext.builtin.dream.bindings import SKILL_DREAM_BINDING, operations_for_binding from powercontext.builtin.dream.generation import DreamGenerator from powercontext.builtin.inference.models import InferenceUsage from powercontext.builtin.inference.usage import bind_usage_reporter @@ -119,13 +119,13 @@ async def _run_family_worker( open_worker_security(spec.worker_security, contexts.database) ) generator = None - if assignment.artifact_family in {"experience", "skill"} and config.runtime.dream_enabled: + operations = operations_for_binding(assignment.binding_name) + if operations and config.runtime.dream_enabled: async with contexts.database.transaction() as connection: - operation = "derive_skill" if assignment.artifact_family == "skill" else "refine_experience" record = await DreamRepository().next_pending( connection, assignment.scope_id, - operation, + operations, through_generation=assignment.claimed_request_generation, ) if record is not None: @@ -188,7 +188,7 @@ async def _process_family_invocation( # noqa: C901 - one guarded dispatch per r authorize_transaction=None if security is None else partial(security.authorize_transaction, scope_id=scope), ) try: - if assignment.artifact_family in {"experience", "skill"}: + if operations_for_binding(assignment.binding_name): from powercontext.builtin.runtime.dream_processing import process_dream_invocation if await process_dream_invocation( diff --git a/src/powercontext/builtin/runtime/processing_registry.py b/src/powercontext/builtin/runtime/processing_registry.py index 84ceb0e7e1..7cd4419a65 100644 --- a/src/powercontext/builtin/runtime/processing_registry.py +++ b/src/powercontext/builtin/runtime/processing_registry.py @@ -21,7 +21,7 @@ from powercontext.builtin.artifacts.experience import EXPERIENCE_INCUBATION_CURSOR_NAME from powercontext.builtin.artifacts.profile.models import PROFILE_SOURCE_WINDOW_BINDING from powercontext.builtin.artifacts.topic_memory import TOPIC_MEMORY_SOURCE_WINDOW_BINDING -from powercontext.builtin.dream.bindings import DREAM_PROVIDERS, SKILL_DREAM_BINDING +from powercontext.builtin.dream.bindings import DREAM_OPERATIONS, DREAM_PROVIDERS, SKILL_DREAM_BINDING from powercontext.builtin.dream.models import DreamOperation from powercontext.builtin.runtime.config import BuiltinConfig from powercontext.builtin.triggers import SOURCE_WINDOW_TRIGGER_NAME @@ -73,9 +73,9 @@ def dream_operations(config: BuiltinConfig) -> tuple[DreamOperation, ...]: return () families = processing_capabilities(config) return tuple( - operation - for operation, family in (("refine_experience", "experience"), ("derive_skill", "skill")) - if family in families + spec.operation + for spec in DREAM_OPERATIONS + if spec.family in families ) diff --git a/src/powercontext/http/_generated/models.py b/src/powercontext/http/_generated/models.py index 0ae18b80cd..10ec679626 100644 --- a/src/powercontext/http/_generated/models.py +++ b/src/powercontext/http/_generated/models.py @@ -107,7 +107,9 @@ class ProfileCandidateProposal(BaseModel): ) schema_: Annotated[Schema, Field(alias="schema")] content: Annotated[StrictStr, Field(min_length=1)] - source_window: ProfileSourceWindow + source_window: ProfileSourceWindow | None = None + dream_run_id: StrictStr | None = None + policy_version: Annotated[StrictInt | None, Field(ge=1)] = None generator_id: Annotated[StrictStr, Field(min_length=1)] generator_version: Annotated[StrictStr, Field(min_length=1)] created_at: AwareDatetime @@ -345,6 +347,7 @@ class CandidatePermissions(BaseModel): class DreamOperation(StrEnum): REFINE_EXPERIENCE = "refine_experience" DERIVE_SKILL = "derive_skill" + REVISE_PROFILE = "revise_profile" class DreamStatus(StrEnum): @@ -364,12 +367,14 @@ class DreamEvidenceKind(StrEnum): SOURCE = "source" EXPERIENCE = "experience" MEMORY = "memory" + PROFILE = "profile" UNRESOLVED = "unresolved" class DreamEvidenceRole(StrEnum): ROOT = "root" DERIVED = "derived" + TARGET = "target" LINEAGE_ONLY = "lineage_only" UNRESOLVED = "unresolved" @@ -3492,7 +3497,7 @@ class ArtifactCandidate(BaseModel): memory_citations: Annotated[ list[MemoryCitation], Field( - description="Exact Memory entry provenance; non-empty only for Experience. Counted toward the combined evidence bound.", + description="Exact Memory entry provenance for Experience or reviewed Profile Dream changes. Counted toward the combined evidence bound.", max_length=32, validate_default=True, ), diff --git a/src/powercontext/http/_generated/schema.py b/src/powercontext/http/_generated/schema.py index 4e027b24f8..24da2ded5e 100644 --- a/src/powercontext/http/_generated/schema.py +++ b/src/powercontext/http/_generated/schema.py @@ -4585,14 +4585,16 @@ "properties": { "schema": {"type": "string", "enum": ["powercontext.profile-candidate.v1"]}, "content": {"type": "string", "minLength": 1}, - "source_window": {"$ref": "#/components/schemas/ProfileSourceWindow"}, + "source_window": {"$ref": "#/components/schemas/ProfileSourceWindow", "nullable": True}, + "dream_run_id": {"type": "string", "nullable": True}, + "policy_version": {"type": "integer", "minimum": 1.0, "nullable": True}, "generator_id": {"type": "string", "minLength": 1}, "generator_version": {"type": "string", "minLength": 1}, "created_at": {"type": "string", "format": "date-time"}, }, "additionalProperties": False, "type": "object", - "required": ["schema", "content", "source_window", "generator_id", "generator_version", "created_at"], + "required": ["schema", "content", "generator_id", "generator_version", "created_at"], }, "CreateProfileArtifactRequest": { "properties": { @@ -4985,11 +4987,17 @@ "required": ["principal", "receiver_identity_matches"], "description": "Server-owned attestation stored separately from the immutable untrusted Receipt.", }, - "DreamOperation": {"type": "string", "enum": ["refine_experience", "derive_skill"]}, + "DreamOperation": {"type": "string", "enum": ["refine_experience", "derive_skill", "revise_profile"]}, "DreamStatus": {"type": "string", "enum": ["queued", "running", "succeeded", "failed"]}, "DreamOutcome": {"type": "string", "enum": ["proposed", "no_change", "needs_evidence"]}, - "DreamEvidenceKind": {"type": "string", "enum": ["source", "experience", "memory", "unresolved"]}, - "DreamEvidenceRole": {"type": "string", "enum": ["root", "derived", "lineage_only", "unresolved"]}, + "DreamEvidenceKind": { + "type": "string", + "enum": ["source", "experience", "memory", "profile", "unresolved"], + }, + "DreamEvidenceRole": { + "type": "string", + "enum": ["root", "derived", "target", "lineage_only", "unresolved"], + }, "DreamEvidenceIndependence": {"type": "string", "enum": ["attested", "unknown"]}, "DreamSourceReference": { "properties": { @@ -5039,14 +5047,14 @@ "additionalProperties": False, "type": "object", "required": ["operation", "idempotency_key"], - "description": "Select 1-20 exact Experience " + "description": "Select 1-20 exact Artifact " "or Memory citations after " "deduplication, with at most " "32 combined references " - "including Sources. Only " - "refine_experience accepts " - "Memory citations or a " - "target.", + "including Sources. A Profile " + "revision requires its exact " + "current Profile target and " + "supporting evidence.", }, "DreamBudget": { "properties": { @@ -5204,11 +5212,14 @@ "description": "Exact " "Memory " "entry " - "provenance; " - "non-empty " - "only " + "provenance " "for " - "Experience. " + "Experience " + "or " + "reviewed " + "Profile " + "Dream " + "changes. " "Counted " "toward " "the " diff --git a/tests/e2e/dream_support.py b/tests/e2e/dream_support.py index b729903701..f844cc146d 100644 --- a/tests/e2e/dream_support.py +++ b/tests/e2e/dream_support.py @@ -23,7 +23,7 @@ from sqlalchemy import func, select -from powercontext.builtin.dream.bindings import DREAM_BINDINGS +from powercontext.builtin.dream.bindings import DREAM_OPERATIONS from powercontext.builtin.persistence.errors import ArtifactProcessingLeadershipLostError from powercontext.builtin.persistence.tables import ARTIFACT_PROCESSING_INTENTS_TABLE from powercontext.builtin.runtime import BuiltinRuntime @@ -84,13 +84,13 @@ async def open_dream_runtime(config, **kwargs): controller = Controller() bindings = tuple( ArtifactProcessingBinding( - binding_name=binding, - artifact_family="skill" if operation == "derive_skill" else "experience", + binding_name=spec.binding, + artifact_family=spec.family, launcher=controller, max_workers=4, worker_timeout_seconds=180, ) - for operation, binding in DREAM_BINDINGS.items() + for spec in DREAM_OPERATIONS ) async with open_runtime(config, artifact_processing_bindings=bindings, **kwargs) as runtime: controller.runtime = runtime diff --git a/tests/e2e/test_artifact_dreaming.py b/tests/e2e/test_artifact_dreaming.py index 3294a3204e..e609597b93 100644 --- a/tests/e2e/test_artifact_dreaming.py +++ b/tests/e2e/test_artifact_dreaming.py @@ -20,6 +20,7 @@ import os from collections.abc import Iterator from contextlib import nullcontext, suppress +from datetime import UTC, datetime from pathlib import Path from typing import cast from uuid import uuid4 @@ -30,6 +31,12 @@ from powercontext.builtin.artifacts.experience import ExperienceContent from powercontext.builtin.artifacts.memory import MemoryCandidateRequest, MemoryEntryInput +from powercontext.builtin.artifacts.profile.models import ( + ProfileContent, + ProfileDraft, + ProfileGeneration, + ProfileWriteContent, +) from powercontext.builtin.artifacts.skill import SkillContent from powercontext.builtin.dream.generation import DreamGenerationInput from powercontext.builtin.dream.models import DreamError, DreamPlan @@ -154,6 +161,79 @@ def config(database: DatabaseConfig) -> BuiltinConfig: return BuiltinConfig(database=database, runtime=RuntimeConfig()) +def test_profile_dream_requires_review_and_preserves_source_cursor(database: DatabaseConfig) -> None: + class ProfileDreamGenerator: + config_id = "profile-dream-test" + + async def generate(self, value: DreamGenerationInput) -> GenerationResult[DreamPlan]: + assert value.operation == "revise_profile" + assert any(item.kind == "profile" for item in value.evidence.evidence) + evidence = next(item.evidence_id for item in value.evidence.evidence if item.kind == "source") + return GenerationResult( + output=DreamPlan( + outcome="proposed", + reason="The explicit relocation supersedes the old location.", + intent="correct", + proposal=ProfileWriteContent(content="# Profile\n\nBased in Shenzhen."), + evidence_ids=(evidence,), + ), + usage=InferenceUsage(requests=1), + ) + + async def scenario() -> None: + async with open_builtin_runtime(config(database), dream_generator=ProfileDreamGenerator()) as runtime: + assert runtime.scopes is not None and runtime.profiles is not None + scope = await runtime.scopes.create( + ScopeDraft(title="Profile Dream", summary="Review before activation", idempotency_key="profile-dream") + ) + scope_id = scope.scope_id + await runtime.profiles.put_policy(scope_id, generation_enabled=False, expected_version=0) + async with runtime.profiles.database.transaction() as connection: + initial = await runtime.profiles.artifacts.create( + connection, + scope_id, + "profile", + ProfileDraft( + content=ProfileContent( + content="# Profile\n\nBased in Shanghai.", + generation=ProfileGeneration(mode="manual_create", created_at=datetime.now(UTC)), + ) + ), + ) + source = await runtime.sources.for_scope(scope_id).capture( + CaptureSource(source_id="relocation", content="I moved to Shenzhen and am now based there.", metadata={}) + ) + run = await runtime.dream.for_scope(scope_id).create( + CreateDreamRunRequest( + operation="revise_profile", + target=initial.as_ref(), + artifacts=(initial.as_ref(),), + sources=(source.source_ref,), + idempotency_key="relocation", + ) + ) + await process_pending(runtime) + completed = await runtime.dream.for_scope(scope_id).get(GetDreamRunRequest(run_id=run.run_id)) + assert completed.outcome == "proposed" and completed.candidate is not None + async with runtime.profiles.database.transaction() as connection: + before = await runtime.profiles.latest(connection, scope_id) + assert before is not None and before.content.content.endswith("Shanghai.\n") + approved = await runtime.review.for_scope(scope_id).approve( + ApproveArtifactCandidateRequest( + candidate_id=completed.candidate.candidate_id, + expected_version=completed.candidate.version, + ) + ) + assert approved.result_artifact is not None + async with runtime.profiles.database.transaction() as connection: + after = await runtime.profiles.latest(connection, scope_id) + assert after is not None and after.content.content.endswith("Shenzhen.\n") + assert after.content.generation.mode == "dream_review_approved" + assert after.content.generation.dream_run_id == run.run_id + + asyncio.run(scenario()) + + @pytest.mark.parametrize("existing", [False, True], ids=["new-config", "enable-skill"]) def test_wizard_skill_selection_accepts_dream_derivation( tmp_path: Path, existing: bool, monkeypatch: pytest.MonkeyPatch From cf342bae81dcf5ec8d7e5365846ee67425232827 Mon Sep 17 00:00:00 2001 From: Frey <370805886@qq.com> Date: Tue, 22 Sep 2026 14:44:58 +0800 Subject: [PATCH 03/48] feat(dream): review Memory entry revisions atomically --- openapi/powercontext.yaml | 43 +++++- .../builtin/artifacts/memory/models.py | 36 ++++- src/powercontext/builtin/dream/bindings.py | 2 + src/powercontext/builtin/dream/generation.py | 3 + src/powercontext/builtin/dream/models.py | 32 ++++- src/powercontext/builtin/dream/service.py | 25 +++- src/powercontext/builtin/review/models.py | 2 +- src/powercontext/builtin/review/service.py | 123 +++++++++++++++++- src/powercontext/builtin/runtime/models.py | 7 +- .../builtin/runtime/relational.py | 3 + src/powercontext/http/__init__.py | 4 + src/powercontext/http/_generated/models.py | 31 ++++- src/powercontext/http/_generated/schema.py | 67 ++++++++-- src/powercontext/server/mapping.py | 16 ++- tests/e2e/test_artifact_dreaming.py | 87 +++++++++++++ 15 files changed, 445 insertions(+), 36 deletions(-) diff --git a/openapi/powercontext.yaml b/openapi/powercontext.yaml index 1171aaff82..12595755ae 100644 --- a/openapi/powercontext.yaml +++ b/openapi/powercontext.yaml @@ -4567,6 +4567,36 @@ components: created_at: type: "string" format: "date-time" + MemoryDreamEntryChange: + type: object + additionalProperties: false + required: [entry_id, entry_version_id, kind, text, reason, sources] + properties: + entry_id: {type: string, minLength: 1, maxLength: 128} + entry_version_id: {type: string, minLength: 1, maxLength: 128} + kind: {type: string, minLength: 1} + text: {type: string, minLength: 1} + reason: {type: string, minLength: 1, maxLength: 2000} + sources: + type: array + minItems: 1 + maxItems: 32 + items: + $ref: "#/components/schemas/DreamSourceReference" + MemoryDreamCandidateProposal: + type: object + additionalProperties: false + required: [base, dream_run_id, changes] + properties: + base: + $ref: "#/components/schemas/ArtifactReference" + dream_run_id: {type: string, minLength: 1} + changes: + type: array + minItems: 1 + maxItems: 20 + items: + $ref: "#/components/schemas/MemoryDreamEntryChange" CreateProfileArtifactRequest: type: "object" additionalProperties: false @@ -5111,6 +5141,7 @@ components: - refine_experience - derive_skill - revise_profile + - revise_memory DreamStatus: type: string enum: @@ -5210,8 +5241,8 @@ components: - operation - idempotency_key type: object - description: Select 1-20 exact Artifact or Memory citations after deduplication, with at most 32 combined references - including Sources. A Profile revision requires its exact current Profile target and supporting evidence. + description: Select exact Artifact or Memory citations after deduplication, with at most 32 combined references + including Sources. Profile and Memory revisions require their exact current target and supporting evidence. DreamBudget: additionalProperties: false properties: @@ -5488,7 +5519,7 @@ components: type: array maxItems: 32 default: [] - description: Exact Memory entry provenance for Experience or reviewed Profile Dream changes. Counted toward the combined evidence bound. + description: Exact Memory entry provenance for Experience, Profile Dream or Memory Dream changes. Counted toward the combined evidence bound. items: $ref: "#/components/schemas/MemoryCitation" permissions: @@ -5512,6 +5543,7 @@ components: - $ref: "#/components/schemas/ExperienceProposal" - $ref: "#/components/schemas/SkillProposal" - $ref: "#/components/schemas/ProfileCandidateProposal" + - $ref: "#/components/schemas/MemoryDreamCandidateProposal" source_refs: type: array maxItems: 32 @@ -8698,7 +8730,7 @@ components: type: array maxItems: 32 nullable: true - description: Omission or null retains the current citations; an explicit array replaces them, including an empty array. Non-empty only for Experience. + description: Omission or null retains the current citations; an explicit array replaces them, including an empty array. Non-empty for supported Memory-citing Candidate families. items: $ref: "#/components/schemas/MemoryCitation" scope_id: @@ -8719,6 +8751,7 @@ components: - $ref: "#/components/schemas/ExperienceProposal" - $ref: "#/components/schemas/SkillProposal" - $ref: "#/components/schemas/ProfileWriteContent" + - $ref: "#/components/schemas/MemoryDreamCandidateProposal" source_refs: type: array maxItems: 32 @@ -9620,7 +9653,7 @@ components: enum: [today, 7d, 30d] CandidateFamily: type: string - enum: [experience, skill, profile] + enum: [experience, skill, profile, memory] ExternalSkillInstallationScope: type: string enum: [user, project, plugin] diff --git a/src/powercontext/builtin/artifacts/memory/models.py b/src/powercontext/builtin/artifacts/memory/models.py index 2e1f52eacb..0f6cc9e9ce 100644 --- a/src/powercontext/builtin/artifacts/memory/models.py +++ b/src/powercontext/builtin/artifacts/memory/models.py @@ -19,7 +19,7 @@ from dataclasses import dataclass from typing import ClassVar, Literal, TypeAlias -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, model_validator from powercontext.artifacts import Artifact, ArtifactRef from powercontext.artifacts import MemoryCitation as MemoryCitation @@ -139,6 +139,40 @@ class MemoryEntryVersion(BaseModel): artifacts: tuple[ArtifactRef, ...] = () +class MemoryDreamEntryChange(BaseModel): + """One current logical entry replacement supported by exact Sources.""" + + entry_id: str = Field(min_length=1, max_length=128) + entry_version_id: str = Field(min_length=1, max_length=128) + kind: str = Field(min_length=1) + text: str = Field(min_length=1) + reason: str = Field(min_length=1, max_length=2000) + sources: tuple[SourceRef, ...] = Field(min_length=1, max_length=32) + + +class MemoryDreamWrite(BaseModel): + """Model-proposed changes; target identity and run provenance stay server-owned.""" + + changes: tuple[MemoryDreamEntryChange, ...] = Field(min_length=1, max_length=20) + + @model_validator(mode="after") + def distinct_entries(self): + if len({change.entry_id for change in self.changes}) != len(self.changes): + raise ValueError("Memory Dream changes must target distinct entries") # noqa: TRY003 + return self + + +class MemoryDreamCandidateProposal(MemoryDreamWrite): + base: ArtifactRef + dream_run_id: str = Field(min_length=1) + + @model_validator(mode="after") + def valid_base(self): + if self.base.family != Memory.family: + raise ValueError("Memory Dream requires a Memory base") # noqa: TRY003 + return self + + class MemoryRevisionChanges(BaseModel): """The compact changes stored by one exact Memory Revision.""" diff --git a/src/powercontext/builtin/dream/bindings.py b/src/powercontext/builtin/dream/bindings.py index 6790cd56e7..ad8c5be214 100644 --- a/src/powercontext/builtin/dream/bindings.py +++ b/src/powercontext/builtin/dream/bindings.py @@ -19,6 +19,7 @@ from powercontext.builtin.artifacts.experience import EXPERIENCE_INCUBATION_CURSOR_NAME from powercontext.builtin.artifacts.profile.models import PROFILE_SOURCE_WINDOW_BINDING from powercontext.builtin.dream.models import DreamOperation +from powercontext.builtin.triggers import SOURCE_WINDOW_TRIGGER_NAME SKILL_DREAM_BINDING = "skill.dream.v1" @@ -37,6 +38,7 @@ class DreamOperationSpec: DreamOperationSpec("refine_experience", "experience", EXPERIENCE_INCUBATION_CURSOR_NAME), DreamOperationSpec("derive_skill", "skill", SKILL_DREAM_BINDING), DreamOperationSpec("revise_profile", "profile", PROFILE_SOURCE_WINDOW_BINDING), + DreamOperationSpec("revise_memory", "memory", SOURCE_WINDOW_TRIGGER_NAME), ) DREAM_BINDINGS = {spec.operation: spec.binding for spec in DREAM_OPERATIONS} diff --git a/src/powercontext/builtin/dream/generation.py b/src/powercontext/builtin/dream/generation.py index 219d0c26dd..221fb7a560 100644 --- a/src/powercontext/builtin/dream/generation.py +++ b/src/powercontext/builtin/dream/generation.py @@ -34,6 +34,9 @@ For revise_profile, compare the exact current Profile with new evidence and propose a complete Markdown replacement. Treat Profile text as the target under review, never as independent support for its own assertions. Distinguish temporary plans from durable facts. Use correct as the intent for a supported replacement. +For revise_memory, revise only selected active Memory entry versions in the one exact target Memory. +For each change, preserve the entry ID, cite selected exact Source references, and explain the correction. +Do not add, merge, deactivate, restore, or rename entries; use correct as the intent. Provide name, description, instructions, and at least one concrete validation check in validation. Use a package-compatible name: at most 64 lowercase letters, digits, and single separating hyphens. Keep description within 1024 characters. Trim text fields and omit trailing whitespace in instructions. diff --git a/src/powercontext/builtin/dream/models.py b/src/powercontext/builtin/dream/models.py index b78bd73f69..4f5a264233 100644 --- a/src/powercontext/builtin/dream/models.py +++ b/src/powercontext/builtin/dream/models.py @@ -23,6 +23,7 @@ from powercontext.artifacts import ArtifactRef, MemoryCitation from powercontext.builtin.artifacts.experience import ExperienceContent +from powercontext.builtin.artifacts.memory.models import MemoryDreamWrite from powercontext.builtin.artifacts.profile.models import ProfileWriteContent from powercontext.builtin.artifacts.skill import SkillContent from powercontext.builtin.evidence.models import ( @@ -35,7 +36,7 @@ from powercontext.errors import PowerContextError from powercontext.sources import SourceRef -DreamOperation = Literal["refine_experience", "derive_skill", "revise_profile"] +DreamOperation = Literal["refine_experience", "derive_skill", "revise_profile", "revise_memory"] DreamStatus = Literal["queued", "running", "succeeded", "failed"] DreamOutcome = Literal["proposed", "no_change", "needs_evidence"] DreamIntent = Literal["create", "corroborate", "refine", "correct", "derive"] @@ -73,9 +74,15 @@ def normalize_references(cls, value): @model_validator(mode="after") def validate_selection(self): selected = len(self.artifacts) + len(self.memory_citations) - if not 1 <= selected <= 20 or selected + len(self.sources) > 32: + selected_limit = 21 if self.operation == "revise_memory" else 20 + if not 1 <= selected <= selected_limit or selected + len(self.sources) > 32: raise DreamError("evidence_limit_exceeded") - allowed_families = {"experience"} if self.operation != "revise_profile" else {"experience", "profile"} + allowed_families = { + "refine_experience": {"experience"}, + "derive_skill": {"experience"}, + "revise_profile": {"experience", "profile"}, + "revise_memory": {"experience", "memory"}, + }[self.operation] if any(ref.family not in allowed_families for ref in self.artifacts): raise DreamError("invalid_artifact_family") if any( @@ -97,6 +104,14 @@ def validate_selection(self): or any(ref.family == "profile" and ref != self.target for ref in self.artifacts) ): raise DreamError("invalid_dream_operation") + if self.operation == "revise_memory" and ( + self.target is None + or self.target.family != "memory" + or not self.memory_citations + or any(citation.memory_ref != self.target for citation in self.memory_citations) + or any(ref.family == "memory" and ref != self.target for ref in self.artifacts) + ): + raise DreamError("invalid_dream_operation") return self def digest(self) -> str: @@ -180,7 +195,7 @@ class DreamPlan(BaseModel): outcome: DreamOutcome reason: str = Field(min_length=1, max_length=2000) intent: DreamIntent | None = None - proposal: ExperienceContent | SkillContent | ProfileWriteContent | None = None + proposal: ExperienceContent | SkillContent | ProfileWriteContent | MemoryDreamWrite | None = None evidence_ids: tuple[str, ...] = Field(default=(), max_length=32) @model_validator(mode="after") @@ -207,6 +222,15 @@ def validate_operation(self, request: CreateDreamRunRequest) -> None: and self.proposal.restored_from_revision is None and self.intent == "correct" ) + elif request.operation == "revise_memory": + selected = {(citation.entry_id, citation.entry_version_id) for citation in request.memory_citations} + valid = ( + isinstance(self.proposal, MemoryDreamWrite) + and self.intent == "correct" + and all( + (change.entry_id, change.entry_version_id) in selected for change in self.proposal.changes + ) + ) else: intents = {"create"} if request.target is None else {"corroborate", "refine", "correct"} valid = isinstance(self.proposal, ExperienceContent) and self.intent in intents diff --git a/src/powercontext/builtin/dream/service.py b/src/powercontext/builtin/dream/service.py index f4f4d4d510..fbd1fb9c4a 100644 --- a/src/powercontext/builtin/dream/service.py +++ b/src/powercontext/builtin/dream/service.py @@ -28,6 +28,7 @@ from powercontext.artifacts import ArtifactRef from powercontext.builtin.artifacts.experience import ExperienceContent +from powercontext.builtin.artifacts.memory.models import MemoryDreamCandidateProposal, MemoryDreamWrite from powercontext.builtin.artifacts.profile.models import ProfileCandidateProposal, ProfileWriteContent from powercontext.builtin.artifacts.skill import SkillContent from powercontext.builtin.dream.bindings import DREAM_BINDINGS, operations_for_binding @@ -327,7 +328,12 @@ async def _commit(self, record: DreamRecord, plan: DreamPlan) -> None: candidate = None if plan.outcome == "proposed": if self.attest_candidate is not None: - family = {"derive_skill": "skill", "refine_experience": "experience", "revise_profile": "profile"}[ + family = { + "derive_skill": "skill", + "refine_experience": "experience", + "revise_profile": "profile", + "revise_memory": "memory", + }[ run.operation ] await self.attest_candidate(connection, record, _candidate_id(record), family) @@ -399,6 +405,23 @@ async def _propose( candidate_id=_candidate_id(record), memory_citations=selected.memory_citations, ) + elif isinstance(plan.proposal, MemoryDreamWrite) and record.run.operation == "revise_memory": + if record.run.target is None: + raise DreamError("invalid_generation_output") + proposal = MemoryDreamCandidateProposal( + base=record.run.target, + dream_run_id=record.run.run_id, + changes=plan.proposal.changes, + ) + candidate = await review.propose_memory_dream( + proposal, + sources=selected.sources, + artifacts=selected.artifacts, + memory_citations=selected.memory_citations, + target=record.run.target, + reason=plan.reason, + candidate_id=_candidate_id(record), + ) else: raise DreamError("invalid_generation_output") return DreamCandidateRef(candidate_id=candidate.candidate_id, version=candidate.version) diff --git a/src/powercontext/builtin/review/models.py b/src/powercontext/builtin/review/models.py index 9520d29cdf..b768dbd8c9 100644 --- a/src/powercontext/builtin/review/models.py +++ b/src/powercontext/builtin/review/models.py @@ -73,7 +73,7 @@ def validate_evidence(self): raise ValueError(f"Candidate evidence must not exceed {MAX_CANDIDATE_EVIDENCE} references") # noqa: TRY003 if self.target is not None and self.target.family != self.family: raise ValueError("Candidate target must belong to the proposed family") # noqa: TRY003 - if self.memory_citations and self.family not in {"experience", "profile"}: + if self.memory_citations and self.family not in {"experience", "profile", "memory"}: raise ValueError("this Candidate Family does not accept Memory citations") # noqa: TRY003 return self diff --git a/src/powercontext/builtin/review/service.py b/src/powercontext/builtin/review/service.py index 3a331e1b5d..201956f195 100644 --- a/src/powercontext/builtin/review/service.py +++ b/src/powercontext/builtin/review/service.py @@ -35,6 +35,12 @@ HandoffMemoryCitation, HandoffSourceCitation, ) +from powercontext.builtin.artifacts.memory.models import ( + Memory, + MemoryDreamCandidateProposal, + MemoryEntryInput, +) +from powercontext.builtin.artifacts.memory.service import MemoryService from powercontext.builtin.artifacts.profile.models import ProfileCandidateProposal, ProfileWriteContent from powercontext.builtin.artifacts.profile.review import decide_profile, revise_profile from powercontext.builtin.artifacts.skill import Skill, SkillContent, SkillDraft, build_instruction_skill_package @@ -68,7 +74,7 @@ from powercontext.sources import SourceRef IdFactory = Callable[[str], str] -ReviewedProposal: TypeAlias = ExperienceContent | SkillContent | ProfileCandidateProposal +ReviewedProposal: TypeAlias = ExperienceContent | SkillContent | ProfileCandidateProposal | MemoryDreamCandidateProposal ReviewedArtifact: TypeAlias = Experience | Skill ReviewedDraft: TypeAlias = ExperienceDraft | SkillDraft ReviewedCandidate: TypeAlias = ArtifactCandidate[ReviewedProposal] @@ -89,6 +95,7 @@ def __init__( sources: GenerationSourceAccess, id_factory: IdFactory, evidence: EvidenceResolver | None = None, + memory: Callable[[AsyncConnection | None], MemoryService] | None = None, authorization_context: Callable[[], AbstractAsyncContextManager[None]] = nullcontext, connection: AsyncConnection | None = None, ) -> None: @@ -101,6 +108,7 @@ def __init__( self._sources = sources self._id_factory = id_factory self._evidence = evidence + self._memory = memory self._authorization_context = authorization_context self._bound_connection = connection @@ -197,6 +205,37 @@ async def propose_profile_dream( ) return ArtifactCandidate[ProfileCandidateProposal].model_validate(candidate.model_dump(mode="python")) + async def propose_memory_dream( + self, + proposal: MemoryDreamCandidateProposal, + /, + *, + sources: tuple[SourceRef, ...], + artifacts: tuple[ArtifactRef, ...], + memory_citations: tuple[MemoryCitation, ...], + target: ArtifactRef, + reason: str, + candidate_id: str, + ) -> ArtifactCandidate[MemoryDreamCandidateProposal]: + if proposal.base != target or not memory_citations: + raise InvalidCandidateError("target", "Memory Dream requires exact current entries") + selected = {(citation.entry_id, citation.entry_version_id) for citation in memory_citations} + if any((change.entry_id, change.entry_version_id) not in selected for change in proposal.changes): + raise InvalidCandidateError("proposal", "Memory change is outside selected entries") + if any(source not in sources for change in proposal.changes for source in change.sources): + raise InvalidCandidateError("proposal", "Memory change cites an unselected Source") + candidate = await self._propose( + Memory.family, + proposal, + sources=sources, + artifacts=artifacts, + memory_citations=memory_citations, + target=target, + reason=reason, + candidate_id=candidate_id, + ) + return ArtifactCandidate[MemoryDreamCandidateProposal].model_validate(candidate.model_dump(mode="python")) + async def _propose( self, family: str, @@ -344,6 +383,21 @@ async def revise( if target != current.target: raise InvalidCandidateError("target", "cannot change across Candidate versions") citations = current.memory_citations if memory_citations is None else unique_references(memory_citations) + if isinstance(proposal, MemoryDreamCandidateProposal): + previous = current.proposal + if ( + not isinstance(previous, MemoryDreamCandidateProposal) + or proposal.base != current.target + or proposal.dream_run_id != previous.dream_run_id + ): + raise InvalidCandidateError("proposal", "Memory Dream origin and base are immutable") + selected = {(citation.entry_id, citation.entry_version_id) for citation in citations} + if any( + (change.entry_id, change.entry_version_id) not in selected + or any(source not in canonical_sources for source in change.sources) + for change in proposal.changes + ): + raise InvalidCandidateError("proposal", "Memory Dream change is outside selected evidence") canonical_sources = await self._validate_evidence( connection, canonical_sources, @@ -404,6 +458,10 @@ async def approve( ) -> ReviewedCandidate: """Atomically commit the reviewed Artifact and Candidate result.""" + async with self._connection() as connection: + current = await self._candidates.get(connection, self._scope_id, candidate_id) + if current.family == Memory.family: + return await self._approve_memory_dream(candidate_id, expected_version) async with self._connection() as connection: current = await self._candidates.get(connection, self._scope_id, candidate_id) if current.family == "profile": @@ -457,6 +515,68 @@ async def approve( ) return _reviewed_candidate(approved) + async def _approve_memory_dream( # noqa: C901 - preparation and publication have distinct transaction boundaries + self, candidate_id: str, expected_version: int + ) -> ReviewedCandidate: + if self._memory is None: + raise InvalidCandidateError("family", "Memory Dream writer is unavailable") + async with self._connection() as connection: + preview = _reviewed_candidate(await self._candidates.get(connection, self._scope_id, candidate_id)) + if preview.version != expected_version: + raise CandidateConflictError(candidate_id, expected_version, preview.version) + if not isinstance(preview.proposal, MemoryDreamCandidateProposal) or preview.target is None: + raise InvalidCandidateError("family", "Memory Dream proposal required") + rows = await self._sources.require_for_generation(connection, self._scope_id, preview.sources) + source_by_ref = {(row.ref.source_type, row.ref.source_id): row.value for row in rows} + memory = self._memory(None) + base, versions = await memory.head_entries(preview.target.artifact_id) + if base.as_ref() != preview.target: + raise ArtifactTargetConflictError(preview.target, base.as_ref()) + versions_by_id = {entry.entry_id: entry for entry in versions} + entries = [] + for change in preview.proposal.changes: + previous = versions_by_id.get(change.entry_id) + if previous is None or previous.entry_version_id != change.entry_version_id: + raise InvalidCandidateError("proposal", "Memory entry baseline changed") + entries.append( + MemoryEntryInput( + kind=change.kind, + text=change.text, + entry=previous, + sources=tuple(source_by_ref[(ref.source_type, ref.source_id)] for ref in change.sources), + reason=change.reason, + ) + ) + plan = await memory.plan_remember( + memory=base, + sources=tuple(source_by_ref.values()), + entries=tuple(entries), + mode="append", + ) + if plan.commit is None: + raise InvalidCandidateError("proposal", "Memory Dream proposal makes no change") + async with self._connection() as connection: + current = await self._artifacts.latest( + connection, self._scope_id, Memory.family, preview.target.artifact_id, for_update=True + ) + if current.as_ref() != preview.target: + raise ArtifactTargetConflictError(preview.target, current.as_ref()) + candidate = _reviewed_candidate( + await self._candidates.lock_pending(connection, self._scope_id, candidate_id, expected_version) + ) + if candidate != preview: + raise CandidateConflictError(candidate_id, expected_version, candidate.version) + await self._validate_evidence( + connection, candidate.sources, candidate.artifacts, candidate.memory_citations + ) + committed = await self._memory(connection).apply(plan) + if committed is None: + raise InvalidCandidateError("proposal", "Memory Dream commit produced no Artifact") + approved = await self._candidates.mark_approved( + connection, self._scope_id, candidate_id, expected_version, committed.as_ref() + ) + return _reviewed_candidate(approved) + async def get_experience(self, ref: ArtifactRef, /) -> Experience: return cast(Experience, await self._get_artifact(ref, Experience)) @@ -610,6 +730,7 @@ def _validate_proposal_family(family: str, proposal: object) -> None: Experience.family: ExperienceContent, Skill.family: SkillContent, "profile": ProfileCandidateProposal, + Memory.family: MemoryDreamCandidateProposal, }.get(family) if expected is None or type(proposal) is not expected: raise InvalidCandidateError("family", family) diff --git a/src/powercontext/builtin/runtime/models.py b/src/powercontext/builtin/runtime/models.py index 1128032ff5..34bc595fc2 100644 --- a/src/powercontext/builtin/runtime/models.py +++ b/src/powercontext/builtin/runtime/models.py @@ -25,6 +25,7 @@ from powercontext.builtin.artifacts.experience import ExperienceContent from powercontext.builtin.artifacts.memory.models import ( MemoryCitation, + MemoryDreamCandidateProposal, MemoryEntryInput, MemoryEntryState, MemoryEntryVersion, @@ -56,7 +57,7 @@ PreparedContextSchema: TypeAlias = Literal["powercontext.prepared-context.v1"] PreparedContextStatus: TypeAlias = Literal["ready", "empty"] -ReviewedProposal: TypeAlias = ExperienceContent | SkillContent | ProfileCandidateProposal +ReviewedProposal: TypeAlias = ExperienceContent | SkillContent | ProfileCandidateProposal | MemoryDreamCandidateProposal PREPARED_CONTEXT_SCHEMA: PreparedContextSchema = "powercontext.prepared-context.v1" @@ -406,7 +407,7 @@ class ListArtifactCandidatesRequest(BaseModel): """Filter and page the current Review Inbox.""" status: CandidateStatus = CandidateStatus.PENDING - family: Literal["experience", "skill", "profile"] | None = None + family: Literal["experience", "skill", "profile", "memory"] | None = None cursor: str | None = None limit: Annotated[int, Field(ge=1, le=MAX_CANDIDATE_PAGE_SIZE)] = DEFAULT_CANDIDATE_PAGE_SIZE @@ -425,7 +426,7 @@ class RejectArtifactCandidateRequest(ApproveArtifactCandidateRequest): class ReviseArtifactCandidateRequest(ApproveArtifactCandidateRequest): - proposal: ExperienceContent | SkillContent | ProfileWriteContent + proposal: ExperienceContent | SkillContent | ProfileWriteContent | MemoryDreamCandidateProposal sources: tuple[SourceRef, ...] = () artifacts: tuple[ArtifactRef, ...] = () memory_citations: tuple[MemoryCitation, ...] | None = None diff --git a/src/powercontext/builtin/runtime/relational.py b/src/powercontext/builtin/runtime/relational.py index d67fb35096..c66ebaa87c 100644 --- a/src/powercontext/builtin/runtime/relational.py +++ b/src/powercontext/builtin/runtime/relational.py @@ -67,6 +67,7 @@ MemoryService, MemoryWritePlan, ) +from powercontext.builtin.artifacts.memory.models import MemoryDreamCandidateProposal from powercontext.builtin.artifacts.profile import Profile from powercontext.builtin.artifacts.profile.management import ProfileManagementWriter from powercontext.builtin.artifacts.profile.models import ProfileCandidateProposal @@ -389,6 +390,7 @@ def review(self, connection: AsyncConnection | None = None) -> ReviewService: id_factory=self.id_factory, evidence=self.evidence(), connection=connection, + memory=lambda bound: self.memory(self.sources(bound)[1], bound), ) def recurrence_ledger(self) -> RelationalRecurrenceLedger: @@ -543,6 +545,7 @@ def __init__( Experience.family: ExperienceContent, Skill.family: SkillContent, Profile.family: ProfileCandidateProposal, + Memory.family: MemoryDreamCandidateProposal, }), connector_checkpoints=ConnectorCheckpointRepository(), source_definitions=SourceDefinitionManifestRepository(), diff --git a/src/powercontext/http/__init__.py b/src/powercontext/http/__init__.py index 13fa21c671..473332f71e 100644 --- a/src/powercontext/http/__init__.py +++ b/src/powercontext/http/__init__.py @@ -223,6 +223,8 @@ LiveStateCheckStatus, ManagedSkillLibraryEntry, MemoryCitation, + MemoryDreamCandidateProposal, + MemoryDreamEntryChange, MemoryEntry, MemoryEntryAccessSelector, MemoryEntryInventoryStatistics, @@ -613,6 +615,8 @@ "LiveStateCheckStatus", "ManagedSkillLibraryEntry", "MemoryCitation", + "MemoryDreamCandidateProposal", + "MemoryDreamEntryChange", "MemoryEntry", "MemoryEntryAccessSelector", "MemoryEntryInventoryStatistics", diff --git a/src/powercontext/http/_generated/models.py b/src/powercontext/http/_generated/models.py index 10ec679626..3557b6ad69 100644 --- a/src/powercontext/http/_generated/models.py +++ b/src/powercontext/http/_generated/models.py @@ -348,6 +348,7 @@ class DreamOperation(StrEnum): REFINE_EXPERIENCE = "refine_experience" DERIVE_SKILL = "derive_skill" REVISE_PROFILE = "revise_profile" + REVISE_MEMORY = "revise_memory" class DreamStatus(StrEnum): @@ -2023,6 +2024,7 @@ class CandidateFamily(StrEnum): EXPERIENCE = "experience" SKILL = "skill" PROFILE = "profile" + MEMORY = "memory" class ExternalSkillInstallationScope(StrEnum): @@ -2547,6 +2549,27 @@ class FlushProfileResponse(BaseModel): candidate_id: StrictStr | None = None +class MemoryDreamEntryChange(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + entry_id: Annotated[StrictStr, Field(max_length=128, min_length=1)] + entry_version_id: Annotated[StrictStr, Field(max_length=128, min_length=1)] + kind: Annotated[StrictStr, Field(min_length=1)] + text: Annotated[StrictStr, Field(min_length=1)] + reason: Annotated[StrictStr, Field(max_length=2000, min_length=1)] + sources: Annotated[list[DreamSourceReference], Field(max_length=32, min_length=1)] + + +class MemoryDreamCandidateProposal(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + base: ArtifactReference + dream_run_id: Annotated[StrictStr, Field(min_length=1)] + changes: Annotated[list[MemoryDreamEntryChange], Field(max_length=20, min_length=1)] + + class ArtifactCollectionItem(BaseModel): scope_id: Annotated[StrictStr, Field(max_length=256, min_length=1, pattern=".*\\S.*")] family: ArtifactReadFamily @@ -3225,14 +3248,14 @@ class ReviseArtifactCandidateRequest(BaseModel): memory_citations: Annotated[ list[MemoryCitation] | None, Field( - description="Omission or null retains the current citations; an explicit array replaces them, including an empty array. Non-empty only for Experience.", + description="Omission or null retains the current citations; an explicit array replaces them, including an empty array. Non-empty for supported Memory-citing Candidate families.", max_length=32, ), ] = None scope_id: Annotated[StrictStr, Field(max_length=256, min_length=1, pattern=".*\\S.*")] candidate_id: Annotated[StrictStr, Field(max_length=128, min_length=1, pattern="^[\\x21-\\x7E]+$")] expected_version: Annotated[StrictInt, Field(ge=1)] - proposal: ExperienceProposal | SkillProposal | ProfileWriteContent + proposal: ExperienceProposal | SkillProposal | ProfileWriteContent | MemoryDreamCandidateProposal source_refs: Annotated[ list[SourceReference], Field( @@ -3497,7 +3520,7 @@ class ArtifactCandidate(BaseModel): memory_citations: Annotated[ list[MemoryCitation], Field( - description="Exact Memory entry provenance for Experience or reviewed Profile Dream changes. Counted toward the combined evidence bound.", + description="Exact Memory entry provenance for Experience, Profile Dream or Memory Dream changes. Counted toward the combined evidence bound.", max_length=32, validate_default=True, ), @@ -3510,7 +3533,7 @@ class ArtifactCandidate(BaseModel): version: Annotated[StrictInt, Field(ge=1)] family: CandidateFamily status: CandidateStatus - proposal: ExperienceProposal | SkillProposal | ProfileCandidateProposal + proposal: ExperienceProposal | SkillProposal | ProfileCandidateProposal | MemoryDreamCandidateProposal source_refs: Annotated[ list[SourceReference], Field( diff --git a/src/powercontext/http/_generated/schema.py b/src/powercontext/http/_generated/schema.py index 24da2ded5e..d5e2903856 100644 --- a/src/powercontext/http/_generated/schema.py +++ b/src/powercontext/http/_generated/schema.py @@ -4596,6 +4596,39 @@ "type": "object", "required": ["schema", "content", "generator_id", "generator_version", "created_at"], }, + "MemoryDreamEntryChange": { + "properties": { + "entry_id": {"type": "string", "maxLength": 128, "minLength": 1}, + "entry_version_id": {"type": "string", "maxLength": 128, "minLength": 1}, + "kind": {"type": "string", "minLength": 1}, + "text": {"type": "string", "minLength": 1}, + "reason": {"type": "string", "maxLength": 2000, "minLength": 1}, + "sources": { + "items": {"$ref": "#/components/schemas/DreamSourceReference"}, + "type": "array", + "maxItems": 32, + "minItems": 1, + }, + }, + "additionalProperties": False, + "type": "object", + "required": ["entry_id", "entry_version_id", "kind", "text", "reason", "sources"], + }, + "MemoryDreamCandidateProposal": { + "properties": { + "base": {"$ref": "#/components/schemas/ArtifactReference"}, + "dream_run_id": {"type": "string", "minLength": 1}, + "changes": { + "items": {"$ref": "#/components/schemas/MemoryDreamEntryChange"}, + "type": "array", + "maxItems": 20, + "minItems": 1, + }, + }, + "additionalProperties": False, + "type": "object", + "required": ["base", "dream_run_id", "changes"], + }, "CreateProfileArtifactRequest": { "properties": { "family": {"type": "string", "enum": ["profile"]}, @@ -4987,7 +5020,10 @@ "required": ["principal", "receiver_identity_matches"], "description": "Server-owned attestation stored separately from the immutable untrusted Receipt.", }, - "DreamOperation": {"type": "string", "enum": ["refine_experience", "derive_skill", "revise_profile"]}, + "DreamOperation": { + "type": "string", + "enum": ["refine_experience", "derive_skill", "revise_profile", "revise_memory"], + }, "DreamStatus": {"type": "string", "enum": ["queued", "running", "succeeded", "failed"]}, "DreamOutcome": {"type": "string", "enum": ["proposed", "no_change", "needs_evidence"]}, "DreamEvidenceKind": { @@ -5047,14 +5083,14 @@ "additionalProperties": False, "type": "object", "required": ["operation", "idempotency_key"], - "description": "Select 1-20 exact Artifact " - "or Memory citations after " + "description": "Select exact Artifact or " + "Memory citations after " "deduplication, with at most " "32 combined references " - "including Sources. A Profile " - "revision requires its exact " - "current Profile target and " - "supporting evidence.", + "including Sources. Profile " + "and Memory revisions require " + "their exact current target " + "and supporting evidence.", }, "DreamBudget": { "properties": { @@ -5214,11 +5250,12 @@ "entry " "provenance " "for " - "Experience " - "or " - "reviewed " + "Experience, " "Profile " "Dream " + "or " + "Memory " + "Dream " "changes. " "Counted " "toward " @@ -5253,6 +5290,7 @@ {"$ref": "#/components/schemas/ExperienceProposal"}, {"$ref": "#/components/schemas/SkillProposal"}, {"$ref": "#/components/schemas/ProfileCandidateProposal"}, + {"$ref": "#/components/schemas/MemoryDreamCandidateProposal"}, ] }, "source_refs": { @@ -7971,9 +8009,11 @@ "empty " "array. " "Non-empty " - "only " "for " - "Experience.", + "supported " + "Memory-citing " + "Candidate " + "families.", "nullable": True, }, "scope_id": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": ".*\\S.*"}, @@ -7984,6 +8024,7 @@ {"$ref": "#/components/schemas/ExperienceProposal"}, {"$ref": "#/components/schemas/SkillProposal"}, {"$ref": "#/components/schemas/ProfileWriteContent"}, + {"$ref": "#/components/schemas/MemoryDreamCandidateProposal"}, ] }, "source_refs": { @@ -8957,7 +8998,7 @@ "enum": ["memory", "experience", "skill", "handoff", "profile", "prompt", "topic-memory"], }, "StatsPeriod": {"type": "string", "enum": ["today", "7d", "30d"]}, - "CandidateFamily": {"type": "string", "enum": ["experience", "skill", "profile"]}, + "CandidateFamily": {"type": "string", "enum": ["experience", "skill", "profile", "memory"]}, "ExternalSkillInstallationScope": {"type": "string", "enum": ["user", "project", "plugin"]}, "ExternalSkillResolutionStatus": {"type": "string", "enum": ["available", "unavailable"]}, "CandidateStatus": {"type": "string", "enum": ["pending", "approved", "rejected"]}, diff --git a/src/powercontext/server/mapping.py b/src/powercontext/server/mapping.py index 913bd45d90..dbb301c189 100644 --- a/src/powercontext/server/mapping.py +++ b/src/powercontext/server/mapping.py @@ -33,6 +33,9 @@ HandoffGenerationEnvelope, HandoffGenerationMetadata, ) +from powercontext.builtin.artifacts.memory.models import ( + MemoryDreamCandidateProposal as RuntimeMemoryDreamCandidateProposal, +) from powercontext.builtin.artifacts.profile.models import ProfileCandidateProposal as RuntimeProfileCandidateProposal from powercontext.builtin.artifacts.profile.models import ProfileWriteContent as RuntimeProfileWriteContent from powercontext.builtin.artifacts.skill import ( @@ -231,6 +234,7 @@ ListMemoryChangesResponse, ListMemoryEntriesResponse, ManagedSkillLibraryEntry, + MemoryDreamCandidateProposal, MemoryEntry, MemoryEntryState, MemoryMatchedBy, @@ -1063,8 +1067,10 @@ def skill_proposal(value: SkillContent) -> SkillProposal: def reviewed_content( - value: ExperienceProposal | SkillProposal | ProfileWriteContent, -) -> ExperienceContent | SkillContent | RuntimeProfileWriteContent: + value: ExperienceProposal | SkillProposal | ProfileWriteContent | MemoryDreamCandidateProposal, +) -> ExperienceContent | SkillContent | RuntimeProfileWriteContent | RuntimeMemoryDreamCandidateProposal: + if isinstance(value, MemoryDreamCandidateProposal): + return RuntimeMemoryDreamCandidateProposal.model_validate(value.model_dump(mode="json")) if isinstance(value, ProfileWriteContent): return RuntimeProfileWriteContent.model_validate(value.model_dump(mode="json")) if isinstance(value, ExperienceProposal): @@ -1072,7 +1078,11 @@ def reviewed_content( return skill_content(value) -def reviewed_proposal(value: object) -> ExperienceProposal | SkillProposal | ProfileCandidateProposal: +def reviewed_proposal( + value: object, +) -> ExperienceProposal | SkillProposal | ProfileCandidateProposal | MemoryDreamCandidateProposal: + if isinstance(value, RuntimeMemoryDreamCandidateProposal): + return MemoryDreamCandidateProposal.model_validate(value.model_dump(mode="json")) if isinstance(value, RuntimeProfileCandidateProposal): return ProfileCandidateProposal.model_validate(value.model_dump(mode="json", by_alias=True)) if isinstance(value, ExperienceContent): diff --git a/tests/e2e/test_artifact_dreaming.py b/tests/e2e/test_artifact_dreaming.py index e609597b93..5c76ab575f 100644 --- a/tests/e2e/test_artifact_dreaming.py +++ b/tests/e2e/test_artifact_dreaming.py @@ -31,6 +31,7 @@ from powercontext.builtin.artifacts.experience import ExperienceContent from powercontext.builtin.artifacts.memory import MemoryCandidateRequest, MemoryEntryInput +from powercontext.builtin.artifacts.memory.models import MemoryDreamEntryChange, MemoryDreamWrite from powercontext.builtin.artifacts.profile.models import ( ProfileContent, ProfileDraft, @@ -234,6 +235,92 @@ async def scenario() -> None: asyncio.run(scenario()) +def test_memory_dream_revises_only_selected_entry_after_review(database: DatabaseConfig) -> None: + class MemoryDreamGenerator: + config_id = "memory-dream-test" + + def __init__(self): + self.citation = None + self.correction = None + + async def generate(self, value: DreamGenerationInput) -> GenerationResult[DreamPlan]: + assert value.operation == "revise_memory" + assert self.citation is not None and self.correction is not None + evidence = tuple( + item.evidence_id for item in value.evidence.evidence if item.kind in {"memory", "source"} + ) + return GenerationResult( + output=DreamPlan( + outcome="proposed", + reason="The correction changes one current Memory entry.", + intent="correct", + proposal=MemoryDreamWrite( + changes=( + MemoryDreamEntryChange( + entry_id=self.citation.entry_id, + entry_version_id=self.citation.entry_version_id, + kind="task_record", + text="The corrected replay did create a duplicate; avoid blind retries.", + reason="A later verified result contradicts the earlier record.", + sources=(self.correction,), + ), + ) + ), + evidence_ids=evidence, + ), + usage=InferenceUsage(requests=1), + ) + + async def scenario() -> None: + generator = MemoryDreamGenerator() + async with open_builtin_runtime(config(database), candidate_pipeline=MemoryPipeline(), dream_generator=generator) as runtime: + scope, _, citation = await seed(runtime) + correction = await runtime.sources.for_scope(scope).capture( + CaptureSource(source_id="correction", content="The replay created two records.", metadata={}) + ) + generator.citation = citation + generator.correction = correction.source_ref + run = await runtime.dream.for_scope(scope).create( + CreateDreamRunRequest( + operation="revise_memory", + target=citation.memory_ref, + artifacts=(citation.memory_ref,), + memory_citations=(citation,), + sources=(correction.source_ref,), + idempotency_key="correct-entry", + ) + ) + await process_pending(runtime) + completed = await runtime.dream.for_scope(scope).get(GetDreamRunRequest(run_id=run.run_id)) + assert completed.outcome == "proposed" and completed.candidate is not None + from powercontext.server.mapping import candidate_response + + candidate = await runtime.review.for_scope(scope).get( + GetArtifactCandidateRequest(candidate_id=completed.candidate.candidate_id) + ) + transport_proposal = candidate_response(candidate).proposal + from powercontext.http import MemoryDreamCandidateProposal as TransportMemoryDreamCandidateProposal + + assert isinstance(transport_proposal, TransportMemoryDreamCandidateProposal) + assert transport_proposal.dream_run_id == run.run_id + before = await runtime.memory.for_scope(scope).list() + assert any(item.citation == citation for item in before.entries) + approved = await runtime.review.for_scope(scope).approve( + ApproveArtifactCandidateRequest( + candidate_id=completed.candidate.candidate_id, + expected_version=completed.candidate.version, + ) + ) + assert approved.result_artifact is not None + after = await runtime.memory.for_scope(scope).list() + replacement = next(item for item in after.entries if item.citation.entry_id == citation.entry_id) + assert replacement.entry.text.startswith("The corrected replay") + assert replacement.citation.entry_version_id != citation.entry_version_id + assert len(after.entries) == len(before.entries) + + asyncio.run(scenario()) + + @pytest.mark.parametrize("existing", [False, True], ids=["new-config", "enable-skill"]) def test_wizard_skill_selection_accepts_dream_derivation( tmp_path: Path, existing: bool, monkeypatch: pytest.MonkeyPatch From c54964f1f584c2731f18d6323ba4b67a081e5174 Mon Sep 17 00:00:00 2001 From: Frey <370805886@qq.com> Date: Tue, 22 Sep 2026 14:55:12 +0800 Subject: [PATCH 04/48] feat(dream): review Topic Memory with atomic projection --- openapi/powercontext.yaml | 14 +++- src/powercontext/builtin/dream/bindings.py | 2 + src/powercontext/builtin/dream/generation.py | 3 + src/powercontext/builtin/dream/models.py | 16 +++- src/powercontext/builtin/dream/service.py | 13 +++ src/powercontext/builtin/evidence/models.py | 4 +- src/powercontext/builtin/evidence/resolver.py | 9 ++ src/powercontext/builtin/review/service.py | 73 +++++++++++++++- src/powercontext/builtin/runtime/models.py | 9 +- .../builtin/runtime/relational.py | 6 ++ .../runtime/topic_memory_processing.py | 46 ++++++++++ src/powercontext/http/__init__.py | 2 + src/powercontext/http/_generated/models.py | 28 ++++++- src/powercontext/http/_generated/schema.py | 18 +++- src/powercontext/server/mapping.py | 12 ++- tests/e2e/test_artifact_dreaming.py | 84 +++++++++++++++++++ 16 files changed, 322 insertions(+), 17 deletions(-) diff --git a/openapi/powercontext.yaml b/openapi/powercontext.yaml index 12595755ae..5ba12c9ef8 100644 --- a/openapi/powercontext.yaml +++ b/openapi/powercontext.yaml @@ -4597,6 +4597,14 @@ components: maxItems: 20 items: $ref: "#/components/schemas/MemoryDreamEntryChange" + TopicMemoryDreamProposal: + type: object + additionalProperties: false + required: [title, summary, detail] + properties: + title: {type: string, minLength: 1, maxLength: 512} + summary: {type: string, minLength: 1, maxLength: 8000} + detail: {type: string, minLength: 1, maxLength: 125000} CreateProfileArtifactRequest: type: "object" additionalProperties: false @@ -5142,6 +5150,7 @@ components: - derive_skill - revise_profile - revise_memory + - revise_topic_memory DreamStatus: type: string enum: @@ -5162,6 +5171,7 @@ components: - experience - memory - profile + - topic_memory - unresolved DreamEvidenceRole: type: string @@ -5544,6 +5554,7 @@ components: - $ref: "#/components/schemas/SkillProposal" - $ref: "#/components/schemas/ProfileCandidateProposal" - $ref: "#/components/schemas/MemoryDreamCandidateProposal" + - $ref: "#/components/schemas/TopicMemoryDreamProposal" source_refs: type: array maxItems: 32 @@ -8752,6 +8763,7 @@ components: - $ref: "#/components/schemas/SkillProposal" - $ref: "#/components/schemas/ProfileWriteContent" - $ref: "#/components/schemas/MemoryDreamCandidateProposal" + - $ref: "#/components/schemas/TopicMemoryDreamProposal" source_refs: type: array maxItems: 32 @@ -9653,7 +9665,7 @@ components: enum: [today, 7d, 30d] CandidateFamily: type: string - enum: [experience, skill, profile, memory] + enum: [experience, skill, profile, memory, topic-memory] ExternalSkillInstallationScope: type: string enum: [user, project, plugin] diff --git a/src/powercontext/builtin/dream/bindings.py b/src/powercontext/builtin/dream/bindings.py index ad8c5be214..e56eabeb69 100644 --- a/src/powercontext/builtin/dream/bindings.py +++ b/src/powercontext/builtin/dream/bindings.py @@ -18,6 +18,7 @@ from powercontext.builtin.artifacts.experience import EXPERIENCE_INCUBATION_CURSOR_NAME from powercontext.builtin.artifacts.profile.models import PROFILE_SOURCE_WINDOW_BINDING +from powercontext.builtin.artifacts.topic_memory import TOPIC_MEMORY_SOURCE_WINDOW_BINDING from powercontext.builtin.dream.models import DreamOperation from powercontext.builtin.triggers import SOURCE_WINDOW_TRIGGER_NAME @@ -39,6 +40,7 @@ class DreamOperationSpec: DreamOperationSpec("derive_skill", "skill", SKILL_DREAM_BINDING), DreamOperationSpec("revise_profile", "profile", PROFILE_SOURCE_WINDOW_BINDING), DreamOperationSpec("revise_memory", "memory", SOURCE_WINDOW_TRIGGER_NAME), + DreamOperationSpec("revise_topic_memory", "topic-memory", TOPIC_MEMORY_SOURCE_WINDOW_BINDING), ) DREAM_BINDINGS = {spec.operation: spec.binding for spec in DREAM_OPERATIONS} diff --git a/src/powercontext/builtin/dream/generation.py b/src/powercontext/builtin/dream/generation.py index 221fb7a560..0589d16446 100644 --- a/src/powercontext/builtin/dream/generation.py +++ b/src/powercontext/builtin/dream/generation.py @@ -37,6 +37,9 @@ For revise_memory, revise only selected active Memory entry versions in the one exact target Memory. For each change, preserve the entry ID, cite selected exact Source references, and explain the correction. Do not add, merge, deactivate, restore, or rename entries; use correct as the intent. +For revise_topic_memory, compare the exact current Topic with verified new evidence and propose +complete title, summary, and detail content. Preserve confirmed results, hypotheses, ruled-out +causes, open questions, and next steps as distinct claims. Use correct as the intent. Provide name, description, instructions, and at least one concrete validation check in validation. Use a package-compatible name: at most 64 lowercase letters, digits, and single separating hyphens. Keep description within 1024 characters. Trim text fields and omit trailing whitespace in instructions. diff --git a/src/powercontext/builtin/dream/models.py b/src/powercontext/builtin/dream/models.py index 4f5a264233..08e35ce402 100644 --- a/src/powercontext/builtin/dream/models.py +++ b/src/powercontext/builtin/dream/models.py @@ -26,6 +26,7 @@ from powercontext.builtin.artifacts.memory.models import MemoryDreamWrite from powercontext.builtin.artifacts.profile.models import ProfileWriteContent from powercontext.builtin.artifacts.skill import SkillContent +from powercontext.builtin.artifacts.topic_memory.models import TopicMemoryContent from powercontext.builtin.evidence.models import ( EvidenceLimits, EvidenceManifest, @@ -36,7 +37,9 @@ from powercontext.errors import PowerContextError from powercontext.sources import SourceRef -DreamOperation = Literal["refine_experience", "derive_skill", "revise_profile", "revise_memory"] +DreamOperation = Literal[ + "refine_experience", "derive_skill", "revise_profile", "revise_memory", "revise_topic_memory" +] DreamStatus = Literal["queued", "running", "succeeded", "failed"] DreamOutcome = Literal["proposed", "no_change", "needs_evidence"] DreamIntent = Literal["create", "corroborate", "refine", "correct", "derive"] @@ -82,6 +85,7 @@ def validate_selection(self): "derive_skill": {"experience"}, "revise_profile": {"experience", "profile"}, "revise_memory": {"experience", "memory"}, + "revise_topic_memory": {"experience", "topic-memory"}, }[self.operation] if any(ref.family not in allowed_families for ref in self.artifacts): raise DreamError("invalid_artifact_family") @@ -112,6 +116,12 @@ def validate_selection(self): or any(ref.family == "memory" and ref != self.target for ref in self.artifacts) ): raise DreamError("invalid_dream_operation") + if self.operation == "revise_topic_memory" and ( + self.target is None + or self.target.family != "topic-memory" + or any(ref.family == "topic-memory" and ref != self.target for ref in self.artifacts) + ): + raise DreamError("invalid_dream_operation") return self def digest(self) -> str: @@ -195,7 +205,7 @@ class DreamPlan(BaseModel): outcome: DreamOutcome reason: str = Field(min_length=1, max_length=2000) intent: DreamIntent | None = None - proposal: ExperienceContent | SkillContent | ProfileWriteContent | MemoryDreamWrite | None = None + proposal: ExperienceContent | SkillContent | ProfileWriteContent | MemoryDreamWrite | TopicMemoryContent | None = None evidence_ids: tuple[str, ...] = Field(default=(), max_length=32) @model_validator(mode="after") @@ -231,6 +241,8 @@ def validate_operation(self, request: CreateDreamRunRequest) -> None: (change.entry_id, change.entry_version_id) in selected for change in self.proposal.changes ) ) + elif request.operation == "revise_topic_memory": + valid = isinstance(self.proposal, TopicMemoryContent) and self.intent == "correct" else: intents = {"create"} if request.target is None else {"corroborate", "refine", "correct"} valid = isinstance(self.proposal, ExperienceContent) and self.intent in intents diff --git a/src/powercontext/builtin/dream/service.py b/src/powercontext/builtin/dream/service.py index fbd1fb9c4a..8edd506641 100644 --- a/src/powercontext/builtin/dream/service.py +++ b/src/powercontext/builtin/dream/service.py @@ -31,6 +31,7 @@ from powercontext.builtin.artifacts.memory.models import MemoryDreamCandidateProposal, MemoryDreamWrite from powercontext.builtin.artifacts.profile.models import ProfileCandidateProposal, ProfileWriteContent from powercontext.builtin.artifacts.skill import SkillContent +from powercontext.builtin.artifacts.topic_memory.models import TopicMemoryContent from powercontext.builtin.dream.bindings import DREAM_BINDINGS, operations_for_binding from powercontext.builtin.dream.generation import DreamGenerationInput, DreamGenerator from powercontext.builtin.dream.models import ( @@ -333,6 +334,7 @@ async def _commit(self, record: DreamRecord, plan: DreamPlan) -> None: "refine_experience": "experience", "revise_profile": "profile", "revise_memory": "memory", + "revise_topic_memory": "topic-memory", }[ run.operation ] @@ -422,6 +424,17 @@ async def _propose( reason=plan.reason, candidate_id=_candidate_id(record), ) + elif isinstance(plan.proposal, TopicMemoryContent) and record.run.operation == "revise_topic_memory": + if record.run.target is None: + raise DreamError("invalid_generation_output") + candidate = await review.propose_topic_memory_dream( + plan.proposal, + sources=selected.sources, + artifacts=selected.artifacts, + target=record.run.target, + reason=plan.reason, + candidate_id=_candidate_id(record), + ) else: raise DreamError("invalid_generation_output") return DreamCandidateRef(candidate_id=candidate.candidate_id, version=candidate.version) diff --git a/src/powercontext/builtin/evidence/models.py b/src/powercontext/builtin/evidence/models.py index 1e8a73c970..5f5a04f74f 100644 --- a/src/powercontext/builtin/evidence/models.py +++ b/src/powercontext/builtin/evidence/models.py @@ -52,7 +52,7 @@ class EvidenceNode(BaseModel): model_config = ConfigDict(frozen=True, extra="forbid") evidence_id: str - kind: Literal["source", "experience", "memory", "profile", "unresolved"] + kind: Literal["source", "experience", "memory", "profile", "topic_memory", "unresolved"] digest: str source: SourceRef | None = None artifact: ArtifactRef | None = None @@ -96,7 +96,7 @@ class ProjectedEvidence(BaseModel): """Untrusted content visible to the model for this operation only.""" evidence_id: str - kind: Literal["source", "experience", "memory", "profile"] + kind: Literal["source", "experience", "memory", "profile", "topic_memory"] text: str historical: bool = False root_group_ids: tuple[str, ...] = () diff --git a/src/powercontext/builtin/evidence/resolver.py b/src/powercontext/builtin/evidence/resolver.py index b9480e5943..e79794f32d 100644 --- a/src/powercontext/builtin/evidence/resolver.py +++ b/src/powercontext/builtin/evidence/resolver.py @@ -29,6 +29,7 @@ from powercontext.builtin.artifacts.memory import Memory, MemoryEntryVersion from powercontext.builtin.artifacts.memory.errors import InvalidMemoryCitationError, MemoryEntryNotFoundError from powercontext.builtin.artifacts.profile.models import Profile +from powercontext.builtin.artifacts.topic_memory.models import TopicMemory from powercontext.builtin.evidence.models import ( EVIDENCE_TRANSFORM_VERSION, EvidenceEdge, @@ -312,6 +313,14 @@ async def _read( artifact.content.content, (), ) + if isinstance(artifact, TopicMemory): + return ( + EvidenceNode( + evidence_id=evidence_id(ref), kind="topic_memory", artifact=ref, digest=digest, role="derived" + ), + artifact.content.model_dump_json(), + (), + ) if ref.family == "prompt": return ( EvidenceNode( diff --git a/src/powercontext/builtin/review/service.py b/src/powercontext/builtin/review/service.py index 201956f195..b71bb3e6ba 100644 --- a/src/powercontext/builtin/review/service.py +++ b/src/powercontext/builtin/review/service.py @@ -44,6 +44,7 @@ from powercontext.builtin.artifacts.profile.models import ProfileCandidateProposal, ProfileWriteContent from powercontext.builtin.artifacts.profile.review import decide_profile, revise_profile from powercontext.builtin.artifacts.skill import Skill, SkillContent, SkillDraft, build_instruction_skill_package +from powercontext.builtin.artifacts.topic_memory.models import TopicMemory, TopicMemoryContent, TopicMemoryDraft from powercontext.builtin.evidence.models import EvidenceResolutionError, unique_references from powercontext.builtin.evidence.resolver import AuthorizationContext, EvidenceAuthorizer, EvidenceResolver from powercontext.builtin.persistence.artifacts import ArtifactRepository @@ -55,6 +56,7 @@ from powercontext.builtin.persistence.profile import ProfilePolicyRepository from powercontext.builtin.persistence.skill_packages import SkillPackageRepository from powercontext.builtin.persistence.sources import StoredSource +from powercontext.builtin.persistence.topic_memory_management import TopicMemoryManagementWriter from powercontext.builtin.review.errors import ( ArtifactTargetConflictError, CandidateConflictError, @@ -74,7 +76,9 @@ from powercontext.sources import SourceRef IdFactory = Callable[[str], str] -ReviewedProposal: TypeAlias = ExperienceContent | SkillContent | ProfileCandidateProposal | MemoryDreamCandidateProposal +ReviewedProposal: TypeAlias = ( + ExperienceContent | SkillContent | ProfileCandidateProposal | MemoryDreamCandidateProposal | TopicMemoryContent +) ReviewedArtifact: TypeAlias = Experience | Skill ReviewedDraft: TypeAlias = ExperienceDraft | SkillDraft ReviewedCandidate: TypeAlias = ArtifactCandidate[ReviewedProposal] @@ -96,6 +100,7 @@ def __init__( id_factory: IdFactory, evidence: EvidenceResolver | None = None, memory: Callable[[AsyncConnection | None], MemoryService] | None = None, + topic_writer: TopicMemoryManagementWriter | None = None, authorization_context: Callable[[], AbstractAsyncContextManager[None]] = nullcontext, connection: AsyncConnection | None = None, ) -> None: @@ -109,6 +114,7 @@ def __init__( self._id_factory = id_factory self._evidence = evidence self._memory = memory + self._topic_writer = topic_writer self._authorization_context = authorization_context self._bound_connection = connection @@ -236,6 +242,28 @@ async def propose_memory_dream( ) return ArtifactCandidate[MemoryDreamCandidateProposal].model_validate(candidate.model_dump(mode="python")) + async def propose_topic_memory_dream( + self, + proposal: TopicMemoryContent, + /, + *, + sources: tuple[SourceRef, ...], + artifacts: tuple[ArtifactRef, ...], + target: ArtifactRef, + reason: str, + candidate_id: str, + ) -> ArtifactCandidate[TopicMemoryContent]: + candidate = await self._propose( + TopicMemory.family, + proposal, + sources=sources, + artifacts=artifacts, + target=target, + reason=reason, + candidate_id=candidate_id, + ) + return ArtifactCandidate[TopicMemoryContent].model_validate(candidate.model_dump(mode="python")) + async def _propose( self, family: str, @@ -462,6 +490,8 @@ async def approve( current = await self._candidates.get(connection, self._scope_id, candidate_id) if current.family == Memory.family: return await self._approve_memory_dream(candidate_id, expected_version) + if current.family == TopicMemory.family: + return await self._approve_topic_memory_dream(candidate_id, expected_version) async with self._connection() as connection: current = await self._candidates.get(connection, self._scope_id, candidate_id) if current.family == "profile": @@ -577,6 +607,46 @@ async def _approve_memory_dream( # noqa: C901 - preparation and publication hav ) return _reviewed_candidate(approved) + async def _approve_topic_memory_dream(self, candidate_id: str, expected_version: int) -> ReviewedCandidate: + if self._topic_writer is None: + raise InvalidCandidateError("family", "Topic Memory Dream writer is unavailable") + async with self._connection() as connection: + preview = _reviewed_candidate(await self._candidates.get(connection, self._scope_id, candidate_id)) + if preview.version != expected_version: + raise CandidateConflictError(candidate_id, expected_version, preview.version) + if not isinstance(preview.proposal, TopicMemoryContent) or preview.target is None: + raise InvalidCandidateError("family", "Topic Memory Dream proposal required") + projection = await self._topic_writer.prepare(preview.proposal, usage_scope_id=self._scope_id) + async with self._connection() as connection: + current = await self._artifacts.latest( + connection, self._scope_id, TopicMemory.family, preview.target.artifact_id, for_update=True + ) + if current.as_ref() != preview.target: + raise ArtifactTargetConflictError(preview.target, current.as_ref()) + candidate = _reviewed_candidate( + await self._candidates.lock_pending(connection, self._scope_id, candidate_id, expected_version) + ) + if candidate != preview or not isinstance(current, TopicMemory) or not isinstance( + candidate.proposal, TopicMemoryContent + ): + raise CandidateConflictError(candidate_id, expected_version, candidate.version) + await self._validate_evidence(connection, candidate.sources, candidate.artifacts) + published = await self._topic_writer.topics.publish_revision( + connection, + self._scope_id, + current, + TopicMemoryDraft( + content=candidate.proposal, + sources=candidate.sources, + artifacts=candidate.artifacts, + ), + projection, + ) + approved = await self._candidates.mark_approved( + connection, self._scope_id, candidate_id, expected_version, published.topic.as_ref() + ) + return _reviewed_candidate(approved) + async def get_experience(self, ref: ArtifactRef, /) -> Experience: return cast(Experience, await self._get_artifact(ref, Experience)) @@ -731,6 +801,7 @@ def _validate_proposal_family(family: str, proposal: object) -> None: Skill.family: SkillContent, "profile": ProfileCandidateProposal, Memory.family: MemoryDreamCandidateProposal, + TopicMemory.family: TopicMemoryContent, }.get(family) if expected is None or type(proposal) is not expected: raise InvalidCandidateError("family", family) diff --git a/src/powercontext/builtin/runtime/models.py b/src/powercontext/builtin/runtime/models.py index 34bc595fc2..a861553cd7 100644 --- a/src/powercontext/builtin/runtime/models.py +++ b/src/powercontext/builtin/runtime/models.py @@ -42,6 +42,7 @@ ExternalSkillResolution, SkillContent, ) +from powercontext.builtin.artifacts.topic_memory.models import TopicMemoryContent from powercontext.builtin.review import ( DEFAULT_CANDIDATE_PAGE_SIZE, MAX_CANDIDATE_EVIDENCE, @@ -57,7 +58,9 @@ PreparedContextSchema: TypeAlias = Literal["powercontext.prepared-context.v1"] PreparedContextStatus: TypeAlias = Literal["ready", "empty"] -ReviewedProposal: TypeAlias = ExperienceContent | SkillContent | ProfileCandidateProposal | MemoryDreamCandidateProposal +ReviewedProposal: TypeAlias = ( + ExperienceContent | SkillContent | ProfileCandidateProposal | MemoryDreamCandidateProposal | TopicMemoryContent +) PREPARED_CONTEXT_SCHEMA: PreparedContextSchema = "powercontext.prepared-context.v1" @@ -407,7 +410,7 @@ class ListArtifactCandidatesRequest(BaseModel): """Filter and page the current Review Inbox.""" status: CandidateStatus = CandidateStatus.PENDING - family: Literal["experience", "skill", "profile", "memory"] | None = None + family: Literal["experience", "skill", "profile", "memory", "topic-memory"] | None = None cursor: str | None = None limit: Annotated[int, Field(ge=1, le=MAX_CANDIDATE_PAGE_SIZE)] = DEFAULT_CANDIDATE_PAGE_SIZE @@ -426,7 +429,7 @@ class RejectArtifactCandidateRequest(ApproveArtifactCandidateRequest): class ReviseArtifactCandidateRequest(ApproveArtifactCandidateRequest): - proposal: ExperienceContent | SkillContent | ProfileWriteContent | MemoryDreamCandidateProposal + proposal: ExperienceContent | SkillContent | ProfileWriteContent | MemoryDreamCandidateProposal | TopicMemoryContent sources: tuple[SourceRef, ...] = () artifacts: tuple[ArtifactRef, ...] = () memory_citations: tuple[MemoryCitation, ...] | None = None diff --git a/src/powercontext/builtin/runtime/relational.py b/src/powercontext/builtin/runtime/relational.py index c66ebaa87c..e180526a7a 100644 --- a/src/powercontext/builtin/runtime/relational.py +++ b/src/powercontext/builtin/runtime/relational.py @@ -103,6 +103,7 @@ PublishedTopicMemory, TopicMemory, TopicMemoryBrowseCursor, + TopicMemoryContent, TopicMemoryCurrentItem, TopicMemorySearchMode, TopicMemorySearchResult, @@ -306,6 +307,7 @@ class _ScopedServices: source_registry: SourceDefinitionRegistry prompts: PromptService generation_receipts: HandoffGenerationReceipts + topic_writer: TopicMemoryManagementWriter def generation_sources(self) -> GenerationSourceAccess: return GenerationSourceAccess(self.repositories.sources) @@ -391,6 +393,7 @@ def review(self, connection: AsyncConnection | None = None) -> ReviewService: evidence=self.evidence(), connection=connection, memory=lambda bound: self.memory(self.sources(bound)[1], bound), + topic_writer=self.topic_writer, ) def recurrence_ledger(self) -> RelationalRecurrenceLedger: @@ -546,6 +549,7 @@ def __init__( Skill.family: SkillContent, Profile.family: ProfileCandidateProposal, Memory.family: MemoryDreamCandidateProposal, + TopicMemory.family: TopicMemoryContent, }), connector_checkpoints=ConnectorCheckpointRepository(), source_definitions=SourceDefinitionManifestRepository(), @@ -589,6 +593,7 @@ def __init__( max_concurrency=topic_memory_write_concurrency, usage_reporter=self.model_usage_reporter, ) + self._topic_memory_writer = topic_memory_writer family_writers = FamilyManagementWriterRegistry(( topic_memory_writer, PromptManagementWriter(self.repositories.artifacts, self.prompt_registry), @@ -1407,6 +1412,7 @@ def _services_for(self, scope_id: str) -> _ScopedServices: generation_receipts=self._generation_receipts, token_estimator=self._token_estimator, source_registry=self.source_registry, + topic_writer=self._topic_memory_writer, ) diff --git a/src/powercontext/builtin/runtime/topic_memory_processing.py b/src/powercontext/builtin/runtime/topic_memory_processing.py index f9c4fd6c5f..9b541ded50 100644 --- a/src/powercontext/builtin/runtime/topic_memory_processing.py +++ b/src/powercontext/builtin/runtime/topic_memory_processing.py @@ -93,6 +93,7 @@ from powercontext.builtin.inference.usage import bind_usage_reporter from powercontext.builtin.persistence.cursors import SourceCursorRepository from powercontext.builtin.persistence.database import AsyncDatabase +from powercontext.builtin.persistence.dream import DreamRepository from powercontext.builtin.persistence.errors import ArtifactProcessingLeadershipLostError, GenerationConflictError from powercontext.builtin.persistence.sources import SourceRepository, StoredSource from powercontext.builtin.persistence.supervision import ArtifactProcessingFence, ArtifactProcessingLeaseRepository @@ -1541,10 +1542,55 @@ async def _run_topic_memory_worker( spec: TopicMemoryWorkerSpec, assignment: ArtifactProcessingWorkAssignment, ) -> ArtifactProcessingWorkerCompletion: + if await _process_topic_dream_if_pending(spec, assignment): + return ArtifactProcessingWorkerCompletion() async with _open_topic_memory_processor(spec, assignment.scope_id) as processor: return await processor.process(assignment) +async def _process_topic_dream_if_pending( + spec: TopicMemoryWorkerSpec, assignment: ArtifactProcessingWorkAssignment +) -> bool: + """Dispatch explicit Dream work without consuming the Topic Source cursor.""" + + from powercontext.builtin.dream.bindings import operations_for_binding + from powercontext.builtin.inference.usage import UsageReportingEmbeddingModel + from powercontext.builtin.runtime.composition import _dream_generator, _embedding_models, open_builtin_contexts + from powercontext.builtin.runtime.dream_processing import process_dream_invocation + + operations = operations_for_binding(assignment.binding_name) + if not operations or not spec.config.runtime.dream_enabled: + return False + async with AsyncExitStack() as resources: + raw_embedding, _ = await _embedding_models(spec.config.inference, resources, None, disable_provider_retries=True) + embedding = None if raw_embedding is None else UsageReportingEmbeddingModel(raw_embedding) + contexts = await resources.enter_async_context( + open_builtin_contexts(spec.config, embedding_model=embedding, _topic_memory_worker=True) + ) + async with contexts.database.transaction() as connection: + record = await DreamRepository().next_pending( + connection, + assignment.scope_id, + operations, + through_generation=assignment.claimed_request_generation, + ) + if record is None: + return False + generator = await _dream_generator(spec.config.inference, record.run.budget, resources, None) + security = None + if spec.worker_security is not None: + from powercontext.server.processing_security import open_worker_security + + security = await resources.enter_async_context(open_worker_security(spec.worker_security, contexts.database)) + return await process_dream_invocation( + contexts, + assignment, + config=spec.config, + generator=generator, + security=security, + ) + + @asynccontextmanager async def _open_topic_memory_processor(spec: TopicMemoryWorkerSpec, scope_id: str): """Reconstruct only the DB, index and inference resources needed by one child.""" diff --git a/src/powercontext/http/__init__.py b/src/powercontext/http/__init__.py index 473332f71e..512bfcc08d 100644 --- a/src/powercontext/http/__init__.py +++ b/src/powercontext/http/__init__.py @@ -388,6 +388,7 @@ Timezone, TokenEstimatorProfile, TopicMemoryArtifact, + TopicMemoryDreamProposal, TopicMemoryFlushStatus, TopicMemoryMatchedBy, TopicMemoryUsedSearchMode, @@ -780,6 +781,7 @@ "Timezone", "TokenEstimatorProfile", "TopicMemoryArtifact", + "TopicMemoryDreamProposal", "TopicMemoryFlushStatus", "TopicMemoryMatchedBy", "TopicMemoryUsedSearchMode", diff --git a/src/powercontext/http/_generated/models.py b/src/powercontext/http/_generated/models.py index 3557b6ad69..ebfbf46f99 100644 --- a/src/powercontext/http/_generated/models.py +++ b/src/powercontext/http/_generated/models.py @@ -115,6 +115,15 @@ class ProfileCandidateProposal(BaseModel): created_at: AwareDatetime +class TopicMemoryDreamProposal(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + title: Annotated[StrictStr, Field(max_length=512, min_length=1)] + summary: Annotated[StrictStr, Field(max_length=8000, min_length=1)] + detail: Annotated[StrictStr, Field(max_length=125000, min_length=1)] + + class Family(StrEnum): PROFILE = "profile" @@ -349,6 +358,7 @@ class DreamOperation(StrEnum): DERIVE_SKILL = "derive_skill" REVISE_PROFILE = "revise_profile" REVISE_MEMORY = "revise_memory" + REVISE_TOPIC_MEMORY = "revise_topic_memory" class DreamStatus(StrEnum): @@ -369,6 +379,7 @@ class DreamEvidenceKind(StrEnum): EXPERIENCE = "experience" MEMORY = "memory" PROFILE = "profile" + TOPIC_MEMORY = "topic_memory" UNRESOLVED = "unresolved" @@ -2025,6 +2036,7 @@ class CandidateFamily(StrEnum): SKILL = "skill" PROFILE = "profile" MEMORY = "memory" + TOPIC_MEMORY = "topic-memory" class ExternalSkillInstallationScope(StrEnum): @@ -3255,7 +3267,13 @@ class ReviseArtifactCandidateRequest(BaseModel): scope_id: Annotated[StrictStr, Field(max_length=256, min_length=1, pattern=".*\\S.*")] candidate_id: Annotated[StrictStr, Field(max_length=128, min_length=1, pattern="^[\\x21-\\x7E]+$")] expected_version: Annotated[StrictInt, Field(ge=1)] - proposal: ExperienceProposal | SkillProposal | ProfileWriteContent | MemoryDreamCandidateProposal + proposal: ( + ExperienceProposal + | SkillProposal + | ProfileWriteContent + | MemoryDreamCandidateProposal + | TopicMemoryDreamProposal + ) source_refs: Annotated[ list[SourceReference], Field( @@ -3533,7 +3551,13 @@ class ArtifactCandidate(BaseModel): version: Annotated[StrictInt, Field(ge=1)] family: CandidateFamily status: CandidateStatus - proposal: ExperienceProposal | SkillProposal | ProfileCandidateProposal | MemoryDreamCandidateProposal + proposal: ( + ExperienceProposal + | SkillProposal + | ProfileCandidateProposal + | MemoryDreamCandidateProposal + | TopicMemoryDreamProposal + ) source_refs: Annotated[ list[SourceReference], Field( diff --git a/src/powercontext/http/_generated/schema.py b/src/powercontext/http/_generated/schema.py index d5e2903856..ba67f91173 100644 --- a/src/powercontext/http/_generated/schema.py +++ b/src/powercontext/http/_generated/schema.py @@ -4629,6 +4629,16 @@ "type": "object", "required": ["base", "dream_run_id", "changes"], }, + "TopicMemoryDreamProposal": { + "properties": { + "title": {"type": "string", "maxLength": 512, "minLength": 1}, + "summary": {"type": "string", "maxLength": 8000, "minLength": 1}, + "detail": {"type": "string", "maxLength": 125000, "minLength": 1}, + }, + "additionalProperties": False, + "type": "object", + "required": ["title", "summary", "detail"], + }, "CreateProfileArtifactRequest": { "properties": { "family": {"type": "string", "enum": ["profile"]}, @@ -5022,13 +5032,13 @@ }, "DreamOperation": { "type": "string", - "enum": ["refine_experience", "derive_skill", "revise_profile", "revise_memory"], + "enum": ["refine_experience", "derive_skill", "revise_profile", "revise_memory", "revise_topic_memory"], }, "DreamStatus": {"type": "string", "enum": ["queued", "running", "succeeded", "failed"]}, "DreamOutcome": {"type": "string", "enum": ["proposed", "no_change", "needs_evidence"]}, "DreamEvidenceKind": { "type": "string", - "enum": ["source", "experience", "memory", "profile", "unresolved"], + "enum": ["source", "experience", "memory", "profile", "topic_memory", "unresolved"], }, "DreamEvidenceRole": { "type": "string", @@ -5291,6 +5301,7 @@ {"$ref": "#/components/schemas/SkillProposal"}, {"$ref": "#/components/schemas/ProfileCandidateProposal"}, {"$ref": "#/components/schemas/MemoryDreamCandidateProposal"}, + {"$ref": "#/components/schemas/TopicMemoryDreamProposal"}, ] }, "source_refs": { @@ -8025,6 +8036,7 @@ {"$ref": "#/components/schemas/SkillProposal"}, {"$ref": "#/components/schemas/ProfileWriteContent"}, {"$ref": "#/components/schemas/MemoryDreamCandidateProposal"}, + {"$ref": "#/components/schemas/TopicMemoryDreamProposal"}, ] }, "source_refs": { @@ -8998,7 +9010,7 @@ "enum": ["memory", "experience", "skill", "handoff", "profile", "prompt", "topic-memory"], }, "StatsPeriod": {"type": "string", "enum": ["today", "7d", "30d"]}, - "CandidateFamily": {"type": "string", "enum": ["experience", "skill", "profile", "memory"]}, + "CandidateFamily": {"type": "string", "enum": ["experience", "skill", "profile", "memory", "topic-memory"]}, "ExternalSkillInstallationScope": {"type": "string", "enum": ["user", "project", "plugin"]}, "ExternalSkillResolutionStatus": {"type": "string", "enum": ["available", "unavailable"]}, "CandidateStatus": {"type": "string", "enum": ["pending", "approved", "rejected"]}, diff --git a/src/powercontext/server/mapping.py b/src/powercontext/server/mapping.py index dbb301c189..4f023eed66 100644 --- a/src/powercontext/server/mapping.py +++ b/src/powercontext/server/mapping.py @@ -51,6 +51,7 @@ ExternalSkillResolution as RuntimeExternalSkillResolution, ) from powercontext.builtin.artifacts.topic_memory import PublishedTopicMemory, TopicMemorySearchResult +from powercontext.builtin.artifacts.topic_memory.models import TopicMemoryContent as RuntimeTopicMemoryContent from powercontext.builtin.persistence.artifact_governance import ArtifactGovernance from powercontext.builtin.review import ArtifactCandidate as RuntimeArtifactCandidate from powercontext.builtin.review import ArtifactCandidatePage as RuntimeArtifactCandidatePage @@ -277,6 +278,7 @@ SubmitSourceObservationRequest, TaskCheck, TopicMemoryArtifact, + TopicMemoryDreamProposal, TopicMemoryFlushStatus, TopicMemoryMatchedBy, TopicMemoryUsedSearchMode, @@ -1067,8 +1069,10 @@ def skill_proposal(value: SkillContent) -> SkillProposal: def reviewed_content( - value: ExperienceProposal | SkillProposal | ProfileWriteContent | MemoryDreamCandidateProposal, -) -> ExperienceContent | SkillContent | RuntimeProfileWriteContent | RuntimeMemoryDreamCandidateProposal: + value: ExperienceProposal | SkillProposal | ProfileWriteContent | MemoryDreamCandidateProposal | TopicMemoryDreamProposal, +) -> ExperienceContent | SkillContent | RuntimeProfileWriteContent | RuntimeMemoryDreamCandidateProposal | RuntimeTopicMemoryContent: + if isinstance(value, TopicMemoryDreamProposal): + return RuntimeTopicMemoryContent.model_validate(value.model_dump(mode="json")) if isinstance(value, MemoryDreamCandidateProposal): return RuntimeMemoryDreamCandidateProposal.model_validate(value.model_dump(mode="json")) if isinstance(value, ProfileWriteContent): @@ -1080,7 +1084,9 @@ def reviewed_content( def reviewed_proposal( value: object, -) -> ExperienceProposal | SkillProposal | ProfileCandidateProposal | MemoryDreamCandidateProposal: +) -> ExperienceProposal | SkillProposal | ProfileCandidateProposal | MemoryDreamCandidateProposal | TopicMemoryDreamProposal: + if isinstance(value, RuntimeTopicMemoryContent): + return TopicMemoryDreamProposal.model_validate(value.model_dump(mode="json")) if isinstance(value, RuntimeMemoryDreamCandidateProposal): return MemoryDreamCandidateProposal.model_validate(value.model_dump(mode="json")) if isinstance(value, RuntimeProfileCandidateProposal): diff --git a/tests/e2e/test_artifact_dreaming.py b/tests/e2e/test_artifact_dreaming.py index 5c76ab575f..ee45f435e5 100644 --- a/tests/e2e/test_artifact_dreaming.py +++ b/tests/e2e/test_artifact_dreaming.py @@ -39,6 +39,11 @@ ProfileWriteContent, ) from powercontext.builtin.artifacts.skill import SkillContent +from powercontext.builtin.artifacts.topic_memory import ( + TopicMemoryContent, + TopicMemoryDraft, + prepare_topic_memory_projection, +) from powercontext.builtin.dream.generation import DreamGenerationInput from powercontext.builtin.dream.models import DreamError, DreamPlan from powercontext.builtin.evidence.models import EvidenceResolutionError @@ -321,6 +326,85 @@ async def scenario() -> None: asyncio.run(scenario()) +def test_topic_memory_dream_publishes_complete_revision_after_review(database: DatabaseConfig) -> None: + class TopicDreamGenerator: + config_id = "topic-dream-test" + + async def generate(self, value: DreamGenerationInput) -> GenerationResult[DreamPlan]: + assert value.operation == "revise_topic_memory" + assert any(item.kind == "topic_memory" for item in value.evidence.evidence) + source_id = next(item.evidence_id for item in value.evidence.evidence if item.kind == "source") + return GenerationResult( + output=DreamPlan( + outcome="proposed", + reason="Verified investigation changed the topic conclusion.", + intent="correct", + proposal=TopicMemoryContent( + title="Retry investigation", + summary="The replay can create a duplicate.", + detail="# Confirmed\nA later check found two records.\n\n# Next\nRequire idempotency.", + ), + evidence_ids=(source_id,), + ), + usage=InferenceUsage(requests=1), + ) + + async def scenario() -> None: + async with open_builtin_runtime(config(database), dream_generator=TopicDreamGenerator()) as runtime: + assert runtime.scopes is not None + scope = await runtime.scopes.create( + ScopeDraft(title="Topic Dream", summary="Complete projection", idempotency_key="topic-dream") + ) + scope_id = scope.scope_id + source = await runtime.sources.for_scope(scope_id).capture( + CaptureSource(source_id="topic-correction", content="A later replay created two records.", metadata={}) + ) + topics = runtime._provider.repositories.topic_memories + original = TopicMemoryContent( + title="Retry investigation", + summary="The replay creates one record.", + detail="# Confirmed\nAn earlier check found one record.", + ) + async with runtime._provider.database.transaction() as connection: + published = await topics.publish_create( + connection, + scope_id, + "topic-dream-target", + TopicMemoryDraft(content=original, sources=(source.source_ref,)), + prepare_topic_memory_projection(original), + ) + run = await runtime.dream.for_scope(scope_id).create( + CreateDreamRunRequest( + operation="revise_topic_memory", + target=published.topic.as_ref(), + artifacts=(published.topic.as_ref(),), + sources=(source.source_ref,), + idempotency_key="topic-correction", + ) + ) + await process_pending(runtime) + completed = await runtime.dream.for_scope(scope_id).get(GetDreamRunRequest(run_id=run.run_id)) + assert completed.outcome == "proposed" and completed.candidate is not None + async with runtime._provider.database.transaction() as connection: + before = await runtime._provider.repositories.artifacts.latest( + connection, scope_id, "topic-memory", "topic-dream-target" + ) + assert before.revision == 1 + approved = await runtime.review.for_scope(scope_id).approve( + ApproveArtifactCandidateRequest( + candidate_id=completed.candidate.candidate_id, + expected_version=completed.candidate.version, + ) + ) + assert approved.result_artifact is not None and approved.result_artifact.revision == 2 + async with runtime._provider.database.transaction() as connection: + after = await topics.get_exact(connection, scope_id, approved.result_artifact) + assert after.topic.content.summary == "The replay can create a duplicate." + assert after.is_current + + asyncio.run(scenario()) + + @pytest.mark.parametrize("existing", [False, True], ids=["new-config", "enable-skill"]) def test_wizard_skill_selection_accepts_dream_derivation( tmp_path: Path, existing: bool, monkeypatch: pytest.MonkeyPatch From 20284ab7039c85785743578e495fa46321573041 Mon Sep 17 00:00:00 2001 From: Frey <370805886@qq.com> Date: Tue, 22 Sep 2026 15:08:14 +0800 Subject: [PATCH 05/48] feat(dream): review Handoff revisions without activation --- openapi/powercontext.yaml | 6 +- .../builtin/artifacts/handoff/models.py | 2 + src/powercontext/builtin/dream/bindings.py | 2 + src/powercontext/builtin/dream/generation.py | 3 + src/powercontext/builtin/dream/models.py | 18 +- src/powercontext/builtin/dream/service.py | 16 +- src/powercontext/builtin/evidence/models.py | 4 +- src/powercontext/builtin/evidence/resolver.py | 9 + .../builtin/persistence/family_management.py | 19 ++ src/powercontext/builtin/review/models.py | 2 +- src/powercontext/builtin/review/service.py | 99 ++++++- .../builtin/runtime/composition.py | 3 +- src/powercontext/builtin/runtime/config.py | 3 + .../builtin/runtime/family_processing.py | 5 +- src/powercontext/builtin/runtime/models.py | 7 +- .../builtin/runtime/processing_registry.py | 9 +- .../builtin/runtime/relational.py | 25 +- src/powercontext/http/_generated/models.py | 241 +++++++++--------- src/powercontext/http/_generated/schema.py | 18 +- src/powercontext/server/mapping.py | 10 +- tests/e2e/test_artifact_dreaming.py | 98 +++++++ 21 files changed, 450 insertions(+), 149 deletions(-) diff --git a/openapi/powercontext.yaml b/openapi/powercontext.yaml index 5ba12c9ef8..33fa4bd317 100644 --- a/openapi/powercontext.yaml +++ b/openapi/powercontext.yaml @@ -5151,6 +5151,7 @@ components: - revise_profile - revise_memory - revise_topic_memory + - refresh_handoff DreamStatus: type: string enum: @@ -5172,6 +5173,7 @@ components: - memory - profile - topic_memory + - handoff - unresolved DreamEvidenceRole: type: string @@ -5555,6 +5557,7 @@ components: - $ref: "#/components/schemas/ProfileCandidateProposal" - $ref: "#/components/schemas/MemoryDreamCandidateProposal" - $ref: "#/components/schemas/TopicMemoryDreamProposal" + - $ref: "#/components/schemas/HandoffContent" source_refs: type: array maxItems: 32 @@ -8764,6 +8767,7 @@ components: - $ref: "#/components/schemas/ProfileWriteContent" - $ref: "#/components/schemas/MemoryDreamCandidateProposal" - $ref: "#/components/schemas/TopicMemoryDreamProposal" + - $ref: "#/components/schemas/HandoffContent" source_refs: type: array maxItems: 32 @@ -9665,7 +9669,7 @@ components: enum: [today, 7d, 30d] CandidateFamily: type: string - enum: [experience, skill, profile, memory, topic-memory] + enum: [experience, skill, profile, memory, topic-memory, handoff] ExternalSkillInstallationScope: type: string enum: [user, project, plugin] diff --git a/src/powercontext/builtin/artifacts/handoff/models.py b/src/powercontext/builtin/artifacts/handoff/models.py index 1a404f2136..4a8c695ea0 100644 --- a/src/powercontext/builtin/artifacts/handoff/models.py +++ b/src/powercontext/builtin/artifacts/handoff/models.py @@ -219,6 +219,8 @@ def require_text(cls, value: str) -> str: class HandoffContent(_HandoffValue): """The complete content shared by temporary and committed Handoffs.""" + model_config = ConfigDict(extra="forbid", frozen=True, strict=True, validate_by_name=True) + generation: HandoffGenerationMetadata | None = Field(default=None, exclude_if=lambda value: value is None) schema_version: Literal["powercontext.handoff.v1"] = Field( diff --git a/src/powercontext/builtin/dream/bindings.py b/src/powercontext/builtin/dream/bindings.py index e56eabeb69..0d0ddab754 100644 --- a/src/powercontext/builtin/dream/bindings.py +++ b/src/powercontext/builtin/dream/bindings.py @@ -23,6 +23,7 @@ from powercontext.builtin.triggers import SOURCE_WINDOW_TRIGGER_NAME SKILL_DREAM_BINDING = "skill.dream.v1" +HANDOFF_DREAM_BINDING = "handoff.dream.v1" @dataclass(frozen=True) @@ -41,6 +42,7 @@ class DreamOperationSpec: DreamOperationSpec("revise_profile", "profile", PROFILE_SOURCE_WINDOW_BINDING), DreamOperationSpec("revise_memory", "memory", SOURCE_WINDOW_TRIGGER_NAME), DreamOperationSpec("revise_topic_memory", "topic-memory", TOPIC_MEMORY_SOURCE_WINDOW_BINDING), + DreamOperationSpec("refresh_handoff", "handoff", HANDOFF_DREAM_BINDING), ) DREAM_BINDINGS = {spec.operation: spec.binding for spec in DREAM_OPERATIONS} diff --git a/src/powercontext/builtin/dream/generation.py b/src/powercontext/builtin/dream/generation.py index 0589d16446..df15a583db 100644 --- a/src/powercontext/builtin/dream/generation.py +++ b/src/powercontext/builtin/dream/generation.py @@ -40,6 +40,9 @@ For revise_topic_memory, compare the exact current Topic with verified new evidence and propose complete title, summary, and detail content. Preserve confirmed results, hypotheses, ruled-out causes, open questions, and next steps as distinct claims. Use correct as the intent. +For refresh_handoff, propose complete Handoff content with exact citations for every state +and next-action claim. A proposed action is not a completed action. Do not claim activation, +receiver acknowledgement, or an executed business tool. Return generation as null and use correct. Provide name, description, instructions, and at least one concrete validation check in validation. Use a package-compatible name: at most 64 lowercase letters, digits, and single separating hyphens. Keep description within 1024 characters. Trim text fields and omit trailing whitespace in instructions. diff --git a/src/powercontext/builtin/dream/models.py b/src/powercontext/builtin/dream/models.py index 08e35ce402..42b7fba63d 100644 --- a/src/powercontext/builtin/dream/models.py +++ b/src/powercontext/builtin/dream/models.py @@ -23,6 +23,7 @@ from powercontext.artifacts import ArtifactRef, MemoryCitation from powercontext.builtin.artifacts.experience import ExperienceContent +from powercontext.builtin.artifacts.handoff.models import HandoffContent from powercontext.builtin.artifacts.memory.models import MemoryDreamWrite from powercontext.builtin.artifacts.profile.models import ProfileWriteContent from powercontext.builtin.artifacts.skill import SkillContent @@ -38,7 +39,7 @@ from powercontext.sources import SourceRef DreamOperation = Literal[ - "refine_experience", "derive_skill", "revise_profile", "revise_memory", "revise_topic_memory" + "refine_experience", "derive_skill", "revise_profile", "revise_memory", "revise_topic_memory", "refresh_handoff" ] DreamStatus = Literal["queued", "running", "succeeded", "failed"] DreamOutcome = Literal["proposed", "no_change", "needs_evidence"] @@ -86,6 +87,7 @@ def validate_selection(self): "revise_profile": {"experience", "profile"}, "revise_memory": {"experience", "memory"}, "revise_topic_memory": {"experience", "topic-memory"}, + "refresh_handoff": {"experience", "handoff"}, }[self.operation] if any(ref.family not in allowed_families for ref in self.artifacts): raise DreamError("invalid_artifact_family") @@ -122,6 +124,12 @@ def validate_selection(self): or any(ref.family == "topic-memory" and ref != self.target for ref in self.artifacts) ): raise DreamError("invalid_dream_operation") + if self.operation == "refresh_handoff" and ( + self.target is None + or self.target.family != "handoff" + or any(ref.family == "handoff" and ref != self.target for ref in self.artifacts) + ): + raise DreamError("invalid_dream_operation") return self def digest(self) -> str: @@ -205,7 +213,7 @@ class DreamPlan(BaseModel): outcome: DreamOutcome reason: str = Field(min_length=1, max_length=2000) intent: DreamIntent | None = None - proposal: ExperienceContent | SkillContent | ProfileWriteContent | MemoryDreamWrite | TopicMemoryContent | None = None + proposal: ExperienceContent | SkillContent | ProfileWriteContent | MemoryDreamWrite | TopicMemoryContent | HandoffContent | None = None evidence_ids: tuple[str, ...] = Field(default=(), max_length=32) @model_validator(mode="after") @@ -243,6 +251,12 @@ def validate_operation(self, request: CreateDreamRunRequest) -> None: ) elif request.operation == "revise_topic_memory": valid = isinstance(self.proposal, TopicMemoryContent) and self.intent == "correct" + elif request.operation == "refresh_handoff": + valid = ( + isinstance(self.proposal, HandoffContent) + and self.proposal.generation is None + and self.intent == "correct" + ) else: intents = {"create"} if request.target is None else {"corroborate", "refine", "correct"} valid = isinstance(self.proposal, ExperienceContent) and self.intent in intents diff --git a/src/powercontext/builtin/dream/service.py b/src/powercontext/builtin/dream/service.py index 8edd506641..db97533cf0 100644 --- a/src/powercontext/builtin/dream/service.py +++ b/src/powercontext/builtin/dream/service.py @@ -28,6 +28,7 @@ from powercontext.artifacts import ArtifactRef from powercontext.builtin.artifacts.experience import ExperienceContent +from powercontext.builtin.artifacts.handoff.models import HandoffContent from powercontext.builtin.artifacts.memory.models import MemoryDreamCandidateProposal, MemoryDreamWrite from powercontext.builtin.artifacts.profile.models import ProfileCandidateProposal, ProfileWriteContent from powercontext.builtin.artifacts.skill import SkillContent @@ -335,6 +336,7 @@ async def _commit(self, record: DreamRecord, plan: DreamPlan) -> None: "revise_profile": "profile", "revise_memory": "memory", "revise_topic_memory": "topic-memory", + "refresh_handoff": "handoff", }[ run.operation ] @@ -352,7 +354,7 @@ async def _commit(self, record: DreamRecord, plan: DreamPlan) -> None: await self.repository.finish(connection, record, completed) await self._complete_invocation(connection) - async def _propose( + async def _propose( # noqa: C901 - operation-specific Candidate adapters share one atomic Run commit self, connection: AsyncConnection, record: DreamRecord, @@ -435,6 +437,18 @@ async def _propose( reason=plan.reason, candidate_id=_candidate_id(record), ) + elif isinstance(plan.proposal, HandoffContent) and record.run.operation == "refresh_handoff": + if record.run.target is None: + raise DreamError("invalid_generation_output") + candidate = await review.propose_handoff_dream( + plan.proposal, + sources=selected.sources, + artifacts=selected.artifacts, + memory_citations=selected.memory_citations, + target=record.run.target, + reason=plan.reason, + candidate_id=_candidate_id(record), + ) else: raise DreamError("invalid_generation_output") return DreamCandidateRef(candidate_id=candidate.candidate_id, version=candidate.version) diff --git a/src/powercontext/builtin/evidence/models.py b/src/powercontext/builtin/evidence/models.py index 5f5a04f74f..12a831e811 100644 --- a/src/powercontext/builtin/evidence/models.py +++ b/src/powercontext/builtin/evidence/models.py @@ -52,7 +52,7 @@ class EvidenceNode(BaseModel): model_config = ConfigDict(frozen=True, extra="forbid") evidence_id: str - kind: Literal["source", "experience", "memory", "profile", "topic_memory", "unresolved"] + kind: Literal["source", "experience", "memory", "profile", "topic_memory", "handoff", "unresolved"] digest: str source: SourceRef | None = None artifact: ArtifactRef | None = None @@ -96,7 +96,7 @@ class ProjectedEvidence(BaseModel): """Untrusted content visible to the model for this operation only.""" evidence_id: str - kind: Literal["source", "experience", "memory", "profile", "topic_memory"] + kind: Literal["source", "experience", "memory", "profile", "topic_memory", "handoff"] text: str historical: bool = False root_group_ids: tuple[str, ...] = () diff --git a/src/powercontext/builtin/evidence/resolver.py b/src/powercontext/builtin/evidence/resolver.py index e79794f32d..68a2f6f313 100644 --- a/src/powercontext/builtin/evidence/resolver.py +++ b/src/powercontext/builtin/evidence/resolver.py @@ -26,6 +26,7 @@ from powercontext.artifacts import ArtifactRef, MemoryCitation from powercontext.builtin.artifacts.experience import Experience +from powercontext.builtin.artifacts.handoff.models import Handoff from powercontext.builtin.artifacts.memory import Memory, MemoryEntryVersion from powercontext.builtin.artifacts.memory.errors import InvalidMemoryCitationError, MemoryEntryNotFoundError from powercontext.builtin.artifacts.profile.models import Profile @@ -321,6 +322,14 @@ async def _read( artifact.content.model_dump_json(), (), ) + if isinstance(artifact, Handoff): + return ( + EvidenceNode( + evidence_id=evidence_id(ref), kind="handoff", artifact=ref, digest=digest, role="derived" + ), + artifact.content.model_dump_json(), + (), + ) if ref.family == "prompt": return ( EvidenceNode( diff --git a/src/powercontext/builtin/persistence/family_management.py b/src/powercontext/builtin/persistence/family_management.py index 090876b78d..9b69dfbdde 100644 --- a/src/powercontext/builtin/persistence/family_management.py +++ b/src/powercontext/builtin/persistence/family_management.py @@ -571,6 +571,25 @@ async def replace( force_revision=True, ) + async def commit_reviewed( + self, + connection: AsyncConnection, + scope_id: str, + current: Handoff, + content: HandoffContent, + sources: tuple[SourceRef, ...], + ) -> Handoff: + """Commit an approved Handoff without activating or acknowledging it.""" + + if current.artifact_id != self._handoff_artifact_id or content.generation is not None: + raise InvalidBaseAccessRequestError("handoff", "reviewed replacement must target the local singleton") + prepared = PreparedHandoff(scope_id=scope_id, base=current.as_ref(), content=content) + return await self._service(scope_id, connection).commit( + prepared, + additional_sources=sources, + force_revision=True, + ) + async def _apply_memory_plan(service: MemoryService, plan: Any, direct_source: SourceRef) -> Memory: if plan.commit is None: diff --git a/src/powercontext/builtin/review/models.py b/src/powercontext/builtin/review/models.py index b768dbd8c9..b37594e8bb 100644 --- a/src/powercontext/builtin/review/models.py +++ b/src/powercontext/builtin/review/models.py @@ -73,7 +73,7 @@ def validate_evidence(self): raise ValueError(f"Candidate evidence must not exceed {MAX_CANDIDATE_EVIDENCE} references") # noqa: TRY003 if self.target is not None and self.target.family != self.family: raise ValueError("Candidate target must belong to the proposed family") # noqa: TRY003 - if self.memory_citations and self.family not in {"experience", "profile", "memory"}: + if self.memory_citations and self.family not in {"experience", "profile", "memory", "handoff"}: raise ValueError("this Candidate Family does not accept Memory citations") # noqa: TRY003 return self diff --git a/src/powercontext/builtin/review/service.py b/src/powercontext/builtin/review/service.py index b71bb3e6ba..2f720fa845 100644 --- a/src/powercontext/builtin/review/service.py +++ b/src/powercontext/builtin/review/service.py @@ -31,7 +31,9 @@ normalize_match_text, ) from powercontext.builtin.artifacts.handoff.models import ( + Handoff, HandoffArtifactCitation, + HandoffContent, HandoffMemoryCitation, HandoffSourceCitation, ) @@ -52,6 +54,7 @@ from powercontext.builtin.persistence.database import AsyncDatabase from powercontext.builtin.persistence.errors import RepositoryNotFoundError from powercontext.builtin.persistence.experience_index import ExperienceIndex +from powercontext.builtin.persistence.family_management import HandoffManagementWriter from powercontext.builtin.persistence.generation_sources import GenerationSourceAccess from powercontext.builtin.persistence.profile import ProfilePolicyRepository from powercontext.builtin.persistence.skill_packages import SkillPackageRepository @@ -77,7 +80,7 @@ IdFactory = Callable[[str], str] ReviewedProposal: TypeAlias = ( - ExperienceContent | SkillContent | ProfileCandidateProposal | MemoryDreamCandidateProposal | TopicMemoryContent + ExperienceContent | SkillContent | ProfileCandidateProposal | MemoryDreamCandidateProposal | TopicMemoryContent | HandoffContent ) ReviewedArtifact: TypeAlias = Experience | Skill ReviewedDraft: TypeAlias = ExperienceDraft | SkillDraft @@ -101,6 +104,7 @@ def __init__( evidence: EvidenceResolver | None = None, memory: Callable[[AsyncConnection | None], MemoryService] | None = None, topic_writer: TopicMemoryManagementWriter | None = None, + handoff_writer: HandoffManagementWriter | None = None, authorization_context: Callable[[], AbstractAsyncContextManager[None]] = nullcontext, connection: AsyncConnection | None = None, ) -> None: @@ -115,6 +119,7 @@ def __init__( self._evidence = evidence self._memory = memory self._topic_writer = topic_writer + self._handoff_writer = handoff_writer self._authorization_context = authorization_context self._bound_connection = connection @@ -264,6 +269,31 @@ async def propose_topic_memory_dream( ) return ArtifactCandidate[TopicMemoryContent].model_validate(candidate.model_dump(mode="python")) + async def propose_handoff_dream( + self, + proposal: HandoffContent, + /, + *, + sources: tuple[SourceRef, ...], + artifacts: tuple[ArtifactRef, ...], + memory_citations: tuple[MemoryCitation, ...], + target: ArtifactRef, + reason: str, + candidate_id: str, + ) -> ArtifactCandidate[HandoffContent]: + _validate_handoff_citations(proposal, sources, artifacts, memory_citations, target) + candidate = await self._propose( + Handoff.family, + proposal, + sources=sources, + artifacts=artifacts, + memory_citations=memory_citations, + target=target, + reason=reason, + candidate_id=candidate_id, + ) + return ArtifactCandidate[HandoffContent].model_validate(candidate.model_dump(mode="python")) + async def _propose( self, family: str, @@ -426,6 +456,8 @@ async def revise( for change in proposal.changes ): raise InvalidCandidateError("proposal", "Memory Dream change is outside selected evidence") + if isinstance(proposal, HandoffContent): + _validate_handoff_citations(proposal, canonical_sources, canonical_artifacts, citations, target) canonical_sources = await self._validate_evidence( connection, canonical_sources, @@ -478,7 +510,7 @@ async def reject( ) return _reviewed_candidate(rejected) - async def approve( + async def approve( # noqa: C901 - each Family keeps its own transactional publication boundary self, candidate_id: str, expected_version: int, @@ -492,6 +524,8 @@ async def approve( return await self._approve_memory_dream(candidate_id, expected_version) if current.family == TopicMemory.family: return await self._approve_topic_memory_dream(candidate_id, expected_version) + if current.family == Handoff.family: + return await self._approve_handoff_dream(candidate_id, expected_version) async with self._connection() as connection: current = await self._candidates.get(connection, self._scope_id, candidate_id) if current.family == "profile": @@ -647,6 +681,41 @@ async def _approve_topic_memory_dream(self, candidate_id: str, expected_version: ) return _reviewed_candidate(approved) + async def _approve_handoff_dream(self, candidate_id: str, expected_version: int) -> ReviewedCandidate: + if self._handoff_writer is None: + raise InvalidCandidateError("family", "Handoff Dream writer is unavailable") + async with self._connection() as connection: + preview = _reviewed_candidate(await self._candidates.get(connection, self._scope_id, candidate_id)) + if preview.target is None: + raise InvalidCandidateError("target", "Handoff Dream requires an existing target") + current = await self._artifacts.latest( + connection, self._scope_id, Handoff.family, preview.target.artifact_id, for_update=True + ) + if current.as_ref() != preview.target: + raise ArtifactTargetConflictError(preview.target, current.as_ref()) + candidate = _reviewed_candidate( + await self._candidates.lock_pending(connection, self._scope_id, candidate_id, expected_version) + ) + if not isinstance(current, Handoff) or not isinstance(candidate.proposal, HandoffContent): + raise InvalidCandidateError("family", "Handoff Dream proposal required") + _validate_handoff_citations( + candidate.proposal, + candidate.sources, + candidate.artifacts, + candidate.memory_citations, + candidate.target, + ) + await self._validate_evidence( + connection, candidate.sources, candidate.artifacts, candidate.memory_citations + ) + committed = await self._handoff_writer.commit_reviewed( + connection, self._scope_id, current, candidate.proposal, candidate.sources + ) + approved = await self._candidates.mark_approved( + connection, self._scope_id, candidate_id, expected_version, committed.as_ref() + ) + return _reviewed_candidate(approved) + async def get_experience(self, ref: ArtifactRef, /) -> Experience: return cast(Experience, await self._get_artifact(ref, Experience)) @@ -802,11 +871,37 @@ def _validate_proposal_family(family: str, proposal: object) -> None: "profile": ProfileCandidateProposal, Memory.family: MemoryDreamCandidateProposal, TopicMemory.family: TopicMemoryContent, + Handoff.family: HandoffContent, }.get(family) if expected is None or type(proposal) is not expected: raise InvalidCandidateError("family", family) +def _validate_handoff_citations( + proposal: HandoffContent, + sources: tuple[SourceRef, ...], + artifacts: tuple[ArtifactRef, ...], + memory_citations: tuple[MemoryCitation, ...], + target: ArtifactRef | None, +) -> None: + if proposal.generation is not None or target is None: + raise InvalidCandidateError("proposal", "Dream Handoff cannot claim generated or activated content") + statements = (*proposal.state, *((proposal.next_action,) if proposal.next_action is not None else ())) + for statement in statements: + for citation in statement.citations: + if isinstance(citation, HandoffSourceCitation) and citation.source_ref in sources: + continue + if ( + isinstance(citation, HandoffArtifactCitation) + and citation.artifact_ref in artifacts + and citation.artifact_ref != target + ): + continue + if isinstance(citation, HandoffMemoryCitation) and citation.memory_citation in memory_citations: + continue + raise InvalidCandidateError("proposal", "Handoff claim cites evidence outside the selected inputs") + + async def _has_resolved_failure_evidence( # noqa: C901 - each citation kind has a distinct resolution boundary sources: GenerationSourceAccess, artifacts: ArtifactRepository, diff --git a/src/powercontext/builtin/runtime/composition.py b/src/powercontext/builtin/runtime/composition.py index fee6723d56..47dca4b67b 100644 --- a/src/powercontext/builtin/runtime/composition.py +++ b/src/powercontext/builtin/runtime/composition.py @@ -598,6 +598,7 @@ def _artifact_processing_bindings( # noqa: C901 - validate and assemble one reg "experience": config.runtime.experience_schedule_seconds, "profile": config.runtime.profile_schedule_enabled or None, "skill": None, + "handoff": None, } for family, schedule in automatic.items(): if schedule is not None and family not in declared | registered: @@ -657,7 +658,7 @@ def _artifact_processing_bindings( # noqa: C901 - validate and assemble one reg else None, timezone=config.runtime.profile_timezone if family == "profile" else "Asia/Shanghai", pending_provider=None - if family == "skill" + if family in {"skill", "handoff"} else SourceProcessingPendingProvider(contexts.database, binding, family), automatic_scope_filter=enabled_profile_scopes if family == "profile" else None, ) diff --git a/src/powercontext/builtin/runtime/config.py b/src/powercontext/builtin/runtime/config.py index 0761cfcad5..76353e1c79 100644 --- a/src/powercontext/builtin/runtime/config.py +++ b/src/powercontext/builtin/runtime/config.py @@ -123,6 +123,7 @@ def normalize_processing_aliases(cls, value: Any) -> Any: "topic_memory_max_workers", "experience_max_workers", "skill_max_workers", + "handoff_max_workers", "profile_max_workers", "profile_max_concurrency", "artifact_processing_max_workers", @@ -199,11 +200,13 @@ def validate_recall_gate_threshold_order(self): topic_memory_max_workers: int = Field(default=10, ge=1) experience_max_workers: int = Field(default=1, ge=1) skill_max_workers: int = Field(default=1, ge=1) + handoff_max_workers: int = Field(default=1, ge=1) profile_max_workers: int = Field(default=4, ge=1) memory_worker_timeout_seconds: float = Field(default=600, gt=0) topic_memory_worker_timeout_seconds: float = Field(default=600, gt=0) experience_worker_timeout_seconds: float = Field(default=600, gt=0) skill_worker_timeout_seconds: float = Field(default=600, gt=0) + handoff_worker_timeout_seconds: float = Field(default=600, gt=0) profile_worker_timeout_seconds: float = Field(default=600, gt=0) @model_validator(mode="after") diff --git a/src/powercontext/builtin/runtime/family_processing.py b/src/powercontext/builtin/runtime/family_processing.py index 2073e1acdf..8e8d1f66dc 100644 --- a/src/powercontext/builtin/runtime/family_processing.py +++ b/src/powercontext/builtin/runtime/family_processing.py @@ -28,7 +28,7 @@ from powercontext._logging import log_safely from powercontext.builtin.artifacts.experience import EXPERIENCE_INCUBATION_CURSOR_NAME from powercontext.builtin.artifacts.profile.models import PROFILE_SOURCE_WINDOW_BINDING -from powercontext.builtin.dream.bindings import SKILL_DREAM_BINDING, operations_for_binding +from powercontext.builtin.dream.bindings import HANDOFF_DREAM_BINDING, SKILL_DREAM_BINDING, operations_for_binding from powercontext.builtin.dream.generation import DreamGenerator from powercontext.builtin.inference.models import InferenceUsage from powercontext.builtin.inference.usage import bind_usage_reporter @@ -56,6 +56,7 @@ "experience": EXPERIENCE_INCUBATION_CURSOR_NAME, "profile": PROFILE_SOURCE_WINDOW_BINDING, "skill": SKILL_DREAM_BINDING, + "handoff": HANDOFF_DREAM_BINDING, } logger = logging.getLogger(__name__) @@ -195,7 +196,7 @@ async def _process_family_invocation( # noqa: C901 - one guarded dispatch per r contexts, assignment, config=config, generator=dream_generator, security=security ): return ArtifactProcessingWorkerCompletion() - if assignment.artifact_family == "skill": + if assignment.artifact_family in {"skill", "handoff"}: async with contexts.database.transaction() as connection: await invocation.start(connection) await invocation.complete(connection, remaining_work=False) diff --git a/src/powercontext/builtin/runtime/models.py b/src/powercontext/builtin/runtime/models.py index a861553cd7..4b33014834 100644 --- a/src/powercontext/builtin/runtime/models.py +++ b/src/powercontext/builtin/runtime/models.py @@ -23,6 +23,7 @@ from powercontext.artifacts import ArtifactRef from powercontext.builtin.artifacts.experience import ExperienceContent +from powercontext.builtin.artifacts.handoff.models import HandoffContent from powercontext.builtin.artifacts.memory.models import ( MemoryCitation, MemoryDreamCandidateProposal, @@ -59,7 +60,7 @@ PreparedContextSchema: TypeAlias = Literal["powercontext.prepared-context.v1"] PreparedContextStatus: TypeAlias = Literal["ready", "empty"] ReviewedProposal: TypeAlias = ( - ExperienceContent | SkillContent | ProfileCandidateProposal | MemoryDreamCandidateProposal | TopicMemoryContent + ExperienceContent | SkillContent | ProfileCandidateProposal | MemoryDreamCandidateProposal | TopicMemoryContent | HandoffContent ) PREPARED_CONTEXT_SCHEMA: PreparedContextSchema = "powercontext.prepared-context.v1" @@ -410,7 +411,7 @@ class ListArtifactCandidatesRequest(BaseModel): """Filter and page the current Review Inbox.""" status: CandidateStatus = CandidateStatus.PENDING - family: Literal["experience", "skill", "profile", "memory", "topic-memory"] | None = None + family: Literal["experience", "skill", "profile", "memory", "topic-memory", "handoff"] | None = None cursor: str | None = None limit: Annotated[int, Field(ge=1, le=MAX_CANDIDATE_PAGE_SIZE)] = DEFAULT_CANDIDATE_PAGE_SIZE @@ -429,7 +430,7 @@ class RejectArtifactCandidateRequest(ApproveArtifactCandidateRequest): class ReviseArtifactCandidateRequest(ApproveArtifactCandidateRequest): - proposal: ExperienceContent | SkillContent | ProfileWriteContent | MemoryDreamCandidateProposal | TopicMemoryContent + proposal: ExperienceContent | SkillContent | ProfileWriteContent | MemoryDreamCandidateProposal | TopicMemoryContent | HandoffContent sources: tuple[SourceRef, ...] = () artifacts: tuple[ArtifactRef, ...] = () memory_citations: tuple[MemoryCitation, ...] | None = None diff --git a/src/powercontext/builtin/runtime/processing_registry.py b/src/powercontext/builtin/runtime/processing_registry.py index 7cd4419a65..202a1c1756 100644 --- a/src/powercontext/builtin/runtime/processing_registry.py +++ b/src/powercontext/builtin/runtime/processing_registry.py @@ -21,7 +21,12 @@ from powercontext.builtin.artifacts.experience import EXPERIENCE_INCUBATION_CURSOR_NAME from powercontext.builtin.artifacts.profile.models import PROFILE_SOURCE_WINDOW_BINDING from powercontext.builtin.artifacts.topic_memory import TOPIC_MEMORY_SOURCE_WINDOW_BINDING -from powercontext.builtin.dream.bindings import DREAM_OPERATIONS, DREAM_PROVIDERS, SKILL_DREAM_BINDING +from powercontext.builtin.dream.bindings import ( + DREAM_OPERATIONS, + DREAM_PROVIDERS, + HANDOFF_DREAM_BINDING, + SKILL_DREAM_BINDING, +) from powercontext.builtin.dream.models import DreamOperation from powercontext.builtin.runtime.config import BuiltinConfig from powercontext.builtin.triggers import SOURCE_WINDOW_TRIGGER_NAME @@ -58,6 +63,7 @@ def processing_capabilities(config: BuiltinConfig) -> tuple[str, ...]: families.append("topic-memory") if config.runtime.dream_enabled and config.inference.generation_model.split(":", 1)[0] in DREAM_PROVIDERS: families.append("skill") + families.append("handoff") return tuple(sorted(families)) @@ -101,6 +107,7 @@ def canonical_processing_manifest(config: BuiltinConfig) -> dict[str, Any]: EXPERIENCE_INCUBATION_CURSOR_NAME: "experience", PROFILE_SOURCE_WINDOW_BINDING: "profile", SKILL_DREAM_BINDING: "skill", + HANDOFF_DREAM_BINDING: "handoff", }, "legacy_automatic_bindings": sorted(automatic), } diff --git a/src/powercontext/builtin/runtime/relational.py b/src/powercontext/builtin/runtime/relational.py index e180526a7a..9a565cc278 100644 --- a/src/powercontext/builtin/runtime/relational.py +++ b/src/powercontext/builtin/runtime/relational.py @@ -52,6 +52,7 @@ ActivateHandoff, Handoff, HandoffActivation, + HandoffContent, HandoffEvidenceUnavailableError, HandoffGenerationPipeline, HandoffService, @@ -308,6 +309,7 @@ class _ScopedServices: prompts: PromptService generation_receipts: HandoffGenerationReceipts topic_writer: TopicMemoryManagementWriter + handoff_writer: HandoffManagementWriter def generation_sources(self) -> GenerationSourceAccess: return GenerationSourceAccess(self.repositories.sources) @@ -394,6 +396,7 @@ def review(self, connection: AsyncConnection | None = None) -> ReviewService: connection=connection, memory=lambda bound: self.memory(self.sources(bound)[1], bound), topic_writer=self.topic_writer, + handoff_writer=self.handoff_writer, ) def recurrence_ledger(self) -> RelationalRecurrenceLedger: @@ -550,6 +553,7 @@ def __init__( Profile.family: ProfileCandidateProposal, Memory.family: MemoryDreamCandidateProposal, TopicMemory.family: TopicMemoryContent, + Handoff.family: HandoffContent, }), connector_checkpoints=ConnectorCheckpointRepository(), source_definitions=SourceDefinitionManifestRepository(), @@ -594,6 +598,16 @@ def __init__( usage_reporter=self.model_usage_reporter, ) self._topic_memory_writer = topic_memory_writer + handoff_writer = HandoffManagementWriter( + database=database, + artifacts=self.repositories.artifacts, + sources=self.repositories.sources, + memory_index=self.index, + id_factory=self._id_factory, + memory_artifact_id=memory_artifact_id, + handoff_artifact_id=handoff_artifact_id, + ) + self._handoff_writer = handoff_writer family_writers = FamilyManagementWriterRegistry(( topic_memory_writer, PromptManagementWriter(self.repositories.artifacts, self.prompt_registry), @@ -611,15 +625,7 @@ def __init__( self.experience_index, self.repositories.skill_packages, ), - HandoffManagementWriter( - database=database, - artifacts=self.repositories.artifacts, - sources=self.repositories.sources, - memory_index=self.index, - id_factory=self._id_factory, - memory_artifact_id=memory_artifact_id, - handoff_artifact_id=handoff_artifact_id, - ), + handoff_writer, )) self.profiles = RelationalProfileService( database, @@ -1413,6 +1419,7 @@ def _services_for(self, scope_id: str) -> _ScopedServices: token_estimator=self._token_estimator, source_registry=self.source_registry, topic_writer=self._topic_memory_writer, + handoff_writer=self._handoff_writer, ) diff --git a/src/powercontext/http/_generated/models.py b/src/powercontext/http/_generated/models.py index ebfbf46f99..11bac56a4d 100644 --- a/src/powercontext/http/_generated/models.py +++ b/src/powercontext/http/_generated/models.py @@ -359,6 +359,7 @@ class DreamOperation(StrEnum): REVISE_PROFILE = "revise_profile" REVISE_MEMORY = "revise_memory" REVISE_TOPIC_MEMORY = "revise_topic_memory" + REFRESH_HANDOFF = "refresh_handoff" class DreamStatus(StrEnum): @@ -380,6 +381,7 @@ class DreamEvidenceKind(StrEnum): MEMORY = "memory" PROFILE = "profile" TOPIC_MEMORY = "topic_memory" + HANDOFF = "handoff" UNRESOLVED = "unresolved" @@ -2037,6 +2039,7 @@ class CandidateFamily(StrEnum): PROFILE = "profile" MEMORY = "memory" TOPIC_MEMORY = "topic-memory" + HANDOFF = "handoff" class ExternalSkillInstallationScope(StrEnum): @@ -3253,53 +3256,6 @@ class ReadinessResponse(BaseModel): checks: dict[str, StrictStr] -class ReviseArtifactCandidateRequest(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - memory_citations: Annotated[ - list[MemoryCitation] | None, - Field( - description="Omission or null retains the current citations; an explicit array replaces them, including an empty array. Non-empty for supported Memory-citing Candidate families.", - max_length=32, - ), - ] = None - scope_id: Annotated[StrictStr, Field(max_length=256, min_length=1, pattern=".*\\S.*")] - candidate_id: Annotated[StrictStr, Field(max_length=128, min_length=1, pattern="^[\\x21-\\x7E]+$")] - expected_version: Annotated[StrictInt, Field(ge=1)] - proposal: ( - ExperienceProposal - | SkillProposal - | ProfileWriteContent - | MemoryDreamCandidateProposal - | TopicMemoryDreamProposal - ) - source_refs: Annotated[ - list[SourceReference], - Field( - description="Exact Source evidence. Counted with artifact_refs toward a combined maximum of 32 references.", - max_length=32, - ), - ] - artifact_refs: Annotated[ - list[ArtifactReference], - Field( - description="Exact Artifact evidence. Counted with source_refs toward a combined maximum of 32 references.", - max_length=32, - ), - ] - target: ArtifactReference | None = None - reason: Annotated[StrictStr | None, Field(max_length=2000, min_length=1)] = None - - @model_validator(mode="after") - def _reject_excess_candidate_evidence(self): - if len(self.source_refs) + len(self.artifact_refs) + len(self.memory_citations or ()) > 32: - raise ValueError( # noqa: TRY003 - "source_refs and artifact_refs together must not exceed 32 references" - ) - return self - - class SearchMemoryHit(BaseModel): model_config = ConfigDict( extra="forbid", @@ -3531,69 +3487,6 @@ class DreamRunPage(BaseModel): next_cursor: StrictStr | None = None -class ArtifactCandidate(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - memory_citations: Annotated[ - list[MemoryCitation], - Field( - description="Exact Memory entry provenance for Experience, Profile Dream or Memory Dream changes. Counted toward the combined evidence bound.", - max_length=32, - validate_default=True, - ), - ] = [] - permissions: Annotated[ - CandidatePermissions | None, - Field(description="Current Principal permissions in enforced mode; advisory and checked again on mutation."), - ] = None - candidate_id: Annotated[StrictStr, Field(max_length=128, min_length=1, pattern="^[\\x21-\\x7E]+$")] - version: Annotated[StrictInt, Field(ge=1)] - family: CandidateFamily - status: CandidateStatus - proposal: ( - ExperienceProposal - | SkillProposal - | ProfileCandidateProposal - | MemoryDreamCandidateProposal - | TopicMemoryDreamProposal - ) - source_refs: Annotated[ - list[SourceReference], - Field( - description="Exact Source evidence. Counted with artifact_refs toward a combined maximum of 32 references.", - max_length=32, - ), - ] - artifact_refs: Annotated[ - list[ArtifactReference], - Field( - description="Exact Artifact evidence. Counted with source_refs toward a combined maximum of 32 references.", - max_length=32, - ), - ] - target: Annotated[ArtifactReference | None, Field(...)] - reason: Annotated[StrictStr | None, Field(max_length=2000, min_length=1)] - result_artifact: Annotated[ArtifactReference | None, Field(...)] - decision_reason: Annotated[StrictStr | None, Field(max_length=2000, min_length=1)] - - @model_validator(mode="after") - def _reject_excess_candidate_evidence(self): - if len(self.source_refs) + len(self.artifact_refs) + len(self.memory_citations or ()) > 32: - raise ValueError( # noqa: TRY003 - "source_refs and artifact_refs together must not exceed 32 references" - ) - return self - - -class ArtifactCandidatePage(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - candidates: list[ArtifactCandidate] - next_cursor: Annotated[StrictStr | None, Field(...)] - - class HandoffCitation(RootModel[HandoffSourceCitation | HandoffArtifactCitation | HandoffMemoryCitation]): root: Annotated[ HandoffSourceCitation | HandoffArtifactCitation | HandoffMemoryCitation, Field(discriminator="kind") @@ -3716,14 +3609,6 @@ class PrepareContextRequest(BaseModel): assembly: ContextAssembly | None = None -class GeneratedCandidateResponse(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - status: GeneratedCandidateStatus - candidate: Annotated[ArtifactCandidate | None, Field(...)] - - class CreatePromptArtifactRequest(BaseModel): model_config = ConfigDict( extra="forbid", @@ -3913,6 +3798,54 @@ class PreparedHandoff(BaseModel): content: HandoffContent +class ReviseArtifactCandidateRequest(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + memory_citations: Annotated[ + list[MemoryCitation] | None, + Field( + description="Omission or null retains the current citations; an explicit array replaces them, including an empty array. Non-empty for supported Memory-citing Candidate families.", + max_length=32, + ), + ] = None + scope_id: Annotated[StrictStr, Field(max_length=256, min_length=1, pattern=".*\\S.*")] + candidate_id: Annotated[StrictStr, Field(max_length=128, min_length=1, pattern="^[\\x21-\\x7E]+$")] + expected_version: Annotated[StrictInt, Field(ge=1)] + proposal: ( + ExperienceProposal + | SkillProposal + | ProfileWriteContent + | MemoryDreamCandidateProposal + | TopicMemoryDreamProposal + | HandoffContent + ) + source_refs: Annotated[ + list[SourceReference], + Field( + description="Exact Source evidence. Counted with artifact_refs toward a combined maximum of 32 references.", + max_length=32, + ), + ] + artifact_refs: Annotated[ + list[ArtifactReference], + Field( + description="Exact Artifact evidence. Counted with source_refs toward a combined maximum of 32 references.", + max_length=32, + ), + ] + target: ArtifactReference | None = None + reason: Annotated[StrictStr | None, Field(max_length=2000, min_length=1)] = None + + @model_validator(mode="after") + def _reject_excess_candidate_evidence(self): + if len(self.source_refs) + len(self.artifact_refs) + len(self.memory_citations or ()) > 32: + raise ValueError( # noqa: TRY003 + "source_refs and artifact_refs together must not exceed 32 references" + ) + return self + + class CreateHandoffArtifactRequest(BaseModel): model_config = ConfigDict( extra="forbid", @@ -3928,6 +3861,70 @@ class ReplaceHandoffArtifactRequest(BaseModel): content: HandoffContent +class ArtifactCandidate(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + memory_citations: Annotated[ + list[MemoryCitation], + Field( + description="Exact Memory entry provenance for Experience, Profile Dream or Memory Dream changes. Counted toward the combined evidence bound.", + max_length=32, + validate_default=True, + ), + ] = [] + permissions: Annotated[ + CandidatePermissions | None, + Field(description="Current Principal permissions in enforced mode; advisory and checked again on mutation."), + ] = None + candidate_id: Annotated[StrictStr, Field(max_length=128, min_length=1, pattern="^[\\x21-\\x7E]+$")] + version: Annotated[StrictInt, Field(ge=1)] + family: CandidateFamily + status: CandidateStatus + proposal: ( + ExperienceProposal + | SkillProposal + | ProfileCandidateProposal + | MemoryDreamCandidateProposal + | TopicMemoryDreamProposal + | HandoffContent + ) + source_refs: Annotated[ + list[SourceReference], + Field( + description="Exact Source evidence. Counted with artifact_refs toward a combined maximum of 32 references.", + max_length=32, + ), + ] + artifact_refs: Annotated[ + list[ArtifactReference], + Field( + description="Exact Artifact evidence. Counted with source_refs toward a combined maximum of 32 references.", + max_length=32, + ), + ] + target: Annotated[ArtifactReference | None, Field(...)] + reason: Annotated[StrictStr | None, Field(max_length=2000, min_length=1)] + result_artifact: Annotated[ArtifactReference | None, Field(...)] + decision_reason: Annotated[StrictStr | None, Field(max_length=2000, min_length=1)] + + @model_validator(mode="after") + def _reject_excess_candidate_evidence(self): + if len(self.source_refs) + len(self.artifact_refs) + len(self.memory_citations or ()) > 32: + raise ValueError( # noqa: TRY003 + "source_refs and artifact_refs together must not exceed 32 references" + ) + return self + + +class ArtifactCandidatePage(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + candidates: list[ArtifactCandidate] + next_cursor: Annotated[StrictStr | None, Field(...)] + + class PreparedWorkHandoff(BaseModel): model_config = ConfigDict( extra="forbid", @@ -4007,6 +4004,14 @@ class HandoffActivation(BaseModel): draft: Annotated[HandoffDraft | None, Field(...)] +class GeneratedCandidateResponse(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + status: GeneratedCandidateStatus + candidate: Annotated[ArtifactCandidate | None, Field(...)] + + class CreateArtifactRequest( RootModel[ CreateTopicMemoryArtifactRequest diff --git a/src/powercontext/http/_generated/schema.py b/src/powercontext/http/_generated/schema.py index ba67f91173..2e9c37baca 100644 --- a/src/powercontext/http/_generated/schema.py +++ b/src/powercontext/http/_generated/schema.py @@ -5032,13 +5032,20 @@ }, "DreamOperation": { "type": "string", - "enum": ["refine_experience", "derive_skill", "revise_profile", "revise_memory", "revise_topic_memory"], + "enum": [ + "refine_experience", + "derive_skill", + "revise_profile", + "revise_memory", + "revise_topic_memory", + "refresh_handoff", + ], }, "DreamStatus": {"type": "string", "enum": ["queued", "running", "succeeded", "failed"]}, "DreamOutcome": {"type": "string", "enum": ["proposed", "no_change", "needs_evidence"]}, "DreamEvidenceKind": { "type": "string", - "enum": ["source", "experience", "memory", "profile", "topic_memory", "unresolved"], + "enum": ["source", "experience", "memory", "profile", "topic_memory", "handoff", "unresolved"], }, "DreamEvidenceRole": { "type": "string", @@ -5302,6 +5309,7 @@ {"$ref": "#/components/schemas/ProfileCandidateProposal"}, {"$ref": "#/components/schemas/MemoryDreamCandidateProposal"}, {"$ref": "#/components/schemas/TopicMemoryDreamProposal"}, + {"$ref": "#/components/schemas/HandoffContent"}, ] }, "source_refs": { @@ -8037,6 +8045,7 @@ {"$ref": "#/components/schemas/ProfileWriteContent"}, {"$ref": "#/components/schemas/MemoryDreamCandidateProposal"}, {"$ref": "#/components/schemas/TopicMemoryDreamProposal"}, + {"$ref": "#/components/schemas/HandoffContent"}, ] }, "source_refs": { @@ -9010,7 +9019,10 @@ "enum": ["memory", "experience", "skill", "handoff", "profile", "prompt", "topic-memory"], }, "StatsPeriod": {"type": "string", "enum": ["today", "7d", "30d"]}, - "CandidateFamily": {"type": "string", "enum": ["experience", "skill", "profile", "memory", "topic-memory"]}, + "CandidateFamily": { + "type": "string", + "enum": ["experience", "skill", "profile", "memory", "topic-memory", "handoff"], + }, "ExternalSkillInstallationScope": {"type": "string", "enum": ["user", "project", "plugin"]}, "ExternalSkillResolutionStatus": {"type": "string", "enum": ["available", "unavailable"]}, "CandidateStatus": {"type": "string", "enum": ["pending", "approved", "rejected"]}, diff --git a/src/powercontext/server/mapping.py b/src/powercontext/server/mapping.py index 4f023eed66..a089ddd244 100644 --- a/src/powercontext/server/mapping.py +++ b/src/powercontext/server/mapping.py @@ -1069,8 +1069,10 @@ def skill_proposal(value: SkillContent) -> SkillProposal: def reviewed_content( - value: ExperienceProposal | SkillProposal | ProfileWriteContent | MemoryDreamCandidateProposal | TopicMemoryDreamProposal, -) -> ExperienceContent | SkillContent | RuntimeProfileWriteContent | RuntimeMemoryDreamCandidateProposal | RuntimeTopicMemoryContent: + value: ExperienceProposal | SkillProposal | ProfileWriteContent | MemoryDreamCandidateProposal | TopicMemoryDreamProposal | TransportHandoffContent, +) -> ExperienceContent | SkillContent | RuntimeProfileWriteContent | RuntimeMemoryDreamCandidateProposal | RuntimeTopicMemoryContent | HandoffContent: + if isinstance(value, TransportHandoffContent): + return runtime_handoff_content(value) if isinstance(value, TopicMemoryDreamProposal): return RuntimeTopicMemoryContent.model_validate(value.model_dump(mode="json")) if isinstance(value, MemoryDreamCandidateProposal): @@ -1084,7 +1086,9 @@ def reviewed_content( def reviewed_proposal( value: object, -) -> ExperienceProposal | SkillProposal | ProfileCandidateProposal | MemoryDreamCandidateProposal | TopicMemoryDreamProposal: +) -> ExperienceProposal | SkillProposal | ProfileCandidateProposal | MemoryDreamCandidateProposal | TopicMemoryDreamProposal | TransportHandoffContent: + if isinstance(value, HandoffContent): + return handoff_content(value) if isinstance(value, RuntimeTopicMemoryContent): return TopicMemoryDreamProposal.model_validate(value.model_dump(mode="json")) if isinstance(value, RuntimeMemoryDreamCandidateProposal): diff --git a/tests/e2e/test_artifact_dreaming.py b/tests/e2e/test_artifact_dreaming.py index ee45f435e5..3e5865b890 100644 --- a/tests/e2e/test_artifact_dreaming.py +++ b/tests/e2e/test_artifact_dreaming.py @@ -30,6 +30,12 @@ from sqlalchemy.engine import make_url from powercontext.builtin.artifacts.experience import ExperienceContent +from powercontext.builtin.artifacts.handoff.models import ( + HandoffContent, + HandoffDraft, + HandoffSourceCitation, + HandoffStatement, +) from powercontext.builtin.artifacts.memory import MemoryCandidateRequest, MemoryEntryInput from powercontext.builtin.artifacts.memory.models import MemoryDreamEntryChange, MemoryDreamWrite from powercontext.builtin.artifacts.profile.models import ( @@ -405,6 +411,98 @@ async def scenario() -> None: asyncio.run(scenario()) +def test_handoff_dream_commit_does_not_activate_handoff(database: DatabaseConfig) -> None: + class HandoffDreamGenerator: + config_id = "handoff-dream-test" + + def __init__(self): + self.correction = None + + async def generate(self, value: DreamGenerationInput) -> GenerationResult[DreamPlan]: + assert value.operation == "refresh_handoff" and self.correction is not None + source_id = next(item.evidence_id for item in value.evidence.evidence if item.kind == "source") + citation = HandoffSourceCitation(source_ref=self.correction) + return GenerationResult( + output=DreamPlan( + outcome="proposed", + reason="The new source confirms the handoff progress.", + intent="correct", + proposal=HandoffContent( + objective="Finish the support request.", + state=(HandoffStatement(text="The replacement has been issued.", citations=(citation,)),), + disposition="continuable", + next_action=HandoffStatement( + text="Confirm the tracking number with the recipient.", citations=(citation,) + ), + ), + evidence_ids=(source_id,), + ), + usage=InferenceUsage(requests=1), + ) + + async def scenario() -> None: + generator = HandoffDreamGenerator() + async with open_builtin_runtime(config(database), dream_generator=generator) as runtime: + assert runtime.scopes is not None + scope = await runtime.scopes.create( + ScopeDraft(title="Handoff Dream", summary="Review without activation", idempotency_key="handoff-dream") + ) + scope_id = scope.scope_id + original_source = await runtime.sources.for_scope(scope_id).capture( + CaptureSource(source_id="handoff-original", content="The replacement is pending.", metadata={}) + ) + handoffs = runtime.handoff.for_scope(scope_id) + initial = await handoffs.finalize( + HandoffDraft( + objective="Finish the support request.", + state=( + HandoffStatement( + text="The replacement is pending.", + citations=(HandoffSourceCitation(source_ref=original_source.source_ref),), + ), + ), + disposition="continuable", + ) + ) + current = await handoffs.commit(initial) + correction = await runtime.sources.for_scope(scope_id).capture( + CaptureSource(source_id="handoff-correction", content="The replacement has been issued.", metadata={}) + ) + generator.correction = correction.source_ref + run = await runtime.dream.for_scope(scope_id).create( + CreateDreamRunRequest( + operation="refresh_handoff", + target=current.as_ref(), + artifacts=(current.as_ref(),), + sources=(correction.source_ref,), + idempotency_key="refresh-handoff", + ) + ) + for _ in range(3): + await process_pending(runtime) + completed = await runtime.dream.for_scope(scope_id).get(GetDreamRunRequest(run_id=run.run_id)) + if completed.terminal: + break + assert completed.outcome == "proposed" and completed.candidate is not None, ( + completed.status, + completed.error, + ) + assert (await handoffs.latest()).revision == 1 + approved = await runtime.review.for_scope(scope_id).approve( + ApproveArtifactCandidateRequest( + candidate_id=completed.candidate.candidate_id, + expected_version=completed.candidate.version, + ) + ) + assert approved.result_artifact is not None + updated = await handoffs.latest() + assert updated is not None and updated.revision == 2 + assert updated.content.generation is None + assert updated.content.state[0].text == "The replacement has been issued." + + asyncio.run(scenario()) + + @pytest.mark.parametrize("existing", [False, True], ids=["new-config", "enable-skill"]) def test_wizard_skill_selection_accepts_dream_derivation( tmp_path: Path, existing: bool, monkeypatch: pytest.MonkeyPatch From 7f3ceaa7f48fb5c9f39598491080e9de978a361a Mon Sep 17 00:00:00 2001 From: Frey <370805886@qq.com> Date: Tue, 22 Sep 2026 15:11:41 +0800 Subject: [PATCH 06/48] feat(dream): expose enabled operation effects in capabilities --- openapi/powercontext.yaml | 19 ++++++++++++ .../builtin/runtime/composition.py | 11 ++++++- src/powercontext/builtin/runtime/models.py | 7 +++++ src/powercontext/http/__init__.py | 2 ++ src/powercontext/http/_generated/models.py | 25 ++++++++++++++++ src/powercontext/http/_generated/schema.py | 29 +++++++++++++++++++ src/powercontext/server/factory.py | 5 ++++ tests/e2e/test_server_cli.py | 1 + tests/test_api_contract.py | 1 + 9 files changed, 99 insertions(+), 1 deletion(-) diff --git a/openapi/powercontext.yaml b/openapi/powercontext.yaml index 33fa4bd317..0659196920 100644 --- a/openapi/powercontext.yaml +++ b/openapi/powercontext.yaml @@ -5616,6 +5616,19 @@ components: expected_version: type: integer minimum: 1 + ArtifactDreamingOperationCapability: + type: object + additionalProperties: false + required: [operation, output_kind, effect] + properties: + operation: + $ref: "#/components/schemas/DreamOperation" + output_kind: + type: string + enum: [artifact_candidate] + effect: + type: string + enum: [review_then_publish, review_then_commit_without_activation] Capabilities: type: object additionalProperties: false @@ -5632,6 +5645,12 @@ components: type: boolean default: false description: Whether asynchronous Artifact Dream execution is configured. + artifact_dreaming_operations: + type: array + default: [] + description: Dream operations enabled by the current Runtime, including their reviewed effect. + items: + $ref: "#/components/schemas/ArtifactDreamingOperationCapability" source_types: type: array items: diff --git a/src/powercontext/builtin/runtime/composition.py b/src/powercontext/builtin/runtime/composition.py index 47dca4b67b..28b1aa8066 100644 --- a/src/powercontext/builtin/runtime/composition.py +++ b/src/powercontext/builtin/runtime/composition.py @@ -125,7 +125,7 @@ ) from powercontext.builtin.runtime.config import BuiltinConfig, ExternalSkillsConfig, InferenceConfig, RuntimeConfig from powercontext.builtin.runtime.family_processing import FAMILY_BINDINGS, FamilyWorkerSpec, run_family_worker -from powercontext.builtin.runtime.models import MemorySearchMode, RuntimeCapabilities +from powercontext.builtin.runtime.models import DreamOperationCapability, MemorySearchMode, RuntimeCapabilities from powercontext.builtin.runtime.processing_discovery import SourceProcessingPendingProvider, enabled_profile_scopes from powercontext.builtin.runtime.processing_registry import ( canonical_processing_manifest, @@ -473,6 +473,15 @@ async def run_profile(scope_id, high): experience_generation=contexts.experience_generation, managed_skill_generation=contexts.managed_skill_generation, artifact_dreaming=bool(configured_operations), + artifact_dreaming_operations=tuple( + DreamOperationCapability( + operation=operation, + effect="review_then_commit_without_activation" + if operation == "refresh_handoff" + else "review_then_publish", + ) + for operation in configured_operations + ), external_skill_registry=contexts.external_skill_registry, memory_search_modes=_search_modes(contexts.index.capabilities), handoff_generation=contexts.handoff_generation, diff --git a/src/powercontext/builtin/runtime/models.py b/src/powercontext/builtin/runtime/models.py index 4b33014834..40608e9c90 100644 --- a/src/powercontext/builtin/runtime/models.py +++ b/src/powercontext/builtin/runtime/models.py @@ -107,6 +107,12 @@ class CommitConnectorCheckpoint(BaseModel): checkpoint: JsonValue | None +class DreamOperationCapability(BaseModel): + operation: str + output_kind: Literal["artifact_candidate"] = "artifact_candidate" + effect: Literal["review_then_publish", "review_then_commit_without_activation"] = "review_then_publish" + + class RuntimeCapabilities(BaseModel): """Behavior available from the assembled Source-to-Memory Runtime.""" @@ -114,6 +120,7 @@ class RuntimeCapabilities(BaseModel): experience_generation: bool = False managed_skill_generation: bool = False artifact_dreaming: bool = False + artifact_dreaming_operations: tuple[DreamOperationCapability, ...] = () external_skill_registry: bool = False memory_search_modes: tuple[MemorySearchMode, ...] handoff_generation: bool = False diff --git a/src/powercontext/http/__init__.py b/src/powercontext/http/__init__.py index 512bfcc08d..060d50093f 100644 --- a/src/powercontext/http/__init__.py +++ b/src/powercontext/http/__init__.py @@ -53,6 +53,7 @@ ArtifactCandidatePage, ArtifactCollectionItem, ArtifactCreated, + ArtifactDreamingOperationCapability, ArtifactFamilyAccessCapability, ArtifactInventoryStatistics, ArtifactPage, @@ -446,6 +447,7 @@ "ArtifactCandidatePage", "ArtifactCollectionItem", "ArtifactCreated", + "ArtifactDreamingOperationCapability", "ArtifactFamilyAccessCapability", "ArtifactInventoryStatistics", "ArtifactPage", diff --git a/src/powercontext/http/_generated/models.py b/src/powercontext/http/_generated/models.py index 11bac56a4d..9f4b8b525a 100644 --- a/src/powercontext/http/_generated/models.py +++ b/src/powercontext/http/_generated/models.py @@ -473,6 +473,24 @@ class ApproveArtifactCandidateRequest(BaseModel): expected_version: Annotated[StrictInt, Field(ge=1)] +class OutputKind(StrEnum): + ARTIFACT_CANDIDATE = "artifact_candidate" + + +class Effect(StrEnum): + REVIEW_THEN_PUBLISH = "review_then_publish" + REVIEW_THEN_COMMIT_WITHOUT_ACTIVATION = "review_then_commit_without_activation" + + +class ArtifactDreamingOperationCapability(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + operation: DreamOperation + output_kind: OutputKind + effect: Effect + + class FamilyCount(BaseModel): model_config = ConfigDict( extra="forbid", @@ -2680,6 +2698,13 @@ class Capabilities(BaseModel): artifact_dreaming: Annotated[ StrictBool, Field(description="Whether asynchronous Artifact Dream execution is configured.") ] = False + artifact_dreaming_operations: Annotated[ + list[ArtifactDreamingOperationCapability], + Field( + description="Dream operations enabled by the current Runtime, including their reviewed effect.", + validate_default=True, + ), + ] = [] source_types: list[StrictStr] artifact_families: list[StrictStr] memory_extraction: Annotated[StrictBool, Field(description="Whether pending Sources can be extracted into Memory.")] diff --git a/src/powercontext/http/_generated/schema.py b/src/powercontext/http/_generated/schema.py index 2e9c37baca..d35d97151b 100644 --- a/src/powercontext/http/_generated/schema.py +++ b/src/powercontext/http/_generated/schema.py @@ -5388,6 +5388,19 @@ "type": "object", "required": ["scope_id", "candidate_id", "expected_version"], }, + "ArtifactDreamingOperationCapability": { + "properties": { + "operation": {"$ref": "#/components/schemas/DreamOperation"}, + "output_kind": {"type": "string", "enum": ["artifact_candidate"]}, + "effect": { + "type": "string", + "enum": ["review_then_publish", "review_then_commit_without_activation"], + }, + }, + "additionalProperties": False, + "type": "object", + "required": ["operation", "output_kind", "effect"], + }, "Capabilities": { "properties": { "prompts": { @@ -5400,6 +5413,22 @@ "description": "Whether asynchronous Artifact Dream execution is configured.", "default": False, }, + "artifact_dreaming_operations": { + "items": {"$ref": "#/components/schemas/ArtifactDreamingOperationCapability"}, + "type": "array", + "description": "Dream " + "operations " + "enabled " + "by " + "the " + "current " + "Runtime, " + "including " + "their " + "reviewed " + "effect.", + "default": [], + }, "source_types": {"items": {"type": "string"}, "type": "array"}, "artifact_families": {"items": {"type": "string"}, "type": "array"}, "memory_extraction": { diff --git a/src/powercontext/server/factory.py b/src/powercontext/server/factory.py index 284e297538..a82d82f73b 100644 --- a/src/powercontext/server/factory.py +++ b/src/powercontext/server/factory.py @@ -47,6 +47,7 @@ from powercontext.builtin.runtime.processing_registry import processing_capabilities from powercontext.builtin.sources import CONTENT_SOURCE_NAME from powercontext.http import ( + ArtifactDreamingOperationCapability, Capabilities, MemorySearchMode, PreparedContextSchema, @@ -606,6 +607,10 @@ async def _server_capabilities(runtime: BuiltinRuntime) -> Capabilities: experience_generation=capabilities.experience_generation, managed_skill_generation=capabilities.managed_skill_generation, artifact_dreaming=capabilities.artifact_dreaming, + artifact_dreaming_operations=[ + ArtifactDreamingOperationCapability.model_validate(value.model_dump()) + for value in capabilities.artifact_dreaming_operations + ], external_skill_registry=capabilities.external_skill_registry, handoff_generation=capabilities.handoff_generation, search_modes=[MemorySearchMode(mode) for mode in capabilities.memory_search_modes], diff --git a/tests/e2e/test_server_cli.py b/tests/e2e/test_server_cli.py index 28ec533fe9..4f2a92ce32 100644 --- a/tests/e2e/test_server_cli.py +++ b/tests/e2e/test_server_cli.py @@ -78,6 +78,7 @@ async def get_capabilities(self) -> Capabilities: "managed_skill_generation": False, "external_skill_registry": False, "artifact_dreaming": False, + "artifact_dreaming_operations": [], "handoff_generation": True, "search_modes": ["fts"], "context_versions": ["powercontext.prepared-context.v1"], diff --git a/tests/test_api_contract.py b/tests/test_api_contract.py index 18a3584c1f..b0c86f9310 100644 --- a/tests/test_api_contract.py +++ b/tests/test_api_contract.py @@ -271,6 +271,7 @@ def test_capabilities_report_semantics_without_runtime_tuning_values() -> None: assert set(properties) == { "artifact_dreaming", + "artifact_dreaming_operations", "source_types", "artifact_families", "memory_extraction", From bc2799b9eb36743edd041f504a6b15cf5fb47366 Mon Sep 17 00:00:00 2001 From: Frey <370805886@qq.com> Date: Tue, 22 Sep 2026 15:20:55 +0800 Subject: [PATCH 07/48] feat(dream): stage Skill revision behind trusted evaluation --- openapi/powercontext.yaml | 1 + src/powercontext/builtin/dream/bindings.py | 1 + src/powercontext/builtin/dream/generation.py | 3 + src/powercontext/builtin/dream/models.py | 14 +++- src/powercontext/builtin/dream/service.py | 20 ++++- src/powercontext/builtin/review/service.py | 45 +++++++++++ src/powercontext/http/_generated/models.py | 1 + src/powercontext/http/_generated/schema.py | 1 + tests/e2e/dream_support.py | 2 +- tests/e2e/test_artifact_dreaming.py | 81 +++++++++++++++++++- 10 files changed, 161 insertions(+), 8 deletions(-) diff --git a/openapi/powercontext.yaml b/openapi/powercontext.yaml index 0659196920..9e6433fa9f 100644 --- a/openapi/powercontext.yaml +++ b/openapi/powercontext.yaml @@ -5148,6 +5148,7 @@ components: enum: - refine_experience - derive_skill + - revise_skill - revise_profile - revise_memory - revise_topic_memory diff --git a/src/powercontext/builtin/dream/bindings.py b/src/powercontext/builtin/dream/bindings.py index 0d0ddab754..4d70ee34c0 100644 --- a/src/powercontext/builtin/dream/bindings.py +++ b/src/powercontext/builtin/dream/bindings.py @@ -39,6 +39,7 @@ class DreamOperationSpec: DREAM_OPERATIONS = ( DreamOperationSpec("refine_experience", "experience", EXPERIENCE_INCUBATION_CURSOR_NAME), DreamOperationSpec("derive_skill", "skill", SKILL_DREAM_BINDING), + DreamOperationSpec("revise_skill", "skill", SKILL_DREAM_BINDING), DreamOperationSpec("revise_profile", "profile", PROFILE_SOURCE_WINDOW_BINDING), DreamOperationSpec("revise_memory", "memory", SOURCE_WINDOW_TRIGGER_NAME), DreamOperationSpec("revise_topic_memory", "topic-memory", TOPIC_MEMORY_SOURCE_WINDOW_BINDING), diff --git a/src/powercontext/builtin/dream/generation.py b/src/powercontext/builtin/dream/generation.py index df15a583db..b718d42d21 100644 --- a/src/powercontext/builtin/dream/generation.py +++ b/src/powercontext/builtin/dream/generation.py @@ -43,6 +43,9 @@ For refresh_handoff, propose complete Handoff content with exact citations for every state and next-action claim. A proposed action is not a completed action. Do not claim activation, receiver acknowledgement, or an executed business tool. Return generation as null and use correct. +For revise_skill, use the exact existing Skill as the target and task outcome evidence as grounds. +Only revise instructions, validation, and description; preserve name and other metadata. +Do not infer that selection means execution or success. Return package as null and use correct. Provide name, description, instructions, and at least one concrete validation check in validation. Use a package-compatible name: at most 64 lowercase letters, digits, and single separating hyphens. Keep description within 1024 characters. Trim text fields and omit trailing whitespace in instructions. diff --git a/src/powercontext/builtin/dream/models.py b/src/powercontext/builtin/dream/models.py index 42b7fba63d..6ef3292b73 100644 --- a/src/powercontext/builtin/dream/models.py +++ b/src/powercontext/builtin/dream/models.py @@ -39,7 +39,7 @@ from powercontext.sources import SourceRef DreamOperation = Literal[ - "refine_experience", "derive_skill", "revise_profile", "revise_memory", "revise_topic_memory", "refresh_handoff" + "refine_experience", "derive_skill", "revise_skill", "revise_profile", "revise_memory", "revise_topic_memory", "refresh_handoff" ] DreamStatus = Literal["queued", "running", "succeeded", "failed"] DreamOutcome = Literal["proposed", "no_change", "needs_evidence"] @@ -76,7 +76,7 @@ def normalize_references(cls, value): return tuple(sorted(unique_references(value), key=reference_key)) @model_validator(mode="after") - def validate_selection(self): + def validate_selection(self): # noqa: C901 - bounded validation for each registered operation selected = len(self.artifacts) + len(self.memory_citations) selected_limit = 21 if self.operation == "revise_memory" else 20 if not 1 <= selected <= selected_limit or selected + len(self.sources) > 32: @@ -84,6 +84,7 @@ def validate_selection(self): allowed_families = { "refine_experience": {"experience"}, "derive_skill": {"experience"}, + "revise_skill": {"experience", "skill"}, "revise_profile": {"experience", "profile"}, "revise_memory": {"experience", "memory"}, "revise_topic_memory": {"experience", "topic-memory"}, @@ -104,6 +105,13 @@ def validate_selection(self): self.memory_citations or self.target is not None or not self.artifacts ): raise DreamError("invalid_dream_operation") + if self.operation == "revise_skill" and ( + self.target is None + or self.target.family != "skill" + or any(ref.family == "skill" and ref != self.target for ref in self.artifacts) + or not self.sources + ): + raise DreamError("invalid_dream_operation") if self.operation == "revise_profile" and ( self.target is None or self.target.family != "profile" @@ -234,6 +242,8 @@ def validate_operation(self, request: CreateDreamRunRequest) -> None: valid = ( isinstance(self.proposal, SkillContent) and self.intent == "derive" and self.proposal.package is None ) + elif request.operation == "revise_skill": + valid = isinstance(self.proposal, SkillContent) and self.intent == "correct" and self.proposal.package is None elif request.operation == "revise_profile": valid = ( isinstance(self.proposal, ProfileWriteContent) diff --git a/src/powercontext/builtin/dream/service.py b/src/powercontext/builtin/dream/service.py index db97533cf0..7e98da1459 100644 --- a/src/powercontext/builtin/dream/service.py +++ b/src/powercontext/builtin/dream/service.py @@ -65,7 +65,7 @@ from powercontext.builtin.persistence.processing_intents import ArtifactProcessingIntentRepository from powercontext.builtin.persistence.profile import ProfilePolicyRepository from powercontext.builtin.persistence.tables import SCOPES_TABLE -from powercontext.builtin.review.errors import ArtifactTargetConflictError +from powercontext.builtin.review.errors import ArtifactTargetConflictError, InvalidCandidateError from powercontext.builtin.review.generation import SkillGenerationOrigin, validate_skill_lineage from powercontext.builtin.review.service import ReviewService from powercontext.builtin.runtime.processing_execution import InvocationAlreadyHandled, ScopeInvocation @@ -264,6 +264,13 @@ async def _execute(self, record: DreamRecord) -> None: ): raise DreamError("capability_unavailable") await self._authorize(run.scope_id, record.principal_id, "contribute") + if run.operation == "revise_skill" and run.target is not None: + try: + await self.review(run.scope_id, None).require_instruction_skill_target(run.target) + except InvalidCandidateError as error: + if error.detail == "unsupported_target": + raise DreamError("unsupported_target") from error + raise resolved = await self._resolve(record) usage = run.usage.model_copy(update={"model_calls": run.usage.model_calls + 1}) if usage.model_calls > run.budget.max_model_calls: @@ -300,7 +307,10 @@ async def _execute(self, record: DreamRecord) -> None: generated.output.validate_operation(record.request) plan = _supported_plan(record, generated.output, resolved) if isinstance(plan.proposal, SkillContent): - prepared = await self.review(run.scope_id, None).prepare_skill(plan.proposal) + if run.operation == "revise_skill" and run.target is not None: + prepared = await self.review(run.scope_id, None).prepare_skill_revision(run.target, plan.proposal) + else: + prepared = await self.review(run.scope_id, None).prepare_skill(plan.proposal) plan = plan.model_copy(update={"proposal": prepared}) await self._commit(record, plan) @@ -332,6 +342,7 @@ async def _commit(self, record: DreamRecord, plan: DreamPlan) -> None: if self.attest_candidate is not None: family = { "derive_skill": "skill", + "revise_skill": "skill", "refine_experience": "experience", "revise_profile": "profile", "revise_memory": "memory", @@ -379,12 +390,13 @@ async def _propose( # noqa: C901 - operation-specific Candidate adapters share candidate_id=_candidate_id(record), ) elif isinstance(plan.proposal, SkillContent): - validate_skill_lineage(SkillGenerationOrigin.EXPERIENCE, selected.sources, selected.artifacts, None) + if record.run.operation == "derive_skill": + validate_skill_lineage(SkillGenerationOrigin.EXPERIENCE, selected.sources, selected.artifacts, None) candidate = await review.propose_skill( plan.proposal, sources=selected.sources, artifacts=selected.artifacts, - target=None, + target=record.run.target, reason=plan.reason, candidate_id=_candidate_id(record), ) diff --git a/src/powercontext/builtin/review/service.py b/src/powercontext/builtin/review/service.py index 2f720fa845..aec342a621 100644 --- a/src/powercontext/builtin/review/service.py +++ b/src/powercontext/builtin/review/service.py @@ -52,6 +52,7 @@ from powercontext.builtin.persistence.artifacts import ArtifactRepository from powercontext.builtin.persistence.candidates import CandidateRepository from powercontext.builtin.persistence.database import AsyncDatabase +from powercontext.builtin.persistence.dream import DreamRepository from powercontext.builtin.persistence.errors import RepositoryNotFoundError from powercontext.builtin.persistence.experience_index import ExperienceIndex from powercontext.builtin.persistence.family_management import HandoffManagementWriter @@ -538,6 +539,17 @@ async def approve( # noqa: C901 - each Family keeps its own transactional publi expected_version, ) ) + if candidate.family == Skill.family and candidate.candidate_id.startswith("cand_dream_"): + run_id = candidate.candidate_id.removeprefix("cand_dream_") + run = await DreamRepository().get(connection, self._scope_id, run_id) + if ( + run.run.operation not in {"derive_skill", "revise_skill"} + or run.run.candidate is None + or run.run.candidate.candidate_id != candidate.candidate_id + ): + raise InvalidCandidateError("evaluation", "Dream Skill origin is invalid") + if run.run.operation == "revise_skill": + raise InvalidCandidateError("evaluation", "trusted_evaluation_required") _validate_approval_lineage(candidate) await self._validate_evidence( connection, @@ -825,6 +837,39 @@ async def prepare_skill(self, proposal: SkillContent, /) -> SkillContent: await self._skill_packages.add(connection, self._scope_id, snapshot) return snapshot.as_skill_content() + async def prepare_skill_revision(self, target: ArtifactRef, proposal: SkillContent, /) -> SkillContent: + """Rebuild a one-file Skill while preserving the target's protected metadata.""" + + async with self._connection() as connection: + current = await self._instruction_skill_target(connection, target) + content = proposal.model_copy( + update={ + "name": current.content.name, + "license": current.content.license, + "compatibility": current.content.compatibility, + "metadata": current.content.metadata, + "allowed_tools": current.content.allowed_tools, + "package": None, + } + ) + snapshot = build_instruction_skill_package(content) + await self._skill_packages.add(connection, self._scope_id, snapshot) + return snapshot.as_skill_content() + + async def require_instruction_skill_target(self, target: ArtifactRef, /) -> None: + async with self._connection() as connection: + await self._instruction_skill_target(connection, target) + + async def _instruction_skill_target(self, connection: AsyncConnection, target: ArtifactRef) -> Skill: + current = await self._artifacts.get(connection, self._scope_id, target) + if not isinstance(current, Skill): + raise InvalidCandidateError("target", "expected an exact managed Skill") + if current.content.package is not None: + snapshot = await self._skill_packages.get(connection, self._scope_id, current.content.package) + if len(snapshot.entries) != 1 or snapshot.entries[0].path != "SKILL.md": + raise InvalidCandidateError("target", "unsupported_target") + return current + async def _canonical_skill_proposal( self, connection: AsyncConnection, diff --git a/src/powercontext/http/_generated/models.py b/src/powercontext/http/_generated/models.py index 9f4b8b525a..8dfcc626a8 100644 --- a/src/powercontext/http/_generated/models.py +++ b/src/powercontext/http/_generated/models.py @@ -356,6 +356,7 @@ class CandidatePermissions(BaseModel): class DreamOperation(StrEnum): REFINE_EXPERIENCE = "refine_experience" DERIVE_SKILL = "derive_skill" + REVISE_SKILL = "revise_skill" REVISE_PROFILE = "revise_profile" REVISE_MEMORY = "revise_memory" REVISE_TOPIC_MEMORY = "revise_topic_memory" diff --git a/src/powercontext/http/_generated/schema.py b/src/powercontext/http/_generated/schema.py index d35d97151b..4da30e9fab 100644 --- a/src/powercontext/http/_generated/schema.py +++ b/src/powercontext/http/_generated/schema.py @@ -5035,6 +5035,7 @@ "enum": [ "refine_experience", "derive_skill", + "revise_skill", "revise_profile", "revise_memory", "revise_topic_memory", diff --git a/tests/e2e/dream_support.py b/tests/e2e/dream_support.py index f844cc146d..9fa2705ded 100644 --- a/tests/e2e/dream_support.py +++ b/tests/e2e/dream_support.py @@ -90,7 +90,7 @@ async def open_dream_runtime(config, **kwargs): max_workers=4, worker_timeout_seconds=180, ) - for spec in DREAM_OPERATIONS + for spec in {item.binding: item for item in DREAM_OPERATIONS}.values() ) async with open_runtime(config, artifact_processing_bindings=bindings, **kwargs) as runtime: controller.runtime = runtime diff --git a/tests/e2e/test_artifact_dreaming.py b/tests/e2e/test_artifact_dreaming.py index 3e5865b890..a229dbeb72 100644 --- a/tests/e2e/test_artifact_dreaming.py +++ b/tests/e2e/test_artifact_dreaming.py @@ -44,7 +44,7 @@ ProfileGeneration, ProfileWriteContent, ) -from powercontext.builtin.artifacts.skill import SkillContent +from powercontext.builtin.artifacts.skill import SkillContent, SkillDraft from powercontext.builtin.artifacts.topic_memory import ( TopicMemoryContent, TopicMemoryDraft, @@ -56,6 +56,7 @@ from powercontext.builtin.inference.models import GenerationResult, InferenceUsage from powercontext.builtin.persistence.oceanbase import OceanBaseConfig, OceanBaseProfile from powercontext.builtin.persistence.sqlite import SQLiteConfig +from powercontext.builtin.review.errors import InvalidCandidateError from powercontext.builtin.runtime import ( ApproveArtifactCandidateRequest, BuiltinConfig, @@ -503,6 +504,84 @@ async def scenario() -> None: asyncio.run(scenario()) +def test_skill_dream_rebuilds_one_file_package_before_review(database: DatabaseConfig) -> None: + class SkillRevisionGenerator: + config_id = "skill-revision-test" + + async def generate(self, value: DreamGenerationInput) -> GenerationResult[DreamPlan]: + assert value.operation == "revise_skill" + evidence = next(item.evidence_id for item in value.evidence.evidence if item.kind == "source") + return GenerationResult( + output=DreamPlan( + outcome="proposed", + reason="A task outcome shows the old retry order was unsafe.", + intent="correct", + proposal=SkillContent( + name="invented-name", + description="Check the receipt before retrying.", + instructions="Check the receipt first, then retry if needed.", + validation=("No duplicate action was issued.",), + ), + evidence_ids=(evidence,), + ), + usage=InferenceUsage(requests=1), + ) + + async def scenario() -> None: + async with open_builtin_runtime(config(database), dream_generator=SkillRevisionGenerator()) as runtime: + assert runtime.scopes is not None + scope = await runtime.scopes.create( + ScopeDraft(title="Skill Revision", summary="Package stays pending", idempotency_key="skill-revision") + ) + source = await runtime.sources.for_scope(scope.scope_id).capture( + CaptureSource(source_id="skill-feedback", content="The retry duplicated the action.", metadata={}) + ) + original = SkillContent( + name="receipt-check", + description="Retry safely.", + instructions="Retry immediately.", + validation=("A retry was attempted.",), + license="MIT", + ) + async with runtime._provider.database.transaction() as connection: + target = await runtime._provider.repositories.artifacts.create( + connection, + scope.scope_id, + "receipt-check", + SkillDraft(content=original, sources=(source.source_ref,)), + ) + run = await runtime.dream.for_scope(scope.scope_id).create( + CreateDreamRunRequest( + operation="revise_skill", + target=target.as_ref(), + artifacts=(target.as_ref(),), + sources=(source.source_ref,), + idempotency_key="skill-feedback", + ) + ) + await process_pending(runtime) + completed = await runtime.dream.for_scope(scope.scope_id).get(GetDreamRunRequest(run_id=run.run_id)) + assert completed.outcome == "proposed" and completed.candidate is not None, completed.error + candidate = await runtime.review.for_scope(scope.scope_id).get( + GetArtifactCandidateRequest(candidate_id=completed.candidate.candidate_id) + ) + assert isinstance(candidate.proposal, SkillContent) + assert candidate.proposal.name == "receipt-check" + assert candidate.proposal.license == "MIT" + assert candidate.proposal.package is not None + assert candidate.proposal.package.file_count == 1 + assert (await runtime.skill.for_scope(scope.scope_id).get(GetSkillRequest(artifact=target.as_ref()))).revision == 1 + with pytest.raises(InvalidCandidateError, match="trusted_evaluation_required"): + await runtime.review.for_scope(scope.scope_id).approve( + ApproveArtifactCandidateRequest( + candidate_id=candidate.candidate_id, + expected_version=candidate.version, + ) + ) + + asyncio.run(scenario()) + + @pytest.mark.parametrize("existing", [False, True], ids=["new-config", "enable-skill"]) def test_wizard_skill_selection_accepts_dream_derivation( tmp_path: Path, existing: bool, monkeypatch: pytest.MonkeyPatch From 76bbb86490f81d6441e0c0c04cdad1844fe09d58 Mon Sep 17 00:00:00 2001 From: Frey <370805886@qq.com> Date: Tue, 22 Sep 2026 15:45:09 +0800 Subject: [PATCH 08/48] fix(dream): keep Skill revision disabled until evaluation is ready --- src/powercontext/builtin/dream/bindings.py | 2 +- tests/e2e/test_artifact_dreaming.py | 69 +++++++--------------- 2 files changed, 21 insertions(+), 50 deletions(-) diff --git a/src/powercontext/builtin/dream/bindings.py b/src/powercontext/builtin/dream/bindings.py index 4d70ee34c0..f40620b24a 100644 --- a/src/powercontext/builtin/dream/bindings.py +++ b/src/powercontext/builtin/dream/bindings.py @@ -39,7 +39,7 @@ class DreamOperationSpec: DREAM_OPERATIONS = ( DreamOperationSpec("refine_experience", "experience", EXPERIENCE_INCUBATION_CURSOR_NAME), DreamOperationSpec("derive_skill", "skill", SKILL_DREAM_BINDING), - DreamOperationSpec("revise_skill", "skill", SKILL_DREAM_BINDING), + # revise_skill requires a trusted evaluation policy before it can be enabled. DreamOperationSpec("revise_profile", "profile", PROFILE_SOURCE_WINDOW_BINDING), DreamOperationSpec("revise_memory", "memory", SOURCE_WINDOW_TRIGGER_NAME), DreamOperationSpec("revise_topic_memory", "topic-memory", TOPIC_MEMORY_SOURCE_WINDOW_BINDING), diff --git a/tests/e2e/test_artifact_dreaming.py b/tests/e2e/test_artifact_dreaming.py index a229dbeb72..f1572b8f34 100644 --- a/tests/e2e/test_artifact_dreaming.py +++ b/tests/e2e/test_artifact_dreaming.py @@ -56,7 +56,6 @@ from powercontext.builtin.inference.models import GenerationResult, InferenceUsage from powercontext.builtin.persistence.oceanbase import OceanBaseConfig, OceanBaseProfile from powercontext.builtin.persistence.sqlite import SQLiteConfig -from powercontext.builtin.review.errors import InvalidCandidateError from powercontext.builtin.runtime import ( ApproveArtifactCandidateRequest, BuiltinConfig, @@ -504,31 +503,9 @@ async def scenario() -> None: asyncio.run(scenario()) -def test_skill_dream_rebuilds_one_file_package_before_review(database: DatabaseConfig) -> None: - class SkillRevisionGenerator: - config_id = "skill-revision-test" - - async def generate(self, value: DreamGenerationInput) -> GenerationResult[DreamPlan]: - assert value.operation == "revise_skill" - evidence = next(item.evidence_id for item in value.evidence.evidence if item.kind == "source") - return GenerationResult( - output=DreamPlan( - outcome="proposed", - reason="A task outcome shows the old retry order was unsafe.", - intent="correct", - proposal=SkillContent( - name="invented-name", - description="Check the receipt before retrying.", - instructions="Check the receipt first, then retry if needed.", - validation=("No duplicate action was issued.",), - ), - evidence_ids=(evidence,), - ), - usage=InferenceUsage(requests=1), - ) - +def test_skill_dream_revision_stays_unavailable_without_trusted_evaluation(database: DatabaseConfig) -> None: async def scenario() -> None: - async with open_builtin_runtime(config(database), dream_generator=SkillRevisionGenerator()) as runtime: + async with open_builtin_runtime(config(database), dream_generator=Generator()) as runtime: assert runtime.scopes is not None scope = await runtime.scopes.create( ScopeDraft(title="Skill Revision", summary="Package stays pending", idempotency_key="skill-revision") @@ -550,32 +527,26 @@ async def scenario() -> None: "receipt-check", SkillDraft(content=original, sources=(source.source_ref,)), ) - run = await runtime.dream.for_scope(scope.scope_id).create( - CreateDreamRunRequest( - operation="revise_skill", - target=target.as_ref(), - artifacts=(target.as_ref(),), - sources=(source.source_ref,), - idempotency_key="skill-feedback", - ) - ) - await process_pending(runtime) - completed = await runtime.dream.for_scope(scope.scope_id).get(GetDreamRunRequest(run_id=run.run_id)) - assert completed.outcome == "proposed" and completed.candidate is not None, completed.error - candidate = await runtime.review.for_scope(scope.scope_id).get( - GetArtifactCandidateRequest(candidate_id=completed.candidate.candidate_id) + prepared = await runtime._provider.review(scope.scope_id).prepare_skill_revision( + target.as_ref(), + SkillContent( + name="invented-name", + description="Check the receipt before retrying.", + instructions="Check the receipt first, then retry if needed.", + validation=("No duplicate action was issued.",), + ), ) - assert isinstance(candidate.proposal, SkillContent) - assert candidate.proposal.name == "receipt-check" - assert candidate.proposal.license == "MIT" - assert candidate.proposal.package is not None - assert candidate.proposal.package.file_count == 1 + assert prepared.name == "receipt-check" and prepared.license == "MIT" + assert prepared.package is not None and prepared.package.file_count == 1 assert (await runtime.skill.for_scope(scope.scope_id).get(GetSkillRequest(artifact=target.as_ref()))).revision == 1 - with pytest.raises(InvalidCandidateError, match="trusted_evaluation_required"): - await runtime.review.for_scope(scope.scope_id).approve( - ApproveArtifactCandidateRequest( - candidate_id=candidate.candidate_id, - expected_version=candidate.version, + with pytest.raises(DreamError, match="capability_unavailable"): + await runtime.dream.for_scope(scope.scope_id).create( + CreateDreamRunRequest( + operation="revise_skill", + target=target.as_ref(), + artifacts=(target.as_ref(),), + sources=(source.source_ref,), + idempotency_key="skill-feedback", ) ) From c1c63e64e5610a4074064b5e7cdae77129e62d54 Mon Sep 17 00:00:00 2001 From: Frey <370805886@qq.com> Date: Tue, 22 Sep 2026 16:14:54 +0800 Subject: [PATCH 09/48] fix(dream): enforce review evidence and fair processing --- docs/en/rfcs/0000_multi_artifact_dreaming.md | 8 +- docs/zh/rfcs/0000_multi_artifact_dreaming.md | 20 +- openapi/powercontext.yaml | 2 +- src/powercontext/artifacts/models.py | 8 +- .../builtin/artifacts/profile/review.py | 8 +- src/powercontext/builtin/dream/generation.py | 5 + src/powercontext/builtin/dream/models.py | 31 ++- src/powercontext/builtin/dream/service.py | 46 ++++- src/powercontext/builtin/evidence/resolver.py | 8 +- .../builtin/persistence/dream_schema.py | 14 +- .../builtin/persistence/processing_intents.py | 8 +- .../builtin/persistence/tables.py | 1 + src/powercontext/builtin/review/models.py | 2 +- src/powercontext/builtin/review/service.py | 26 ++- .../builtin/runtime/composition.py | 4 +- src/powercontext/builtin/runtime/models.py | 16 +- .../builtin/runtime/processing_execution.py | 3 +- .../builtin/runtime/processing_registry.py | 6 +- .../runtime/topic_memory_processing.py | 8 +- src/powercontext/http/_generated/models.py | 2 +- src/powercontext/http/_generated/schema.py | 2 +- src/powercontext/server/mapping.py | 25 ++- .../persistence/test_processing_intents.py | 23 +++ .../builtin/runtime/test_family_processing.py | 68 +++++++ tests/e2e/test_artifact_dreaming.py | 183 +++++++++++++++--- 25 files changed, 427 insertions(+), 100 deletions(-) diff --git a/docs/en/rfcs/0000_multi_artifact_dreaming.md b/docs/en/rfcs/0000_multi_artifact_dreaming.md index b787c7e715..e1fabe2bbd 100644 --- a/docs/en/rfcs/0000_multi_artifact_dreaming.md +++ b/docs/en/rfcs/0000_multi_artifact_dreaming.md @@ -48,17 +48,17 @@ The run lifecycle remains `queued/running/succeeded/failed`; a successful run re Add a server-owned `DreamOperationSpec` registry declaring an operation name and version, target kind, allowed evidence roles, proposal schema and validator, processing Family and canonical binding, review and commit adapters, effect, and evaluation policy. Requests cannot name Python paths, tools, SQL, or arbitrary code. Expose an operation only when resolution, generation, review, commit, and worker execution are all implemented and configured. Family read support alone is insufficient. -The current worker dispatch assumes a Skill run or an Experience run. Replace that binary assumption with a binding-to-operation registry and claim one accepted run in deterministic request-generation, acceptance-time, and run-ID order. Reuse one Supervisor binding per Family. Do not create another scheduler or clear ordinary Source progress when a Dream run finishes. Keep explicit work from starving ordinary Source work: after at most four consecutive Dream attempts on one binding, perform one eligible Source pass before continuing, with the counter durable across worker restarts. +The current worker dispatch assumes a Skill run or an Experience run. Replace that binary assumption with a binding-to-operation registry and claim one accepted run in deterministic request-generation, acceptance-time, and run-ID order. Reuse one Supervisor binding per Family. Do not create another scheduler or clear ordinary Source progress when a Dream run finishes. Keep explicit work from starving ordinary Source work: after at most four consecutive Dream attempts on one binding, perform one eligible Source pass before continuing, with the counter durable across worker restarts in a new `consecutive_dream_attempts` column on the existing intent table. A completed Source pass resets the counter and preserves queued Dream requests; no new table is required. ## API and Client compatibility -Extend the existing `POST/GET /v1/scopes/{scope_id}/dream` and `GET /v1/scopes/{scope_id}/dream/{run_id}` operations with typed targets, new operations, and corresponding run results. Extend `POST /v1/artifact-candidates/list`, `get`, `revise`, `approve`, and `reject` for new Artifact proposal types while retaining `candidate_id` and `expected_version`. Extend `GET /v1/capabilities` with available Dream operations, output kinds, and effects. No parallel Dream or Artifact Review path is needed. +Extend the existing `POST/GET /v1/scopes/{scope_id}/dream` and `GET /v1/scopes/{scope_id}/dream/{run_id}` operations with exact Artifact targets, new operations, and corresponding run results. Extend `POST /v1/artifact-candidates/list`, `get`, `revise`, `approve`, and `reject` for new Artifact proposal types while retaining `candidate_id` and `expected_version`. Extend `GET /v1/capabilities` with available Dream operations, output kinds, and effects. No parallel Dream or Artifact Review path is needed. Generated Clients currently use closed Dream operation enums and Candidate proposal unions. Deploy Server, generated Python Client, CLI, integrations, and Dashboard coherently before advertising new operations. An old Client must receive an explicit upgrade signal or only a compatibility-filtered list that it can decode; it must not silently reinterpret a new proposal as an old type. Define and test the minimum supported Client release. Tag proposals have a different result type and ETag transaction; add dedicated Tag candidate interfaces only in stage C, once that different resource lifecycle is implemented. Trusted evaluation registration belongs to stage B1, not A0/A1. -For new operations, `target` is a discriminated value: an exact ArtifactRef; for Memory, the owning Memory Ref and exact Entry Version identities; for Tag, a TagTarget, expected ETag, and exact content basis. The target contributes to the bounded model context and evidence budget but is not independent proof of its own claims. Explicit `artifacts`, `memory_citations`, and `sources` remain exact, same-Scope references. A target cannot be `latest`, a Candidate, or another DreamRun. +For Artifact operations, `target` remains an exact ArtifactRef and must also appear in `artifacts`. Memory entry identities and versions are supplied through `memory_citations`, all belonging to that target Memory. References are deduplicated and counted against the evidence budget; a target cannot independently support its own claims. Explicit Sources and references must belong to the same Scope. Stage C expresses TagTarget, expected ETag, and the exact content basis through its separate Catalog Change request, without changing the Artifact Dream target type. -The existing idempotency identity remains `(scope_id, principal_id, idempotency_key)`, stored using the current principal key digest. The same normalized request returns its original run; changed input under the same key conflicts. Different principals may use the same key without sharing a run, ownership, or worker identity. On first execution freeze the operation adapter, policy, model, and server prompt version for retries. Existing HTTP 202/200 replay semantics remain. +The existing idempotency identity remains `(scope_id, principal_id, idempotency_key)`, stored using the current principal key digest. The same normalized request returns its original run; changed input under the same key conflicts. Different principals may use the same key without sharing a run, ownership, or worker identity. Capture Profile policy at admission and supply its snapshot to generation; reject changes during queueing, generation, or approval, while allowing stale candidates to be rejected. On first execution freeze the operation adapter, model, and server prompt version for retries. Existing HTTP 202/200 replay semantics remain. ## Evidence, model output, and trust boundary diff --git a/docs/zh/rfcs/0000_multi_artifact_dreaming.md b/docs/zh/rfcs/0000_multi_artifact_dreaming.md index a0713b88d6..6dd9356dd3 100644 --- a/docs/zh/rfcs/0000_multi_artifact_dreaming.md +++ b/docs/zh/rfcs/0000_multi_artifact_dreaming.md @@ -153,21 +153,21 @@ Dream 保留 `queued/running/succeeded/failed` 状态及 `proposed/no_change/nee Tag 不是 Artifact;C 阶段的 Catalog Change Candidate 与 Artifact Candidate 的结果类型不同。只在该阶段证明现有接口无法准确表达 ETag 与结果类型后,才增加独立的 Tag 候选接口。可信评测登记接口同样归 B1 阶段,不成为 A0/A1 前置条件。 -新增操作请求沿用 `operation`、`artifacts`、`memory_citations`、`sources` 和 `idempotency_key`;`target` 扩展为按 `kind` 区分的类型化值:普通制品为精确 ArtifactRef,Memory 为 Memory Ref 加目标 Entry Version 集合,Tag 为 TagTarget、expected_etag 和正文基准。普通制品目标自动进入待修订上下文,不要求调用方重复提交。目标与输入合并去重并计入统一预算;目标内容不能单独证明自身断言。 +新增操作请求复用 `operation`、`artifacts`、`memory_citations`、`sources` 和 `idempotency_key`。Artifact 操作的 `target` 保持精确 ArtifactRef,并必须同时出现在 `artifacts` 中;Memory 的目标条目及版本通过 `memory_citations` 指定,必须属于该目标 Memory。引用去重后计入统一预算;目标内容不能单独证明自身断言。Tag 的 TagTarget、expected_etag 和正文基准由 C 阶段的独立 Catalog Change 请求表达,不改变 Artifact Dream 的 target 类型。 示例中的标识须替换为服务返回的真实值: ```json { "operation": "revise_memory", - "target": { - "kind": "memory_entries", - "memory_ref": {"family": "memory", "artifact_id": "memory", "revision": 8}, - "entries": [{"entry_id": "E_CITY", "entry_version_id": "EV_CITY_1"}] - }, + "target": {"family": "memory", "artifact_id": "memory", "revision": 8}, "sources": [{"source_type": "content", "source_id": "S03"}], - "artifacts": [], - "memory_citations": [], + "artifacts": [{"family": "memory", "artifact_id": "memory", "revision": 8}], + "memory_citations": [{ + "memory_ref": {"family": "memory", "artifact_id": "memory", "revision": 8}, + "entry_id": "E_CITY", + "entry_version_id": "EV_CITY_1" + }], "idempotency_key": "U1-city-explicit-change-20260921" } ``` @@ -197,7 +197,7 @@ proposal 类型为 `MemoryRevisionProposal`,包含固定的 base Memory Ref, “信息陈旧”需要显式新证据或可验证的事件时间,不以最后访问时间、模型置信度或重复次数自动改写事实。过期计划的首期表达是保留历史时间及当前有效性说明,不增加自动删除或新的全局 TTL 契约。`forget()` 的显式授权路径保持独立。 ## 5. Profile 修订契约 -沿用一个 Scope 一个 Profile 的身份与 Markdown 快照。模型输出完整 content,同时生成逐段变化理由、主体依据和证据引用。保持当前生成策略对范围、主体和允许信息的限制,排队或审核期间 Policy 改变应使候选失效并要求重新生成。 +沿用一个 Scope 一个 Profile 的身份与 Markdown 快照。模型输出完整 content,同时生成逐段变化理由、主体依据和证据引用。保持当前生成策略对范围、主体和允许信息的限制,接收请求时固定 Policy,生成输入携带该快照;排队、生成或审核期间 Policy 改变应阻止发布并要求重新生成。失效候选仍可拒绝以结束审核。 新增 `ProfileDreamCandidateProposal` 和可区分的 `generation.mode=dream_review_approved`,附精确 dream_run_id 和 policy revision/digest。Dream 模式不允许 `source_window`,也不伪造 after/through。 @@ -274,7 +274,7 @@ A0/A1 复用 `pc_dream_runs`、`pc_artifact_candidate_heads`、`pc_artifact_cand Supervisor 保持一个 processing family 一个 canonical binding,不为 Dream 另建常驻 scheduler。Memory、Profile、Topic、Experience、Skill 复用各自资源归属;Handoff、Prompt 等尚无完整后台 Dream 能力的 Family,须补齐规范 binding、Worker 配置与能力声明后才能启用。Tag 作业路由到其 owning Artifact family binding,并在注册表区分 operation,不能为 `tag` 伪造 Family。 -同一 binding 内显式 Run 按固定顺序执行。为避免普通 Source 处理饥饿,新增轮转策略:最多连续处理 4 个显式 Run 后,若存在可执行的 Source 工作则执行一次 Source pass,再继续 Dream。计数持久化到调用状态,不因 Worker 重启重置。没有 Source 工作时不人为阻塞 Dream;现有 v1 Run 的语义与预算保持。 +同一 binding 内显式 Run 按固定顺序执行。为避免普通 Source 处理饥饿,新增轮转策略:最多连续处理 4 个显式 Run 后,若存在可执行的 Source 工作则执行一次 Source pass,再继续 Dream。计数通过现有调用状态表的 `consecutive_dream_attempts` 列持久化,不新增表,不因 Worker 重启重置;Source pass 完成时归零,并保留尚待处理的 Dream 请求。没有 Source 工作时不人为阻塞 Dream;现有 v1 Run 的语义与预算保持。 Run 的候选落库、结果记录、租约 fence 检查、调用确认和后继调度意图在同一事务提交。Family 审核复用各自已有的投影发布契约,不统一引入异步 outbox;Topic Memory 按第 6 节在事务外准备完整投影,在批准事务内原子发布。只有既有 Family 已采用异步投影时才沿用其 processing intent,不为本次扩展新增通用索引任务表。审批等待不占 Dream Worker。查询只读取,不因 prepare_context 或 search 自动启动 Dream。 diff --git a/openapi/powercontext.yaml b/openapi/powercontext.yaml index 9e6433fa9f..75ba69a3b7 100644 --- a/openapi/powercontext.yaml +++ b/openapi/powercontext.yaml @@ -5486,7 +5486,7 @@ components: budget: $ref: '#/components/schemas/DreamBudget' prompt_version: - default: powercontext.dream.v1 + default: powercontext.dream.v1.1 type: string model_config_id: type: string diff --git a/src/powercontext/artifacts/models.py b/src/powercontext/artifacts/models.py index baee45bb89..ed95a77b49 100644 --- a/src/powercontext/artifacts/models.py +++ b/src/powercontext/artifacts/models.py @@ -110,8 +110,8 @@ class ArtifactDraft(BaseModel, Generic[ContentT]): @model_validator(mode="after") def validate_family(self): _validate_reference_part("family", self.family) - if self.memory_citations and self.family != "experience": - raise ValueError("only Experience accepts direct Memory citations") # noqa: TRY003 + if self.memory_citations and self.family not in {"experience", "profile", "topic-memory"}: + raise ValueError("this Artifact family does not accept direct Memory citations") # noqa: TRY003 return self @@ -127,8 +127,8 @@ class Artifact(BaseModel, Generic[ContentT]): @model_validator(mode="after") def validate_entry_lineage(self): - if self.lineage.memory_citations and self.family != "experience": - raise ValueError("only Experience accepts direct Memory citations") # noqa: TRY003 + if self.lineage.memory_citations and self.family not in {"experience", "profile", "topic-memory"}: + raise ValueError("this Artifact family does not accept direct Memory citations") # noqa: TRY003 return self @field_validator("artifact_id") diff --git a/src/powercontext/builtin/artifacts/profile/review.py b/src/powercontext/builtin/artifacts/profile/review.py index c75c4a1557..975dfd2c2c 100644 --- a/src/powercontext/builtin/artifacts/profile/review.py +++ b/src/powercontext/builtin/artifacts/profile/review.py @@ -102,15 +102,13 @@ async def decide_profile(service, connection, candidate_id, expected_version, *, async def _decide_dream_profile(service, connection, candidate, policy, *, reason=None): proposal = candidate.proposal - if proposal.policy_version != (None if policy is None else policy.version): - raise BaseValueConflictError("profile_policy", (service._scope_id,)) if reason is not None: return await service._candidates.reject( connection, service._scope_id, candidate.candidate_id, candidate.version, reason ) - await service._validate_evidence( - connection, candidate.sources, candidate.artifacts, candidate.memory_citations - ) + if proposal.policy_version != (None if policy is None else policy.version): + raise BaseValueConflictError("profile_policy", (service._scope_id,)) + await service._validate_evidence(connection, candidate.sources, candidate.artifacts, candidate.memory_citations) current = await service._artifacts.latest(connection, service._scope_id, "profile", PROFILE_ARTIFACT_ID) if current.as_ref() != candidate.target: raise BaseValueConflictError("profile_head", (service._scope_id,)) diff --git a/src/powercontext/builtin/dream/generation.py b/src/powercontext/builtin/dream/generation.py index b718d42d21..8ce8fe36cb 100644 --- a/src/powercontext/builtin/dream/generation.py +++ b/src/powercontext/builtin/dream/generation.py @@ -18,6 +18,7 @@ from pydantic import BaseModel, Field +from powercontext.builtin.artifacts.profile.models import ProfilePolicy from powercontext.builtin.dream.models import DreamOperation, DreamPlan from powercontext.builtin.evidence.models import EvidenceProjection from powercontext.builtin.inference.models import GenerationResult @@ -33,6 +34,9 @@ For derive_skill, derive one instruction-only Skill from the supplied Experience evidence. For revise_profile, compare the exact current Profile with new evidence and propose a complete Markdown replacement. Treat Profile text as the target under review, never as independent support for its own assertions. +Attribute every person's facts correctly in multi-person scopes; never merge speakers. +Do not invent sensitive attributes or infer lasting preferences from temporary instructions. +Retain supported information and do not infer what an isolated agreement refers to. Distinguish temporary plans from durable facts. Use correct as the intent for a supported replacement. For revise_memory, revise only selected active Memory entry versions in the one exact target Memory. For each change, preserve the entry ID, cite selected exact Source references, and explain the correction. @@ -71,6 +75,7 @@ class DreamGenerationInput(BaseModel): description="Exact evidence ID of the Artifact being replaced; null when creating a new Artifact." ) evidence: EvidenceProjection + profile_policy: ProfilePolicy | None = None class DreamGenerator(Protocol): diff --git a/src/powercontext/builtin/dream/models.py b/src/powercontext/builtin/dream/models.py index 6ef3292b73..ace76b63a2 100644 --- a/src/powercontext/builtin/dream/models.py +++ b/src/powercontext/builtin/dream/models.py @@ -25,7 +25,7 @@ from powercontext.builtin.artifacts.experience import ExperienceContent from powercontext.builtin.artifacts.handoff.models import HandoffContent from powercontext.builtin.artifacts.memory.models import MemoryDreamWrite -from powercontext.builtin.artifacts.profile.models import ProfileWriteContent +from powercontext.builtin.artifacts.profile.models import ProfilePolicy, ProfileWriteContent from powercontext.builtin.artifacts.skill import SkillContent from powercontext.builtin.artifacts.topic_memory.models import TopicMemoryContent from powercontext.builtin.evidence.models import ( @@ -39,12 +39,18 @@ from powercontext.sources import SourceRef DreamOperation = Literal[ - "refine_experience", "derive_skill", "revise_skill", "revise_profile", "revise_memory", "revise_topic_memory", "refresh_handoff" + "refine_experience", + "derive_skill", + "revise_skill", + "revise_profile", + "revise_memory", + "revise_topic_memory", + "refresh_handoff", ] DreamStatus = Literal["queued", "running", "succeeded", "failed"] DreamOutcome = Literal["proposed", "no_change", "needs_evidence"] DreamIntent = Literal["create", "corroborate", "refine", "correct", "derive"] -DREAM_PROMPT_VERSION = "powercontext.dream.v1" +DREAM_PROMPT_VERSION = "powercontext.dream.v1.1" class DreamError(PowerContextError, ValueError): @@ -213,6 +219,7 @@ class DreamRecord(BaseModel): generation: int = 0 request_generation: int = 0 deadline_at: datetime | None = None + profile_policy: ProfilePolicy | None = None class DreamPlan(BaseModel): @@ -221,7 +228,15 @@ class DreamPlan(BaseModel): outcome: DreamOutcome reason: str = Field(min_length=1, max_length=2000) intent: DreamIntent | None = None - proposal: ExperienceContent | SkillContent | ProfileWriteContent | MemoryDreamWrite | TopicMemoryContent | HandoffContent | None = None + proposal: ( + ExperienceContent + | SkillContent + | ProfileWriteContent + | MemoryDreamWrite + | TopicMemoryContent + | HandoffContent + | None + ) = None evidence_ids: tuple[str, ...] = Field(default=(), max_length=32) @model_validator(mode="after") @@ -243,7 +258,9 @@ def validate_operation(self, request: CreateDreamRunRequest) -> None: isinstance(self.proposal, SkillContent) and self.intent == "derive" and self.proposal.package is None ) elif request.operation == "revise_skill": - valid = isinstance(self.proposal, SkillContent) and self.intent == "correct" and self.proposal.package is None + valid = ( + isinstance(self.proposal, SkillContent) and self.intent == "correct" and self.proposal.package is None + ) elif request.operation == "revise_profile": valid = ( isinstance(self.proposal, ProfileWriteContent) @@ -255,9 +272,7 @@ def validate_operation(self, request: CreateDreamRunRequest) -> None: valid = ( isinstance(self.proposal, MemoryDreamWrite) and self.intent == "correct" - and all( - (change.entry_id, change.entry_version_id) in selected for change in self.proposal.changes - ) + and all((change.entry_id, change.entry_version_id) in selected for change in self.proposal.changes) ) elif request.operation == "revise_topic_memory": valid = isinstance(self.proposal, TopicMemoryContent) and self.intent == "correct" diff --git a/src/powercontext/builtin/dream/service.py b/src/powercontext/builtin/dream/service.py index 7e98da1459..fe4f993d33 100644 --- a/src/powercontext/builtin/dream/service.py +++ b/src/powercontext/builtin/dream/service.py @@ -164,6 +164,11 @@ async def create(self, scope_id: str, principal_id: str, request: CreateDreamRun existing = await self.repository.find_request(connection, scope_id, principal_id, request, current=True) if existing is not None: return existing.run + profile_policy = ( + await ProfilePolicyRepository().get(connection, scope_id, for_update=True) + if request.operation == "revise_profile" + else None + ) await self._check_target(connection, scope_id, principal_id, request.target) await resolver.resolve( connection, @@ -189,6 +194,7 @@ async def create(self, scope_id: str, principal_id: str, request: CreateDreamRun request=request, principal_id=principal_id, request_generation=intent.requested_generation, + profile_policy=profile_policy, ) return (await self.repository.create(connection, record)).run @@ -202,7 +208,7 @@ async def list(self, scope_id: str, principal_id: str, request: ListDreamRunsReq async with self._transaction() as connection: return await self.repository.list(connection, scope_id, request) - async def execute(self) -> bool: + async def execute(self) -> bool: # noqa: C901 - fenced admission, fair dispatch, and bounded recovery """Execute one accepted Run attempt within a Supervisor Scope invocation. Returning false leaves a non-Dream invocation to the Family processor. @@ -223,6 +229,17 @@ async def execute(self) -> bool: ) if record is None: return False + intent = await self.processing.guard(connection) + if ( + intent.consecutive_dream_attempts >= 4 + and intent.dirty_generation > intent.clean_generation + and work.artifact_family not in {"skill", "handoff"} + ): + # The Source pass acknowledges this invocation. Keep the queued + # Dream runnable even if that pass consumes all ordinary input. + if intent.requested_generation == work.claimed_request_generation: + await self.intents.request(connection, work.scope_id, work.binding_name) + return False record = await self.repository.claim( connection, record, model_config_id=None if self.generator is None else self.generator.config_id ) @@ -249,11 +266,13 @@ async def _complete_invocation(self, connection: AsyncConnection) -> None: raise RuntimeError("Dream execution requires a Supervisor invocation") # noqa: TRY003 work = self.processing.assignment current = await self.processing.guard(connection) - remaining = await self.repository.next_pending(connection, work.scope_id, operations_for_binding(work.binding_name)) + remaining = await self.repository.next_pending( + connection, work.scope_id, operations_for_binding(work.binding_name) + ) if remaining is not None and current.requested_generation == work.claimed_request_generation: await self.intents.request(connection, work.scope_id, work.binding_name) # Dream never consumes or clears the Family's ordinary Source progress. - await self.processing.complete(connection, remaining_work=True) + await self.processing.complete(connection, remaining_work=True, dream=True) async def _execute(self, record: DreamRecord) -> None: run = record.run @@ -271,6 +290,9 @@ async def _execute(self, record: DreamRecord) -> None: if error.detail == "unsupported_target": raise DreamError("unsupported_target") from error raise + if run.operation == "revise_profile": + async with self._transaction() as connection: + await self._check_profile_policy(connection, record) resolved = await self._resolve(record) usage = run.usage.model_copy(update={"model_calls": run.usage.model_calls + 1}) if usage.model_calls > run.budget.max_model_calls: @@ -287,6 +309,7 @@ async def _execute(self, record: DreamRecord) -> None: operation=run.operation, target_evidence_id=None if run.target is None else evidence_id(run.target), evidence=resolved.projection, + profile_policy=record.profile_policy, ) ) record = record.model_copy( @@ -336,6 +359,8 @@ async def _commit(self, record: DreamRecord, plan: DreamPlan) -> None: async with self._transaction() as connection: await self.repository.lock_owned(connection, record) await self._authorize(run.scope_id, record.principal_id, "contribute") + if run.operation == "revise_profile": + await self._check_profile_policy(connection, record) resolved = await self._resolve(record, connection) candidate = None if plan.outcome == "proposed": @@ -348,9 +373,7 @@ async def _commit(self, record: DreamRecord, plan: DreamPlan) -> None: "revise_memory": "memory", "revise_topic_memory": "topic-memory", "refresh_handoff": "handoff", - }[ - run.operation - ] + }[run.operation] await self.attest_candidate(connection, record, _candidate_id(record), family) candidate = await self._propose(connection, record, plan, resolved) completed = run.model_copy( @@ -365,6 +388,13 @@ async def _commit(self, record: DreamRecord, plan: DreamPlan) -> None: await self.repository.finish(connection, record, completed) await self._complete_invocation(connection) + async def _check_profile_policy(self, connection: AsyncConnection, record: DreamRecord) -> None: + policy = await ProfilePolicyRepository().get(connection, record.run.scope_id, for_update=True) + if record.profile_policy is None or policy is None: + raise DreamError("capability_unavailable") + if policy.version != record.profile_policy.version: + raise DreamError("policy_changed") + async def _propose( # noqa: C901 - operation-specific Candidate adapters share one atomic Run commit self, connection: AsyncConnection, @@ -401,7 +431,8 @@ async def _propose( # noqa: C901 - operation-specific Candidate adapters share candidate_id=_candidate_id(record), ) elif isinstance(plan.proposal, ProfileWriteContent) and record.run.operation == "revise_profile": - policy = await ProfilePolicyRepository().get(connection, record.run.scope_id, for_update=True) + await self._check_profile_policy(connection, record) + policy = record.profile_policy if policy is None or record.run.target is None or self.generator is None: raise DreamError("capability_unavailable") proposal = ProfileCandidateProposal( @@ -443,6 +474,7 @@ async def _propose( # noqa: C901 - operation-specific Candidate adapters share raise DreamError("invalid_generation_output") candidate = await review.propose_topic_memory_dream( plan.proposal, + memory_citations=selected.memory_citations, sources=selected.sources, artifacts=selected.artifacts, target=record.run.target, diff --git a/src/powercontext/builtin/evidence/resolver.py b/src/powercontext/builtin/evidence/resolver.py index 68a2f6f313..652b5741e3 100644 --- a/src/powercontext/builtin/evidence/resolver.py +++ b/src/powercontext/builtin/evidence/resolver.py @@ -308,9 +308,7 @@ async def _read( digest = content_digest(artifact.model_dump_json().encode()) if isinstance(artifact, Profile): return ( - EvidenceNode( - evidence_id=evidence_id(ref), kind="profile", artifact=ref, digest=digest, role="derived" - ), + EvidenceNode(evidence_id=evidence_id(ref), kind="profile", artifact=ref, digest=digest, role="derived"), artifact.content.content, (), ) @@ -324,9 +322,7 @@ async def _read( ) if isinstance(artifact, Handoff): return ( - EvidenceNode( - evidence_id=evidence_id(ref), kind="handoff", artifact=ref, digest=digest, role="derived" - ), + EvidenceNode(evidence_id=evidence_id(ref), kind="handoff", artifact=ref, digest=digest, role="derived"), artifact.content.model_dump_json(), (), ) diff --git a/src/powercontext/builtin/persistence/dream_schema.py b/src/powercontext/builtin/persistence/dream_schema.py index ec5fbc4e76..51f725a243 100644 --- a/src/powercontext/builtin/persistence/dream_schema.py +++ b/src/powercontext/builtin/persistence/dream_schema.py @@ -19,12 +19,20 @@ from sqlalchemy.ext.asyncio import AsyncConnection from sqlalchemy.schema import CreateColumn -from powercontext.builtin.persistence.tables import ARTIFACT_CANDIDATE_VERSIONS_TABLE, ARTIFACTS_TABLE +from powercontext.builtin.persistence.tables import ( + ARTIFACT_CANDIDATE_VERSIONS_TABLE, + ARTIFACT_PROCESSING_INTENTS_TABLE, + ARTIFACTS_TABLE, +) async def ensure_dream_schema(connection: AsyncConnection, /) -> None: - for table in (ARTIFACTS_TABLE, ARTIFACT_CANDIDATE_VERSIONS_TABLE): - column = table.c.memory_citations + for table, name in ( + (ARTIFACTS_TABLE, "memory_citations"), + (ARTIFACT_CANDIDATE_VERSIONS_TABLE, "memory_citations"), + (ARTIFACT_PROCESSING_INTENTS_TABLE, "consecutive_dream_attempts"), + ): + column = table.c[name] if await _has_column(connection, table.name, column.name): continue declaration = str(CreateColumn(column).compile(dialect=connection.dialect)) diff --git a/src/powercontext/builtin/persistence/processing_intents.py b/src/powercontext/builtin/persistence/processing_intents.py index 0635f6dd22..06b7998929 100644 --- a/src/powercontext/builtin/persistence/processing_intents.py +++ b/src/powercontext/builtin/persistence/processing_intents.py @@ -50,6 +50,7 @@ class StoredArtifactProcessingIntent(BaseModel): requested_generation: int handled_generation: int last_auto_scan_generation: int + consecutive_dream_attempts: int = 0 class ArtifactProcessingIntentRepository: @@ -150,6 +151,7 @@ async def acknowledge( /, *, clean_generation: int | None = None, + dream: bool = False, ) -> StoredArtifactProcessingIntent: _require_nonnegative("generation", generation) current = await self.load(connection, scope_id, binding_name, for_update=True) @@ -167,7 +169,11 @@ async def acknowledge( await connection.execute( update(table) .where(table.c.scope_id == scope_id, table.c.binding_name == binding_name) - .values(handled_generation=max(current.handled_generation, generation), clean_generation=clean) + .values( + handled_generation=max(current.handled_generation, generation), + clean_generation=clean, + consecutive_dream_attempts=min(current.consecutive_dream_attempts + 1, 4) if dream else 0, + ) ) return cast(StoredArtifactProcessingIntent, await self.load(connection, scope_id, binding_name)) diff --git a/src/powercontext/builtin/persistence/tables.py b/src/powercontext/builtin/persistence/tables.py index 530d3f13de..bc1315a7ef 100644 --- a/src/powercontext/builtin/persistence/tables.py +++ b/src/powercontext/builtin/persistence/tables.py @@ -519,6 +519,7 @@ def _entry_text_type(): Column("requested_generation", BigInteger, nullable=False, server_default="0"), Column("handled_generation", BigInteger, nullable=False, server_default="0"), Column("last_auto_scan_generation", BigInteger, nullable=False, server_default="0"), + Column("consecutive_dream_attempts", BigInteger, nullable=False, server_default="0"), CheckConstraint("pending_sequence > 0", name="ck_pc_processing_intent_sequence"), CheckConstraint( "clean_generation >= 0 AND clean_generation <= dirty_generation", diff --git a/src/powercontext/builtin/review/models.py b/src/powercontext/builtin/review/models.py index b37594e8bb..9b7a14b652 100644 --- a/src/powercontext/builtin/review/models.py +++ b/src/powercontext/builtin/review/models.py @@ -73,7 +73,7 @@ def validate_evidence(self): raise ValueError(f"Candidate evidence must not exceed {MAX_CANDIDATE_EVIDENCE} references") # noqa: TRY003 if self.target is not None and self.target.family != self.family: raise ValueError("Candidate target must belong to the proposed family") # noqa: TRY003 - if self.memory_citations and self.family not in {"experience", "profile", "memory", "handoff"}: + if self.memory_citations and self.family not in {"experience", "profile", "memory", "handoff", "topic-memory"}: raise ValueError("this Candidate Family does not accept Memory citations") # noqa: TRY003 return self diff --git a/src/powercontext/builtin/review/service.py b/src/powercontext/builtin/review/service.py index aec342a621..b659730b94 100644 --- a/src/powercontext/builtin/review/service.py +++ b/src/powercontext/builtin/review/service.py @@ -81,7 +81,12 @@ IdFactory = Callable[[str], str] ReviewedProposal: TypeAlias = ( - ExperienceContent | SkillContent | ProfileCandidateProposal | MemoryDreamCandidateProposal | TopicMemoryContent | HandoffContent + ExperienceContent + | SkillContent + | ProfileCandidateProposal + | MemoryDreamCandidateProposal + | TopicMemoryContent + | HandoffContent ) ReviewedArtifact: TypeAlias = Experience | Skill ReviewedDraft: TypeAlias = ExperienceDraft | SkillDraft @@ -253,6 +258,7 @@ async def propose_topic_memory_dream( proposal: TopicMemoryContent, /, *, + memory_citations: tuple[MemoryCitation, ...] = (), sources: tuple[SourceRef, ...], artifacts: tuple[ArtifactRef, ...], target: ArtifactRef, @@ -262,6 +268,7 @@ async def propose_topic_memory_dream( candidate = await self._propose( TopicMemory.family, proposal, + memory_citations=memory_citations, sources=sources, artifacts=artifacts, target=target, @@ -407,7 +414,7 @@ async def list_candidates( next_cursor=page.next_cursor, ) - async def revise( + async def revise( # noqa: C901 - Family-specific validation shares one versioned transaction self, candidate_id: str, expected_version: int, @@ -427,6 +434,10 @@ async def revise( preview = await self._candidates.get(connection, self._scope_id, candidate_id) if preview.family == "profile": await ProfilePolicyRepository().get(connection, self._scope_id, for_update=True) + if preview.family in {"memory", "topic-memory", "handoff"} and preview.target is not None: + await self._artifacts.latest( + connection, self._scope_id, preview.family, preview.target.artifact_id, for_update=True + ) current = await self._candidates.lock_pending( connection, self._scope_id, @@ -672,17 +683,22 @@ async def _approve_topic_memory_dream(self, candidate_id: str, expected_version: candidate = _reviewed_candidate( await self._candidates.lock_pending(connection, self._scope_id, candidate_id, expected_version) ) - if candidate != preview or not isinstance(current, TopicMemory) or not isinstance( - candidate.proposal, TopicMemoryContent + if ( + candidate != preview + or not isinstance(current, TopicMemory) + or not isinstance(candidate.proposal, TopicMemoryContent) ): raise CandidateConflictError(candidate_id, expected_version, candidate.version) - await self._validate_evidence(connection, candidate.sources, candidate.artifacts) + await self._validate_evidence( + connection, candidate.sources, candidate.artifacts, candidate.memory_citations + ) published = await self._topic_writer.topics.publish_revision( connection, self._scope_id, current, TopicMemoryDraft( content=candidate.proposal, + memory_citations=candidate.memory_citations, sources=candidate.sources, artifacts=candidate.artifacts, ), diff --git a/src/powercontext/builtin/runtime/composition.py b/src/powercontext/builtin/runtime/composition.py index 28b1aa8066..0ff0bb59d1 100644 --- a/src/powercontext/builtin/runtime/composition.py +++ b/src/powercontext/builtin/runtime/composition.py @@ -460,9 +460,7 @@ async def run_profile(scope_id, high): if dream_generator is not None and config.runtime.dream_enabled: registered_families = {binding.artifact_family for binding in processing_bindings} configured_operations = tuple( - spec.operation - for spec in DREAM_OPERATIONS - if spec.family in registered_families + spec.operation for spec in DREAM_OPERATIONS if spec.family in registered_families ) topic_memory_processing_available = _topic_memory_processing_available(config, processing_bindings) runtime = await resources.enter_async_context( diff --git a/src/powercontext/builtin/runtime/models.py b/src/powercontext/builtin/runtime/models.py index 40608e9c90..64c4eafce7 100644 --- a/src/powercontext/builtin/runtime/models.py +++ b/src/powercontext/builtin/runtime/models.py @@ -60,7 +60,12 @@ PreparedContextSchema: TypeAlias = Literal["powercontext.prepared-context.v1"] PreparedContextStatus: TypeAlias = Literal["ready", "empty"] ReviewedProposal: TypeAlias = ( - ExperienceContent | SkillContent | ProfileCandidateProposal | MemoryDreamCandidateProposal | TopicMemoryContent | HandoffContent + ExperienceContent + | SkillContent + | ProfileCandidateProposal + | MemoryDreamCandidateProposal + | TopicMemoryContent + | HandoffContent ) PREPARED_CONTEXT_SCHEMA: PreparedContextSchema = "powercontext.prepared-context.v1" @@ -437,7 +442,14 @@ class RejectArtifactCandidateRequest(ApproveArtifactCandidateRequest): class ReviseArtifactCandidateRequest(ApproveArtifactCandidateRequest): - proposal: ExperienceContent | SkillContent | ProfileWriteContent | MemoryDreamCandidateProposal | TopicMemoryContent | HandoffContent + proposal: ( + ExperienceContent + | SkillContent + | ProfileWriteContent + | MemoryDreamCandidateProposal + | TopicMemoryContent + | HandoffContent + ) sources: tuple[SourceRef, ...] = () artifacts: tuple[ArtifactRef, ...] = () memory_citations: tuple[MemoryCitation, ...] | None = None diff --git a/src/powercontext/builtin/runtime/processing_execution.py b/src/powercontext/builtin/runtime/processing_execution.py index 3908219b4e..c13cabca3c 100644 --- a/src/powercontext/builtin/runtime/processing_execution.py +++ b/src/powercontext/builtin/runtime/processing_execution.py @@ -69,7 +69,7 @@ async def start(self, connection: AsyncConnection) -> None: intent = await self.guard(connection) self.dirty_generation = intent.dirty_generation - async def complete(self, connection: AsyncConnection, *, remaining_work: bool) -> None: + async def complete(self, connection: AsyncConnection, *, remaining_work: bool, dream: bool = False) -> None: await self.guard(connection) work = self.assignment await self.intents.acknowledge( @@ -78,5 +78,6 @@ async def complete(self, connection: AsyncConnection, *, remaining_work: bool) - work.binding_name, work.claimed_request_generation, clean_generation=None if remaining_work else self.dirty_generation, + dream=dream, ) await self.leases.require_fence(connection, work.fence) diff --git a/src/powercontext/builtin/runtime/processing_registry.py b/src/powercontext/builtin/runtime/processing_registry.py index 202a1c1756..c702546d09 100644 --- a/src/powercontext/builtin/runtime/processing_registry.py +++ b/src/powercontext/builtin/runtime/processing_registry.py @@ -78,11 +78,7 @@ def dream_operations(config: BuiltinConfig) -> tuple[DreamOperation, ...]: ): return () families = processing_capabilities(config) - return tuple( - spec.operation - for spec in DREAM_OPERATIONS - if spec.family in families - ) + return tuple(spec.operation for spec in DREAM_OPERATIONS if spec.family in families) def canonical_processing_manifest(config: BuiltinConfig) -> dict[str, Any]: diff --git a/src/powercontext/builtin/runtime/topic_memory_processing.py b/src/powercontext/builtin/runtime/topic_memory_processing.py index 9b541ded50..7860b6598d 100644 --- a/src/powercontext/builtin/runtime/topic_memory_processing.py +++ b/src/powercontext/builtin/runtime/topic_memory_processing.py @@ -1562,7 +1562,9 @@ async def _process_topic_dream_if_pending( if not operations or not spec.config.runtime.dream_enabled: return False async with AsyncExitStack() as resources: - raw_embedding, _ = await _embedding_models(spec.config.inference, resources, None, disable_provider_retries=True) + raw_embedding, _ = await _embedding_models( + spec.config.inference, resources, None, disable_provider_retries=True + ) embedding = None if raw_embedding is None else UsageReportingEmbeddingModel(raw_embedding) contexts = await resources.enter_async_context( open_builtin_contexts(spec.config, embedding_model=embedding, _topic_memory_worker=True) @@ -1581,7 +1583,9 @@ async def _process_topic_dream_if_pending( if spec.worker_security is not None: from powercontext.server.processing_security import open_worker_security - security = await resources.enter_async_context(open_worker_security(spec.worker_security, contexts.database)) + security = await resources.enter_async_context( + open_worker_security(spec.worker_security, contexts.database) + ) return await process_dream_invocation( contexts, assignment, diff --git a/src/powercontext/http/_generated/models.py b/src/powercontext/http/_generated/models.py index 8dfcc626a8..1535aec169 100644 --- a/src/powercontext/http/_generated/models.py +++ b/src/powercontext/http/_generated/models.py @@ -3501,7 +3501,7 @@ class DreamRun(BaseModel): input_manifest: DreamInputManifest | None = None usage: DreamUsage | None = None budget: DreamBudget | None = None - prompt_version: StrictStr = "powercontext.dream.v1" + prompt_version: StrictStr = "powercontext.dream.v1.1" model_config_id: Annotated[StrictStr | None, Field(...)] diff --git a/src/powercontext/http/_generated/schema.py b/src/powercontext/http/_generated/schema.py index 4da30e9fab..6fd7b358c3 100644 --- a/src/powercontext/http/_generated/schema.py +++ b/src/powercontext/http/_generated/schema.py @@ -5241,7 +5241,7 @@ "input_manifest": {"$ref": "#/components/schemas/DreamInputManifest", "nullable": True}, "usage": {"$ref": "#/components/schemas/DreamUsage"}, "budget": {"$ref": "#/components/schemas/DreamBudget"}, - "prompt_version": {"type": "string", "default": "powercontext.dream.v1"}, + "prompt_version": {"type": "string", "default": "powercontext.dream.v1.1"}, "model_config_id": {"type": "string", "nullable": True}, }, "additionalProperties": False, diff --git a/src/powercontext/server/mapping.py b/src/powercontext/server/mapping.py index a089ddd244..fa578432ae 100644 --- a/src/powercontext/server/mapping.py +++ b/src/powercontext/server/mapping.py @@ -1069,8 +1069,20 @@ def skill_proposal(value: SkillContent) -> SkillProposal: def reviewed_content( - value: ExperienceProposal | SkillProposal | ProfileWriteContent | MemoryDreamCandidateProposal | TopicMemoryDreamProposal | TransportHandoffContent, -) -> ExperienceContent | SkillContent | RuntimeProfileWriteContent | RuntimeMemoryDreamCandidateProposal | RuntimeTopicMemoryContent | HandoffContent: + value: ExperienceProposal + | SkillProposal + | ProfileWriteContent + | MemoryDreamCandidateProposal + | TopicMemoryDreamProposal + | TransportHandoffContent, +) -> ( + ExperienceContent + | SkillContent + | RuntimeProfileWriteContent + | RuntimeMemoryDreamCandidateProposal + | RuntimeTopicMemoryContent + | HandoffContent +): if isinstance(value, TransportHandoffContent): return runtime_handoff_content(value) if isinstance(value, TopicMemoryDreamProposal): @@ -1086,7 +1098,14 @@ def reviewed_content( def reviewed_proposal( value: object, -) -> ExperienceProposal | SkillProposal | ProfileCandidateProposal | MemoryDreamCandidateProposal | TopicMemoryDreamProposal | TransportHandoffContent: +) -> ( + ExperienceProposal + | SkillProposal + | ProfileCandidateProposal + | MemoryDreamCandidateProposal + | TopicMemoryDreamProposal + | TransportHandoffContent +): if isinstance(value, HandoffContent): return handoff_content(value) if isinstance(value, RuntimeTopicMemoryContent): diff --git a/tests/builtin/persistence/test_processing_intents.py b/tests/builtin/persistence/test_processing_intents.py index a74c62ad63..302b96896e 100644 --- a/tests/builtin/persistence/test_processing_intents.py +++ b/tests/builtin/persistence/test_processing_intents.py @@ -195,3 +195,26 @@ async def replace(): assert scopes == ("commit",) asyncio.run(scenario()) + + +def test_dream_counter_migration_preserves_pending_work_and_is_repeatable(): + from powercontext.builtin.persistence.dream_schema import ensure_dream_schema + + async def scenario(): + intents = ArtifactProcessingIntentRepository() + async with ( + SQLiteProfile.open(SQLiteConfig(), tables=SHARED_TABLES) as profile, + profile.database.transaction() as connection, + ): + await intents.mark_dirty(connection, "migration", BINDING) + before = await intents.request(connection, "migration", BINDING) + await connection.exec_driver_sql( + "ALTER TABLE pc_artifact_processing_intents DROP COLUMN consecutive_dream_attempts" + ) + await ensure_dream_schema(connection) + await ensure_dream_schema(connection) + restored = await intents.load(connection, "migration", BINDING) + assert restored is not None and restored == before + assert restored.consecutive_dream_attempts == 0 + + asyncio.run(scenario()) diff --git a/tests/builtin/runtime/test_family_processing.py b/tests/builtin/runtime/test_family_processing.py index d0f6a30ab1..2b60b568c6 100644 --- a/tests/builtin/runtime/test_family_processing.py +++ b/tests/builtin/runtime/test_family_processing.py @@ -351,3 +351,71 @@ async def scenario(): assert intent is not None and intent.handled_generation == 0 asyncio.run(scenario()) + + +def test_dream_yields_to_source_after_four_attempts_across_worker_instances(tmp_path): + from powercontext.builtin.dream.models import CreateDreamRunRequest, DreamPlan + from powercontext.builtin.persistence.dream import DreamRepository + + class NoChangeGenerator: + config_id = "fairness-test" + calls = 0 + + async def generate(self, value): + self.calls += 1 + return GenerationResult( + output=DreamPlan(outcome="no_change", reason="No supported change."), + usage=InferenceUsage(requests=1), + ) + + async def scenario(): + config = BuiltinConfig(database=SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'fairness.db'}")) + intents = ArtifactProcessingIntentRepository() + generator = NoChangeGenerator() + async with _open_sqlite(config, tables=BUILTIN_TABLES) as profile: + contexts, assignment = await prepare(profile, "profile") + await process_family_invocation(contexts, assignment, config=config) + async with profile.database.transaction() as connection: + target = await contexts.profiles.latest(connection, assignment.scope_id) + assert target is not None + source = await contexts.records.create_source(assignment.scope_id, "content", "Verify the new setting.") + service = contexts.dream( + generator, + budget=config.runtime.dream_budget, + max_pending_per_scope=32, + operations=("revise_profile",), + ) + runs = [] + for index in range(5): + runs.append( + await service.create( + assignment.scope_id, + "runtime", + CreateDreamRunRequest( + operation="revise_profile", + target=target.as_ref(), + artifacts=(target.as_ref(),), + sources=(SourceRef(source_type="content", source_id=source.source_id),), + idempotency_key=f"fair-{index}", + ), + ) + ) + for index in range(6): + # Every invocation uses a fresh worker/service instance. + contexts = RelationalContexts(database=profile.database) + contexts.profiles.generator = ProfileGenerator() + async with profile.database.transaction() as connection: + intent = await intents.load(connection, assignment.scope_id, assignment.binding_name) + assert intent is not None and intent.requested_generation > intent.handled_generation + work = replace(assignment, claimed_request_generation=intent.requested_generation) + await process_family_invocation(contexts, work, config=config, dream_generator=generator) + async with profile.database.transaction() as connection: + cursor = await SourceCursorRepository().load(connection, work.scope_id, work.binding_name) + assert cursor is not None and cursor.cursor.sequence == (1 if index < 4 else 2) + if index == 4: + assert generator.calls == 4 + async with profile.database.transaction() as connection: + for run in runs: + assert (await DreamRepository().get(connection, assignment.scope_id, run.run_id)).run.terminal + + asyncio.run(scenario()) diff --git a/tests/e2e/test_artifact_dreaming.py b/tests/e2e/test_artifact_dreaming.py index f1572b8f34..e2ef4ffea4 100644 --- a/tests/e2e/test_artifact_dreaming.py +++ b/tests/e2e/test_artifact_dreaming.py @@ -56,6 +56,7 @@ from powercontext.builtin.inference.models import GenerationResult, InferenceUsage from powercontext.builtin.persistence.oceanbase import OceanBaseConfig, OceanBaseProfile from powercontext.builtin.persistence.sqlite import SQLiteConfig +from powercontext.builtin.records import BaseValueConflictError from powercontext.builtin.runtime import ( ApproveArtifactCandidateRequest, BuiltinConfig, @@ -71,6 +72,7 @@ PrepareContextRequest, ProposeExperienceRequest, ProposeSkillRequest, + RejectArtifactCandidateRequest, RememberMemoryRequest, RetireMemoryEntryRequest, ReviseArtifactCandidateRequest, @@ -173,32 +175,55 @@ def config(database: DatabaseConfig) -> BuiltinConfig: return BuiltinConfig(database=database, runtime=RuntimeConfig()) -def test_profile_dream_requires_review_and_preserves_source_cursor(database: DatabaseConfig) -> None: +@pytest.mark.parametrize("policy_change", [None, "queued", "generation", "review", "memory"]) +def test_profile_dream_requires_review_and_preserves_source_cursor( + database: DatabaseConfig, policy_change: str | None +) -> None: class ProfileDreamGenerator: config_id = "profile-dream-test" async def generate(self, value: DreamGenerationInput) -> GenerationResult[DreamPlan]: assert value.operation == "revise_profile" + assert value.profile_policy is not None and value.profile_policy.version == 1 + if policy_change == "generation": + await change_policy() assert any(item.kind == "profile" for item in value.evidence.evidence) - evidence = next(item.evidence_id for item in value.evidence.evidence if item.kind == "source") + evidence = tuple(item.evidence_id for item in value.evidence.evidence if item.kind in {"memory", "source"}) return GenerationResult( output=DreamPlan( outcome="proposed", reason="The explicit relocation supersedes the old location.", intent="correct", proposal=ProfileWriteContent(content="# Profile\n\nBased in Shenzhen."), - evidence_ids=(evidence,), + evidence_ids=evidence, ), usage=InferenceUsage(requests=1), ) + async def change_policy() -> None: + assert profiles is not None + await profiles.put_policy(scope_id, generation_enabled=True, expected_version=1) + + profiles = None + scope_id = "" + async def scenario() -> None: - async with open_builtin_runtime(config(database), dream_generator=ProfileDreamGenerator()) as runtime: + nonlocal profiles, scope_id + async with open_builtin_runtime( + config(database), candidate_pipeline=MemoryPipeline(), dream_generator=ProfileDreamGenerator() + ) as runtime: assert runtime.scopes is not None and runtime.profiles is not None - scope = await runtime.scopes.create( - ScopeDraft(title="Profile Dream", summary="Review before activation", idempotency_key="profile-dream") - ) - scope_id = scope.scope_id + citation = None + if policy_change == "memory": + scope_id, _, citation = await seed(runtime) + else: + scope = await runtime.scopes.create( + ScopeDraft( + title="Profile Dream", summary="Review before activation", idempotency_key="profile-dream" + ) + ) + scope_id = scope.scope_id + profiles = runtime.profiles await runtime.profiles.put_policy(scope_id, generation_enabled=False, expected_version=0) async with runtime.profiles.database.transaction() as connection: initial = await runtime.profiles.artifacts.create( @@ -213,23 +238,50 @@ async def scenario() -> None: ), ) source = await runtime.sources.for_scope(scope_id).capture( - CaptureSource(source_id="relocation", content="I moved to Shenzhen and am now based there.", metadata={}) + CaptureSource( + source_id="relocation", content="I moved to Shenzhen and am now based there.", metadata={} + ) ) run = await runtime.dream.for_scope(scope_id).create( CreateDreamRunRequest( operation="revise_profile", + memory_citations=() if citation is None else (citation,), target=initial.as_ref(), artifacts=(initial.as_ref(),), sources=(source.source_ref,), idempotency_key="relocation", ) ) + if policy_change == "queued": + await change_policy() await process_pending(runtime) completed = await runtime.dream.for_scope(scope_id).get(GetDreamRunRequest(run_id=run.run_id)) + if policy_change in {"queued", "generation"}: + assert completed.status == "failed" and completed.error == "policy_changed" + assert completed.candidate is None + return assert completed.outcome == "proposed" and completed.candidate is not None async with runtime.profiles.database.transaction() as connection: before = await runtime.profiles.latest(connection, scope_id) assert before is not None and before.content.content.endswith("Shanghai.\n") + if policy_change == "review": + await change_policy() + with pytest.raises(BaseValueConflictError): + await runtime.review.for_scope(scope_id).approve( + ApproveArtifactCandidateRequest( + candidate_id=completed.candidate.candidate_id, + expected_version=completed.candidate.version, + ) + ) + rejected = await runtime.review.for_scope(scope_id).reject( + RejectArtifactCandidateRequest( + candidate_id=completed.candidate.candidate_id, + expected_version=completed.candidate.version, + reason="Policy changed; regenerate under the current policy.", + ) + ) + assert rejected.status == "rejected" + return approved = await runtime.review.for_scope(scope_id).approve( ApproveArtifactCandidateRequest( candidate_id=completed.candidate.candidate_id, @@ -242,11 +294,15 @@ async def scenario() -> None: assert after is not None and after.content.content.endswith("Shenzhen.\n") assert after.content.generation.mode == "dream_review_approved" assert after.content.generation.dream_run_id == run.run_id + assert after.lineage.memory_citations == (() if citation is None else (citation,)) asyncio.run(scenario()) -def test_memory_dream_revises_only_selected_entry_after_review(database: DatabaseConfig) -> None: +@pytest.mark.parametrize("concurrent_revision", [False, True]) +def test_memory_dream_revises_only_selected_entry_after_review( + database: DatabaseConfig, concurrent_revision: bool +) -> None: class MemoryDreamGenerator: config_id = "memory-dream-test" @@ -257,9 +313,7 @@ def __init__(self): async def generate(self, value: DreamGenerationInput) -> GenerationResult[DreamPlan]: assert value.operation == "revise_memory" assert self.citation is not None and self.correction is not None - evidence = tuple( - item.evidence_id for item in value.evidence.evidence if item.kind in {"memory", "source"} - ) + evidence = tuple(item.evidence_id for item in value.evidence.evidence if item.kind in {"memory", "source"}) return GenerationResult( output=DreamPlan( outcome="proposed", @@ -284,7 +338,9 @@ async def generate(self, value: DreamGenerationInput) -> GenerationResult[DreamP async def scenario() -> None: generator = MemoryDreamGenerator() - async with open_builtin_runtime(config(database), candidate_pipeline=MemoryPipeline(), dream_generator=generator) as runtime: + async with open_builtin_runtime( + config(database), candidate_pipeline=MemoryPipeline(), dream_generator=generator + ) as runtime: scope, _, citation = await seed(runtime) correction = await runtime.sources.for_scope(scope).capture( CaptureSource(source_id="correction", content="The replay created two records.", metadata={}) @@ -316,12 +372,49 @@ async def scenario() -> None: assert transport_proposal.dream_run_id == run.run_id before = await runtime.memory.for_scope(scope).list() assert any(item.citation == citation for item in before.entries) - approved = await runtime.review.for_scope(scope).approve( - ApproveArtifactCandidateRequest( - candidate_id=completed.candidate.candidate_id, - expected_version=completed.candidate.version, + approval = ApproveArtifactCandidateRequest( + candidate_id=candidate.candidate_id, expected_version=candidate.version + ) + if concurrent_revision: + from powercontext.builtin.review.errors import CandidateConflictError, CandidateTerminalError + + results = await asyncio.wait_for( + asyncio.gather( + runtime.review.for_scope(scope).approve(approval), + runtime.review.for_scope(scope).revise( + ReviseArtifactCandidateRequest( + candidate_id=candidate.candidate_id, + expected_version=candidate.version, + proposal=candidate.proposal, + sources=candidate.sources, + artifacts=candidate.artifacts, + memory_citations=candidate.memory_citations, + target=candidate.target, + reason="Review confirms the correction.", + ) + ), + return_exceptions=True, + ), + timeout=10, ) - ) + assert not all(isinstance(result, Exception) for result in results) + for result in results: + if isinstance(result, Exception): + assert isinstance(result, (CandidateConflictError, CandidateTerminalError)) + latest = await runtime.review.for_scope(scope).get( + GetArtifactCandidateRequest(candidate_id=candidate.candidate_id) + ) + approved = ( + latest + if latest.status == "approved" + else await runtime.review.for_scope(scope).approve( + ApproveArtifactCandidateRequest( + candidate_id=latest.candidate_id, expected_version=latest.version + ) + ) + ) + else: + approved = await runtime.review.for_scope(scope).approve(approval) assert approved.result_artifact is not None after = await runtime.memory.for_scope(scope).list() replacement = next(item for item in after.entries if item.citation.entry_id == citation.entry_id) @@ -332,14 +425,17 @@ async def scenario() -> None: asyncio.run(scenario()) -def test_topic_memory_dream_publishes_complete_revision_after_review(database: DatabaseConfig) -> None: +@pytest.mark.parametrize("retire_evidence", [False, True]) +def test_topic_memory_dream_publishes_complete_revision_after_review( + database: DatabaseConfig, retire_evidence: bool +) -> None: class TopicDreamGenerator: config_id = "topic-dream-test" async def generate(self, value: DreamGenerationInput) -> GenerationResult[DreamPlan]: assert value.operation == "revise_topic_memory" assert any(item.kind == "topic_memory" for item in value.evidence.evidence) - source_id = next(item.evidence_id for item in value.evidence.evidence if item.kind == "source") + source_id = next(item.evidence_id for item in value.evidence.evidence if item.kind == "memory") return GenerationResult( output=DreamPlan( outcome="proposed", @@ -356,12 +452,11 @@ async def generate(self, value: DreamGenerationInput) -> GenerationResult[DreamP ) async def scenario() -> None: - async with open_builtin_runtime(config(database), dream_generator=TopicDreamGenerator()) as runtime: + async with open_builtin_runtime( + config(database), candidate_pipeline=MemoryPipeline(), dream_generator=TopicDreamGenerator() + ) as runtime: assert runtime.scopes is not None - scope = await runtime.scopes.create( - ScopeDraft(title="Topic Dream", summary="Complete projection", idempotency_key="topic-dream") - ) - scope_id = scope.scope_id + scope_id, _, citation = await seed(runtime) source = await runtime.sources.for_scope(scope_id).capture( CaptureSource(source_id="topic-correction", content="A later replay created two records.", metadata={}) ) @@ -382,6 +477,7 @@ async def scenario() -> None: run = await runtime.dream.for_scope(scope_id).create( CreateDreamRunRequest( operation="revise_topic_memory", + memory_citations=(citation,), target=published.topic.as_ref(), artifacts=(published.topic.as_ref(),), sources=(source.source_ref,), @@ -396,6 +492,22 @@ async def scenario() -> None: connection, scope_id, "topic-memory", "topic-dream-target" ) assert before.revision == 1 + candidate = await runtime.review.for_scope(scope_id).get( + GetArtifactCandidateRequest(candidate_id=completed.candidate.candidate_id) + ) + assert candidate.memory_citations == (citation,) + if retire_evidence: + await runtime.memory.for_scope(scope_id).retire(RetireMemoryEntryRequest(citation=citation)) + with pytest.raises(EvidenceResolutionError, match="memory_entry_inactive"): + await runtime.review.for_scope(scope_id).approve( + ApproveArtifactCandidateRequest( + candidate_id=candidate.candidate_id, expected_version=candidate.version + ) + ) + async with runtime._provider.database.transaction() as connection: + unchanged = await topics.get_exact(connection, scope_id, published.topic.as_ref()) + assert unchanged.is_current + return approved = await runtime.review.for_scope(scope_id).approve( ApproveArtifactCandidateRequest( candidate_id=completed.candidate.candidate_id, @@ -407,6 +519,7 @@ async def scenario() -> None: after = await topics.get_exact(connection, scope_id, approved.result_artifact) assert after.topic.content.summary == "The replay can create a duplicate." assert after.is_current + assert after.topic.lineage.memory_citations == (citation,) asyncio.run(scenario()) @@ -538,7 +651,9 @@ async def scenario() -> None: ) assert prepared.name == "receipt-check" and prepared.license == "MIT" assert prepared.package is not None and prepared.package.file_count == 1 - assert (await runtime.skill.for_scope(scope.scope_id).get(GetSkillRequest(artifact=target.as_ref()))).revision == 1 + assert ( + await runtime.skill.for_scope(scope.scope_id).get(GetSkillRequest(artifact=target.as_ref())) + ).revision == 1 with pytest.raises(DreamError, match="capability_unavailable"): await runtime.dream.for_scope(scope.scope_id).create( CreateDreamRunRequest( @@ -2037,3 +2152,17 @@ async def scenario() -> None: assert await SourceCursorRepository().load(connection, scope, EXPERIENCE_INCUBATION_CURSOR_NAME) is None asyncio.run(scenario()) + + +def test_rfc_memory_example_matches_native_and_http_request_contract(): + import json + + from powercontext.http import CreateDreamRunRequest as HttpRequest + + document = (Path(__file__).parents[2] / "docs/zh/rfcs/0000_multi_artifact_dreaming.md").read_text() + example = json.loads(document.split("```json\n", 1)[1].split("```", 1)[0]) + native = CreateDreamRunRequest.model_validate(example) + transport = HttpRequest.model_validate(example) + assert native.target is not None and native.target in native.artifacts + assert native.memory_citations[0].memory_ref == native.target + assert CreateDreamRunRequest.model_validate(transport.model_dump(mode="json")) == native From 69fa13d5dc885c3aae44cd17243a858f030eb173 Mon Sep 17 00:00:00 2001 From: Frey <370805886@qq.com> Date: Tue, 22 Sep 2026 16:34:19 +0800 Subject: [PATCH 10/48] fix(dream): preserve upgrade and target correction boundaries --- docs/en/rfcs/0000_multi_artifact_dreaming.md | 6 +- docs/zh/rfcs/0000_multi_artifact_dreaming.md | 6 +- .../builtin/artifacts/profile/review.py | 4 +- .../builtin/persistence/family_management.py | 2 + src/powercontext/builtin/review/service.py | 33 +++++-- .../builtin/runtime/composition.py | 4 +- .../persistence/test_processing_intents.py | 19 ++++ tests/e2e/test_artifact_dreaming.py | 88 ++++++++++++++++++- 8 files changed, 144 insertions(+), 18 deletions(-) diff --git a/docs/en/rfcs/0000_multi_artifact_dreaming.md b/docs/en/rfcs/0000_multi_artifact_dreaming.md index e1fabe2bbd..4fc03ebcc7 100644 --- a/docs/en/rfcs/0000_multi_artifact_dreaming.md +++ b/docs/en/rfcs/0000_multi_artifact_dreaming.md @@ -48,7 +48,7 @@ The run lifecycle remains `queued/running/succeeded/failed`; a successful run re Add a server-owned `DreamOperationSpec` registry declaring an operation name and version, target kind, allowed evidence roles, proposal schema and validator, processing Family and canonical binding, review and commit adapters, effect, and evaluation policy. Requests cannot name Python paths, tools, SQL, or arbitrary code. Expose an operation only when resolution, generation, review, commit, and worker execution are all implemented and configured. Family read support alone is insufficient. -The current worker dispatch assumes a Skill run or an Experience run. Replace that binary assumption with a binding-to-operation registry and claim one accepted run in deterministic request-generation, acceptance-time, and run-ID order. Reuse one Supervisor binding per Family. Do not create another scheduler or clear ordinary Source progress when a Dream run finishes. Keep explicit work from starving ordinary Source work: after at most four consecutive Dream attempts on one binding, perform one eligible Source pass before continuing, with the counter durable across worker restarts in a new `consecutive_dream_attempts` column on the existing intent table. A completed Source pass resets the counter and preserves queued Dream requests; no new table is required. +The current worker dispatch assumes a Skill run or an Experience run. Replace that binary assumption with a binding-to-operation registry and claim one accepted run in deterministic request-generation, acceptance-time, and run-ID order. Reuse one Supervisor binding per Family. Do not create another scheduler or clear ordinary Source progress when a Dream run finishes. Keep explicit work from starving ordinary Source work: after at most four consecutive Dream attempts on one binding, perform one eligible Source pass before continuing, with the counter durable across worker restarts in a new `consecutive_dream_attempts` column on the existing intent table. A completed Source pass resets the counter and preserves queued Dream requests; no new table is required. Apply the additive column migration before processing schema validation on startup, preserving pending work in existing databases. ## API and Client compatibility @@ -56,7 +56,7 @@ Extend the existing `POST/GET /v1/scopes/{scope_id}/dream` and `GET /v1/scopes/{ Generated Clients currently use closed Dream operation enums and Candidate proposal unions. Deploy Server, generated Python Client, CLI, integrations, and Dashboard coherently before advertising new operations. An old Client must receive an explicit upgrade signal or only a compatibility-filtered list that it can decode; it must not silently reinterpret a new proposal as an old type. Define and test the minimum supported Client release. Tag proposals have a different result type and ETag transaction; add dedicated Tag candidate interfaces only in stage C, once that different resource lifecycle is implemented. Trusted evaluation registration belongs to stage B1, not A0/A1. -For Artifact operations, `target` remains an exact ArtifactRef and must also appear in `artifacts`. Memory entry identities and versions are supplied through `memory_citations`, all belonging to that target Memory. References are deduplicated and counted against the evidence budget; a target cannot independently support its own claims. Explicit Sources and references must belong to the same Scope. Stage C expresses TagTarget, expected ETag, and the exact content basis through its separate Catalog Change request, without changing the Artifact Dream target type. +For Artifact operations, `target` remains an exact ArtifactRef and must also appear in `artifacts`. Memory entry identities and versions are supplied through `memory_citations`, all belonging to that target Memory. References are deduplicated and counted against the evidence budget; a target cannot independently support its own claims. For Profile, Topic Memory, and Handoff, validate the target permission, existence, and exact head separately: retired historical dependencies must not prevent correction using new valid evidence. Recursively validate all selected supporting evidence without granting it the target exemption. Explicit Sources and references must belong to the same Scope. Stage C expresses TagTarget, expected ETag, and the exact content basis through its separate Catalog Change request, without changing the Artifact Dream target type. The existing idempotency identity remains `(scope_id, principal_id, idempotency_key)`, stored using the current principal key digest. The same normalized request returns its original run; changed input under the same key conflicts. Different principals may use the same key without sharing a run, ownership, or worker identity. Capture Profile policy at admission and supply its snapshot to generation; reject changes during queueing, generation, or approval, while allowing stale candidates to be rejected. On first execution freeze the operation adapter, model, and server prompt version for retries. Existing HTTP 202/200 replay semantics remain. @@ -74,7 +74,7 @@ The model returns a typed complete proposal, a concise reason, intent, and only **Topic Memory.** Revise one Topic identity with complete title, summary, and detail; preserve the distinction between confirmed results, hypotheses, ruled-out causes, open questions, and next steps. Prepare the complete FTS, configured vector, and detail-chunk projection for the exact candidate version and digest outside the publishing transaction. On approval, recheck candidate, head, evidence, permission, and retrieval configuration; atomically publish the new Revision, head, full active projection, publication time, and Candidate decision through the existing Topic publication contract. A failed embedding or changed candidate leaves the old complete head and projection in place. Do not introduce a “new head, index pending” state or bypass startup consistency checks. Dream never advances the Topic Source cursor. -**Handoff.** Require the exact current revision and evidence for every claimed change in completed, pending, blocked, or next work. Reuse existing prepare/commit validation through a transaction-safe writer, but approval only commits a non-activated revision. Existing activation tokens, live-state, capability, authorization, and receiver acknowledgement remain separate. A plan does not prove its action happened. +**Handoff.** Generation validation, Candidate edits, and final commit must preserve the target objective; changing tasks requires a separate explicit operation. Require the exact current revision and evidence for every claimed change in completed, pending, blocked, or next work. Reuse existing prepare/commit validation through a transaction-safe writer, but approval only commits a non-activated revision. Existing activation tokens, live-state, capability, authorization, and receiver acknowledgement remain separate. A plan does not prove its action happened. **Skill.** `revise_skill` targets a managed Skill with an exact package snapshot. A package with only root `SKILL.md` is an instruction-only Skill and is supported, including existing generated Skills. Legacy instruction content without a package follows the existing one-file package normalization. A package containing additional assets, scripts, or dependencies is rejected as an unsupported target in this stage. Keep authorized metadata and frontmatter, produce a new valid standard package, and bind evaluation to the exact target and candidate; never edit cached instructions while retaining the old package digest. Approval does not install, run, or automatically distribute the Skill. diff --git a/docs/zh/rfcs/0000_multi_artifact_dreaming.md b/docs/zh/rfcs/0000_multi_artifact_dreaming.md index 6dd9356dd3..6a6ba64abf 100644 --- a/docs/zh/rfcs/0000_multi_artifact_dreaming.md +++ b/docs/zh/rfcs/0000_multi_artifact_dreaming.md @@ -153,7 +153,7 @@ Dream 保留 `queued/running/succeeded/failed` 状态及 `proposed/no_change/nee Tag 不是 Artifact;C 阶段的 Catalog Change Candidate 与 Artifact Candidate 的结果类型不同。只在该阶段证明现有接口无法准确表达 ETag 与结果类型后,才增加独立的 Tag 候选接口。可信评测登记接口同样归 B1 阶段,不成为 A0/A1 前置条件。 -新增操作请求复用 `operation`、`artifacts`、`memory_citations`、`sources` 和 `idempotency_key`。Artifact 操作的 `target` 保持精确 ArtifactRef,并必须同时出现在 `artifacts` 中;Memory 的目标条目及版本通过 `memory_citations` 指定,必须属于该目标 Memory。引用去重后计入统一预算;目标内容不能单独证明自身断言。Tag 的 TagTarget、expected_etag 和正文基准由 C 阶段的独立 Catalog Change 请求表达,不改变 Artifact Dream 的 target 类型。 +新增操作请求复用 `operation`、`artifacts`、`memory_citations`、`sources` 和 `idempotency_key`。Artifact 操作的 `target` 保持精确 ArtifactRef,并必须同时出现在 `artifacts` 中;Memory 的目标条目及版本通过 `memory_citations` 指定,必须属于该目标 Memory。引用去重后计入统一预算;目标内容不能单独证明自身断言。Profile、Topic Memory 和 Handoff 的待修订目标仅按权限、存在性与精确 head 校验;其历史引用失效不阻止以新的有效证据纠正目标。本次选中的支持证据仍须递归校验,不能通过目标角色豁免。Tag 的 TagTarget、expected_etag 和正文基准由 C 阶段的独立 Catalog Change 请求表达,不改变 Artifact Dream 的 target 类型。 示例中的标识须替换为服务返回的真实值: @@ -217,7 +217,7 @@ Family 名称使用现有 `topic-memory`。候选保持 `title/summary/detail` Dream 不推进 Topic Source Cursor。后续正常融合以新 head 作为基准处理新增 Source,不从旧快照重建后覆盖审核结论。相关后台 CAS 与投影一致性验收是开启该 operation 的前置条件。 ## 7. Handoff 刷新契约 -只针对精确 Handoff Revision,保持 work identity、目标任务与原有结构化内容约束。对已完成、未完成、阻塞和下一步的每一项变更提供结果来源。没有新的状态证据时返回 no_change 或 needs_evidence,不能仅凭时间流逝宣称任务完成。 +只针对精确 Handoff Revision,保持 work identity、目标任务与原有结构化内容约束。生成输出、候选修订及最终提交均要求 objective 与目标版本一致;改变任务应走独立的显式操作。对已完成、未完成、阻塞和下一步的每一项变更提供结果来源。没有新的状态证据时返回 no_change 或 needs_evidence,不能仅凭时间流逝宣称任务完成。 候选生成不改变当前激活交接,不写 accepted acknowledgement,不变更任务授权。批准形成正式 Handoff Revision,应用随后按现有接口显式激活该版本,接收方仍核对 live state、capability 和 authorization。 @@ -274,7 +274,7 @@ A0/A1 复用 `pc_dream_runs`、`pc_artifact_candidate_heads`、`pc_artifact_cand Supervisor 保持一个 processing family 一个 canonical binding,不为 Dream 另建常驻 scheduler。Memory、Profile、Topic、Experience、Skill 复用各自资源归属;Handoff、Prompt 等尚无完整后台 Dream 能力的 Family,须补齐规范 binding、Worker 配置与能力声明后才能启用。Tag 作业路由到其 owning Artifact family binding,并在注册表区分 operation,不能为 `tag` 伪造 Family。 -同一 binding 内显式 Run 按固定顺序执行。为避免普通 Source 处理饥饿,新增轮转策略:最多连续处理 4 个显式 Run 后,若存在可执行的 Source 工作则执行一次 Source pass,再继续 Dream。计数通过现有调用状态表的 `consecutive_dream_attempts` 列持久化,不新增表,不因 Worker 重启重置;Source pass 完成时归零,并保留尚待处理的 Dream 请求。没有 Source 工作时不人为阻塞 Dream;现有 v1 Run 的语义与预算保持。 +同一 binding 内显式 Run 按固定顺序执行。为避免普通 Source 处理饥饿,新增轮转策略:最多连续处理 4 个显式 Run 后,若存在可执行的 Source 工作则执行一次 Source pass,再继续 Dream。计数通过现有调用状态表的 `consecutive_dream_attempts` 列持久化,不新增表,不因 Worker 重启重置;启动先补齐该增量列,再执行 processing schema 完整性检查,旧数据库保留已有待处理请求;Source pass 完成时归零,并保留尚待处理的 Dream 请求。没有 Source 工作时不人为阻塞 Dream;现有 v1 Run 的语义与预算保持。 Run 的候选落库、结果记录、租约 fence 检查、调用确认和后继调度意图在同一事务提交。Family 审核复用各自已有的投影发布契约,不统一引入异步 outbox;Topic Memory 按第 6 节在事务外准备完整投影,在批准事务内原子发布。只有既有 Family 已采用异步投影时才沿用其 processing intent,不为本次扩展新增通用索引任务表。审批等待不占 Dream Worker。查询只读取,不因 prepare_context 或 search 自动启动 Dream。 diff --git a/src/powercontext/builtin/artifacts/profile/review.py b/src/powercontext/builtin/artifacts/profile/review.py index 975dfd2c2c..1cdec1f99b 100644 --- a/src/powercontext/builtin/artifacts/profile/review.py +++ b/src/powercontext/builtin/artifacts/profile/review.py @@ -108,7 +108,9 @@ async def _decide_dream_profile(service, connection, candidate, policy, *, reaso ) if proposal.policy_version != (None if policy is None else policy.version): raise BaseValueConflictError("profile_policy", (service._scope_id,)) - await service._validate_evidence(connection, candidate.sources, candidate.artifacts, candidate.memory_citations) + await service._validate_evidence( + connection, candidate.sources, candidate.artifacts, candidate.memory_citations, target=candidate.target + ) current = await service._artifacts.latest(connection, service._scope_id, "profile", PROFILE_ARTIFACT_ID) if current.as_ref() != candidate.target: raise BaseValueConflictError("profile_head", (service._scope_id,)) diff --git a/src/powercontext/builtin/persistence/family_management.py b/src/powercontext/builtin/persistence/family_management.py index 9b69dfbdde..038be34e8f 100644 --- a/src/powercontext/builtin/persistence/family_management.py +++ b/src/powercontext/builtin/persistence/family_management.py @@ -583,6 +583,8 @@ async def commit_reviewed( if current.artifact_id != self._handoff_artifact_id or content.generation is not None: raise InvalidBaseAccessRequestError("handoff", "reviewed replacement must target the local singleton") + if content.objective != current.content.objective: + raise InvalidBaseAccessRequestError("handoff", "reviewed replacement must preserve the objective") prepared = PreparedHandoff(scope_id=scope_id, base=current.as_ref(), content=content) return await self._service(scope_id, connection).commit( prepared, diff --git a/src/powercontext/builtin/review/service.py b/src/powercontext/builtin/review/service.py index b659730b94..019581cb3a 100644 --- a/src/powercontext/builtin/review/service.py +++ b/src/powercontext/builtin/review/service.py @@ -346,7 +346,10 @@ async def _propose_with_connection( candidate_id: str | None = None, memory_citations: tuple[MemoryCitation, ...] = (), ) -> ReviewedCandidate: - sources = await self._validate_evidence(connection, sources, artifacts, memory_citations, proposal=proposal) + sources = await self._validate_evidence( + connection, sources, artifacts, memory_citations, proposal=proposal, target=target + ) + await self._validate_handoff_objective(connection, proposal, target) await self._validate_target(connection, family, target, artifacts) candidate = await self._candidates.create( connection, @@ -476,7 +479,9 @@ async def revise( # noqa: C901 - Family-specific validation shares one versione canonical_artifacts, citations, proposal=proposal if isinstance(proposal, (ExperienceContent, SkillContent)) else None, + target=current.target, ) + await self._validate_handoff_objective(connection, proposal, current.target) if reviewed.family != "profile": await self._validate_target(connection, reviewed.family, target, canonical_artifacts) revised = await self._candidates.revise( @@ -654,7 +659,7 @@ async def _approve_memory_dream( # noqa: C901 - preparation and publication hav if candidate != preview: raise CandidateConflictError(candidate_id, expected_version, candidate.version) await self._validate_evidence( - connection, candidate.sources, candidate.artifacts, candidate.memory_citations + connection, candidate.sources, candidate.artifacts, candidate.memory_citations, target=candidate.target ) committed = await self._memory(connection).apply(plan) if committed is None: @@ -690,7 +695,7 @@ async def _approve_topic_memory_dream(self, candidate_id: str, expected_version: ): raise CandidateConflictError(candidate_id, expected_version, candidate.version) await self._validate_evidence( - connection, candidate.sources, candidate.artifacts, candidate.memory_citations + connection, candidate.sources, candidate.artifacts, candidate.memory_citations, target=candidate.target ) published = await self._topic_writer.topics.publish_revision( connection, @@ -734,7 +739,7 @@ async def _approve_handoff_dream(self, candidate_id: str, expected_version: int) candidate.target, ) await self._validate_evidence( - connection, candidate.sources, candidate.artifacts, candidate.memory_citations + connection, candidate.sources, candidate.artifacts, candidate.memory_citations, target=candidate.target ) committed = await self._handoff_writer.commit_reviewed( connection, self._scope_id, current, candidate.proposal, candidate.sources @@ -775,8 +780,16 @@ async def _validate_evidence( memory_citations: tuple[MemoryCitation, ...] = (), *, proposal: ReviewedProposal | None = None, + target: ArtifactRef | None = None, ) -> tuple[SourceRef, ...]: - if not sources and not memory_citations and not any(artifact.family != "prompt" for artifact in artifacts): + supporting_artifacts = tuple( + ref for ref in artifacts if ref != target or ref.family not in {"profile", "topic-memory", "handoff"} + ) + if ( + not sources + and not memory_citations + and not any(artifact.family != "prompt" for artifact in supporting_artifacts) + ): raise InvalidCandidateError("evidence", "at least one exact reference is required") if len(sources) + len(artifacts) + len(memory_citations) > MAX_CANDIDATE_EVIDENCE: raise InvalidCandidateError("evidence", f"must not exceed {MAX_CANDIDATE_EVIDENCE} exact references") @@ -790,7 +803,7 @@ async def _validate_evidence( roots = await self._evidence.validate( connection, sources=sources, - artifacts=artifacts, + artifacts=supporting_artifacts, memory_citations=memory_citations, ) sources = _unique_sources((*sources, *roots)) @@ -817,6 +830,14 @@ async def _validate_evidence( ) return sources + async def _validate_handoff_objective( + self, connection: AsyncConnection, proposal: object, target: ArtifactRef | None + ) -> None: + if isinstance(proposal, HandoffContent) and target is not None: + current = await self._artifacts.get(connection, self._scope_id, target) + if not isinstance(current, Handoff) or proposal.objective != current.content.objective: + raise InvalidCandidateError("proposal", "Handoff Dream must preserve the target objective") + async def _validate_target( self, connection: AsyncConnection, diff --git a/src/powercontext/builtin/runtime/composition.py b/src/powercontext/builtin/runtime/composition.py index 0ff0bb59d1..789eb044b2 100644 --- a/src/powercontext/builtin/runtime/composition.py +++ b/src/powercontext/builtin/runtime/composition.py @@ -775,11 +775,11 @@ async def open_builtin_contexts( load_vector_extension=embedding_model is not None, ) as profile: async with profile.database.transaction() as connection: + await ensure_dream_schema(connection) await bootstrap_processing_schema(connection, canonical_processing_manifest(config)) await assert_processing_schema_ready(connection, canonical_processing_manifest(config)) await ensure_skill_distribution_schema(connection) await ensure_topic_memory_tag_schema(connection) - await ensure_dream_schema(connection) await ensure_scope_search_schema(connection) # A Topic child reuses its parent's schema. It never reads or # writes Memory/Experience projections; rebuilding their FTS @@ -836,11 +836,11 @@ async def open_builtin_contexts( raise BuiltinConfigurationError("database") async with profile_context as profile: async with profile.database.transaction() as connection: + await ensure_dream_schema(connection) await bootstrap_processing_schema(connection, canonical_processing_manifest(config)) await assert_processing_schema_ready(connection, canonical_processing_manifest(config)) await ensure_skill_distribution_schema(connection) await ensure_topic_memory_tag_schema(connection) - await ensure_dream_schema(connection) await ensure_scope_search_schema(connection) if not _topic_memory_worker: await index.initialize(connection) diff --git a/tests/builtin/persistence/test_processing_intents.py b/tests/builtin/persistence/test_processing_intents.py index 302b96896e..92fe39a8f8 100644 --- a/tests/builtin/persistence/test_processing_intents.py +++ b/tests/builtin/persistence/test_processing_intents.py @@ -218,3 +218,22 @@ async def scenario(): assert restored.consecutive_dream_attempts == 0 asyncio.run(scenario()) + + +def test_runtime_upgrades_dream_counter_before_validating_processing_schema(tmp_path): + from powercontext.builtin.runtime.composition import open_builtin_contexts + from powercontext.builtin.runtime.config import BuiltinConfig + + async def scenario(): + config = BuiltinConfig(database=SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'upgrade.db'}")) + async with open_builtin_contexts(config) as contexts, contexts.database.transaction() as connection: + await ArtifactProcessingIntentRepository().request(connection, "upgrade", BINDING) + await connection.exec_driver_sql( + "ALTER TABLE pc_artifact_processing_intents DROP COLUMN consecutive_dream_attempts" + ) + async with open_builtin_contexts(config) as contexts, contexts.database.transaction() as connection: + intent = await ArtifactProcessingIntentRepository().load(connection, "upgrade", BINDING) + assert intent is not None and intent.requested_generation == 1 + assert intent.consecutive_dream_attempts == 0 + + asyncio.run(scenario()) diff --git a/tests/e2e/test_artifact_dreaming.py b/tests/e2e/test_artifact_dreaming.py index e2ef4ffea4..c4024296af 100644 --- a/tests/e2e/test_artifact_dreaming.py +++ b/tests/e2e/test_artifact_dreaming.py @@ -295,6 +295,27 @@ async def scenario() -> None: assert after.content.generation.mode == "dream_review_approved" assert after.content.generation.dream_run_id == run.run_id assert after.lineage.memory_citations == (() if citation is None else (citation,)) + if citation is not None: + await runtime.memory.for_scope(scope_id).retire(RetireMemoryEntryRequest(citation=citation)) + correction = await runtime.dream.for_scope(scope_id).create( + CreateDreamRunRequest( + operation="revise_profile", + target=after.as_ref(), + artifacts=(after.as_ref(),), + sources=(source.source_ref,), + idempotency_key="correct-profile-after-retirement", + ) + ) + await process_pending(runtime) + correction = await runtime.dream.for_scope(scope_id).get(GetDreamRunRequest(run_id=correction.run_id)) + assert correction.candidate is not None + corrected = await runtime.review.for_scope(scope_id).approve( + ApproveArtifactCandidateRequest( + candidate_id=correction.candidate.candidate_id, + expected_version=correction.candidate.version, + ) + ) + assert corrected.result_artifact is not None and corrected.result_artifact.revision == 3 asyncio.run(scenario()) @@ -435,7 +456,10 @@ class TopicDreamGenerator: async def generate(self, value: DreamGenerationInput) -> GenerationResult[DreamPlan]: assert value.operation == "revise_topic_memory" assert any(item.kind == "topic_memory" for item in value.evidence.evidence) - source_id = next(item.evidence_id for item in value.evidence.evidence if item.kind == "memory") + source_id = next( + (item.evidence_id for item in value.evidence.evidence if item.kind == "memory"), + next(item.evidence_id for item in value.evidence.evidence if item.kind == "source"), + ) return GenerationResult( output=DreamPlan( outcome="proposed", @@ -520,11 +544,48 @@ async def scenario() -> None: assert after.topic.content.summary == "The replay can create a duplicate." assert after.is_current assert after.topic.lineage.memory_citations == (citation,) + # A retired historical dependency must not prevent correcting the target + # using independent, still-valid evidence. + await runtime.memory.for_scope(scope_id).retire(RetireMemoryEntryRequest(citation=citation)) + second = await runtime.dream.for_scope(scope_id).create( + CreateDreamRunRequest( + operation="revise_topic_memory", + target=after.topic.as_ref(), + artifacts=(after.topic.as_ref(),), + sources=(source.source_ref,), + idempotency_key="correct-after-retirement", + ) + ) + await process_pending(runtime) + second = await runtime.dream.for_scope(scope_id).get(GetDreamRunRequest(run_id=second.run_id)) + assert second.outcome == "proposed" and second.candidate is not None + candidate = await runtime.review.for_scope(scope_id).get( + GetArtifactCandidateRequest(candidate_id=second.candidate.candidate_id) + ) + revised = await runtime.review.for_scope(scope_id).revise( + ReviseArtifactCandidateRequest( + candidate_id=candidate.candidate_id, + expected_version=candidate.version, + proposal=candidate.proposal, + sources=candidate.sources, + artifacts=candidate.artifacts, + target=candidate.target, + reason="Confirmed against the independent source.", + ) + ) + corrected = await runtime.review.for_scope(scope_id).approve( + ApproveArtifactCandidateRequest( + candidate_id=revised.candidate_id, + expected_version=revised.version, + ) + ) + assert corrected.result_artifact is not None and corrected.result_artifact.revision == 3 asyncio.run(scenario()) -def test_handoff_dream_commit_does_not_activate_handoff(database: DatabaseConfig) -> None: +@pytest.mark.parametrize("change_objective", [False, True]) +def test_handoff_dream_commit_does_not_activate_handoff(database: DatabaseConfig, change_objective: bool) -> None: class HandoffDreamGenerator: config_id = "handoff-dream-test" @@ -541,7 +602,7 @@ async def generate(self, value: DreamGenerationInput) -> GenerationResult[DreamP reason="The new source confirms the handoff progress.", intent="correct", proposal=HandoffContent( - objective="Finish the support request.", + objective="Work on another task." if change_objective else "Finish the support request.", state=(HandoffStatement(text="The replacement has been issued.", citations=(citation,)),), disposition="continuable", next_action=HandoffStatement( @@ -596,11 +657,32 @@ async def scenario() -> None: completed = await runtime.dream.for_scope(scope_id).get(GetDreamRunRequest(run_id=run.run_id)) if completed.terminal: break + if change_objective: + assert completed.status == "failed" and completed.candidate is None + assert (await handoffs.latest()).content.objective == "Finish the support request." + return assert completed.outcome == "proposed" and completed.candidate is not None, ( completed.status, completed.error, ) assert (await handoffs.latest()).revision == 1 + from powercontext.builtin.review.errors import InvalidCandidateError + + candidate = await runtime.review.for_scope(scope_id).get( + GetArtifactCandidateRequest(candidate_id=completed.candidate.candidate_id) + ) + with pytest.raises(InvalidCandidateError, match="preserve the target objective"): + await runtime.review.for_scope(scope_id).revise( + ReviseArtifactCandidateRequest( + candidate_id=candidate.candidate_id, + expected_version=candidate.version, + proposal=candidate.proposal.model_copy(update={"objective": "Work on another task."}), + sources=candidate.sources, + artifacts=candidate.artifacts, + target=candidate.target, + reason="Changed task.", + ) + ) approved = await runtime.review.for_scope(scope_id).approve( ApproveArtifactCandidateRequest( candidate_id=completed.candidate.candidate_id, From c6948e267213141a26027b4b4f9676d66b85d725 Mon Sep 17 00:00:00 2001 From: Frey <370805886@qq.com> Date: Tue, 22 Sep 2026 19:19:33 +0800 Subject: [PATCH 11/48] feat(dream): complete reviewed artifact and tag operations --- docs/en/docs/workflows/meta.json | 1 + .../docs/workflows/review-dream-revisions.md | 84 +++ docs/en/rfcs/0000_multi_artifact_dreaming.md | 52 +- docs/zh/docs/workflows/meta.json | 1 + .../docs/workflows/review-dream-revisions.md | 83 +++ docs/zh/rfcs/0000_multi_artifact_dreaming.md | 63 +- integrations/capabilities.toml | 18 + .../powercontext/hooks/user_prompt_submit.py | 1 + .../plugins/powercontext/hooks/bind_tools.py | 6 + .../plugins/powercontext/hooks/recall.py | 1 + .../dsh/plugins/powercontext/lib/index.js | 67 ++ .../dsh/plugins/powercontext/src/client.ts | 1 + .../powercontext/src/operations.generated.ts | 6 + .../hermes/plugins/powercontext/client.py | 1 + .../plugins/powercontext/lib/index.js | 67 ++ .../opencode/plugins/powercontext/lib/tui.js | 67 ++ .../plugins/powercontext/src/client.ts | 1 + .../powercontext/src/operations.generated.ts | 6 + .../pi/plugins/powercontext/src/client.ts | 1 + .../powercontext/src/operations.generated.ts | 6 + .../hooks/workbuddy_powercontext_hook.py | 1 + openapi/powercontext.yaml | 554 ++++++++++++++- src/powercontext/artifacts/models.py | 10 +- .../builtin/artifacts/profile/review.py | 2 + .../builtin/catalog_changes/__init__.py | 15 + .../builtin/catalog_changes/application.py | 28 + .../builtin/catalog_changes/models.py | 113 ++++ .../builtin/catalog_changes/service.py | 360 ++++++++++ src/powercontext/builtin/dream/application.py | 4 +- src/powercontext/builtin/dream/bindings.py | 173 ++++- src/powercontext/builtin/dream/generation.py | 17 +- src/powercontext/builtin/dream/models.py | 40 +- src/powercontext/builtin/dream/provenance.py | 68 ++ src/powercontext/builtin/dream/service.py | 301 ++++++++- src/powercontext/builtin/evidence/models.py | 17 +- src/powercontext/builtin/evidence/resolver.py | 38 +- .../builtin/persistence/candidates.py | 102 ++- .../builtin/persistence/catalog_changes.py | 170 +++++ src/powercontext/builtin/persistence/dream.py | 33 +- .../builtin/persistence/dream_schema.py | 12 + .../builtin/persistence/family_management.py | 32 +- .../builtin/persistence/tables.py | 2 + src/powercontext/builtin/persistence/tags.py | 112 +-- src/powercontext/builtin/review/models.py | 26 +- src/powercontext/builtin/review/service.py | 301 ++++++++- .../builtin/runtime/application.py | 28 + .../builtin/runtime/composition.py | 17 +- src/powercontext/builtin/runtime/config.py | 3 + .../builtin/runtime/family_processing.py | 19 +- src/powercontext/builtin/runtime/models.py | 15 +- .../builtin/runtime/processing_registry.py | 7 +- .../builtin/runtime/relational.py | 20 +- src/powercontext/client/cli.py | 105 +++ src/powercontext/client/client.py | 48 +- src/powercontext/http/__init__.py | 24 + src/powercontext/http/_generated/models.py | 179 ++++- .../http/_generated/operations.py | 172 +++++ src/powercontext/http/_generated/schema.py | 517 +++++++++++++- src/powercontext/server/app.py | 177 +++++ src/powercontext/server/dashboard/api.py | 1 + .../server/dashboard/labels.en.json | 31 +- src/powercontext/server/dashboard/labels.json | 31 +- src/powercontext/server/dashboard/review.py | 202 ++++++ src/powercontext/server/dashboard/routes.py | 32 +- .../templates/components/navigation.html | 2 +- .../server/dashboard/templates/macros.html | 2 +- .../server/dashboard/templates/review.html | 56 ++ src/powercontext/server/dream_access.py | 88 ++- .../server/dream_compatibility.py | 161 +++++ src/powercontext/server/factory.py | 1 + src/powercontext/server/mapping.py | 13 +- src/powercontext/server/mcp.py | 19 + tests/e2e/dream_support.py | 7 +- tests/e2e/test_artifact_dreaming.py | 97 ++- tests/e2e/test_catalog_changes.py | 639 ++++++++++++++++++ tests/e2e/test_dream_deduplication.py | 197 ++++++ tests/e2e/test_dream_memory_access.py | 185 +++++ tests/e2e/test_dream_skill_prompt.py | 317 +++++++++ tests/e2e/test_mcp_transport.py | 6 + tests/test_dream_review_transport.py | 411 +++++++++++ tests/test_mcp.py | 6 + 81 files changed, 6635 insertions(+), 264 deletions(-) create mode 100644 docs/en/docs/workflows/review-dream-revisions.md create mode 100644 docs/zh/docs/workflows/review-dream-revisions.md create mode 100644 src/powercontext/builtin/catalog_changes/__init__.py create mode 100644 src/powercontext/builtin/catalog_changes/application.py create mode 100644 src/powercontext/builtin/catalog_changes/models.py create mode 100644 src/powercontext/builtin/catalog_changes/service.py create mode 100644 src/powercontext/builtin/dream/provenance.py create mode 100644 src/powercontext/builtin/persistence/catalog_changes.py create mode 100644 src/powercontext/server/dashboard/review.py create mode 100644 src/powercontext/server/dashboard/templates/review.html create mode 100644 src/powercontext/server/dream_compatibility.py create mode 100644 tests/e2e/test_catalog_changes.py create mode 100644 tests/e2e/test_dream_deduplication.py create mode 100644 tests/e2e/test_dream_memory_access.py create mode 100644 tests/e2e/test_dream_skill_prompt.py create mode 100644 tests/test_dream_review_transport.py diff --git a/docs/en/docs/workflows/meta.json b/docs/en/docs/workflows/meta.json index 75dcaac636..eacbaf90fa 100644 --- a/docs/en/docs/workflows/meta.json +++ b/docs/en/docs/workflows/meta.json @@ -13,6 +13,7 @@ "memory-and-context", "---Experience---", "create-and-review-experience", + "review-dream-revisions", "review-candidates", "experience-and-skill-lifecycle", "---Skill---", diff --git a/docs/en/docs/workflows/review-dream-revisions.md b/docs/en/docs/workflows/review-dream-revisions.md new file mode 100644 index 0000000000..159fe5463b --- /dev/null +++ b/docs/en/docs/workflows/review-dream-revisions.md @@ -0,0 +1,84 @@ +--- +title: Review Dream revisions +description: Propose and review Prompt configuration and Tag changes using the existing v1 Dream API. +--- + +# Review Dream revisions + +Use a coordinated Client supporting **Dream contract 2**. The Python Client and CLI send `X-PowerContext-Dream-Contract: 2` automatically. External integrations must send the header after updating their closed operation enums and proposal types. A legacy Client receives HTTP 426 `client_upgrade_required` when it encounters a new type. `GET /v1/capabilities` advertises the enabled operations; unconfigured operations reject admission. + +Dream selects exact evidence and creates a pending Candidate. Generation does not approve, install, execute, or publish anything. Existing automatic generation retains its own policy. Approval is an explicit human decision against the exact current Candidate version. + +## Prompt configuration + +Read the current Prompt Artifact revision for a registered custom-capable key, such as `memory.extract`, and choose an exact Source describing an observed error and its expected output. The same `/v1/scopes/{scope_id}/dream` route handles the request: + +```python +from powercontext.client import PowerContextClient +from powercontext.http import ( + ArtifactReference, CreateDreamRunRequest, DreamSourceReference, + GetArtifactCandidateRequest, ApproveArtifactCandidateRequest, +) + +async def propose_prompt(client: PowerContextClient, scope_id: str, + current: ArtifactReference, evidence_source_id: str): + # current names the existing Prompt key and its exact current revision. + request = CreateDreamRunRequest( + operation="revise_prompt", target=current, artifacts=[current], + sources=[DreamSourceReference(source_type="content", source_id=evidence_source_id)], + idempotency_key="prompt-correction-2026-09-22", + ) + return await client.create_dream_run(scope_id, request) + +async def inspect_prompt(client: PowerContextClient, scope_id: str, run_id: str): + run = await client.get_dream_run(scope_id, run_id) + if run.status.value == "succeeded" and run.candidate is not None: + return await client.get_artifact_candidate(GetArtifactCandidateRequest( + scope_id=scope_id, candidate_id=run.candidate.candidate_id)) +``` + +Read the proposal, reason, exact evidence, and diff in `/dashboard/review`. Only after a human approves the inspected version, call `approve_artifact_candidate(ApproveArtifactCandidateRequest(scope_id=scope_id, candidate_id=candidate.candidate_id, expected_version=candidate.version))`. Approval requires review authority and Prompt write authority. It publishes the configuration for future inferences; in-flight inference retains its frozen revision. Rollback uses the existing Artifact replacement API to write the selected earlier configuration as a higher revision. + +The CLI accepts the same JSON request: + +```sh +powercontext dream run --scope-id "$SCOPE_ID" --request-file prompt-dream.json +powercontext dream show --scope-id "$SCOPE_ID" "$RUN_ID" +powercontext candidate show --scope-id "$SCOPE_ID" "$CANDIDATE_ID" +powercontext candidate revise json --request-file candidate-revision.json +powercontext candidate approve --scope-id "$SCOPE_ID" "$CANDIDATE_ID" --expected-version 2 +``` + +`candidate-revision.json` is a complete `ReviseArtifactCandidateRequest`, including scope, identity, expected version, proposal, exact evidence, and target. Saving a revision creates a new pending version requiring review. + +## Tag metadata + +Read the current target Tag set and its ETag with `get_artifact_tags` or `get_memory_entry_tags`, and read the exact Artifact revision or Memory citation that provides the content baseline. Submit `revise_tags` on the same Dream route. Its `tag_target` is separate from the Artifact `target`: + +```python +from powercontext.http import TagDreamTarget, ArtifactTagTarget + +async def propose_tags(client, scope_id, current, tags_etag, evidence_source_id): + return await client.create_dream_run(scope_id, CreateDreamRunRequest( + operation="revise_tags", + tag_target=TagDreamTarget( + target=ArtifactTagTarget(type="artifact", family=current.family, + artifact_id=current.artifact_id), + expected_etag=tags_etag, basis_ref=current), + artifacts=[current], + sources=[DreamSourceReference(source_type="content", source_id=evidence_source_id)], + idempotency_key="tag-correction-2026-09-22", + )) +``` + +For an entry target use `MemoryEntryTagTarget` and `basis_citation`, and include that citation in `memory_citations`. The resulting run has `candidate.kind="catalog_change"`. Inspect it using `get_catalog_candidate(GetCatalogCandidateRequest(...))`; approval uses `approve_catalog_candidate(ApproveCatalogCandidateRequest(...))`. The dedicated `/v1/catalog-change-candidates/list|get|history|revise|approve|reject` interfaces preserve Tag's distinct lifecycle. Approval checks both the Tag ETag and content baseline atomically. The result contains the complete tags and new ETag, with no Artifact revision. + +```sh +powercontext catalog-candidate list --scope-id "$SCOPE_ID" +powercontext catalog-candidate show --scope-id "$SCOPE_ID" "$CANDIDATE_ID" +powercontext catalog-candidate history --scope-id "$SCOPE_ID" "$CANDIDATE_ID" +powercontext catalog-candidate revise --request-file tag-revision.json +powercontext catalog-candidate approve --scope-id "$SCOPE_ID" "$CANDIDATE_ID" --expected-version 2 +``` + +The unified Dashboard filters Artifact and Catalog Change resources and shows their independent paginated sections. A stale version, changed target, revoked evidence, or changed Tag ETag leaves the candidate pending with a conflict. Inspect current state before submitting another decision. `no_change` and `needs_evidence` create no candidate. Neither a model proposal nor approval proves improvement on later tasks. diff --git a/docs/en/rfcs/0000_multi_artifact_dreaming.md b/docs/en/rfcs/0000_multi_artifact_dreaming.md index 4fc03ebcc7..8d786352e5 100644 --- a/docs/en/rfcs/0000_multi_artifact_dreaming.md +++ b/docs/en/rfcs/0000_multi_artifact_dreaming.md @@ -6,7 +6,9 @@ Extend explicit Artifact Dreaming beyond `refine_experience` and `derive_skill` with bounded revisions of Memory entries, Profile, Topic Memory, Handoff, managed Skill, and Prompt, plus a separate proposal for Tag changes. Every new Dream operation produces a reviewable candidate or an explicit `no_change`/`needs_evidence` result. A Dream candidate cannot change the current Artifact or Tag set before an authorized reviewer approves its exact version against the current target. Existing automatic Source-window generation retains its current activation and review policy; adding Dream to a Family does not turn automatic generation into a review flow or let an automatic policy bypass Dream review. -Each run addresses one selected target using exact, same-Scope evidence. The existing Dream run, Candidate, Review, Family writer, and Processing Supervisor machinery remains the foundation. A0/A1 introduce no new business tables; later evaluation and Tag storage is justified and introduced only when those stages ship. Existing `/v1` interfaces are extended in place, with coordinated Client upgrades for new closed enum and proposal types. No periodic discovery or unattended publication is required. +Each run addresses one selected target using exact, same-Scope evidence. The existing Dream run, Candidate, Review, Family writer, and Processing Supervisor machinery remains the foundation. A0/A1 introduce no new business tables; Tag storage is justified and introduced with its stage. Existing `/v1` interfaces are extended in place, with coordinated Client upgrades for new closed enum and proposal types. No periodic discovery or unattended publication is required. + +All operations extended by this PR—Memory, Profile, Topic Memory, Handoff, Skill, Prompt, and Tag—use the existing Experience refinement and Skill derivation model: evidence-driven proposals, deterministic validation, and human Candidate review. Trusted evaluation is an optional future enhancement, not implemented in this PR and not required for operation enablement or approval. Family-specific commit rules, including Tag ETag/content checks, remain mandatory. # Motivation @@ -32,21 +34,21 @@ For example, a user's Profile says “permanently based in Shanghai.” “In Sh | `revise_profile` | Current complete Profile | Replace the head without consuming a Source window | | `revise_topic_memory` | Current Topic identity and complete content | Publish a new revision with its complete retrieval projection | | `refresh_handoff` | Current Handoff Revision | Commit a new revision; activation and receiver checks remain explicit | -| `revise_skill` | Existing instruction-only managed Skill | Commit a validated standard package after trusted evaluation | -| `revise_prompt` | Existing registered custom-capable Prompt key | Publish a new configuration revision after trusted evaluation | +| `revise_skill` | Existing instruction-only managed Skill | Commit a validated standard package after deterministic validation and human review | +| `revise_prompt` | Existing registered custom-capable Prompt key | Publish a new configuration revision after deterministic validation and human review | | `revise_tags` | One TagTarget with an ETag and content basis | Replace the Tag set conditionally without an Artifact Revision | New revision operations require an existing target. They do not create empty Profiles, Topics, Handoffs, or Prompt keys. They do not merge Memory entries, split Topics, run business tools, perform package scripts, or change cross-Scope permissions. An event can justify submitting a request, but the event itself is not evidence; the request carries inspectable citations. Periodic evidence discovery is a separate future policy. ## Outcomes and review -The run lifecycle remains `queued/running/succeeded/failed`; a successful run returns `proposed`, `no_change`, or `needs_evidence`. Missing or revoked evidence, target conflict, and exhausted budget are failures, not no-change. A `proposed` result points to an exact Candidate ID and version. The reviewer can inspect, revise, approve, or reject that version. A substantive candidate edit invalidates its old evaluation records. +The run lifecycle remains `queued/running/succeeded/failed`; a successful run returns `proposed`, `no_change`, or `needs_evidence`. Missing or revoked evidence, target conflict, and exhausted budget are failures, not no-change. A `proposed` result points to an exact Candidate ID and version. The reviewer can inspect, revise, approve, or reject that version. A substantive candidate edit creates a new version requiring renewed deterministic validation and human review. # Reference-level explanation ## Operation registry and admission -Add a server-owned `DreamOperationSpec` registry declaring an operation name and version, target kind, allowed evidence roles, proposal schema and validator, processing Family and canonical binding, review and commit adapters, effect, and evaluation policy. Requests cannot name Python paths, tools, SQL, or arbitrary code. Expose an operation only when resolution, generation, review, commit, and worker execution are all implemented and configured. Family read support alone is insufficient. +Add a server-owned `DreamOperationSpec` registry declaring an operation name and version, target kind, allowed evidence roles, proposal schema and validator, processing Family and canonical binding, review and commit adapters, and effect. Requests cannot name Python paths, tools, SQL, or arbitrary code. Expose an operation only when resolution, generation, review, commit, and worker execution are all implemented and configured. Family read support alone is insufficient. The current worker dispatch assumes a Skill run or an Experience run. Replace that binary assumption with a binding-to-operation registry and claim one accepted run in deterministic request-generation, acceptance-time, and run-ID order. Reuse one Supervisor binding per Family. Do not create another scheduler or clear ordinary Source progress when a Dream run finishes. Keep explicit work from starving ordinary Source work: after at most four consecutive Dream attempts on one binding, perform one eligible Source pass before continuing, with the counter durable across worker restarts in a new `consecutive_dream_attempts` column on the existing intent table. A completed Source pass resets the counter and preserves queued Dream requests; no new table is required. Apply the additive column migration before processing schema validation on startup, preserving pending work in existing databases. @@ -54,9 +56,9 @@ The current worker dispatch assumes a Skill run or an Experience run. Replace th Extend the existing `POST/GET /v1/scopes/{scope_id}/dream` and `GET /v1/scopes/{scope_id}/dream/{run_id}` operations with exact Artifact targets, new operations, and corresponding run results. Extend `POST /v1/artifact-candidates/list`, `get`, `revise`, `approve`, and `reject` for new Artifact proposal types while retaining `candidate_id` and `expected_version`. Extend `GET /v1/capabilities` with available Dream operations, output kinds, and effects. No parallel Dream or Artifact Review path is needed. -Generated Clients currently use closed Dream operation enums and Candidate proposal unions. Deploy Server, generated Python Client, CLI, integrations, and Dashboard coherently before advertising new operations. An old Client must receive an explicit upgrade signal or only a compatibility-filtered list that it can decode; it must not silently reinterpret a new proposal as an old type. Define and test the minimum supported Client release. Tag proposals have a different result type and ETag transaction; add dedicated Tag candidate interfaces only in stage C, once that different resource lifecycle is implemented. Trusted evaluation registration belongs to stage B1, not A0/A1. +Generated Clients use closed Dream enums and Candidate proposal unions. The minimum supported feature level is **Dream contract 2**. The coordinated Python Client, CLI, MCP bridge, and Dashboard send `X-PowerContext-Dream-Contract: 2`; external integrations must implement and declare the same contract. This feature level is independent of package release numbering, including development snapshots. Responses advertise the minimum level. Clients without the header retain the original operations and see only `refine_experience` and `derive_skill` in capabilities. Reading an extended Run, Candidate, or `dream_review_approved` Profile returns HTTP 426 `client_upgrade_required`; mutations using new types are rejected before a write. Compatible responses omit new optional audit fields without disguising operation, mode, or Source windows. Test both the coordinated SDK and a legacy client without the header. Tag proposals retain separate Candidate interfaces and their ETag result lifecycle. Trusted evaluation infrastructure remains outside this PR. -For Artifact operations, `target` remains an exact ArtifactRef and must also appear in `artifacts`. Memory entry identities and versions are supplied through `memory_citations`, all belonging to that target Memory. References are deduplicated and counted against the evidence budget; a target cannot independently support its own claims. For Profile, Topic Memory, and Handoff, validate the target permission, existence, and exact head separately: retired historical dependencies must not prevent correction using new valid evidence. Recursively validate all selected supporting evidence without granting it the target exemption. Explicit Sources and references must belong to the same Scope. Stage C expresses TagTarget, expected ETag, and the exact content basis through its separate Catalog Change request, without changing the Artifact Dream target type. +For Artifact operations, `target` remains an exact ArtifactRef and must also appear in `artifacts`. Memory entry identities and versions are supplied through `memory_citations`, all belonging to that target Memory. References are deduplicated and counted against the evidence budget; a target cannot independently support its own claims. For Profile, Topic Memory, and Handoff, validate the target permission, existence, and exact head separately: retired historical dependencies must not prevent correction using new valid evidence. Recursively validate all selected supporting evidence without granting it the target exemption. Explicit Sources and references must belong to the same Scope. Stage C expresses TagTarget, expected ETag, and the exact content basis through a separate `tag_target` object on the existing Dream request, without changing the Artifact `target` type. Its result uses `candidate.kind=catalog_change` to identify the separate Catalog Change Candidate resource. The existing idempotency identity remains `(scope_id, principal_id, idempotency_key)`, stored using the current principal key digest. The same normalized request returns its original run; changed input under the same key conflicts. Different principals may use the same key without sharing a run, ownership, or worker identity. Capture Profile policy at admission and supply its snapshot to generation; reject changes during queueing, generation, or approval, while allowing stale candidates to be rejected. On first execution freeze the operation adapter, model, and server prompt version for retries. Existing HTTP 202/200 replay semantics remain. @@ -64,7 +66,7 @@ The existing idempotency identity remains `(scope_id, principal_id, idempotency_ Resolve the target and evidence under the requester's current permission, within the same Scope, at admission and again during execution and approval. Freeze exact revisions and content digests; never substitute search snippets for Memory Entry text. Attribute projection nodes as target content, supporting evidence, counterevidence, configuration under review, or lineage only. Derived copies of one root Source count as one root observation; unknown independence remains unknown. Lineage-only administrative text cannot become business-result evidence. Untrusted content, including Prompt instructions under review, never becomes the Dream system prompt or changes tools, trust rules, budgets, or policy. -The model returns a typed complete proposal, a concise reason, intent, and only evidence IDs supplied by the resolver. Deterministic validators reject forged IDs, invalid shape, target-external changes, and unsafe package material. `no_change` and `needs_evidence` carry no proposal or selected evidence IDs. Prompt and Skill improvements require trusted evaluation bound to the exact candidate version and content digest; a self-reported `passed=true` Source does not satisfy that gate. Independent later task results, not candidate count or usage frequency, measure actual benefit. +The model returns a typed complete proposal, a concise reason, intent, and only evidence IDs supplied by the resolver. Deterministic validators reject forged IDs, invalid shape, target-external changes, and unsafe package material. `no_change` and `needs_evidence` carry no proposal or selected evidence IDs. All extended operations follow the existing Experience refinement and Skill derivation logic: evidence-driven model proposals, deterministic validation, pending Candidates, and human approval before conditional publication. A self-reported `passed=true` Source does not replace validation or review, and neither a model proposal nor approval proves quality improvement. Independent later task results, not candidate count or usage frequency, measure actual benefit. ## Family commit contracts @@ -76,39 +78,43 @@ The model returns a typed complete proposal, a concise reason, intent, and only **Handoff.** Generation validation, Candidate edits, and final commit must preserve the target objective; changing tasks requires a separate explicit operation. Require the exact current revision and evidence for every claimed change in completed, pending, blocked, or next work. Reuse existing prepare/commit validation through a transaction-safe writer, but approval only commits a non-activated revision. Existing activation tokens, live-state, capability, authorization, and receiver acknowledgement remain separate. A plan does not prove its action happened. -**Skill.** `revise_skill` targets a managed Skill with an exact package snapshot. A package with only root `SKILL.md` is an instruction-only Skill and is supported, including existing generated Skills. Legacy instruction content without a package follows the existing one-file package normalization. A package containing additional assets, scripts, or dependencies is rejected as an unsupported target in this stage. Keep authorized metadata and frontmatter, produce a new valid standard package, and bind evaluation to the exact target and candidate; never edit cached instructions while retaining the old package digest. Approval does not install, run, or automatically distribute the Skill. +**Skill.** `revise_skill` targets a managed Skill with an exact package snapshot. A package with only root `SKILL.md` is an instruction-only Skill and is supported, including existing generated Skills. Legacy instruction content without a package follows the existing one-file package normalization. A package containing additional assets, scripts, or dependencies is rejected as an unsupported target in this stage. Keep authorized metadata and frontmatter, produce a new valid standard package, and validate its evidence and exact target before human review; never edit cached instructions while retaining the old package digest. Approval does not install, run, or automatically distribute the Skill. -**Prompt.** Target only an existing registered, custom-capable key. Propose full `mode/instructions/demonstrations` content after comparing verified errors and their original evidence with human-labelled or otherwise trusted expected outputs. The Dream system prompts and review/evaluation rules cannot be targets. Validate demonstrations against that key's schemas. Keep training roots separate from held-out evaluation roots. Approval requires review and Prompt write authority plus a trusted, current evaluation record; it publishes a new Prompt Revision for future inferences. In-flight inference keeps its frozen prior revision; rollback writes a higher revision. +**Prompt.** Target only an existing registered, custom-capable key. Propose full `mode/instructions/demonstrations` content after comparing verified errors and their original evidence with human-labelled or otherwise trusted expected outputs. The Dream system prompts and review/evaluation rules cannot be targets. Validate demonstrations against that key's schemas. No held-out dataset service, automated baseline/candidate scoring, or trusted evaluation gate is implemented in this PR. Approval requires deterministic schema/evidence/version checks, human review, and Prompt write authority; it publishes a new Prompt Revision for future inferences. In-flight inference keeps its frozen prior revision; rollback writes a higher revision. **Tag.** Tag is catalog metadata, not an Artifact Family. A Catalog Change Candidate captures one current TagTarget, expected ETag, exact Artifact or Memory Entry content basis, before/after complete Tag sets, evidence, and reason. Review checks both ETag and current content basis. Approval atomically replaces the Tag set and records its decision, without creating an Artifact Revision or changing its content digest or embeddings. It must not be squeezed into the Artifact Candidate result shape, where approved implies `result_artifact`. +## Review Inbox presentation + +The Dashboard combines the two public Candidate services with `all/artifact/catalog_change` and status filters. Each resource uses its own cursor, with 12 candidates per section and independent next-page links; the combined view does not claim a global chronological order. Detail pages show the exact target diff, complete proposal, evidence, and decision history, with explicit Prompt publication, Skill non-installation, and Handoff non-activation effects. Approve, reject, and revise submit the reviewed version through the same authorized HTTP interfaces. Browser mutations require same-origin requests. No aggregate table or Inbox API is introduced. + ## Review transaction, storage, and implementation stages -A common review path validates current authority, reads the candidate to choose the server-registered Family/origin adapter, performs any external preparation, then lets that adapter acquire all locks in the Family's established order. The common layer must not lock Candidate before the Profile adapter locks Policy. Inside the transaction, revalidate exact candidate version and digest, target baseline, evidence availability, policy, and required evaluation. The Family writer and Candidate decision commit together. Stale or invalid candidates remain pending with an actionable conflict; rejected Dream candidates never alter formal content or a Source cursor. +A common review path validates current authority, reads the candidate to choose the server-registered Family/origin adapter, performs any external preparation, then lets that adapter acquire all locks in the Family's established order. The common layer must not lock Candidate before the Profile adapter locks Policy. Inside the transaction, revalidate exact candidate version and digest, target baseline, evidence availability, and applicable deterministic policy. The Family writer and Candidate decision commit together. Stale or invalid candidates remain pending with an actionable conflict; rejected Dream candidates never alter formal content or a Source cursor. -A0/A1 reuse `pc_dream_runs`, `pc_artifact_candidate_heads`, `pc_artifact_candidate_versions`, and Family storage. Prefer existing typed payloads for operation version, target, origin, proposal, and result; add columns or indexes only for proven query, uniqueness, or transactional needs. No new A0/A1 business table, generic index-outbox table, or second Dream scheduler is needed. Preserve the current `(scope_id, principal_key, idempotency_key)` constraint. Stage B1 may add a dedicated trusted evaluation table if existing trusted storage cannot bind evaluator identity, candidate version/digest, test set and policy versions, result, and usage. Stage C may add Tag candidate and candidate-version storage justified by its distinct ETag and result lifecycle. Neither is a prerequisite for A0/A1 startup or migration. Apply any schema change to SQLite and OceanBase. +A0/A1 reuse `pc_dream_runs`, `pc_artifact_candidate_heads`, `pc_artifact_candidate_versions`, and Family storage. Prefer existing typed payloads for operation version, target, origin, proposal, and result; add columns or indexes only for proven query, uniqueness, or transactional needs. No new A0/A1 business table, generic index-outbox table, or second Dream scheduler is needed. Preserve the current `(scope_id, principal_key, idempotency_key)` constraint. Evaluator registration, trusted result storage/submission, versioned quality gates, and isolated evaluation execution are excluded from this PR; do not add evaluation tables or placeholder APIs. Stage C may add Tag candidate and candidate-version storage justified by its distinct ETag and result lifecycle. Tag storage is not a prerequisite for A0/A1 startup or migration. Apply any schema change to SQLite and OceanBase. | Stage | Deliverable | Enablement gate | | --- | --- | --- | | A0 | Operation registry, typed targets/evidence roles, `/v1` schema extension, shared review adapters | Current operations regressions pass; Client compatibility and lock ordering are tested; incomplete operations stay disabled | | A1 | Explicit Memory, Profile, and Topic Memory revision | Atomic Memory write, Profile cursor isolation, and Topic complete-projection publication pass on SQLite and OceanBase | -| B1 | Handoff refresh, one-file Skill revision, trusted evaluation | Non-activation boundary and version-bound evaluation gate pass | -| B2 | Prompt revision | Same-key schemas, held-out tests, Prompt write authority, and rollback pass | +| B1 | Handoff refresh and one-file Skill revision | Non-activation boundary, package/evidence validation, and human review pass | +| B2 | Prompt revision | Same-key schemas, evidence validation, human review, Prompt write authority, and rollback pass | | C | Single-target Tag governance | Both ETag and content baseline, distinct result type, and decision history pass | Per-run defaults remain at most 20 explicitly selected target/artifact/entry references, at most 32 projected evidence items including Sources, 64 KiB model-visible evidence, two model calls, 4,096 output tokens per call, and 120 seconds from first execution. A Scope has at most 32 pending/running runs. Root traversal retains its 128-node, 256-edge, 8-level budget. Complete snapshots that exceed budget fail; do not truncate them and claim a complete revision. -The idempotency key handles request replay. Candidate suppression may additionally fingerprint operation, complete target baseline, root evidence digest, and policy within one Scope and one principal. Before reusing a pending Candidate, verify its current version, actual content and evidence, and permission; editing the proposal invalidates an outdated fingerprint. Do not reuse across principals in the first release. A rejected proposal with unchanged evidence can yield `no_change` and a readable reference to the earlier decision, but cannot conceal new counterevidence or a changed target. +The idempotency key handles request replay. Candidate suppression additionally fingerprints operation, complete target baseline, root evidence digest, and policy within one Scope and one principal. Before reusing a pending Candidate, verify its current version, actual content and evidence, and permission; editing the proposal invalidates an outdated fingerprint. Do not reuse across principals in the first release. A rejected proposal with unchanged evidence yields `no_change` and a readable reference to the earlier decision, but cannot conceal new counterevidence or a changed target. -Unregistered operation or invalid target yields a stable validation error; unavailable capability rejects before creating a run; stale target and Tag ETag have distinct conflicts; permission or evidence revocation blocks generation or approval; invalid model output and exhausted budget fail the run. Approval without current trusted evaluation returns an actionable conflict. Record bounded operation/family timings, outcome, usage, candidate reuse, review conflicts, and Topic projection preparation/publication time without recording raw evidence or identifiers in low-cardinality metrics. +Unregistered operation or invalid target yields a stable validation error; unavailable capability rejects before creating a run; stale target and Tag ETag have distinct conflicts; permission or evidence revocation blocks generation or approval; invalid model output and exhausted budget fail the run. An unconfigured evaluation service or absent quality score does not prevent implementation, enablement, or approval of any operation in this PR. Record bounded operation/family timings, outcome, usage, candidate reuse, review conflicts, and Topic projection preparation/publication time without recording raw evidence or identifiers in low-cardinality metrics. ## Validation -Acceptance scenarios cover temporary travel versus an explicit move; a single Memory entry revision without changing other entries; revoked or superseded evidence; simultaneous Dream and Source-window Profile candidates; Topic embedding failure, worker restart, stale projection and competing fusion; Handoff plans without execution evidence; supported one-file versus unsupported multi-file Skill packages; stale evaluation after candidate edit; Prompt held-out-set leakage and rollback; Tag content-basis changes while ETag stays fixed; same-root evidence copies; per-principal idempotency; a shared lock order across HTTP and SDK review paths; worker lease takeover; and preservation of ordinary Source progress while Dream runs execute. Use an isolated real model chain for quality evaluation and compare no Dream, normal regeneration, and this proposal without presupposing an improvement percentage. +Acceptance scenarios cover temporary travel versus an explicit move; a single Memory entry revision without changing other entries; revoked or superseded evidence; simultaneous Dream and Source-window Profile candidates; Topic embedding failure, worker restart, stale projection and competing fusion; Handoff plans without execution evidence; supported one-file versus unsupported multi-file Skill packages; stale candidate versions after edits; invalid Prompt demonstration schemas and rollback; successful review without an evaluation service; Tag content-basis changes while ETag stays fixed; same-root evidence copies; per-principal idempotency; a shared lock order across HTTP and SDK review paths; worker lease takeover; and preservation of ordinary Source progress while Dream runs execute. Use isolated end-to-end tests for the evidence-to-review-to-publication flow. Engineering tests verify observable behavior and transaction correctness; they do not introduce a runtime evaluation dependency or prove quality gains. # Drawbacks -Each Family still needs its own validator and transaction-safe writer. Full Memory-head CAS may conflict in active Scopes. Complete Profile/Topic snapshots can exceed model budgets. Human review and trusted evaluation add latency and operational cost. Closed Client types require coordinated upgrades. Tag's distinct lifecycle may ultimately justify its own candidate storage and interfaces. +Each Family still needs its own validator and transaction-safe writer. Full Memory-head CAS may conflict in active Scopes. Complete Profile/Topic snapshots can exceed model budgets. Human review adds latency and operational cost; approval alone does not prove quality improvement. Closed Client types require coordinated upgrades. Tag's distinct lifecycle may ultimately justify its own candidate storage and interfaces. # Rationale and alternatives @@ -120,14 +126,16 @@ This proposal builds on [Artifact Dreaming](1510-artifact-dreaming.md) for exact # Unresolved questions -1. Define the minimum coordinated Client release and explicit upgrade or compatibility-filtering behavior for old closed enums and proposal unions. -2. Define the first trusted held-out evaluation sets, versioned quality thresholds, and owners for Skill and each supported Prompt key. +1. Coordinate external integration releases against the defined Dream contract 2 feature level. +2. Define the Reviewer presentation of evidence, diffs, and configuration-publication effects for Skill and each supported Prompt key. 3. Extract the transaction-safe, non-activating Handoff writer without bypassing existing tokens or receiver checks. -4. Choose the Stage C Tag Inbox pagination strategy while preserving its separate lifecycle. +4. Consider cross-resource chronological sorting only when needed; the initial Inbox uses resource filters and independent cursors. 5. Decide which application observes post-publication quality and requests explicit rollback; no automatic causal attribution is claimed. -Stage A0/A1 can be developed independently; an operation in a later stage remains unavailable until its own contract, storage, evaluation, and concurrency gate is complete. +Stage A0/A1 can be developed independently; an operation in a later stage remains unavailable until its own contract, storage, deterministic validation, review, and concurrency gate is complete. # Future possibilities +Trusted evaluation may be added as an optional enhancement in a separate proposal covering evaluator identity, held-out datasets, versioned thresholds, exact-candidate result binding, and isolated execution. This PR implements none of that infrastructure and does not depend on it. + A later discovery policy could select new root evidence, counterexamples, and corrections with quiet periods and candidate deduplication. Future governance may merge Memory entries, split or merge Topics, support signed multi-file Skill package diffs, or enable separately authorized cross-Scope sharing. Automatic approval and rollback would need another RFC establishing reversibility, evidence thresholds, auditability, and independent task-outcome evaluation. diff --git a/docs/zh/docs/workflows/meta.json b/docs/zh/docs/workflows/meta.json index 119c3152d6..d09c5bd484 100644 --- a/docs/zh/docs/workflows/meta.json +++ b/docs/zh/docs/workflows/meta.json @@ -13,6 +13,7 @@ "memory-and-context", "---Experience---", "create-and-review-experience", + "review-dream-revisions", "review-candidates", "experience-and-skill-lifecycle", "---Skill---", diff --git a/docs/zh/docs/workflows/review-dream-revisions.md b/docs/zh/docs/workflows/review-dream-revisions.md new file mode 100644 index 0000000000..a2937d4190 --- /dev/null +++ b/docs/zh/docs/workflows/review-dream-revisions.md @@ -0,0 +1,83 @@ +--- +title: 审核 Dream 修订 +description: 使用现有 v1 Dream 接口提出、检查和审核 Prompt 配置与标签变更。 +--- + +# 审核 Dream 修订 + +最低客户端功能级别为 **Dream contract 2**。配套 Python Client、CLI、MCP bridge 和 Dashboard 自动发送 `X-PowerContext-Dream-Contract: 2`。其他集成先更新 operation 枚举和 proposal 类型,再发送该声明。旧客户端遇到新类型时收到 HTTP 426 `client_upgrade_required`;原有操作仍可用。`GET /v1/capabilities` 列出当前已配置且可执行的操作。 + +Dream 使用精确目标与证据生成待审 Candidate。生成不会批准、安装、执行或发布内容;已有自动生成流程保持原有策略。审批须由用户明确决定,并携带实际检查过的候选版本。 + +## Prompt 配置 + +先读取已注册、支持 custom 模式的 Prompt key(如 `memory.extract`)的当前 ArtifactRef,再选取包含已观察错误及可信预期结果的精确 Source。通过原有 Dream 接口提交: + +```python +from powercontext.client import PowerContextClient +from powercontext.http import ( + ArtifactReference, CreateDreamRunRequest, DreamSourceReference, + GetArtifactCandidateRequest, ApproveArtifactCandidateRequest, +) + +async def propose_prompt(client: PowerContextClient, scope_id: str, + current: ArtifactReference, evidence_source_id: str): + # current 必须指向已存在的 Prompt key 及其当前精确版本。 + return await client.create_dream_run(scope_id, CreateDreamRunRequest( + operation="revise_prompt", target=current, artifacts=[current], + sources=[DreamSourceReference(source_type="content", source_id=evidence_source_id)], + idempotency_key="prompt-correction-2026-09-22", + )) + +async def inspect_prompt(client, scope_id, run_id): + run = await client.get_dream_run(scope_id, run_id) + if run.status.value == "succeeded" and run.candidate is not None: + return await client.get_artifact_candidate(GetArtifactCandidateRequest( + scope_id=scope_id, candidate_id=run.candidate.candidate_id)) +``` + +在 `/dashboard/review` 检查完整提案、目标差异和精确证据。用户明确批准所检查的版本后,调用 `approve_artifact_candidate(ApproveArtifactCandidateRequest(scope_id=scope_id, candidate_id=candidate.candidate_id, expected_version=candidate.version))`。审批同时要求审核权限与 Prompt 写权限。新配置用于之后的推理;已开始的推理继续使用冻结的旧版本。回滚复用已有 Artifact replacement 接口,将选中的历史配置写为更高版本。 + +CLI 接受相同的 JSON 请求: + +```sh +powercontext dream run --scope-id "$SCOPE_ID" --request-file prompt-dream.json +powercontext dream show --scope-id "$SCOPE_ID" "$RUN_ID" +powercontext candidate show --scope-id "$SCOPE_ID" "$CANDIDATE_ID" +powercontext candidate revise json --request-file candidate-revision.json +powercontext candidate approve --scope-id "$SCOPE_ID" "$CANDIDATE_ID" --expected-version 2 +``` + +`candidate-revision.json` 为完整 `ReviseArtifactCandidateRequest`,包含 scope、候选标识、expected_version、proposal、精确证据和 target。保存后产生新的待审版本,必须再次检查后才能批准。 + +## 标签变更 + +通过 `get_artifact_tags` 或 `get_memory_entry_tags` 读取标签全集与 ETag,同时读取当前正文的精确 ArtifactRef 或 MemoryCitation。仍在同一个 Dream 入口提交 `revise_tags`,以独立 `tag_target` 表达标签和正文双基准: + +```python +from powercontext.http import TagDreamTarget, ArtifactTagTarget + +async def propose_tags(client, scope_id, current, tags_etag, evidence_source_id): + return await client.create_dream_run(scope_id, CreateDreamRunRequest( + operation="revise_tags", + tag_target=TagDreamTarget( + target=ArtifactTagTarget(type="artifact", family=current.family, + artifact_id=current.artifact_id), + expected_etag=tags_etag, basis_ref=current), + artifacts=[current], + sources=[DreamSourceReference(source_type="content", source_id=evidence_source_id)], + idempotency_key="tag-correction-2026-09-22", + )) +``` + +Memory 条目使用 `MemoryEntryTagTarget` 与 `basis_citation`,并在 `memory_citations` 中提供该精确引用。成功生成后 `candidate.kind="catalog_change"`,通过 `get_catalog_candidate(GetCatalogCandidateRequest(...))` 检查,通过 `approve_catalog_candidate(ApproveCatalogCandidateRequest(...))` 决定。独立的 `/v1/catalog-change-candidates/list|get|history|revise|approve|reject` 接口保留标签生命周期,批准时同时校验 ETag 和正文版本,原子替换标签并记录决定,不创建 Artifact Revision。 + +```sh +powercontext catalog-candidate list --scope-id "$SCOPE_ID" +powercontext catalog-candidate show --scope-id "$SCOPE_ID" "$CANDIDATE_ID" +powercontext catalog-candidate history --scope-id "$SCOPE_ID" "$CANDIDATE_ID" +powercontext catalog-candidate revise --request-file tag-revision.json +powercontext catalog-candidate approve --scope-id "$SCOPE_ID" "$CANDIDATE_ID" --expected-version 2 +``` + +统一收件箱支持制品与标签候选筛选,两类资源分别分页,显示完整提案、证据、差异和决定历史。候选版本过期、目标变化、证据撤销或 ETag 变化时,候选保持 pending 并返回冲突;重新检查当前状态后再决定。`no_change` 和 `needs_evidence` 不创建候选。模型提案与人工批准均不等于后续任务质量已经提升。 diff --git a/docs/zh/rfcs/0000_multi_artifact_dreaming.md b/docs/zh/rfcs/0000_multi_artifact_dreaming.md index 6a6ba64abf..703bfd5dd5 100644 --- a/docs/zh/rfcs/0000_multi_artifact_dreaming.md +++ b/docs/zh/rfcs/0000_multi_artifact_dreaming.md @@ -6,7 +6,9 @@ # Summary 将 Dream 从现有的 Experience 提炼与 Skill 派生,扩展为按制品生命周期执行的受控复盘能力。新增 Memory 条目修订、Profile 画像校正、Topic Memory 主题校正、Handoff 交接刷新、现有 Skill 修订和 Prompt 改进,并为 Tag 提供独立的分类变更提案。所有操作沿用“精确证据、后台运行、待审候选、条件提交、完整来源”的原则,一次运行只处理一个目标,不直接覆盖正式状态。所有新增 Dream 操作都必须先写入待审核 Candidate;只有 Reviewer 批准当前候选版本并通过目标当前基准校验后,变更才能生效:Artifact 由所属 Family 的写入器提交新 Revision、推进 head,Tag 按其 ETag 条件替换标签集。Dream 生成成功或候选入库本身不改变正式状态。 -Dream 负责提出可解释的变化,Family adapter 负责语义校验和提交,Reviewer 负责批准,应用或评测服务负责实际业务验证。Source 保持原始证据身份,不被 Dream 改写;Tag 保持 catalog 属性身份,不伪装成 Artifact。第一阶段交付 Memory、Profile、Topic Memory,后续阶段扩展 Handoff、Skill、Prompt 和 Tag。自动发现、全量夜间扫描、多目标原子合并及无人值守发布不作为本提案的交付前提。 +Dream 负责提出可解释的变化,Family adapter 负责语义校验和提交,Reviewer 负责批准,应用负责后续实际业务验证。Source 保持原始证据身份,不被 Dream 改写;Tag 保持 catalog 属性身份,不伪装成 Artifact。第一阶段交付 Memory、Profile、Topic Memory,后续阶段扩展 Handoff、Skill、Prompt 和 Tag。自动发现、全量夜间扫描、多目标原子合并及无人值守发布不作为本提案的交付前提。 + +本 PR 的所有扩展操作统一沿用 Experience 提炼、Skill 派生的证据驱动提案与人工审核逻辑。可信评测为后续可选增强,本 PR 不实现相关服务、接口、存储或审批门槛,也不以其配置作为能力启用条件。 # Motivation ## 现有能力与缺口 @@ -22,7 +24,7 @@ Dream 负责提出可解释的变化,Family adapter 负责语义校验和提 | Profile 已有 Candidate 审核,但绑定 Source window、Policy pending 指针和游标推进 | Dream 选取离散证据,不能冒充连续消费窗口 | | Topic Memory 有自身生成、融合和检索投影 | 手动修订不能被旧后台结果覆盖,旧 chunk 不得与新 head 混用 | | Handoff 有 prepare、commit/activate、接收方核验等语义 | 生成文本不能替代交接激活或接收确认 | -| Prompt 是运行配置,正式 head 会影响后续推理选择 | 发布不是纯内容编辑,需要评测门槛、明确权限及回滚 | +| Prompt 是运行配置,正式 head 会影响后续推理选择 | 发布不是纯内容编辑,需要确定性校验、人工审核、明确权限及回滚 | | Tag 使用独立的可变集合和 ETag | 标签变化不应创建 Artifact Revision | | Candidate 的 MemoryCitation 当前限制为 Experience | 新 family 必须显式增加合法引用与审核校验,不能全局取消约束 | @@ -68,8 +70,8 @@ DreamRun:解析来源、冻结证据、生成与类型校验 | `revise_profile`,新增 | 当前 Profile 的完整快照候选 | 主体归属、稳定偏好、临时安排与明确变更 | 人工审核后替换 head,不消费 Source window | | `revise_topic_memory`,新增 | 一个 Topic Memory 的完整内容候选 | 新进展、已排除假设、未决问题及来源 | 保持 topic identity,提交新版本与检索更新意图 | | `refresh_handoff`,新增 | 当前 Handoff 的完整修订候选 | 目标、已完成事项、阻塞、下一步 | 批准只形成正式版本,仍需现有显式激活与接收核验 | -| `revise_skill`,新增 | 当前 managed Skill 的替换候选 | 使用反例、步骤、前置条件与检查方法 | 通过内容校验、评测和审核后发布,安装执行仍独立 | -| `revise_prompt`,新增 | 已注册 Prompt key 的完整配置候选 | instructions、demonstrations 的失败模式 | 评测与配置写权限通过后发布,后续推理解析新 head | +| `revise_skill`,新增 | 当前 managed Skill 的替换候选 | 使用反例、步骤、前置条件与检查方法 | 通过内容校验和人工审核后发布,安装执行仍独立 | +| `revise_prompt`,新增 | 已注册 Prompt key 的完整配置候选 | instructions、demonstrations 的失败模式 | 内容校验、人工审核与配置写权限通过后发布,后续推理解析新 head | | `revise_tags`,新增 | 单一逻辑 target 的标签集合变更候选 | 错误分类、同义重复、缺失分类 | ETag 条件替换,不创建 Artifact Revision | @@ -131,7 +133,7 @@ Dream 保留 `queued/running/succeeded/failed` 状态及 `proposed/no_change/nee | proposal_schema / validator | 类型化输出与确定性业务约束 | | processing_family / binding | 所属执行资源与现有 Supervisor 绑定 | | review_adapter / commit_adapter | 审核校验、锁顺序和原子提交 | -| effect / evaluation_policy | 审核后的实际影响及必须满足的评测要求 | +| effect | 审核后的实际影响 | 注册表是服务端固定配置,不接收请求内 Python 路径、任意工具名、SQL 或自定义执行代码。Family registry 可发现读取能力,不等于已经支持 Dream。只有解析、生成、审核、提交和后台执行能力全部就绪时,operation 才能对外启用。 @@ -149,11 +151,11 @@ Dream 保留 `queued/running/succeeded/failed` 状态及 `proposed/no_change/nee | `POST /v1/artifact-candidates/revise`、`approve`、`reject` | 复用 candidate_id、expected_version 和现有审核权限;由 Family adapter 校验并提交 | | `GET /v1/capabilities` | 增加 `artifact_dreaming_operations`,声明操作、可用性、输出种类与生效方式 | -当前生成 Client 将 Dream operation 和 Candidate proposal 解析为封闭枚举/联合类型。新操作上线前必须协调升级服务端、Python Client、CLI、集成插件和 Dashboard;旧 Client 不得在不理解新类型的情况下读取新 Run 或 Candidate。不能以忽略未知字段、伪造旧 operation 或返回空 proposal 掩盖不兼容。部署验收须明确最低 Client 版本;若生产环境无法协调升级,应先提供按能力过滤的兼容读取策略,再启用新 operation,而不是让旧 Client 在 list/get 上解析失败。 +生成 Client 使用封闭枚举与 proposal 联合类型,最低支持级别为 **Dream contract 2**。本 PR 配套生成的 Python Client、CLI、MCP bridge 与 Dashboard 自动发送 `X-PowerContext-Dream-Contract: 2`;外部集成须升级相同契约并显式声明该级别。功能级别独立于包的发行版本,避免开发快照共用版本号造成误判。服务端在响应头声明最低级别;无声明或旧级别的 Client 仍可调用原有操作,capabilities 仅列出 `refine_experience`、`derive_skill`。读到新 Run、新 Candidate 或 `dream_review_approved` Profile 时返回 HTTP 426 `client_upgrade_required`,写新类型时在变更前拒绝。原类型响应移除新增的可选审计字段,绝不伪造 operation、mode 或空 Source window。部署验收覆盖新版 SDK 和无能力声明的旧客户端。 -Tag 不是 Artifact;C 阶段的 Catalog Change Candidate 与 Artifact Candidate 的结果类型不同。只在该阶段证明现有接口无法准确表达 ETag 与结果类型后,才增加独立的 Tag 候选接口。可信评测登记接口同样归 B1 阶段,不成为 A0/A1 前置条件。 +Tag 不是 Artifact;C 阶段的 Catalog Change Candidate 与 Artifact Candidate 的结果类型不同。只在该阶段证明现有接口无法准确表达 ETag 与结果类型后,才增加独立的 Tag 候选接口。可信评测仅作为后续可选增强,本 PR 不实现评测器注册、评测结果登记接口、评测存储或评测审批门槛。 -新增操作请求复用 `operation`、`artifacts`、`memory_citations`、`sources` 和 `idempotency_key`。Artifact 操作的 `target` 保持精确 ArtifactRef,并必须同时出现在 `artifacts` 中;Memory 的目标条目及版本通过 `memory_citations` 指定,必须属于该目标 Memory。引用去重后计入统一预算;目标内容不能单独证明自身断言。Profile、Topic Memory 和 Handoff 的待修订目标仅按权限、存在性与精确 head 校验;其历史引用失效不阻止以新的有效证据纠正目标。本次选中的支持证据仍须递归校验,不能通过目标角色豁免。Tag 的 TagTarget、expected_etag 和正文基准由 C 阶段的独立 Catalog Change 请求表达,不改变 Artifact Dream 的 target 类型。 +新增操作请求复用 `operation`、`artifacts`、`memory_citations`、`sources` 和 `idempotency_key`。Artifact 操作的 `target` 保持精确 ArtifactRef,并必须同时出现在 `artifacts` 中;Memory 的目标条目及版本通过 `memory_citations` 指定,必须属于该目标 Memory。引用去重后计入统一预算;目标内容不能单独证明自身断言。Profile、Topic Memory 和 Handoff 的待修订目标仅按权限、存在性与精确 head 校验;其历史引用失效不阻止以新的有效证据纠正目标。本次选中的支持证据仍须递归校验,不能通过目标角色豁免。Tag 的 TagTarget、expected_etag 和正文基准通过原 Dream 请求的独立 `tag_target` 对象表达,不改变 Artifact Dream 的 `target` 类型;生成结果的 `candidate.kind=catalog_change` 指向独立 Catalog Change Candidate 资源。 示例中的标识须替换为服务返回的真实值: @@ -223,23 +225,23 @@ Dream 不推进 Topic Source Cursor。后续正常融合以新 head 作为基准 当前 prepare/commit 已校验的 token、basis、引用和激活条件不能因增加 Candidate 被旁路。实现需把可复用的内容校验和非激活提交提取到事务内 writer,再由 Dream 审核 adapter 调用;不得伪造接收方核验记录。已被接收的历史 Revision 保持不变,新版本不改写旧接收记录。 -## 8. Skill 修订与评测契约 +## 8. Skill 修订与审核契约 `derive_skill` 保持原语义,`revise_skill` 明确指向一个已有 managed Skill。首期支持 instruction-only Skill,按包内实际文件判断支持范围,不以 package 字段是否存在判断。现有纯指令内容也会规范化为仅含根目录 SKILL.md 的标准包;这种单文件包属于支持范围。对于尚无 package 的旧纯指令内容,沿用既有规范化流程生成单文件标准包。 -服务端读取目标精确 package digest 对应的包快照,只有包内仅含根目录 SKILL.md 时才允许本期修订;包含额外附件、脚本或依赖文件的包返回明确的 unsupported_target,不默默丢弃文件。修订保留未授权改变的 frontmatter 和元数据,生成新的标准包,经过既有包校验、评测与审核后提交精确 package 引用;不得只修改缓存的 instructions 字段而保留旧 package 引用。多文件包的路径、签名和资产替换另行设计。 +服务端读取目标精确 package digest 对应的包快照,只有包内仅含根目录 SKILL.md 时才允许本期修订;包含额外附件、脚本或依赖文件的包返回明确的 unsupported_target,不默默丢弃文件。修订保留未授权改变的 frontmatter 和元数据,生成新的标准包,经过既有包校验与人工审核后提交精确 package 引用;不得只修改缓存的 instructions 字段而保留旧 package 引用。多文件包的路径、签名和资产替换另行设计。 输入需要与目标版本关联的任务结果、使用反馈或已批准 Experience。输出保留适用范围、前置条件、指令与验证要求,不推断“被选中”就是“被执行”或“有效”。负面用例不删除,不以新增步骤数作为质量指标。 -审核要求可信评测记录固定到 candidate_id、candidate_version、proposal_digest、目标版本、评测集版本和评测器版本。评测服务由部署者注册,单独鉴权提交。普通用户随手填写 `passed=true` 或上传自述 Source 不能满足门槛。评测在隔离环境运行,无生产凭据和任意外部副作用,预算独立计量。 +所有本 PR 扩展的 Dream 能力(Memory、Profile、Topic Memory、Handoff、Skill、Prompt、Tag)统一沿用 Experience 提炼与 Skill 派生的逻辑:基于精确证据由模型生成改进提案,程序执行结构、来源、权限和版本等确定性校验,先形成 pending Candidate,再由人工审核批准后按所属资源的条件提交规则生效。Tag 保留独立 Catalog Change Candidate,不改变其 ETag 和正文双基准约束。 -确定性 schema、来源和禁止越权检查必须全部通过;业务门槛由版本化 evaluation policy 固定,失败和 unknown 都不能当成通过。Reviewer 修改实质内容或证据后,旧评测记录失效。批准不安装、不执行、不自动分发 Skill,原有显式使用路径保持。 +确定性 schema、来源和禁止越权检查必须全部通过;Reviewer 修改实质内容或证据后产生新候选版本,重新校验并审核。未接入可信评测服务、没有评测集或评分阈值,不阻塞本 PR 任一扩展操作的实现、启用或批准。批准不安装、不执行、不自动分发 Skill,原有显式使用路径保持。模型给出的改进理由、validation 文本和人工批准均不代表已证明质量提升。 ## 9. Prompt 改进与生效契约 -仅支持已注册且允许自定义的 Prompt key,例如 `memory.extract`。输入包含当前精确 Prompt、已核验的错误输出及原始 Source、期望输出与独立保留测试集。错误输出作为被诊断对象,不作为事实来源;expected output 需要人工标注或其他可信来源。 +仅支持已注册且允许自定义的 Prompt key,例如 `memory.extract`。输入包含当前精确 Prompt、已核验的错误输出及原始 Source,以及有依据的期望输出或纠正反馈。错误输出作为被诊断对象,不作为事实来源;expected output 需要人工标注或其他可信来源。 候选只修改现有 `mode/instructions/demonstrations`,所有 demonstration 按该 Prompt Definition 的输入输出 schema 校验。首期输出固定为 custom 模式,不修改 Prompt key、模型配置、trust rules、工具集合或资源上限。 -训练示例与保留测试集必须按根 Source 分离,不能把保留答案写进 demonstrations 后宣称改进。评分对比同一输入和固定执行配置下的 baseline 与 candidate,检查误提取、漏提取、主体混淆、时态混淆、token 成本和 trust-rule 回归。只有已注册的评测器可以登记评测通过记录。 +Prompt 与其他扩展操作采用相同的证据提案、确定性校验和人工审核流程。保留目标 key 的 schema、证据来源、权限、版本与 trust rules 校验;本 PR 不实现保留测试集登记、baseline/candidate 自动评分或可信评测准入。缺少合理改进依据时返回 no_change 或 needs_evidence,不因模型自述通过测试而宣称有效改进。 当前 Prompt head 在后续 inference 中解析,因此批准该候选就是配置发布。审批 UI 和响应必须明确显示这一影响,Reviewer 同时需要 review authority 与现有 Prompt 写权限。先保存一个“已批准但不生效”的 head 会与现有语义冲突,本期不增加该中间状态。 @@ -252,23 +254,23 @@ Tag 不是 Artifact Family。新增 `CatalogChangeCandidate`,复用 Review Inb 审批时同时核对标签 ETag 与正文基准。正文 head 或条目版本改变,即使 ETag 未变也要求重新提案,防止按旧内容打标。按既有权限读取 basis;ETag 冲突返回 412,正文基准冲突返回 409。事务内替换标签集合并标记候选 approved,Artifact Revision、digest、embedding 均不改变。 -统一 Inbox 在服务层聚合两种候选,不创建 `family=tag`,不把新 Tag 审批混进要求 approved 必须有 result_artifact 的旧 DTO。首期不批量改名、不定义全局别名、不检查或修改外部系统自动化。涉及业务规则的标签需要业务方确认其影响,PowerContext 标签本身始终不授予权限。 +统一 Inbox 通过两个公共候选服务聚合展示,支持 `all/artifact/catalog_change` 类型筛选和 pending/approved/rejected 状态筛选。两种资源保持独立游标;全部视图分组展示,每组每页 12 条及独立下一页,不伪造跨资源时间顺序。详情展示目标差异、完整提案、精确证据、版本与决定记录,并明确 Prompt 发布、Skill 不安装和 Handoff 不激活的影响。批准、拒绝和修订均携带当前候选版本,通过现有权限检查;浏览器写入校验同源。此方案不创建新聚合表或 Inbox API,不创建 `family=tag`,不把新 Tag 审批混进要求 approved 必须有 result_artifact 的旧 DTO。首期不批量改名、不定义全局别名、不检查或修改外部系统自动化。涉及业务规则的标签需要业务方确认其影响,PowerContext 标签本身始终不授予权限。 ## 11. 候选模型与审批事务 对新增 Artifact family 注册专门 proposal 类型和审核 adapter,复用已有候选版本、pending/approved/rejected 及 result_artifact 语义。所有 Dream-origin Artifact 变更,无论该 Family 的既有自动生成是否需要审核,都只能先进入 pending Candidate;批准时重新核对候选版本、证据、权限和目标当前 head,条件提交新 Revision 并原子标记 approved。审核前以及冲突时不得让 Dream 结果进入正式 head 或检索生效状态。既有非 Dream 自动生成路径保持原有策略,不以 Family 级开关统一改变其行为。MemoryCitation 从“仅 Experience”改为逐 operation/family 的白名单校验,而不是无条件放开。 -新增 Candidate 保存 origin、operation、精确 target、proposal digest、evidence manifest 引用、validation policy digest 和可选 evaluation refs。需要模型生成的只有内容计划、已给定 evidence ID 及说明,目标身份、CAS token、事务结果和 generation 元数据均由服务端填写。 +新增 Candidate 保存 origin、operation、精确 target、proposal digest、evidence manifest 引用、validation policy digest;本 PR 不增加 evaluation refs。需要模型生成的只有内容计划、已给定 evidence ID 及说明,目标身份、CAS token、事务结果和 generation 元数据均由服务端填写。 -审批共同步骤:校验身份及当前权限;读取候选以确定服务端注册的 Family/origin adapter;完成所需的事务外准备;由 adapter 从事务开始决定完整锁顺序并锁定候选 expected_version;核对目标基准;复核证据及当前 active 状态;核对 schema、评测记录和 policy;调用事务内 Family writer;原子保存正式结果和审核状态。通用层不得先锁 Candidate 再交给 adapter 补锁其他资源;预读的候选版本、origin 和内容必须在事务内重新校验。 +审批共同步骤:校验身份及当前权限;读取候选以确定服务端注册的 Family/origin adapter;完成所需的事务外准备;由 adapter 从事务开始决定完整锁顺序并锁定候选 expected_version;核对目标基准;复核证据及当前 active 状态;核对 schema 和适用的确定性 policy;调用事务内 Family writer;原子保存正式结果和审核状态。通用层不得先锁 Candidate 再交给 adapter 补锁其他资源;预读的候选版本、origin 和内容必须在事务内重新校验。 同一 Family 的 HTTP、SDK 及其他审核入口共用相同的事务与锁顺序。Profile 保持 Policy → Candidate → Source Cursor(仅 Source-window 候选)→ Profile head 校验与条件写入;Dream Profile 不读取或推进 Source Cursor。approve/revise/reject 中涉及相同资源的操作也必须遵守该顺序,不允许某一入口改成 Candidate → Policy。其他 Family 按既有写入流程确定锁顺序,并通过并发验收验证。 -发生冲突或校验失败时,候选保留 pending 并返回可操作原因,不自动 rebase。拒绝 Dream 候选不会修改 Artifact/Tag,也不会推进普通 Source cursor;既有 Source-window 候选仍按原有规则处理游标。修订不能换 target,目标变化需要新候选。候选正文修改会产生新 version,旧版本和其评测记录继续可追溯。 +发生冲突或校验失败时,候选保留 pending 并返回可操作原因,不自动 rebase。拒绝 Dream 候选不会修改 Artifact/Tag,也不会推进普通 Source cursor;既有 Source-window 候选仍按原有规则处理游标。修订不能换 target,目标变化需要新候选。候选正文修改会产生新 version,旧版本及其证据继续可追溯。 ## 12. 持久化、索引与后台执行 A0/A1 复用 `pc_dream_runs`、`pc_artifact_candidate_heads`、`pc_artifact_candidate_versions` 和各 Family 的既有存储,不新增业务表。operation spec version、类型化 target、result reference 及 proposal/origin 优先使用已有类型化 payload;只有查询、唯一约束或事务校验确实需要时才增加必要列和索引。Dream 的数据库唯一约束和请求查询沿用 `(scope_id, principal_key, idempotency_key)`,principal_key 沿用既有请求者身份编码。 -可信评测记录存储归属 B1,记录主体、候选 digest、评测集版本、policy 版本、结果与用量,不复制用户凭据或完整训练集;仅在无法用既有可信存储满足不可伪造记录与版本绑定时新增专用表。Tag 候选及版本存储归属 C,以其独立结果类型和 ETag 事务语义论证所需新表,不复用 Artifact head 表。两者的接口、表结构与迁移均随所属阶段交付,不作为 A0/A1 的启动、迁移或能力启用前置条件。 +Tag 候选及版本存储归属 C,以其独立结果类型和 ETag 事务语义论证所需新表,不复用 Artifact head 表;接口、表结构与迁移随该阶段交付,不作为 A0/A1 前置条件。可信评测的注册、记录存储、结果登记、版本化质量门槛与隔离执行基础设施均不在本 PR 范围内,不新增评测专用表或预留空接口。 所有 schema 变化同时覆盖 SQLite 与 OceanBase。新增 Profile generation mode 和 Candidate proposal 在对应 operation 启用前,必须使持久化 reader、HTTP serializer、Client 和 Dashboard 同步识别。上线后不理解新增封闭类型的旧 Client 应收到明确的升级提示或使用兼容过滤,不得伪造旧 mode、空 Source window 或成功的空结果。 @@ -296,7 +298,6 @@ idempotency_key 按第 2 节包含请求者身份的唯一范围处理相同请 | 生成期间目标改变、证据撤回 | Run failed,无候选或正式写入 | | 内容太大、预算耗尽 | Run failed,明确 evidence_limit_exceeded/budget_exceeded | | 模型输出伪造引用、错误类型或目标外变更 | failed: invalid_generation_output | -| 审批缺少有效评测 | 409 evaluation_required/stale_evaluation,保留 pending | | policy 改变或候选 expected_version 不匹配 | 409,要求重新检查 | | Topic 投影准备失败或准备期间候选、head、检索配置改变 | 批准失败,Candidate 保持 pending,正式 head 和检索投影均不变;重新准备后再审核 | @@ -310,8 +311,8 @@ idempotency_key 按第 2 节包含请求者身份的唯一范围处理相同请 | --- | --- | --- | | A0 | Operation registry、现有 v1 契约扩展、证据角色、typed target、共用审核扩展;复用现有 Run/Candidate 表 | Client 与 Server 协调升级,旧操作回归、请求者幂等隔离与统一锁顺序通过,未注册操作关闭 | | A1 | Memory、Profile、Topic Memory 显式复盘;不依赖 Tag 或可信评测存储 | 条目原子提交、Profile 游标隔离、Topic 完整投影原子发布及启动一致性通过 | -| B1 | Handoff 刷新、仅含 SKILL.md 的纯指令 Skill 包修订、可信评测接口与存储 | 激活边界、包内容准入与评测门槛通过,真实任务端到端验证完成 | -| B2 | Prompt 改进 | 同 key schema、保留测试集、配置发布权限及回滚通过 | +| B1 | Handoff 刷新、仅含 SKILL.md 的纯指令 Skill 包修订 | 激活边界、包内容准入、证据校验与人工审核链路通过,真实任务端到端验证完成 | +| B2 | Prompt 改进 | 同 key schema、证据校验、人工审核、配置发布权限及回滚通过 | | C | Tag 单 target 分类治理、独立候选接口与存储、统一 Inbox | ETag/正文双基准、独立结果类型、历史记录通过,新表必要性明确 | @@ -332,9 +333,10 @@ idempotency_key 按第 2 节包含请求者身份的唯一范围处理相同请 | Handoff 只有计划,没有执行结果 | 不把计划改成完成,不产生 accepted | | Skill 仅含 SKILL.md 的标准包(含现有纯指令生成结果) | 可以生成修订候选,审核通过后提交新的合法标准包 | | Skill 包含额外文件 | 明确拒绝,不能悄悄删附件、脚本或依赖文件 | -| 候选编辑后复用旧评测 | 审核拒绝,要求重测 | -| 普通调用方提交自述 passed=true | 不能获得可信评测通过记录 | -| Prompt 保留测试集进入 demonstrations | 隔离检查失败,不能报告有效改进 | +| 候选编辑后使用旧 expected_version 批准 | 版本冲突;新版本重新完成确定性校验与人工审核 | +| 普通调用方提交自述 passed=true | 不替代证据校验和人工审核,不宣称质量已经提升 | +| Prompt demonstration 不符合目标 key 的 schema | 校验失败,不发布配置 | +| 所有扩展操作均未配置可信评测服务 | 已实现且其他配置就绪时可生成候选并按确定性校验与人工审核批准 | | Prompt 发布与回滚 | 新推理使用新 head,在途推理固定旧版本,回滚产生更高 Revision | | Tag 标签未变但正文已变 | 正文基准冲突,不按旧内容修改分类 | | Tag 审核成功 | 只改变标签集,不创建 Artifact Revision | @@ -348,13 +350,13 @@ idempotency_key 按第 2 节包含请求者身份的唯一范围处理相同请 | 旧 Client 与扩展后的 v1 Server | 未升级时收到明确升级提示或只读取其可解析类型;升级后可读取新增 Run 与 Candidate | -SQLite 与 OceanBase 都执行并发冲突、租约隔离和原子失败恢复验收。使用隔离数据完成一条真实链路:原始会话写入、生成初始制品、追加纠正证据、Dream 提案、人工审核、后续任务使用、结果回查。模型质量评测使用时间隔离的后续任务,对比“无 Dream”“普通重新生成”“本提案”,报告错误更新率、遗漏率、审核修改量、后续任务质量、延迟与总成本,不预设提升百分比。 +SQLite 与 OceanBase 都执行并发冲突、租约隔离和原子失败恢复验收。使用隔离数据完成一条真实链路:原始会话写入、生成初始制品、追加纠正证据、Dream 提案、人工审核、后续任务使用、结果回查。工程测试验证公开行为、约束和事务正确性,不构成产品运行时的可信评测依赖,也不将模型提案或人工批准视为质量提升证明。 # Drawbacks + 每种 Family 都需要专门 writer 和校验,统一入口不能消除条目、游标、激活和配置发布的语义差异。 + 全量 Memory head CAS 在活跃 Scope 中可能频繁冲突;采用更细粒度 CAS 会增加 manifest 合并复杂度,本期选择可解释的保守冲突。 + 完整 Profile/Topic 快照增加上下文和输出成本,预算可能使大型目标暂不可处理。 -+ 人工审核与 Skill/Prompt 评测增加等待和运营负担;错误的评测集仍可能选出错误配置。 ++ 人工审核增加等待和运营负担;模型提案与人工批准本身不能证明实际任务质量提升。 + 扩展现有 v1 封闭枚举与 Profile generation 需要协调升级 Client;静默伪装成旧类型会更危险。 + Tag 的独立候选实现增加代码,但可避免把可变分类与不可变 Artifact Revision 混成一种生命周期。 @@ -388,15 +390,16 @@ SQLite 与 OceanBase 都执行并发冲突、租约隔离和原子失败恢复 # Unresolved questions 下列问题应在对应阶段启用前由维护者决策,不能委托模型临时决定: -1. 扩展现有 v1 后的最低支持 Client 版本,以及旧 Client 的升级提示或兼容过滤策略。 -2. Skill 与各 Prompt key 的首组保留评测集、版本化质量门槛和评测责任人。本文规定必须有可信门槛,具体业务分数需实测确定。 +1. 外部集成采用 Dream contract 2 的发布节奏;最低级别与旧 Client 升级行为已定义于协议章节。 +2. Skill 与各 Prompt key 的证据展示、差异比较及配置发布影响提示应如何呈现给 Reviewer。 3. 现有 Handoff 非激活提交能力应如何从 prepare/commit 代码提取,确保与现有 token 和 receiver checks 完全一致。 -4. Catalog Change Candidate 的统一 Inbox 分页是否由服务端合并游标实现,还是先通过类型筛选页提供;无论 UI 方案如何,底层生命周期不混用。 +4. 是否在有实际需求时增加跨资源时间排序;首期统一 Inbox 使用类型筛选与独立资源游标。 5. 发布后质量异常由哪个应用监控并发起显式回滚;本 RFC 不承诺自动因果归因或无人值守回滚。 这些问题不阻塞 A0/A1 的独立实现;涉及后续阶段的能力必须在问题解决、契约测试通过后才对外宣告可用。 # Future possibilities ++ 可信评测作为后续可选增强,另行定义评测器注册与鉴权、独立保留集、版本化质量门槛、绑定精确候选版本的结果记录及隔离执行。是否接入由后续方案决定;本 PR 不实现,也不阻塞本 PR 的审核发布链路。 + 基于新增根证据、反例和纠正的周期性 discovery,设置静默期、候选去重和待审容量限制。 + Memory entry 合并、明确停用与恢复,Topic 拆分/合并,以及可解释的多目标原子变更组。 + 完整 Skill package 的静态差异、隔离测试和制品签名,保持执行与发布权限独立。 diff --git a/integrations/capabilities.toml b/integrations/capabilities.toml index d78fffda62..8d47fe0e9b 100644 --- a/integrations/capabilities.toml +++ b/integrations/capabilities.toml @@ -137,6 +137,24 @@ non_profile_reason = "Candidate decision authority is outside this capability co [[toolsets.tools]] id = "revise_artifact_candidate" non_profile_reason = "Candidate decision authority is outside this capability contract." +[[toolsets.tools]] +id = "list_catalog_candidates" +capabilities = ["candidate_review"] +[[toolsets.tools]] +id = "get_catalog_candidate" +capabilities = ["candidate_review"] +[[toolsets.tools]] +id = "get_catalog_candidate_history" +capabilities = ["candidate_review"] +[[toolsets.tools]] +id = "approve_catalog_candidate" +non_profile_reason = "Candidate decision authority is outside this capability contract." +[[toolsets.tools]] +id = "reject_catalog_candidate" +non_profile_reason = "Candidate decision authority is outside this capability contract." +[[toolsets.tools]] +id = "revise_catalog_candidate" +non_profile_reason = "Candidate decision authority is outside this capability contract." [[toolsets]] id = "prompt-hooks" diff --git a/integrations/claude-code/plugins/powercontext/hooks/user_prompt_submit.py b/integrations/claude-code/plugins/powercontext/hooks/user_prompt_submit.py index 71cdee16cc..881c818424 100644 --- a/integrations/claude-code/plugins/powercontext/hooks/user_prompt_submit.py +++ b/integrations/claude-code/plugins/powercontext/hooks/user_prompt_submit.py @@ -45,6 +45,7 @@ _MAX_SOURCE_LENGTH = 200_000 _READ_CHUNK_BYTES = 65_536 _REQUEST_HEADERS = { + "X-PowerContext-Dream-Contract": "2", "Accept": "application/json", "Content-Type": "application/json", "User-Agent": "powercontext-claude-code-plugin/0.1.2", diff --git a/integrations/codex/plugins/powercontext/hooks/bind_tools.py b/integrations/codex/plugins/powercontext/hooks/bind_tools.py index 6838f6232f..1de68e8cc1 100644 --- a/integrations/codex/plugins/powercontext/hooks/bind_tools.py +++ b/integrations/codex/plugins/powercontext/hooks/bind_tools.py @@ -44,6 +44,7 @@ "acknowledge_handoff", "activate_handoff", "approve_artifact_candidate", + "approve_catalog_candidate", "capture_content_source", "commit_handoff", "continue_handoff", @@ -56,6 +57,8 @@ "generate_experience", "generate_skill", "get_artifact_candidate", + "get_catalog_candidate", + "get_catalog_candidate_history", "get_experience", "get_memory_entry", "get_skill", @@ -64,6 +67,7 @@ "handoff_current_work", "import_external_skill", "list_artifact_candidates", + "list_catalog_candidates", "list_external_skills", "list_managed_skills", "list_memory_changes", @@ -78,11 +82,13 @@ "record_skill_usage", "record_task_outcome", "reject_artifact_candidate", + "reject_catalog_candidate", "remember_memory", "rename_remote_skill_target", "resolve_external_skill", "retire_memory_entry", "revise_artifact_candidate", + "revise_catalog_candidate", "revise_memory_entry", "revoke_remote_skill_target", "scan_external_skills", diff --git a/integrations/codex/plugins/powercontext/hooks/recall.py b/integrations/codex/plugins/powercontext/hooks/recall.py index 6496d18aad..0686b61729 100644 --- a/integrations/codex/plugins/powercontext/hooks/recall.py +++ b/integrations/codex/plugins/powercontext/hooks/recall.py @@ -47,6 +47,7 @@ _MAX_SOURCE_LENGTH = 200_000 _READ_CHUNK_BYTES = 65_536 _REQUEST_HEADERS = { + "X-PowerContext-Dream-Contract": "2", "Accept": "application/json", "Content-Type": "application/json", "User-Agent": "powercontext-codex-plugin/0.2.0", diff --git a/integrations/dsh/plugins/powercontext/lib/index.js b/integrations/dsh/plugins/powercontext/lib/index.js index b545b7e433..ef0ed7e4e5 100644 --- a/integrations/dsh/plugins/powercontext/lib/index.js +++ b/integrations/dsh/plugins/powercontext/lib/index.js @@ -1028,6 +1028,72 @@ const OPERATIONS$1 = { successStatuses: [200], emptyStatuses: [] }, + list_catalog_candidates: { + method: "POST", + path: "/v1/catalog-change-candidates/list", + location: "body", + scopeMode: "current", + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, + get_catalog_candidate: { + method: "POST", + path: "/v1/catalog-change-candidates/get", + location: "body", + scopeMode: "current", + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, + get_catalog_candidate_history: { + method: "POST", + path: "/v1/catalog-change-candidates/history", + location: "body", + scopeMode: "current", + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, + revise_catalog_candidate: { + method: "POST", + path: "/v1/catalog-change-candidates/revise", + location: "body", + scopeMode: "current", + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, + approve_catalog_candidate: { + method: "POST", + path: "/v1/catalog-change-candidates/approve", + location: "body", + scopeMode: "current", + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, + reject_catalog_candidate: { + method: "POST", + path: "/v1/catalog-change-candidates/reject", + location: "body", + scopeMode: "current", + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, get_stats: { method: "POST", path: "/v1/stats", @@ -1633,6 +1699,7 @@ var PowerContextClient = class { } buildInit(spec, request, signal) { const headers = { + "X-PowerContext-Dream-Contract": "2", Accept: "application/json", "User-Agent": PLUGIN_USER_AGENT, ...request.headers diff --git a/integrations/dsh/plugins/powercontext/src/client.ts b/integrations/dsh/plugins/powercontext/src/client.ts index c30f8cf3ed..0bf242ff78 100644 --- a/integrations/dsh/plugins/powercontext/src/client.ts +++ b/integrations/dsh/plugins/powercontext/src/client.ts @@ -239,6 +239,7 @@ export class PowerContextClient { private buildInit(spec: OperationSpec, request: PreparedRequest, signal?: AbortSignal): RequestInit { const headers: Record = { + 'X-PowerContext-Dream-Contract': '2', Accept: 'application/json', 'User-Agent': PLUGIN_USER_AGENT, ...request.headers, diff --git a/integrations/dsh/plugins/powercontext/src/operations.generated.ts b/integrations/dsh/plugins/powercontext/src/operations.generated.ts index 0988d3c908..5cc99fe950 100644 --- a/integrations/dsh/plugins/powercontext/src/operations.generated.ts +++ b/integrations/dsh/plugins/powercontext/src/operations.generated.ts @@ -95,6 +95,12 @@ export const OPERATIONS = { approve_artifact_candidate: { method: 'POST', path: '/v1/artifact-candidates/approve', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, reject_artifact_candidate: { method: 'POST', path: '/v1/artifact-candidates/reject', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, revise_artifact_candidate: { method: 'POST', path: '/v1/artifact-candidates/revise', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + list_catalog_candidates: { method: 'POST', path: '/v1/catalog-change-candidates/list', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + get_catalog_candidate: { method: 'POST', path: '/v1/catalog-change-candidates/get', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + get_catalog_candidate_history: { method: 'POST', path: '/v1/catalog-change-candidates/history', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + revise_catalog_candidate: { method: 'POST', path: '/v1/catalog-change-candidates/revise', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + approve_catalog_candidate: { method: 'POST', path: '/v1/catalog-change-candidates/approve', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + reject_catalog_candidate: { method: 'POST', path: '/v1/catalog-change-candidates/reject', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, get_stats: { method: 'POST', path: '/v1/stats', location: "body", scopeMode: 'selection', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, get_handoff_report: { method: 'POST', path: '/v1/handoff-reports/get', location: "body", scopeMode: 'selection', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, list_sources: { method: 'GET', path: '/v1/scopes/{scope_id}/sources', location: "query", scopeMode: 'none', pathParameters: ['scope_id'], queryParams: ['limit','cursor'], headerParams: [], successStatuses: [200], emptyStatuses: [] }, diff --git a/integrations/hermes/plugins/powercontext/client.py b/integrations/hermes/plugins/powercontext/client.py index fde8cd0b97..eb0935ce13 100644 --- a/integrations/hermes/plugins/powercontext/client.py +++ b/integrations/hermes/plugins/powercontext/client.py @@ -174,6 +174,7 @@ def _request( # noqa: C901 if method != "GET": body = json.dumps(payload or {}, ensure_ascii=False, separators=(",", ":")).encode("utf-8") headers = { + "X-PowerContext-Dream-Contract": "2", "Accept": "application/json", "User-Agent": "powercontext-hermes/0.1", } diff --git a/integrations/opencode/plugins/powercontext/lib/index.js b/integrations/opencode/plugins/powercontext/lib/index.js index 5ed47a9cc2..119510f8f1 100644 --- a/integrations/opencode/plugins/powercontext/lib/index.js +++ b/integrations/opencode/plugins/powercontext/lib/index.js @@ -941,6 +941,72 @@ const OPERATIONS = { successStatuses: [200], emptyStatuses: [] }, + list_catalog_candidates: { + method: "POST", + path: "/v1/catalog-change-candidates/list", + location: "body", + scopeMode: "current", + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, + get_catalog_candidate: { + method: "POST", + path: "/v1/catalog-change-candidates/get", + location: "body", + scopeMode: "current", + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, + get_catalog_candidate_history: { + method: "POST", + path: "/v1/catalog-change-candidates/history", + location: "body", + scopeMode: "current", + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, + revise_catalog_candidate: { + method: "POST", + path: "/v1/catalog-change-candidates/revise", + location: "body", + scopeMode: "current", + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, + approve_catalog_candidate: { + method: "POST", + path: "/v1/catalog-change-candidates/approve", + location: "body", + scopeMode: "current", + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, + reject_catalog_candidate: { + method: "POST", + path: "/v1/catalog-change-candidates/reject", + location: "body", + scopeMode: "current", + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, get_stats: { method: "POST", path: "/v1/stats", @@ -1551,6 +1617,7 @@ var PowerContextClient = class { } init(spec, request, signal) { const headers = { + "X-PowerContext-Dream-Contract": "2", Accept: "application/json", "User-Agent": PLUGIN_USER_AGENT, ...request.headers diff --git a/integrations/opencode/plugins/powercontext/lib/tui.js b/integrations/opencode/plugins/powercontext/lib/tui.js index fe852d1b70..9b299d097b 100644 --- a/integrations/opencode/plugins/powercontext/lib/tui.js +++ b/integrations/opencode/plugins/powercontext/lib/tui.js @@ -941,6 +941,72 @@ const OPERATIONS = { successStatuses: [200], emptyStatuses: [] }, + list_catalog_candidates: { + method: "POST", + path: "/v1/catalog-change-candidates/list", + location: "body", + scopeMode: "current", + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, + get_catalog_candidate: { + method: "POST", + path: "/v1/catalog-change-candidates/get", + location: "body", + scopeMode: "current", + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, + get_catalog_candidate_history: { + method: "POST", + path: "/v1/catalog-change-candidates/history", + location: "body", + scopeMode: "current", + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, + revise_catalog_candidate: { + method: "POST", + path: "/v1/catalog-change-candidates/revise", + location: "body", + scopeMode: "current", + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, + approve_catalog_candidate: { + method: "POST", + path: "/v1/catalog-change-candidates/approve", + location: "body", + scopeMode: "current", + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, + reject_catalog_candidate: { + method: "POST", + path: "/v1/catalog-change-candidates/reject", + location: "body", + scopeMode: "current", + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, get_stats: { method: "POST", path: "/v1/stats", @@ -1551,6 +1617,7 @@ var PowerContextClient = class { } init(spec, request, signal) { const headers = { + "X-PowerContext-Dream-Contract": "2", Accept: "application/json", "User-Agent": PLUGIN_USER_AGENT, ...request.headers diff --git a/integrations/opencode/plugins/powercontext/src/client.ts b/integrations/opencode/plugins/powercontext/src/client.ts index 32797ab989..c25f909585 100644 --- a/integrations/opencode/plugins/powercontext/src/client.ts +++ b/integrations/opencode/plugins/powercontext/src/client.ts @@ -233,6 +233,7 @@ export class PowerContextClient { private init(spec: OperationSpec, request: PreparedRequest, signal?: AbortSignal): RequestInit { const headers: Record = { + 'X-PowerContext-Dream-Contract': '2', Accept: 'application/json', 'User-Agent': PLUGIN_USER_AGENT, ...request.headers, diff --git a/integrations/opencode/plugins/powercontext/src/operations.generated.ts b/integrations/opencode/plugins/powercontext/src/operations.generated.ts index 0988d3c908..5cc99fe950 100644 --- a/integrations/opencode/plugins/powercontext/src/operations.generated.ts +++ b/integrations/opencode/plugins/powercontext/src/operations.generated.ts @@ -95,6 +95,12 @@ export const OPERATIONS = { approve_artifact_candidate: { method: 'POST', path: '/v1/artifact-candidates/approve', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, reject_artifact_candidate: { method: 'POST', path: '/v1/artifact-candidates/reject', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, revise_artifact_candidate: { method: 'POST', path: '/v1/artifact-candidates/revise', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + list_catalog_candidates: { method: 'POST', path: '/v1/catalog-change-candidates/list', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + get_catalog_candidate: { method: 'POST', path: '/v1/catalog-change-candidates/get', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + get_catalog_candidate_history: { method: 'POST', path: '/v1/catalog-change-candidates/history', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + revise_catalog_candidate: { method: 'POST', path: '/v1/catalog-change-candidates/revise', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + approve_catalog_candidate: { method: 'POST', path: '/v1/catalog-change-candidates/approve', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + reject_catalog_candidate: { method: 'POST', path: '/v1/catalog-change-candidates/reject', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, get_stats: { method: 'POST', path: '/v1/stats', location: "body", scopeMode: 'selection', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, get_handoff_report: { method: 'POST', path: '/v1/handoff-reports/get', location: "body", scopeMode: 'selection', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, list_sources: { method: 'GET', path: '/v1/scopes/{scope_id}/sources', location: "query", scopeMode: 'none', pathParameters: ['scope_id'], queryParams: ['limit','cursor'], headerParams: [], successStatuses: [200], emptyStatuses: [] }, diff --git a/integrations/pi/plugins/powercontext/src/client.ts b/integrations/pi/plugins/powercontext/src/client.ts index 25226373d2..d8133e924a 100644 --- a/integrations/pi/plugins/powercontext/src/client.ts +++ b/integrations/pi/plugins/powercontext/src/client.ts @@ -228,6 +228,7 @@ export class PowerContextClient { private buildInit(spec: OperationSpec, request: PreparedRequest, signal?: AbortSignal): RequestInit { const headers: Record = { + 'X-PowerContext-Dream-Contract': '2', Accept: 'application/json', 'User-Agent': PLUGIN_USER_AGENT, ...request.headers, diff --git a/integrations/pi/plugins/powercontext/src/operations.generated.ts b/integrations/pi/plugins/powercontext/src/operations.generated.ts index 0988d3c908..5cc99fe950 100644 --- a/integrations/pi/plugins/powercontext/src/operations.generated.ts +++ b/integrations/pi/plugins/powercontext/src/operations.generated.ts @@ -95,6 +95,12 @@ export const OPERATIONS = { approve_artifact_candidate: { method: 'POST', path: '/v1/artifact-candidates/approve', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, reject_artifact_candidate: { method: 'POST', path: '/v1/artifact-candidates/reject', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, revise_artifact_candidate: { method: 'POST', path: '/v1/artifact-candidates/revise', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + list_catalog_candidates: { method: 'POST', path: '/v1/catalog-change-candidates/list', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + get_catalog_candidate: { method: 'POST', path: '/v1/catalog-change-candidates/get', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + get_catalog_candidate_history: { method: 'POST', path: '/v1/catalog-change-candidates/history', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + revise_catalog_candidate: { method: 'POST', path: '/v1/catalog-change-candidates/revise', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + approve_catalog_candidate: { method: 'POST', path: '/v1/catalog-change-candidates/approve', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + reject_catalog_candidate: { method: 'POST', path: '/v1/catalog-change-candidates/reject', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, get_stats: { method: 'POST', path: '/v1/stats', location: "body", scopeMode: 'selection', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, get_handoff_report: { method: 'POST', path: '/v1/handoff-reports/get', location: "body", scopeMode: 'selection', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, list_sources: { method: 'GET', path: '/v1/scopes/{scope_id}/sources', location: "query", scopeMode: 'none', pathParameters: ['scope_id'], queryParams: ['limit','cursor'], headerParams: [], successStatuses: [200], emptyStatuses: [] }, diff --git a/integrations/workbuddy/plugins/powercontext/hooks/workbuddy_powercontext_hook.py b/integrations/workbuddy/plugins/powercontext/hooks/workbuddy_powercontext_hook.py index 8dc5701d2a..3db633d692 100644 --- a/integrations/workbuddy/plugins/powercontext/hooks/workbuddy_powercontext_hook.py +++ b/integrations/workbuddy/plugins/powercontext/hooks/workbuddy_powercontext_hook.py @@ -62,6 +62,7 @@ def override(method: _MethodT, /) -> _MethodT: _MAX_SOURCE_LENGTH = 200_000 _READ_CHUNK_BYTES = 65_536 _REQUEST_HEADERS = { + "X-PowerContext-Dream-Contract": "2", "Accept": "application/json", "Content-Type": "application/json", "User-Agent": "powercontext-workbuddy-plugin/0.1.0", diff --git a/openapi/powercontext.yaml b/openapi/powercontext.yaml index 75ba69a3b7..f96e100ee9 100644 --- a/openapi/powercontext.yaml +++ b/openapi/powercontext.yaml @@ -1716,6 +1716,8 @@ paths: maxLength: 256 pattern: .*\S.* responses: + "426": + $ref: "#/components/responses/ClientUpgradeRequired" '202': description: The accepted queued or running Dream. headers: @@ -1815,6 +1817,8 @@ paths: maximum: 100 default: 20 responses: + "426": + $ref: "#/components/responses/ClientUpgradeRequired" '200': description: The requested Dream state. headers: @@ -1864,6 +1868,8 @@ paths: minLength: 1 maxLength: 64 responses: + "426": + $ref: "#/components/responses/ClientUpgradeRequired" '200': description: The requested Dream state. headers: @@ -2831,6 +2837,8 @@ paths: schema: $ref: "#/components/schemas/ListArtifactCandidatesRequest" responses: + "426": + $ref: "#/components/responses/ClientUpgradeRequired" "200": description: The selected current Candidate heads. headers: @@ -2868,6 +2876,8 @@ paths: schema: $ref: "#/components/schemas/GetArtifactCandidateRequest" responses: + "426": + $ref: "#/components/responses/ClientUpgradeRequired" "200": description: The current Candidate head. headers: @@ -2908,6 +2918,8 @@ paths: schema: $ref: "#/components/schemas/ApproveArtifactCandidateRequest" responses: + "426": + $ref: "#/components/responses/ClientUpgradeRequired" "200": description: The approved Candidate and exact result Artifact. headers: @@ -2950,6 +2962,8 @@ paths: schema: $ref: "#/components/schemas/RejectArtifactCandidateRequest" responses: + "426": + $ref: "#/components/responses/ClientUpgradeRequired" "200": description: The rejected Candidate. headers: @@ -2992,6 +3006,8 @@ paths: schema: $ref: "#/components/schemas/ReviseArtifactCandidateRequest" responses: + "426": + $ref: "#/components/responses/ClientUpgradeRequired" "200": description: The next pending Candidate version. headers: @@ -3015,6 +3031,264 @@ paths: $ref: "#/components/responses/Unavailable" "500": $ref: "#/components/responses/InternalError" + /v1/catalog-change-candidates/list: + post: + tags: + - review + summary: List Catalog Change Candidates + description: Review one Tag metadata change. Approval checks both the exact candidate + version and current Tag/content baseline; it never creates an Artifact revision. + operationId: list_catalog_candidates + x-powercontext-access: + action: scope.read + resource: + type: scope + scope-id-from: scope_id + x-powercontext-scope-mode: current + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ListCatalogCandidatesRequest' + responses: + "426": + $ref: "#/components/responses/ClientUpgradeRequired" + '200': + description: Current Catalog Change Candidate state. + content: + application/json: + schema: + $ref: '#/components/schemas/CatalogCandidatePage' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '409': + $ref: '#/components/responses/Conflict' + '422': + $ref: '#/components/responses/InvalidRequest' + '503': + $ref: '#/components/responses/Unavailable' + '500': + $ref: '#/components/responses/InternalError' + /v1/catalog-change-candidates/get: + post: + tags: + - review + summary: Get Catalog Change Candidates + description: Review one Tag metadata change. Approval checks both the exact candidate + version and current Tag/content baseline; it never creates an Artifact revision. + operationId: get_catalog_candidate + x-powercontext-access: + action: scope.read + resource: + type: scope + scope-id-from: scope_id + x-powercontext-scope-mode: current + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/GetCatalogCandidateRequest' + responses: + "426": + $ref: "#/components/responses/ClientUpgradeRequired" + '200': + description: Current Catalog Change Candidate state. + content: + application/json: + schema: + $ref: '#/components/schemas/CatalogChangeCandidate' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '409': + $ref: '#/components/responses/Conflict' + '422': + $ref: '#/components/responses/InvalidRequest' + '503': + $ref: '#/components/responses/Unavailable' + '500': + $ref: '#/components/responses/InternalError' + /v1/catalog-change-candidates/history: + post: + tags: + - review + summary: History Catalog Change Candidates + description: Review one Tag metadata change. Approval checks both the exact candidate + version and current Tag/content baseline; it never creates an Artifact revision. + operationId: get_catalog_candidate_history + x-powercontext-access: + action: scope.read + resource: + type: scope + scope-id-from: scope_id + x-powercontext-scope-mode: current + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/GetCatalogCandidateRequest' + responses: + "426": + $ref: "#/components/responses/ClientUpgradeRequired" + '200': + description: Current Catalog Change Candidate state. + content: + application/json: + schema: + $ref: '#/components/schemas/CatalogCandidateHistory' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '409': + $ref: '#/components/responses/Conflict' + '422': + $ref: '#/components/responses/InvalidRequest' + '503': + $ref: '#/components/responses/Unavailable' + '500': + $ref: '#/components/responses/InternalError' + /v1/catalog-change-candidates/revise: + post: + tags: + - review + summary: Revise Catalog Change Candidates + description: Review one Tag metadata change. Approval checks both the exact candidate + version and current Tag/content baseline; it never creates an Artifact revision. + operationId: revise_catalog_candidate + x-powercontext-access: + action: scope.review + resource: + type: scope + scope-id-from: scope_id + x-powercontext-scope-mode: current + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ReviseCatalogCandidateRequest' + responses: + "426": + $ref: "#/components/responses/ClientUpgradeRequired" + '200': + description: Current Catalog Change Candidate state. + content: + application/json: + schema: + $ref: '#/components/schemas/CatalogChangeCandidate' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '409': + $ref: '#/components/responses/Conflict' + '422': + $ref: '#/components/responses/InvalidRequest' + '503': + $ref: '#/components/responses/Unavailable' + '500': + $ref: '#/components/responses/InternalError' + /v1/catalog-change-candidates/approve: + post: + tags: + - review + summary: Approve Catalog Change Candidates + description: Review one Tag metadata change. Approval checks both the exact candidate + version and current Tag/content baseline; it never creates an Artifact revision. + operationId: approve_catalog_candidate + x-powercontext-access: + action: scope.review + resource: + type: scope + scope-id-from: scope_id + x-powercontext-scope-mode: current + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ApproveCatalogCandidateRequest' + responses: + "426": + $ref: "#/components/responses/ClientUpgradeRequired" + '200': + description: Current Catalog Change Candidate state. + content: + application/json: + schema: + $ref: '#/components/schemas/CatalogChangeCandidate' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '409': + $ref: '#/components/responses/Conflict' + '422': + $ref: '#/components/responses/InvalidRequest' + '503': + $ref: '#/components/responses/Unavailable' + '500': + $ref: '#/components/responses/InternalError' + /v1/catalog-change-candidates/reject: + post: + tags: + - review + summary: Reject Catalog Change Candidates + description: Review one Tag metadata change. Approval checks both the exact candidate + version and current Tag/content baseline; it never creates an Artifact revision. + operationId: reject_catalog_candidate + x-powercontext-access: + action: scope.review + resource: + type: scope + scope-id-from: scope_id + x-powercontext-scope-mode: current + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/RejectCatalogCandidateRequest' + responses: + "426": + $ref: "#/components/responses/ClientUpgradeRequired" + '200': + description: Current Catalog Change Candidate state. + content: + application/json: + schema: + $ref: '#/components/schemas/CatalogChangeCandidate' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '409': + $ref: '#/components/responses/Conflict' + '422': + $ref: '#/components/responses/InvalidRequest' + '503': + $ref: '#/components/responses/Unavailable' + '500': + $ref: '#/components/responses/InternalError' /v1/stats: post: tags: [stats] @@ -4279,6 +4553,15 @@ components: schema: type: string responses: + ClientUpgradeRequired: + description: >- + This response requires a coordinated client supporting Dream contract 2. Upgrade the Python SDK, + CLI or integration and send X-PowerContext-Dream-Contract: 2. Original operations and compatible + candidate shapes remain available without the header. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' BadRequest: description: The request query or pagination cursor is invalid. headers: @@ -5153,6 +5436,8 @@ components: - revise_memory - revise_topic_memory - refresh_handoff + - revise_prompt + - revise_tags DreamStatus: type: string enum: @@ -5175,6 +5460,9 @@ components: - profile - topic_memory - handoff + - skill + - prompt + - catalog_target - unresolved DreamEvidenceRole: type: string @@ -5225,6 +5513,9 @@ components: CreateDreamRunRequest: additionalProperties: false properties: + tag_target: + $ref: '#/components/schemas/TagDreamTarget' + nullable: true operation: $ref: '#/components/schemas/DreamOperation' artifacts: @@ -5303,6 +5594,12 @@ components: type: object DreamCandidateRef: properties: + kind: + type: string + enum: + - artifact + - catalog_change + default: artifact candidate_id: type: string version: @@ -5440,6 +5737,12 @@ components: type: object DreamRun: properties: + tag_target: + $ref: '#/components/schemas/TagDreamTarget' + nullable: true + reused: + type: boolean + default: false scope_id: type: string run_id: @@ -5486,7 +5789,7 @@ components: budget: $ref: '#/components/schemas/DreamBudget' prompt_version: - default: powercontext.dream.v1.1 + default: powercontext.dream.v1.2 type: string model_config_id: type: string @@ -5528,6 +5831,9 @@ components: - result_artifact - decision_reason properties: + audit: + $ref: '#/components/schemas/CandidateAudit' + nullable: true memory_citations: type: array maxItems: 32 @@ -5559,6 +5865,7 @@ components: - $ref: "#/components/schemas/MemoryDreamCandidateProposal" - $ref: "#/components/schemas/TopicMemoryDreamProposal" - $ref: "#/components/schemas/HandoffContent" + - $ref: "#/components/schemas/PromptContent" source_refs: type: array maxItems: 32 @@ -5626,10 +5933,10 @@ components: $ref: "#/components/schemas/DreamOperation" output_kind: type: string - enum: [artifact_candidate] + enum: [artifact_candidate, catalog_change_candidate] effect: type: string - enum: [review_then_publish, review_then_commit_without_activation] + enum: [review_then_publish, review_then_commit_without_activation, review_then_publish_configuration, review_then_replace_tags] Capabilities: type: object additionalProperties: false @@ -8788,6 +9095,7 @@ components: - $ref: "#/components/schemas/MemoryDreamCandidateProposal" - $ref: "#/components/schemas/TopicMemoryDreamProposal" - $ref: "#/components/schemas/HandoffContent" + - $ref: "#/components/schemas/PromptContent" source_refs: type: array maxItems: 32 @@ -9689,7 +9997,7 @@ components: enum: [today, 7d, 30d] CandidateFamily: type: string - enum: [experience, skill, profile, memory, topic-memory, handoff] + enum: [experience, skill, profile, memory, topic-memory, handoff, prompt] ExternalSkillInstallationScope: type: string enum: [user, project, plugin] @@ -10279,3 +10587,241 @@ components: items: $ref: "#/components/schemas/AccessAuditEvent" next_cursor: {type: string, maxLength: 2048, nullable: true} + + TagDreamTarget: + type: object + additionalProperties: false + required: + - target + - expected_etag + properties: + target: &id002 + $ref: '#/components/schemas/TagTarget' + expected_etag: &id001 + type: string + minLength: 1 + basis_ref: &id003 + $ref: '#/components/schemas/ArtifactReference' + nullable: true + basis_citation: &id004 + $ref: '#/components/schemas/MemoryCitation' + nullable: true + CandidateAudit: + type: object + additionalProperties: false + required: + - origin + - operation + - dream_run_id + - spec_version + - proposal_digest + - evidence_manifest_ref + - validation_policy_digest + - proposal_fingerprint + properties: + origin: *id001 + operation: *id001 + dream_run_id: *id001 + spec_version: *id001 + proposal_digest: *id001 + evidence_manifest_ref: *id001 + validation_policy_digest: *id001 + proposal_fingerprint: + type: string + nullable: true + CatalogChangeProposal: + type: object + additionalProperties: false + required: + - target + - expected_etag + - before_tags + - after_tags + properties: + target: *id002 + expected_etag: *id001 + basis_ref: *id003 + basis_citation: *id004 + before_tags: &id005 + type: array + items: + type: string + minLength: 1 + maxLength: 64 + maxItems: 32 + after_tags: *id005 + CatalogTagResult: + type: object + additionalProperties: false + required: + - scope_id + - target + - tags + - tag_digest + - etag + properties: + scope_id: *id001 + target: + $ref: '#/components/schemas/TagTarget' + tags: *id005 + tag_digest: *id001 + etag: *id001 + CatalogChangeCandidate: + type: object + additionalProperties: false + required: + - candidate_id + - version + - status + - proposal + - reason + properties: + audit: + $ref: '#/components/schemas/CandidateAudit' + nullable: true + operation: {type: string, enum: [revise_tags], default: revise_tags} + origin: {type: string, enum: [dream, manual], default: dream} + candidate_id: *id001 + version: + type: integer + minimum: 1 + status: + $ref: '#/components/schemas/CandidateStatus' + proposal: + $ref: '#/components/schemas/CatalogChangeProposal' + sources: + type: array + items: + $ref: '#/components/schemas/DreamSourceReference' + default: [] + artifacts: + type: array + items: + $ref: '#/components/schemas/ArtifactReference' + default: [] + memory_citations: + type: array + items: + $ref: '#/components/schemas/MemoryCitation' + default: [] + reason: *id001 + dream_run_id: + type: string + nullable: true + result: + $ref: '#/components/schemas/CatalogTagResult' + nullable: true + decision_reason: + type: string + nullable: true + CatalogCandidatePage: + type: object + additionalProperties: false + required: + - candidates + properties: + candidates: + type: array + items: + $ref: '#/components/schemas/CatalogChangeCandidate' + default: [] + next_cursor: + type: string + nullable: true + CatalogCandidateHistory: + type: object + additionalProperties: false + required: + - versions + properties: + versions: + type: array + items: + $ref: '#/components/schemas/CatalogChangeCandidate' + default: [] + ListCatalogCandidatesRequest: + type: object + additionalProperties: false + required: + - scope_id + properties: + scope_id: &id006 + type: string + minLength: 1 + maxLength: 256 + status: + $ref: '#/components/schemas/CandidateStatus' + nullable: true + cursor: + type: string + nullable: true + limit: + type: integer + minimum: 1 + maximum: 100 + default: 50 + GetCatalogCandidateRequest: + type: object + additionalProperties: false + required: + - scope_id + - candidate_id + properties: + scope_id: *id006 + candidate_id: *id001 + ApproveCatalogCandidateRequest: + type: object + additionalProperties: false + required: + - scope_id + - candidate_id + - expected_version + properties: + scope_id: *id006 + candidate_id: *id001 + expected_version: &id007 + type: integer + minimum: 1 + RejectCatalogCandidateRequest: + type: object + additionalProperties: false + required: + - scope_id + - candidate_id + - expected_version + - reason + properties: + scope_id: *id006 + candidate_id: *id001 + expected_version: *id007 + reason: *id001 + ReviseCatalogCandidateRequest: + type: object + additionalProperties: false + required: + - scope_id + - candidate_id + - expected_version + - after_tags + - reason + properties: + scope_id: *id006 + candidate_id: *id001 + expected_version: *id007 + after_tags: *id005 + reason: *id001 + sources: + type: array + items: + $ref: '#/components/schemas/DreamSourceReference' + nullable: true + artifacts: + type: array + items: + $ref: '#/components/schemas/ArtifactReference' + nullable: true + memory_citations: + type: array + items: + $ref: '#/components/schemas/MemoryCitation' + nullable: true diff --git a/src/powercontext/artifacts/models.py b/src/powercontext/artifacts/models.py index ed95a77b49..afbb46fd7c 100644 --- a/src/powercontext/artifacts/models.py +++ b/src/powercontext/artifacts/models.py @@ -110,7 +110,7 @@ class ArtifactDraft(BaseModel, Generic[ContentT]): @model_validator(mode="after") def validate_family(self): _validate_reference_part("family", self.family) - if self.memory_citations and self.family not in {"experience", "profile", "topic-memory"}: + if self.memory_citations and self.family not in {"experience", "profile", "topic-memory", "skill", "prompt"}: raise ValueError("this Artifact family does not accept direct Memory citations") # noqa: TRY003 return self @@ -127,7 +127,13 @@ class Artifact(BaseModel, Generic[ContentT]): @model_validator(mode="after") def validate_entry_lineage(self): - if self.lineage.memory_citations and self.family not in {"experience", "profile", "topic-memory"}: + if self.lineage.memory_citations and self.family not in { + "experience", + "profile", + "topic-memory", + "skill", + "prompt", + }: raise ValueError("this Artifact family does not accept direct Memory citations") # noqa: TRY003 return self diff --git a/src/powercontext/builtin/artifacts/profile/review.py b/src/powercontext/builtin/artifacts/profile/review.py index 1cdec1f99b..f1dd1e81ce 100644 --- a/src/powercontext/builtin/artifacts/profile/review.py +++ b/src/powercontext/builtin/artifacts/profile/review.py @@ -40,6 +40,7 @@ async def decide_profile(service, connection, candidate_id, expected_version, *, policies = ProfilePolicyRepository() policy = await policies.get(connection, service._scope_id, for_update=True) candidate = await service._candidates.lock_pending(connection, service._scope_id, candidate_id, expected_version) + await service._authorize_decision("approve" if reason is None else "reject", candidate) proposal = candidate.proposal if not isinstance(proposal, ProfileCandidateProposal): raise InvalidCandidateError("family", "profile required") @@ -106,6 +107,7 @@ async def _decide_dream_profile(service, connection, candidate, policy, *, reaso return await service._candidates.reject( connection, service._scope_id, candidate.candidate_id, candidate.version, reason ) + await service._validate_candidate_audit(connection, candidate) if proposal.policy_version != (None if policy is None else policy.version): raise BaseValueConflictError("profile_policy", (service._scope_id,)) await service._validate_evidence( diff --git a/src/powercontext/builtin/catalog_changes/__init__.py b/src/powercontext/builtin/catalog_changes/__init__.py new file mode 100644 index 0000000000..ca2b7967dd --- /dev/null +++ b/src/powercontext/builtin/catalog_changes/__init__.py @@ -0,0 +1,15 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Reviewable changes to mutable catalog metadata.""" diff --git a/src/powercontext/builtin/catalog_changes/application.py b/src/powercontext/builtin/catalog_changes/application.py new file mode 100644 index 0000000000..2af71bd4e6 --- /dev/null +++ b/src/powercontext/builtin/catalog_changes/application.py @@ -0,0 +1,28 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Scope-bound public catalog review facade.""" + +from collections.abc import Callable + +from powercontext.builtin.catalog_changes.service import CatalogChangeService +from powercontext.builtin.sources import validate_scope_id + + +class CatalogChangeApplication: + def __init__(self, service_factory: Callable[[str], CatalogChangeService]) -> None: + self._service_factory = service_factory + + def for_scope(self, scope_id: str) -> CatalogChangeService: + return self._service_factory(validate_scope_id(scope_id)) diff --git a/src/powercontext/builtin/catalog_changes/models.py b/src/powercontext/builtin/catalog_changes/models.py new file mode 100644 index 0000000000..54aa2fcbd5 --- /dev/null +++ b/src/powercontext/builtin/catalog_changes/models.py @@ -0,0 +1,113 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Typed Tag proposals separate from immutable Artifact candidates.""" + +from __future__ import annotations + +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator + +from powercontext.artifacts import ArtifactRef, MemoryCitation +from powercontext.builtin.evidence.models import ResolvedEvidence +from powercontext.builtin.review.models import CandidateAudit +from powercontext.builtin.tags import ArtifactTagSet, ArtifactTagTarget, TagTarget, normalize_tags +from powercontext.sources import SourceRef + + +class CatalogValue(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + +class TagDreamTarget(CatalogValue): + target: TagTarget + expected_etag: str = Field(min_length=1, max_length=256) + basis_ref: ArtifactRef | None = None + basis_citation: MemoryCitation | None = None + + @model_validator(mode="after") + def validate_basis(self): + if isinstance(self.target, ArtifactTagTarget): + if self.basis_ref is None or self.basis_citation is not None: + raise ValueError("Artifact tags require an exact Artifact basis") # noqa: TRY003 + ref = self.basis_ref + else: + if self.basis_citation is None or self.basis_ref is not None: + raise ValueError("Memory entry tags require an exact entry basis") # noqa: TRY003 + if self.basis_citation.entry_id != self.target.entry_id: + raise ValueError("Tag target and entry basis must match") # noqa: TRY003 + ref = self.basis_citation.memory_ref + if (ref.family, ref.artifact_id) != (self.target.family, self.target.artifact_id): + raise ValueError("Tag target and content basis must match") # noqa: TRY003 + return self + + +class TagChangeProposal(CatalogValue): + after_tags: tuple[str, ...] = Field(max_length=32) + + @field_validator("after_tags") + @classmethod + def valid_tags(cls, value: tuple[str, ...]) -> tuple[str, ...]: + return tuple(normalize_tags(value).values()) + + +class CatalogChangeProposal(TagDreamTarget, TagChangeProposal): + before_tags: tuple[str, ...] = Field(max_length=32) + + @field_validator("before_tags") + @classmethod + def valid_before_tags(cls, value: tuple[str, ...]) -> tuple[str, ...]: + return tuple(normalize_tags(value).values()) + + +class CatalogChangeCandidate(CatalogValue): + candidate_id: str = Field(min_length=1, max_length=128) + version: int = Field(ge=1) + status: Literal["pending", "approved", "rejected"] = "pending" + operation: Literal["revise_tags"] = "revise_tags" + origin: Literal["dream", "manual"] = "dream" + proposal: CatalogChangeProposal + sources: tuple[SourceRef, ...] = Field(default=(), max_length=32) + artifacts: tuple[ArtifactRef, ...] = Field(default=(), max_length=32) + memory_citations: tuple[MemoryCitation, ...] = Field(default=(), max_length=32) + reason: str = Field(min_length=1, max_length=2000) + dream_run_id: str | None = None + audit: CandidateAudit | None = None + result: ArtifactTagSet | None = None + decision_reason: str | None = Field(default=None, min_length=1, max_length=2000) + + @model_validator(mode="after") + def valid_state(self): + if not 1 <= len(self.sources) + len(self.artifacts) + len(self.memory_citations) <= 32: + raise ValueError("Catalog candidates require 1 to 32 evidence references") # noqa: TRY003 + if (self.status == "approved") != (self.result is not None): + raise ValueError("Only an approved Catalog candidate has a tag result") # noqa: TRY003 + if (self.status == "rejected") != (self.decision_reason is not None): + raise ValueError("Only a rejected Catalog candidate has a rejection reason") # noqa: TRY003 + if (self.origin == "dream") != (self.dream_run_id is not None): + raise ValueError("Dream Catalog candidates identify their run") # noqa: TRY003 + return self + + +class CatalogCandidatePage(CatalogValue): + candidates: tuple[CatalogChangeCandidate, ...] + next_cursor: str | None = None + + +class CatalogCandidateEvidence(CatalogValue): + candidate_id: str + version: int + resolved: ResolvedEvidence | None = None + unavailable: str | None = None diff --git a/src/powercontext/builtin/catalog_changes/service.py b/src/powercontext/builtin/catalog_changes/service.py new file mode 100644 index 0000000000..726a046bd4 --- /dev/null +++ b/src/powercontext/builtin/catalog_changes/service.py @@ -0,0 +1,360 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Review Tag changes with an ETag and an independently checked content basis.""" + +from __future__ import annotations + +from collections.abc import AsyncIterator, Awaitable, Callable +from contextlib import AbstractAsyncContextManager, asynccontextmanager, nullcontext +from typing import Literal + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncConnection + +from powercontext.artifacts import ArtifactRef, MemoryCitation +from powercontext.builtin.artifacts.memory.models import Memory +from powercontext.builtin.catalog_changes.models import ( + CatalogCandidateEvidence, + CatalogCandidatePage, + CatalogChangeCandidate, + CatalogChangeProposal, + TagDreamTarget, +) +from powercontext.builtin.evidence.models import EvidenceResolutionError +from powercontext.builtin.evidence.resolver import AuthorizationContext, EvidenceAuthorizer, EvidenceResolver +from powercontext.builtin.persistence.artifacts import ArtifactRepository +from powercontext.builtin.persistence.candidates import proposal_digest +from powercontext.builtin.persistence.catalog_changes import CatalogCandidateRepository +from powercontext.builtin.persistence.database import AsyncDatabase +from powercontext.builtin.persistence.tables import ARTIFACT_HEADS_TABLE +from powercontext.builtin.persistence.tags import RelationalTagService +from powercontext.builtin.records import InvalidCursorError +from powercontext.builtin.review.errors import ( + ArtifactTargetConflictError, + CandidateConflictError, + InvalidCandidateError, +) +from powercontext.builtin.review.models import CandidateAudit +from powercontext.builtin.tags import ArtifactTagSet, TagPreconditionError +from powercontext.sources import SourceRef + + +class CatalogChangeService: + def __init__( + self, + *, + database: AsyncDatabase, + scope_id: str, + artifacts: ArtifactRepository, + evidence: EvidenceResolver, + id_factory: Callable[[str], str], + tags: RelationalTagService | None = None, + connection: AsyncConnection | None = None, + authorization_context: Callable[[], AbstractAsyncContextManager[None]] = nullcontext, + ) -> None: + self._database = database + self._scope_id = scope_id + self._artifacts = artifacts + self._evidence = evidence + self._id_factory = id_factory + self._tags = tags or RelationalTagService(database, artifacts) + self._repository = CatalogCandidateRepository() + self._bound_connection = connection + self._authorization_context = authorization_context + self.authorize_action: Callable[[str, str, TagDreamTarget], Awaitable[None]] | None = None + + def configure_authorization(self, authorize: EvidenceAuthorizer, context: AuthorizationContext) -> None: + self._evidence.authorize = authorize + self._authorization_context = context + + @asynccontextmanager + async def _connection(self) -> AsyncIterator[AsyncConnection]: + async with self._authorization_context(), self._database.connection(self._bound_connection) as connection: + yield connection + + async def _authorize_target(self, target: TagDreamTarget) -> None: + reference = target.basis_ref if target.basis_ref is not None else target.basis_citation + if reference is None: + raise InvalidCandidateError("basis", "missing content basis") + if self._evidence.authorize is not None: + await self._evidence.authorize(reference) + + async def _basis(self, connection: AsyncConnection, target: TagDreamTarget) -> None: + await self._authorize_target(target) + artifact = await self._artifacts.latest( + connection, self._scope_id, target.target.family, target.target.artifact_id, for_update=True + ) + table = ARTIFACT_HEADS_TABLE + lifecycle = await connection.scalar( + select(table.c.lifecycle_state) + .where( + table.c.scope_id == self._scope_id, + table.c.family == target.target.family, + table.c.artifact_id == target.target.artifact_id, + ) + .with_for_update() + ) + if lifecycle != "active": + raise InvalidCandidateError("target", "target_inactive") + if target.basis_ref is not None: + if artifact.as_ref() != target.basis_ref: + raise ArtifactTargetConflictError(target.basis_ref, artifact.as_ref()) + else: + citation = target.basis_citation + if citation is None: + raise InvalidCandidateError("basis", "missing entry basis") + if not isinstance(artifact, Memory): + raise InvalidCandidateError("target", "expected a Memory entry") + # Validate the old exact manifest and entry version as well as today's + # entry. Unrelated changes elsewhere in this Memory do not invalidate it. + await self._evidence.memory_reader(connection, citation) + entry = next( + (entry for entry in artifact.content.manifest.entries if entry.entry_id == citation.entry_id), None + ) + if entry is None or entry.state != "active" or entry.entry_version_id != citation.entry_version_id: + raise ArtifactTargetConflictError(citation.memory_ref, artifact.as_ref()) + + async def _lock_target(self, connection: AsyncConnection, target: TagDreamTarget) -> None: + # Profile's automatic and review writers serialize on Policy first. + # Other Families lock their owning head before evidence and Candidate. + if target.target.family == "profile": + from powercontext.builtin.persistence.profile import ProfilePolicyRepository + + await ProfilePolicyRepository().get(connection, self._scope_id, for_update=True) + await self._tags.lock_target(connection, self._scope_id, target.target) + + async def _evidence_and_lock(self, connection: AsyncConnection, candidate: CatalogChangeCandidate) -> None: + await self._lock_target(connection, candidate.proposal) + citations = candidate.memory_citations + basis = candidate.proposal.basis_citation + if basis is not None and basis not in citations: + citations = (*citations, basis) + await self._evidence.validate( + connection, + sources=candidate.sources, + artifacts=tuple(ref for ref in candidate.artifacts if ref != candidate.proposal.basis_ref), + memory_citations=citations, + ) + await self._basis(connection, candidate.proposal) + + async def inspect_target(self, target: TagDreamTarget) -> ArtifactTagSet: + """Validate both bases before accepting or generating a Tag Dream.""" + async with self._connection() as connection: + await self._lock_target(connection, target) + await self._basis(connection, target) + current = await self._tags.read_in_transaction(connection, self._scope_id, target.target) + if current.etag != target.expected_etag: + raise TagPreconditionError + return current + + async def propose( + self, + proposal: CatalogChangeProposal, + *, + reason: str, + sources: tuple[SourceRef, ...] = (), + artifacts: tuple[ArtifactRef, ...] = (), + memory_citations: tuple[MemoryCitation, ...] = (), + dream_run_id: str | None = None, + candidate_id: str | None = None, + audit: CandidateAudit | None = None, + ) -> CatalogChangeCandidate: + candidate = CatalogChangeCandidate( + candidate_id=candidate_id or self._id_factory("candidate"), + version=1, + proposal=proposal, + sources=sources, + artifacts=artifacts, + memory_citations=memory_citations, + reason=reason, + dream_run_id=dream_run_id, + origin="dream" if dream_run_id else "manual", + audit=audit, + ) + async with self._connection() as connection: + await self._evidence_and_lock(connection, candidate) + current = await self._tags.read_in_transaction(connection, self._scope_id, proposal.target) + if current.etag != proposal.expected_etag or current.tags != proposal.before_tags: + raise TagPreconditionError + return await self._repository.create(connection, self._scope_id, candidate) + + async def get(self, candidate_id: str, *, current: bool = False) -> CatalogChangeCandidate: + async with self._connection() as connection: + candidate = await self._repository.get(connection, self._scope_id, candidate_id, current=current) + await self._authorize_target(candidate.proposal) + return candidate + + async def list( + self, + *, + status: Literal["pending", "approved", "rejected"] | None = None, + cursor: str | None = None, + limit: int = 50, + ) -> CatalogCandidatePage: + if not 1 <= limit <= 100: + raise InvalidCandidateError("limit", "expected 1 to 100") + if cursor is not None and (not cursor or len(cursor) > 128): + raise InvalidCursorError + async with self._connection() as connection: + candidates = await self._repository.list( + connection, self._scope_id, status=status, after=cursor or "", limit=limit + 1 + ) + for candidate in candidates[:limit]: + await self._authorize_target(candidate.proposal) + return CatalogCandidatePage( + candidates=candidates[:limit], + next_cursor=candidates[limit - 1].candidate_id if len(candidates) > limit else None, + ) + + async def history(self, candidate_id: str) -> tuple[CatalogChangeCandidate, ...]: + async with self._connection() as connection: + candidates = await self._repository.history(connection, self._scope_id, candidate_id) + await self._authorize_target(candidates[-1].proposal) + return candidates + + async def revise( + self, + candidate_id: str, + expected_version: int, + *, + after_tags: tuple[str, ...], + reason: str, + sources: tuple[SourceRef, ...] | None = None, + artifacts: tuple[ArtifactRef, ...] | None = None, + memory_citations: tuple[MemoryCitation, ...] | None = None, + ) -> CatalogChangeCandidate: + async with self._connection() as connection: + preview = await self._repository.get(connection, self._scope_id, candidate_id) + if self.authorize_action is not None: + await self.authorize_action(self._scope_id, "revise", preview.proposal) + proposal = CatalogChangeProposal.model_validate({**preview.proposal.model_dump(), "after_tags": after_tags}) + revised = CatalogChangeCandidate.model_validate({ + **preview.model_dump(), + "version": expected_version + 1, + "proposal": proposal, + "sources": preview.sources if sources is None else sources, + "artifacts": preview.artifacts if artifacts is None else artifacts, + "memory_citations": preview.memory_citations if memory_citations is None else memory_citations, + "reason": reason, + }) + if revised.audit is not None: + revised = revised.model_copy( + update={ + "audit": revised.audit.model_copy( + update={ + "proposal_digest": proposal_digest(revised.proposal), + "evidence_manifest_ref": f"candidate:{candidate_id}:{revised.version}", + "proposal_fingerprint": None, + } + ) + } + ) + await self._evidence_and_lock(connection, revised) + current = await self._repository.lock_pending(connection, self._scope_id, candidate_id, expected_version) + if current != preview: + raise CandidateConflictError(candidate_id, expected_version, current.version) + return await self._repository.save(connection, self._scope_id, current, revised) + + async def approve(self, candidate_id: str, expected_version: int) -> CatalogChangeCandidate: + async with self._connection() as connection: + preview = await self._repository.get(connection, self._scope_id, candidate_id) + if self.authorize_action is not None: + await self.authorize_action(self._scope_id, "approve", preview.proposal) + await self._evidence_and_lock(connection, preview) + current = await self._repository.lock_pending(connection, self._scope_id, candidate_id, expected_version) + if current != preview: + raise CandidateConflictError(candidate_id, expected_version, current.version) + await self._validate_audit(connection, current) + result = await self._tags.replace_in_transaction( + connection, + self._scope_id, + current.proposal.target, + current.proposal.after_tags, + expected_etag=current.proposal.expected_etag, + ) + approved = CatalogChangeCandidate.model_validate({ + **current.model_dump(), + "status": "approved", + "result": result, + }) + return await self._repository.save(connection, self._scope_id, current, approved) + + async def _validate_audit(self, connection: AsyncConnection, candidate: CatalogChangeCandidate) -> None: + from powercontext.builtin.dream.bindings import DREAM_SPECS + from powercontext.builtin.dream.provenance import validation_policy_digest + from powercontext.builtin.persistence.dream import DreamRepository + + audit = candidate.audit + if audit is None: + if candidate.origin == "dream": + raise InvalidCandidateError("audit", "Dream Catalog candidate has no provenance") + return + spec = DREAM_SPECS.get(audit.operation) + if ( + audit.operation != "revise_tags" + or audit.dream_run_id != candidate.dream_run_id + or spec is None + or spec.spec_version != audit.spec_version + or audit.proposal_digest != proposal_digest(candidate.proposal) + ): + raise InvalidCandidateError("audit", "Catalog candidate content or operation contract changed") + record = await DreamRepository().get(connection, self._scope_id, audit.dream_run_id) + baseline = TagDreamTarget.model_validate({ + key: value + for key, value in candidate.proposal.model_dump().items() + if key not in {"before_tags", "after_tags"} + }) + reference = record.run.candidate + if ( + record.run.operation != "revise_tags" + or record.run.tag_target != baseline + or reference is None + or reference.kind != "catalog_change" + or reference.candidate_id != candidate.candidate_id + or reference.version > candidate.version + or validation_policy_digest(record) != audit.validation_policy_digest + ): + raise InvalidCandidateError("audit", "Catalog candidate policy or Dream origin changed") + + async def reject(self, candidate_id: str, expected_version: int, reason: str) -> CatalogChangeCandidate: + async with self._connection() as connection: + preview = await self._repository.get(connection, self._scope_id, candidate_id) + if self.authorize_action is not None: + await self.authorize_action(self._scope_id, "reject", preview.proposal) + await self._authorize_target(preview.proposal) + current = await self._repository.lock_pending(connection, self._scope_id, candidate_id, expected_version) + rejected = CatalogChangeCandidate.model_validate({ + **current.model_dump(), + "status": "rejected", + "decision_reason": reason, + }) + return await self._repository.save(connection, self._scope_id, current, rejected) + + async def evidence(self, candidate_id: str) -> CatalogCandidateEvidence: + async with self._connection() as connection: + candidate = await self._repository.get(connection, self._scope_id, candidate_id) + await self._authorize_target(candidate.proposal) + try: + resolved = await self._evidence.resolve( + connection, + sources=candidate.sources, + artifacts=candidate.artifacts, + memory_citations=candidate.memory_citations, + ) + except EvidenceResolutionError as error: + return CatalogCandidateEvidence( + candidate_id=candidate_id, version=candidate.version, unavailable=error.code + ) + return CatalogCandidateEvidence(candidate_id=candidate_id, version=candidate.version, resolved=resolved) diff --git a/src/powercontext/builtin/dream/application.py b/src/powercontext/builtin/dream/application.py index 87bd94720b..bac9e28e4f 100644 --- a/src/powercontext/builtin/dream/application.py +++ b/src/powercontext/builtin/dream/application.py @@ -18,7 +18,7 @@ from typing import TYPE_CHECKING -from powercontext.builtin.dream.bindings import DREAM_BINDINGS +from powercontext.builtin.dream.bindings import binding_for_request from powercontext.builtin.dream.models import ( CreateDreamRunRequest, DreamError, @@ -49,7 +49,7 @@ async def create(self, request: CreateDreamRunRequest, /) -> DreamRun: async with self._runtime._scoped_operation(self.scope_id): run = await self._service().create(self.scope_id, self.principal_id, request) if not run.terminal and self._runtime.artifact_processing_supervisor is not None: - self._runtime.artifact_processing_supervisor.wake(DREAM_BINDINGS[run.operation]) + self._runtime.artifact_processing_supervisor.wake(binding_for_request(request)) return run async def get(self, request: GetDreamRunRequest, /) -> DreamRun: diff --git a/src/powercontext/builtin/dream/bindings.py b/src/powercontext/builtin/dream/bindings.py index f40620b24a..097985b631 100644 --- a/src/powercontext/builtin/dream/bindings.py +++ b/src/powercontext/builtin/dream/bindings.py @@ -14,42 +14,181 @@ """Map enabled Dream operations to their owning Family processing binding.""" +from collections.abc import Callable from dataclasses import dataclass +from typing import Literal -from powercontext.builtin.artifacts.experience import EXPERIENCE_INCUBATION_CURSOR_NAME -from powercontext.builtin.artifacts.profile.models import PROFILE_SOURCE_WINDOW_BINDING +from pydantic import BaseModel + +from powercontext.builtin.artifacts.experience import EXPERIENCE_INCUBATION_CURSOR_NAME, ExperienceContent +from powercontext.builtin.artifacts.handoff.models import HandoffContent +from powercontext.builtin.artifacts.memory.models import MemoryDreamWrite +from powercontext.builtin.artifacts.profile.models import PROFILE_SOURCE_WINDOW_BINDING, ProfileWriteContent +from powercontext.builtin.artifacts.prompt.models import PromptContent +from powercontext.builtin.artifacts.skill import SkillContent from powercontext.builtin.artifacts.topic_memory import TOPIC_MEMORY_SOURCE_WINDOW_BINDING -from powercontext.builtin.dream.models import DreamOperation +from powercontext.builtin.artifacts.topic_memory.models import TopicMemoryContent +from powercontext.builtin.catalog_changes.models import TagChangeProposal +from powercontext.builtin.dream.models import CreateDreamRunRequest, DreamError, DreamOperation, DreamPlan from powercontext.builtin.triggers import SOURCE_WINDOW_TRIGGER_NAME SKILL_DREAM_BINDING = "skill.dream.v1" HANDOFF_DREAM_BINDING = "handoff.dream.v1" +PROMPT_DREAM_BINDING = "prompt.dream.v1" + +DreamEffect = Literal[ + "review_then_publish", + "review_then_commit_without_activation", + "review_then_publish_configuration", + "review_then_replace_tags", +] @dataclass(frozen=True) class DreamOperationSpec: + """Server-owned adapters and contracts; callers cannot supply executable behavior.""" + operation: DreamOperation - family: str - binding: str + family: str | None + binding: str | None + target_kind: str + allowed_evidence: tuple[str, ...] + proposal_schema: type[BaseModel] + review_adapter: str + commit_adapter: str + effect: DreamEffect = "review_then_publish" + spec_version: str = "powercontext.dream.operation.v1" + validator: Callable[[DreamPlan, CreateDreamRunRequest], None] = DreamPlan.validate_operation + output_kind: Literal["artifact_candidate", "catalog_change_candidate"] = "artifact_candidate" + + @property + def processing_family(self) -> str | None: + return self.family -# An operation belongs here only after its resolver, generator, Candidate and -# approval writer are implemented. This registry controls worker dispatch as -# well as admission; a readable Artifact Family alone does not enable Dream. DREAM_OPERATIONS = ( - DreamOperationSpec("refine_experience", "experience", EXPERIENCE_INCUBATION_CURSOR_NAME), - DreamOperationSpec("derive_skill", "skill", SKILL_DREAM_BINDING), - # revise_skill requires a trusted evaluation policy before it can be enabled. - DreamOperationSpec("revise_profile", "profile", PROFILE_SOURCE_WINDOW_BINDING), - DreamOperationSpec("revise_memory", "memory", SOURCE_WINDOW_TRIGGER_NAME), - DreamOperationSpec("revise_topic_memory", "topic-memory", TOPIC_MEMORY_SOURCE_WINDOW_BINDING), - DreamOperationSpec("refresh_handoff", "handoff", HANDOFF_DREAM_BINDING), + DreamOperationSpec( + "refine_experience", + "experience", + EXPERIENCE_INCUBATION_CURSOR_NAME, + "optional_artifact", + ("experience", "memory", "source"), + ExperienceContent, + "approve", + "experience.revise", + ), + DreamOperationSpec( + "derive_skill", + "skill", + SKILL_DREAM_BINDING, + "none", + ("experience", "source"), + SkillContent, + "approve", + "skill.create", + ), + DreamOperationSpec( + "revise_skill", + "skill", + SKILL_DREAM_BINDING, + "artifact", + ("skill", "experience", "memory", "source"), + SkillContent, + "approve", + "skill.revise", + ), + DreamOperationSpec( + "revise_profile", + "profile", + PROFILE_SOURCE_WINDOW_BINDING, + "artifact", + ("profile", "experience", "memory", "source"), + ProfileWriteContent, + "decide_profile", + "profile.revise", + ), + DreamOperationSpec( + "revise_memory", + "memory", + SOURCE_WINDOW_TRIGGER_NAME, + "memory_entries", + ("memory", "experience", "source"), + MemoryDreamWrite, + "_approve_memory_dream", + "memory.apply", + ), + DreamOperationSpec( + "revise_topic_memory", + "topic-memory", + TOPIC_MEMORY_SOURCE_WINDOW_BINDING, + "artifact", + ("topic-memory", "experience", "memory", "source"), + TopicMemoryContent, + "_approve_topic_memory_dream", + "topic_memory.publish_revision", + ), + DreamOperationSpec( + "refresh_handoff", + "handoff", + HANDOFF_DREAM_BINDING, + "artifact", + ("handoff", "experience", "memory", "source"), + HandoffContent, + "_approve_handoff_dream", + "handoff.commit_reviewed", + "review_then_commit_without_activation", + ), + DreamOperationSpec( + "revise_prompt", + "prompt", + PROMPT_DREAM_BINDING, + "prompt_key", + ("prompt", "experience", "memory", "source"), + PromptContent, + "_approve_prompt_dream", + "prompt.commit_reviewed", + "review_then_publish_configuration", + ), + # A Catalog job belongs to the target Artifact's existing processing Family. + DreamOperationSpec( + "revise_tags", + None, + None, + "tag_target", + ("artifact", "memory", "source"), + TagChangeProposal, + "catalog.approve", + "catalog.replace_tags", + "review_then_replace_tags", + output_kind="catalog_change_candidate", + ), ) -DREAM_BINDINGS = {spec.operation: spec.binding for spec in DREAM_OPERATIONS} +DREAM_SPECS = {spec.operation: spec for spec in DREAM_OPERATIONS} +DREAM_BINDINGS = {spec.operation: spec.binding for spec in DREAM_OPERATIONS if spec.binding is not None} +FAMILY_DREAM_BINDINGS = {spec.family: spec.binding for spec in DREAM_OPERATIONS if spec.family is not None} + + +def operation_spec(operation: DreamOperation) -> DreamOperationSpec: + return DREAM_SPECS[operation] + + +def binding_for_request(request: CreateDreamRunRequest) -> str: + if request.operation == "revise_tags": + family = None if request.tag_target is None else request.tag_target.target.family + binding = FAMILY_DREAM_BINDINGS.get(family) + else: + binding = DREAM_BINDINGS.get(request.operation) + if binding is None: + raise DreamError("capability_unavailable") + return binding def operations_for_binding(binding: str) -> tuple[DreamOperation, ...]: - return tuple(spec.operation for spec in DREAM_OPERATIONS if spec.binding == binding) + return tuple( + spec.operation + for spec in DREAM_OPERATIONS + if spec.binding == binding or (spec.operation == "revise_tags" and binding in FAMILY_DREAM_BINDINGS.values()) + ) DREAM_PROVIDERS = frozenset({"openai", "openai-chat", "openai-responses", "anthropic"}) diff --git a/src/powercontext/builtin/dream/generation.py b/src/powercontext/builtin/dream/generation.py index 8ce8fe36cb..87db1b2f38 100644 --- a/src/powercontext/builtin/dream/generation.py +++ b/src/powercontext/builtin/dream/generation.py @@ -19,6 +19,7 @@ from pydantic import BaseModel, Field from powercontext.builtin.artifacts.profile.models import ProfilePolicy +from powercontext.builtin.catalog_changes.models import TagDreamTarget from powercontext.builtin.dream.models import DreamOperation, DreamPlan from powercontext.builtin.evidence.models import EvidenceProjection from powercontext.builtin.inference.models import GenerationResult @@ -45,11 +46,20 @@ complete title, summary, and detail content. Preserve confirmed results, hypotheses, ruled-out causes, open questions, and next steps as distinct claims. Use correct as the intent. For refresh_handoff, propose complete Handoff content with exact citations for every state -and next-action claim. A proposed action is not a completed action. Do not claim activation, -receiver acknowledgement, or an executed business tool. Return generation as null and use correct. +and next-action claim. Omission citations, when present, must also refer only to selected evidence. +A proposed action is not a completed action. Do not claim activation, receiver acknowledgement, +or an executed business tool. Return generation as null and use correct. For revise_skill, use the exact existing Skill as the target and task outcome evidence as grounds. Only revise instructions, validation, and description; preserve name and other metadata. Do not infer that selection means execution or success. Return package as null and use correct. +For revise_prompt, diagnose the exact registered Prompt from verified errors and corrections in supplied Sources. +Treat incorrect outputs as the diagnosed failure, never as factual support for their own content. +Only propose custom mode, instructions, and demonstrations; keep the target key and operation contract unchanged. +Demonstrations must match the supplied Prompt Definition input/output schemas, with grounded expected outputs. +Do not alter trust rules, tools, model configuration, budgets, Dream instructions or review rules. Use correct. +For revise_tags, propose a complete after_tags set for the one target, based on its pinned content and evidence. +Tags are classification only, not factual proof or authorization. Do not rename tags on other resources. +Keep useful existing labels, remove unsupported or duplicate labels, and use correct as the intent. Provide name, description, instructions, and at least one concrete validation check in validation. Use a package-compatible name: at most 64 lowercase letters, digits, and single separating hyphens. Keep description within 1024 characters. Trim text fields and omit trailing whitespace in instructions. @@ -76,6 +86,9 @@ class DreamGenerationInput(BaseModel): ) evidence: EvidenceProjection profile_policy: ProfilePolicy | None = None + prompt_definition: dict[str, object] | None = None + tag_target: TagDreamTarget | None = None + before_tags: tuple[str, ...] = () class DreamGenerator(Protocol): diff --git a/src/powercontext/builtin/dream/models.py b/src/powercontext/builtin/dream/models.py index ace76b63a2..5d9f322234 100644 --- a/src/powercontext/builtin/dream/models.py +++ b/src/powercontext/builtin/dream/models.py @@ -26,8 +26,10 @@ from powercontext.builtin.artifacts.handoff.models import HandoffContent from powercontext.builtin.artifacts.memory.models import MemoryDreamWrite from powercontext.builtin.artifacts.profile.models import ProfilePolicy, ProfileWriteContent +from powercontext.builtin.artifacts.prompt.models import PROMPT_KEYS, PromptContent from powercontext.builtin.artifacts.skill import SkillContent from powercontext.builtin.artifacts.topic_memory.models import TopicMemoryContent +from powercontext.builtin.catalog_changes.models import TagChangeProposal, TagDreamTarget from powercontext.builtin.evidence.models import ( EvidenceLimits, EvidenceManifest, @@ -46,11 +48,13 @@ "revise_memory", "revise_topic_memory", "refresh_handoff", + "revise_prompt", + "revise_tags", ] DreamStatus = Literal["queued", "running", "succeeded", "failed"] DreamOutcome = Literal["proposed", "no_change", "needs_evidence"] DreamIntent = Literal["create", "corroborate", "refine", "correct", "derive"] -DREAM_PROMPT_VERSION = "powercontext.dream.v1.1" +DREAM_PROMPT_VERSION = "powercontext.dream.v1.2" class DreamError(PowerContextError, ValueError): @@ -67,6 +71,7 @@ class CreateDreamRunRequest(BaseModel): memory_citations: tuple[MemoryCitation, ...] = () sources: tuple[SourceRef, ...] = () target: ArtifactRef | None = None + tag_target: TagDreamTarget | None = None idempotency_key: str = Field(min_length=1, max_length=128) @field_validator("idempotency_key") @@ -83,6 +88,14 @@ def normalize_references(cls, value): @model_validator(mode="after") def validate_selection(self): # noqa: C901 - bounded validation for each registered operation + if self.operation == "revise_tags": + if self.target is not None or self.tag_target is None: + raise DreamError("invalid_target") + if not 1 <= len(self.sources) + len(self.artifacts) + len(self.memory_citations) <= 32: + raise DreamError("evidence_limit_exceeded") + return self + if self.tag_target is not None: + raise DreamError("invalid_target") selected = len(self.artifacts) + len(self.memory_citations) selected_limit = 21 if self.operation == "revise_memory" else 20 if not 1 <= selected <= selected_limit or selected + len(self.sources) > 32: @@ -95,6 +108,7 @@ def validate_selection(self): # noqa: C901 - bounded validation for each regist "revise_memory": {"experience", "memory"}, "revise_topic_memory": {"experience", "topic-memory"}, "refresh_handoff": {"experience", "handoff"}, + "revise_prompt": {"experience", "prompt"}, }[self.operation] if any(ref.family not in allowed_families for ref in self.artifacts): raise DreamError("invalid_artifact_family") @@ -115,7 +129,7 @@ def validate_selection(self): # noqa: C901 - bounded validation for each regist self.target is None or self.target.family != "skill" or any(ref.family == "skill" and ref != self.target for ref in self.artifacts) - or not self.sources + or not (self.sources or self.memory_citations or any(ref != self.target for ref in self.artifacts)) ): raise DreamError("invalid_dream_operation") if self.operation == "revise_profile" and ( @@ -144,6 +158,14 @@ def validate_selection(self): # noqa: C901 - bounded validation for each regist or any(ref.family == "handoff" and ref != self.target for ref in self.artifacts) ): raise DreamError("invalid_dream_operation") + if self.operation == "revise_prompt" and ( + self.target is None + or self.target.family != "prompt" + or self.target.artifact_id not in PROMPT_KEYS + or any(ref.family == "prompt" and ref != self.target for ref in self.artifacts) + or not self.sources + ): + raise DreamError("invalid_dream_operation") return self def digest(self) -> str: @@ -163,6 +185,7 @@ class DreamUsage(BaseModel): class DreamCandidateRef(BaseModel): + kind: Literal["artifact", "catalog_change"] = "artifact" candidate_id: str version: int = Field(ge=1) @@ -176,6 +199,7 @@ class DreamRun(BaseModel): status: DreamStatus = "queued" outcome: DreamOutcome | None = None target: ArtifactRef | None = None + tag_target: TagDreamTarget | None = None candidate: DreamCandidateRef | None = None reason: str | None = None error: str | None = None @@ -188,6 +212,7 @@ class DreamRun(BaseModel): budget: DreamBudget = Field(default_factory=DreamBudget) prompt_version: str = DREAM_PROMPT_VERSION model_config_id: str | None = None + reused: bool = False @property def terminal(self) -> bool: @@ -220,6 +245,7 @@ class DreamRecord(BaseModel): request_generation: int = 0 deadline_at: datetime | None = None profile_policy: ProfilePolicy | None = None + proposal_fingerprint: str | None = None class DreamPlan(BaseModel): @@ -235,6 +261,8 @@ class DreamPlan(BaseModel): | MemoryDreamWrite | TopicMemoryContent | HandoffContent + | PromptContent + | TagChangeProposal | None ) = None evidence_ids: tuple[str, ...] = Field(default=(), max_length=32) @@ -250,7 +278,7 @@ def validate_result(self): raise DreamError("invalid_generation_output") return self - def validate_operation(self, request: CreateDreamRunRequest) -> None: + def validate_operation(self, request: CreateDreamRunRequest) -> None: # noqa: C901 - explicit operation contracts if self.outcome != "proposed": return if request.operation == "derive_skill": @@ -282,6 +310,12 @@ def validate_operation(self, request: CreateDreamRunRequest) -> None: and self.proposal.generation is None and self.intent == "correct" ) + elif request.operation == "revise_prompt": + valid = ( + isinstance(self.proposal, PromptContent) and self.proposal.mode == "custom" and self.intent == "correct" + ) + elif request.operation == "revise_tags": + valid = isinstance(self.proposal, TagChangeProposal) and self.intent == "correct" else: intents = {"create"} if request.target is None else {"corroborate", "refine", "correct"} valid = isinstance(self.proposal, ExperienceContent) and self.intent in intents diff --git a/src/powercontext/builtin/dream/provenance.py b/src/powercontext/builtin/dream/provenance.py new file mode 100644 index 0000000000..7bd9e1e9fa --- /dev/null +++ b/src/powercontext/builtin/dream/provenance.py @@ -0,0 +1,68 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Content-free proposal identity and immutable reviewer provenance.""" + +import json + +from pydantic import BaseModel + +from powercontext.builtin.dream.bindings import DREAM_OPERATIONS +from powercontext.builtin.dream.models import DREAM_PROMPT_VERSION, DreamRecord +from powercontext.builtin.evidence.models import ResolvedEvidence, content_digest +from powercontext.builtin.persistence.candidates import proposal_digest +from powercontext.builtin.review.models import CandidateAudit + + +def validation_policy_digest(record: DreamRecord) -> str: + spec = next(spec for spec in DREAM_OPERATIONS if spec.operation == record.run.operation) + return _digest({ + "operation": spec.operation, + "spec_version": spec.spec_version, + "prompt_version": DREAM_PROMPT_VERSION, + "profile_policy": None if record.profile_policy is None else record.profile_policy.model_dump(mode="json"), + }) + + +def proposal_fingerprint(record: DreamRecord, evidence: ResolvedEvidence) -> str: + roots = sorted((node.evidence_id, node.digest) for node in evidence.manifest.nodes if node.role == "root") + # Evidence without a resolvable root must retain its exact independent identity. + if not roots: + roots = sorted((node.evidence_id, node.digest) for node in evidence.manifest.nodes if node.role != "target") + request = record.request + return _digest({ + "operation": request.operation, + "target": None if request.target is None else request.target.model_dump(mode="json"), + "tag_target": None if request.tag_target is None else request.tag_target.model_dump(mode="json"), + "entries": [ref.model_dump(mode="json") for ref in request.memory_citations], + "roots": roots, + "policy": validation_policy_digest(record), + }) + + +def candidate_audit(record: DreamRecord, proposal: BaseModel) -> CandidateAudit: + spec = next(spec for spec in DREAM_OPERATIONS if spec.operation == record.run.operation) + return CandidateAudit( + operation=record.run.operation, + dream_run_id=record.run.run_id, + spec_version=spec.spec_version, + proposal_digest=proposal_digest(proposal), + evidence_manifest_ref=f"dream:{record.run.run_id}", + validation_policy_digest=validation_policy_digest(record), + proposal_fingerprint=record.proposal_fingerprint, + ) + + +def _digest(value: object) -> str: + return content_digest(json.dumps(value, sort_keys=True, ensure_ascii=False).encode()) diff --git a/src/powercontext/builtin/dream/service.py b/src/powercontext/builtin/dream/service.py index fe4f993d33..91f7f8a1d2 100644 --- a/src/powercontext/builtin/dream/service.py +++ b/src/powercontext/builtin/dream/service.py @@ -20,7 +20,7 @@ from collections.abc import AsyncIterator, Awaitable, Callable from contextlib import AbstractAsyncContextManager, asynccontextmanager, nullcontext from datetime import datetime -from typing import Literal +from typing import Any, Literal from uuid import uuid4 from sqlalchemy import update @@ -31,9 +31,17 @@ from powercontext.builtin.artifacts.handoff.models import HandoffContent from powercontext.builtin.artifacts.memory.models import MemoryDreamCandidateProposal, MemoryDreamWrite from powercontext.builtin.artifacts.profile.models import ProfileCandidateProposal, ProfileWriteContent +from powercontext.builtin.artifacts.prompt.models import PromptContent from powercontext.builtin.artifacts.skill import SkillContent from powercontext.builtin.artifacts.topic_memory.models import TopicMemoryContent -from powercontext.builtin.dream.bindings import DREAM_BINDINGS, operations_for_binding +from powercontext.builtin.catalog_changes.models import CatalogChangeCandidate, CatalogChangeProposal, TagChangeProposal +from powercontext.builtin.catalog_changes.service import CatalogChangeService +from powercontext.builtin.dream.bindings import ( + DREAM_BINDINGS, + binding_for_request, + operation_spec, + operations_for_binding, +) from powercontext.builtin.dream.generation import DreamGenerationInput, DreamGenerator from powercontext.builtin.dream.models import ( DREAM_PROMPT_VERSION, @@ -49,7 +57,13 @@ DreamUsage, ListDreamRunsRequest, ) -from powercontext.builtin.evidence.models import EvidenceResolutionError, ResolvedEvidence +from powercontext.builtin.dream.provenance import candidate_audit, proposal_fingerprint +from powercontext.builtin.evidence.models import ( + EvidenceResolutionError, + ResolvedEvidence, + reference_key, + unique_references, +) from powercontext.builtin.evidence.resolver import EvidenceAuthorizer, EvidenceReference, EvidenceResolver, evidence_id from powercontext.builtin.evidence.selection import select_evidence from powercontext.builtin.inference.errors import ( @@ -59,22 +73,27 @@ ) from powercontext.builtin.inference.models import InferenceUsage from powercontext.builtin.persistence.artifacts import ArtifactRepository +from powercontext.builtin.persistence.candidates import attach_candidate_audit, proposal_digest +from powercontext.builtin.persistence.catalog_changes import CATALOG_CANDIDATE_HEADS from powercontext.builtin.persistence.database import AsyncDatabase from powercontext.builtin.persistence.dream import DreamRepository, database_now from powercontext.builtin.persistence.errors import ArtifactProcessingLeadershipLostError from powercontext.builtin.persistence.processing_intents import ArtifactProcessingIntentRepository from powercontext.builtin.persistence.profile import ProfilePolicyRepository -from powercontext.builtin.persistence.tables import SCOPES_TABLE +from powercontext.builtin.persistence.tables import ARTIFACT_CANDIDATE_HEADS_TABLE, SCOPES_TABLE from powercontext.builtin.review.errors import ArtifactTargetConflictError, InvalidCandidateError from powercontext.builtin.review.generation import SkillGenerationOrigin, validate_skill_lineage +from powercontext.builtin.review.models import ArtifactCandidate, CandidateStatus from powercontext.builtin.review.service import ReviewService from powercontext.builtin.runtime.processing_execution import InvocationAlreadyHandled, ScopeInvocation +from powercontext.builtin.tags import TagPreconditionError -DreamPermission = Literal["read", "contribute", "write"] +DreamPermission = Literal["read", "contribute", "write", "write_tags"] DreamAuthorizer = Callable[[str, str, DreamPermission, EvidenceReference | None], Awaitable[None]] EvidenceFactory = Callable[[str, EvidenceAuthorizer], EvidenceResolver] ReviewFactory = Callable[[str, AsyncConnection | None], ReviewService] CandidateAttester = Callable[[AsyncConnection, DreamRecord, str, str], Awaitable[None]] +CatalogFactory = Callable[[str, AsyncConnection | None], CatalogChangeService] class DreamService: @@ -91,8 +110,10 @@ def __init__( authorize: DreamAuthorizer | None = None, authorization_context: Callable[[], AbstractAsyncContextManager[None]] = nullcontext, attest_candidate: CandidateAttester | None = None, + authorize_candidate: CandidateAttester | None = None, operations: tuple[DreamOperation, ...] = (), processing: ScopeInvocation | None = None, + catalog_changes: CatalogFactory | None = None, ) -> None: self.database = database self.repository = DreamRepository() @@ -105,9 +126,11 @@ def __init__( self.authorize = authorize self.authorization_context = authorization_context self.attest_candidate = attest_candidate + self.authorize_candidate = authorize_candidate self.operations = operations self.processing = processing self.intents = ArtifactProcessingIntentRepository() + self.catalog_changes = catalog_changes @asynccontextmanager async def _transaction(self) -> AsyncIterator[AsyncConnection]: @@ -145,10 +168,14 @@ async def create(self, scope_id: str, principal_id: str, request: CreateDreamRun await self._authorize(scope_id, principal_id, "contribute") if request.operation not in self.operations: raise DreamError("capability_unavailable") + if request.operation == "revise_prompt" and request.target is not None: + await self.review(scope_id, None).require_prompt_target(request.target) resolver = self._resolver(scope_id, principal_id) async with self._transaction() as connection: # Lock the intent before business rows, matching Worker commit order. - binding = DREAM_BINDINGS[request.operation] + binding = binding_for_request(request) + if not any(DREAM_BINDINGS.get(operation) == binding for operation in self.operations): + raise DreamError("capability_unavailable") await self.intents.ensure(connection, scope_id, binding) await self.intents.load(connection, scope_id, binding, for_update=True) # Scope-local admission capacity is durable and serialized across replicas. @@ -170,24 +197,30 @@ async def create(self, scope_id: str, principal_id: str, request: CreateDreamRun else None ) await self._check_target(connection, scope_id, principal_id, request.target) + await self._authorize_memory_changes(scope_id, principal_id, request) + if request.tag_target is not None: + await self._check_tag_target(connection, scope_id, principal_id, request) + artifacts, citations, target = _resolve_references(request) await resolver.resolve( connection, sources=request.sources, - artifacts=request.artifacts, - memory_citations=request.memory_citations, - target=request.target, + artifacts=artifacts, + memory_citations=citations, + target=target, project=False, lock_memory=True, + expose_catalog_target=request.operation == "revise_tags", ) if await self.repository.pending_count(connection, scope_id) >= self.max_pending_per_scope: raise DreamError("capacity_exceeded") - intent = await self.intents.request(connection, scope_id, DREAM_BINDINGS[request.operation]) + intent = await self.intents.request(connection, scope_id, binding) record = DreamRecord( run=DreamRun( scope_id=scope_id, run_id="dr_" + uuid4().hex, operation=request.operation, target=request.target, + tag_target=request.tag_target, accepted_at=await database_now(connection), budget=self.budget, ), @@ -225,7 +258,11 @@ async def execute(self) -> bool: # noqa: C901 - fenced admission, fair dispatch async with self._transaction() as connection: await self.processing.start(connection) record = await self.repository.next_pending( - connection, work.scope_id, operations, through_generation=work.claimed_request_generation + connection, + work.scope_id, + operations, + through_generation=work.claimed_request_generation, + binding=work.binding_name, ) if record is None: return False @@ -233,7 +270,7 @@ async def execute(self) -> bool: # noqa: C901 - fenced admission, fair dispatch if ( intent.consecutive_dream_attempts >= 4 and intent.dirty_generation > intent.clean_generation - and work.artifact_family not in {"skill", "handoff"} + and work.artifact_family not in {"skill", "handoff", "prompt"} ): # The Source pass acknowledges this invocation. Keep the queued # Dream runnable even if that pass consumes all ordinary input. @@ -267,7 +304,10 @@ async def _complete_invocation(self, connection: AsyncConnection) -> None: work = self.processing.assignment current = await self.processing.guard(connection) remaining = await self.repository.next_pending( - connection, work.scope_id, operations_for_binding(work.binding_name) + connection, + work.scope_id, + operations_for_binding(work.binding_name), + binding=work.binding_name, ) if remaining is not None and current.requested_generation == work.claimed_request_generation: await self.intents.request(connection, work.scope_id, work.binding_name) @@ -283,17 +323,22 @@ async def _execute(self, record: DreamRecord) -> None: ): raise DreamError("capability_unavailable") await self._authorize(run.scope_id, record.principal_id, "contribute") - if run.operation == "revise_skill" and run.target is not None: - try: - await self.review(run.scope_id, None).require_instruction_skill_target(run.target) - except InvalidCandidateError as error: - if error.detail == "unsupported_target": - raise DreamError("unsupported_target") from error - raise - if run.operation == "revise_profile": - async with self._transaction() as connection: - await self._check_profile_policy(connection, record) + prompt_definition = None + if run.operation == "revise_prompt" and run.target is not None: + prompt_definition = await self.review(run.scope_id, None).require_prompt_target(run.target) + before_tags = () + if record.request.tag_target is not None: + before_tags = (await self._catalog(run.scope_id, None).inspect_target(record.request.tag_target)).tags + await self._preflight_target(record) resolved = await self._resolve(record) + record = record.model_copy( + update={ + "proposal_fingerprint": proposal_fingerprint(record, resolved), + "run": run.model_copy(update={"input_manifest": resolved.manifest}), + } + ) + if await self._reuse_before_generation(record): + return usage = run.usage.model_copy(update={"model_calls": run.usage.model_calls + 1}) if usage.model_calls > run.budget.max_model_calls: raise DreamError("budget_exceeded") @@ -307,9 +352,12 @@ async def _execute(self, record: DreamRecord) -> None: generated = await self.generator.generate( DreamGenerationInput( operation=run.operation, - target_evidence_id=None if run.target is None else evidence_id(run.target), + target_evidence_id=_target_evidence_id(record.request), evidence=resolved.projection, profile_policy=record.profile_policy, + prompt_definition=prompt_definition, + tag_target=record.request.tag_target, + before_tags=before_tags, ) ) record = record.model_copy( @@ -327,8 +375,10 @@ async def _execute(self, record: DreamRecord) -> None: generated.usage.output_tokens is not None and generated.usage.output_tokens > run.budget.max_output_tokens ): raise DreamError("budget_exceeded") - generated.output.validate_operation(record.request) + operation_spec(run.operation).validator(generated.output, record.request) plan = _supported_plan(record, generated.output, resolved) + if isinstance(plan.proposal, TagChangeProposal) and plan.proposal.after_tags == before_tags: + plan = DreamPlan(outcome="no_change", reason="The proposed labels already match the current tag set.") if isinstance(plan.proposal, SkillContent): if run.operation == "revise_skill" and run.target is not None: prepared = await self.review(run.scope_id, None).prepare_skill_revision(run.target, plan.proposal) @@ -337,21 +387,40 @@ async def _execute(self, record: DreamRecord) -> None: plan = plan.model_copy(update={"proposal": prepared}) await self._commit(record, plan) + async def _preflight_target(self, record: DreamRecord) -> None: + run = record.run + if run.operation == "revise_skill" and run.target is not None: + try: + await self.review(run.scope_id, None).require_instruction_skill_target(run.target) + except InvalidCandidateError as error: + if error.detail == "unsupported_target": + raise DreamError("unsupported_target") from error + raise + if run.operation == "revise_profile": + async with self._transaction() as connection: + await self._check_profile_policy(connection, record) + async def _resolve(self, record: DreamRecord, connection: AsyncConnection | None = None) -> ResolvedEvidence: request = record.request async with self.database.connection(connection) as bound: await self._check_target(bound, record.run.scope_id, record.principal_id, request.target) + await self._authorize_memory_changes(record.run.scope_id, record.principal_id, request) + if request.tag_target is not None: + await self._check_tag_target(bound, record.run.scope_id, record.principal_id, request) resolver = self._resolver(record.run.scope_id, record.principal_id) resolver.limits = record.run.budget + artifacts, citations, target = _resolve_references(request) return await resolver.resolve( bound, sources=request.sources, - artifacts=request.artifacts, - memory_citations=request.memory_citations, - target=request.target, + artifacts=artifacts, + memory_citations=citations, + target=target, include_memory_text=request.operation != "derive_skill", pinned=record.run.input_manifest, lock_memory=connection is not None, + expose_prompt_target=request.operation == "revise_prompt", + expose_catalog_target=request.operation == "revise_tags", ) async def _commit(self, record: DreamRecord, plan: DreamPlan) -> None: @@ -364,7 +433,7 @@ async def _commit(self, record: DreamRecord, plan: DreamPlan) -> None: resolved = await self._resolve(record, connection) candidate = None if plan.outcome == "proposed": - if self.attest_candidate is not None: + if self.attest_candidate is not None and run.operation != "revise_tags": family = { "derive_skill": "skill", "revise_skill": "skill", @@ -373,6 +442,7 @@ async def _commit(self, record: DreamRecord, plan: DreamPlan) -> None: "revise_memory": "memory", "revise_topic_memory": "topic-memory", "refresh_handoff": "handoff", + "revise_prompt": "prompt", }[run.operation] await self.attest_candidate(connection, record, _candidate_id(record), family) candidate = await self._propose(connection, record, plan, resolved) @@ -406,8 +476,29 @@ async def _propose( # noqa: C901 - operation-specific Candidate adapters share resolved.manifest, plan.evidence_ids, skill=record.run.operation == "derive_skill", - target=record.run.target, + target=_resolve_references(record.request)[2], ) + if isinstance(plan.proposal, TagChangeProposal) and record.request.tag_target is not None: + service = self._catalog(record.run.scope_id, connection) + current = await service.inspect_target(record.request.tag_target) + proposal = CatalogChangeProposal( + **record.request.tag_target.model_dump(mode="python"), + before_tags=current.tags, + after_tags=plan.proposal.after_tags, + ) + candidate = await service.propose( + proposal, + reason=plan.reason, + sources=selected.sources, + artifacts=selected.artifacts, + memory_citations=selected.memory_citations, + dream_run_id=record.run.run_id, + candidate_id=_candidate_id(record), + audit=candidate_audit(record, proposal), + ) + return DreamCandidateRef( + candidate_id=candidate.candidate_id, version=candidate.version, kind="catalog_change" + ) review = self.review(record.run.scope_id, connection) if isinstance(plan.proposal, ExperienceContent): candidate = await review.propose_experience( @@ -426,6 +517,7 @@ async def _propose( # noqa: C901 - operation-specific Candidate adapters share plan.proposal, sources=selected.sources, artifacts=selected.artifacts, + memory_citations=selected.memory_citations, target=record.run.target, reason=plan.reason, candidate_id=_candidate_id(record), @@ -493,10 +585,104 @@ async def _propose( # noqa: C901 - operation-specific Candidate adapters share reason=plan.reason, candidate_id=_candidate_id(record), ) + elif isinstance(plan.proposal, PromptContent) and record.run.operation == "revise_prompt": + if record.run.target is None: + raise DreamError("invalid_generation_output") + candidate = await review.propose_prompt_dream( + plan.proposal, + sources=selected.sources, + artifacts=selected.artifacts, + memory_citations=selected.memory_citations, + target=record.run.target, + reason=plan.reason, + candidate_id=_candidate_id(record), + ) else: raise DreamError("invalid_generation_output") + await attach_candidate_audit( + connection, record.run.scope_id, candidate, candidate_audit(record, candidate.proposal) + ) return DreamCandidateRef(candidate_id=candidate.candidate_id, version=candidate.version) + async def _reuse_before_generation(self, record: DreamRecord) -> bool: + async with self._transaction() as connection: + await self.repository.lock_owned(connection, record) + if record.run.operation == "revise_profile": + await self._check_profile_policy(connection, record) + resolved = await self._resolve(record, connection) + for previous in await self.repository.matching_proposals(connection, record): + ref = previous.run.candidate + if ref is None: + continue + table = CATALOG_CANDIDATE_HEADS if ref.kind == "catalog_change" else ARTIFACT_CANDIDATE_HEADS_TABLE + await connection.execute( + update(table) + .where( + table.c.scope_id == record.run.scope_id, + table.c.candidate_id == ref.candidate_id, + ) + .values(version=table.c.version) + ) + if ref.kind == "catalog_change": + candidate = await self._catalog(record.run.scope_id, connection).get(ref.candidate_id, current=True) + else: + candidate = await self.review(record.run.scope_id, connection).get_candidate( + ref.candidate_id, current=True + ) + audit = candidate.audit + if ( + audit is None + or audit.proposal_fingerprint != record.proposal_fingerprint + or audit.proposal_digest != proposal_digest(candidate.proposal) + or candidate.status == CandidateStatus.APPROVED + or not _candidate_matches_evidence(candidate, record, resolved) + ): + continue + if self.authorize_candidate is not None: + family = ( + candidate.proposal.target.family + if isinstance(candidate, CatalogChangeCandidate) + else candidate.family + ) + await self.authorize_candidate(connection, record, candidate.candidate_id, family) + completed = record.run.model_copy( + update={ + "status": "succeeded", + "outcome": "proposed" if candidate.status == CandidateStatus.PENDING else "no_change", + "reason": "Reused pending proposal for the same target, evidence and policy." + if candidate.status == CandidateStatus.PENDING + else "An unchanged proposal was already rejected; new evidence or a changed target is required.", + "candidate": DreamCandidateRef( + candidate_id=candidate.candidate_id, version=candidate.version, kind=ref.kind + ), + "reused": True, + "completed_at": await database_now(connection), + } + ) + await self.repository.finish(connection, record, completed) + await self._complete_invocation(connection) + return True + return False + + def _catalog(self, scope_id: str, connection: AsyncConnection | None) -> CatalogChangeService: + if self.catalog_changes is None: + raise DreamError("capability_unavailable") + return self.catalog_changes(scope_id, connection) + + async def _check_tag_target( + self, connection: AsyncConnection, scope_id: str, principal_id: str, request: CreateDreamRunRequest + ) -> None: + target = request.tag_target + if target is None: + raise DreamError("invalid_target") + await self._authorize(scope_id, principal_id, "write_tags", target.basis_ref or target.basis_citation) + await self._catalog(scope_id, connection).inspect_target(target) + + async def _authorize_memory_changes(self, scope_id: str, principal_id: str, request: CreateDreamRunRequest) -> None: + if request.operation == "revise_memory": + for citation in request.memory_citations: + await self._authorize(scope_id, principal_id, "write", citation) + async def _check_target( self, connection: AsyncConnection, @@ -506,7 +692,7 @@ async def _check_target( ) -> None: if target is None: return - await self._authorize(scope_id, principal_id, "write", target) + await self._authorize(scope_id, principal_id, "read" if target.family == "memory" else "write", target) current = await self.artifacts.latest( connection, scope_id, @@ -551,6 +737,53 @@ def _candidate_id(record: DreamRecord) -> str: return "cand_dream_" + record.run.run_id +def _candidate_matches_evidence( + candidate: ArtifactCandidate[Any] | CatalogChangeCandidate, record: DreamRecord, resolved: ResolvedEvidence +) -> bool: + """A shared root alone cannot authorize stale or differently selected evidence.""" + if isinstance(candidate, CatalogChangeCandidate): + requested = record.request.tag_target + if requested is None or any( + getattr(candidate.proposal, field) != getattr(requested, field) + for field in ("target", "expected_etag", "basis_ref", "basis_citation") + ): + return False + elif candidate.target != record.run.target: + return False + available = set() + for node in resolved.manifest.nodes: + if node.source is not None: + available.add(reference_key(node.source)) + if node.artifact is not None: + available.add(reference_key(node.artifact)) + available.update(reference_key(citation) for citation in node.memory_citations) + return all( + reference_key(reference) in available + for reference in (*candidate.sources, *candidate.artifacts, *candidate.memory_citations) + ) + + +def _resolve_references(request: CreateDreamRunRequest): + target = request.tag_target + if target is None: + return request.artifacts, request.memory_citations, request.target + return ( + unique_references((*request.artifacts, *((target.basis_ref,) if target.basis_ref is not None else ()))), + unique_references(( + *request.memory_citations, + *((target.basis_citation,) if target.basis_citation is not None else ()), + )), + target.basis_ref, + ) + + +def _target_evidence_id(request: CreateDreamRunRequest) -> str | None: + target = request.target + if request.tag_target is not None: + target = request.tag_target.basis_ref or request.tag_target.basis_citation + return None if target is None else evidence_id(target) + + def _supported_plan(record: DreamRecord, plan: DreamPlan, resolved: ResolvedEvidence) -> DreamPlan: if plan.outcome != "proposed": return plan @@ -559,7 +792,7 @@ def _supported_plan(record: DreamRecord, plan: DreamPlan, resolved: ResolvedEvid resolved.manifest, plan.evidence_ids, skill=record.run.operation == "derive_skill", - target=record.run.target, + target=_resolve_references(record.request)[2], ) except EvidenceResolutionError as error: if error.code != "needs_evidence": @@ -599,6 +832,8 @@ def _error_code(error: Exception) -> str: return error.code if isinstance(error, ArtifactTargetConflictError): return "artifact_conflict" - if isinstance(error, InvalidInferenceOutputError): + if isinstance(error, TagPreconditionError): + return "tag_precondition_failed" + if isinstance(error, (InvalidInferenceOutputError, InvalidCandidateError)): return "invalid_generation_output" return "generation_failed" diff --git a/src/powercontext/builtin/evidence/models.py b/src/powercontext/builtin/evidence/models.py index 12a831e811..594e40e2a1 100644 --- a/src/powercontext/builtin/evidence/models.py +++ b/src/powercontext/builtin/evidence/models.py @@ -52,7 +52,18 @@ class EvidenceNode(BaseModel): model_config = ConfigDict(frozen=True, extra="forbid") evidence_id: str - kind: Literal["source", "experience", "memory", "profile", "topic_memory", "handoff", "unresolved"] + kind: Literal[ + "source", + "experience", + "memory", + "profile", + "topic_memory", + "handoff", + "skill", + "prompt", + "catalog_target", + "unresolved", + ] digest: str source: SourceRef | None = None artifact: ArtifactRef | None = None @@ -96,7 +107,9 @@ class ProjectedEvidence(BaseModel): """Untrusted content visible to the model for this operation only.""" evidence_id: str - kind: Literal["source", "experience", "memory", "profile", "topic_memory", "handoff"] + kind: Literal[ + "source", "experience", "memory", "profile", "topic_memory", "handoff", "skill", "prompt", "catalog_target" + ] text: str historical: bool = False root_group_ids: tuple[str, ...] = () diff --git a/src/powercontext/builtin/evidence/resolver.py b/src/powercontext/builtin/evidence/resolver.py index 652b5741e3..6f6d5e6711 100644 --- a/src/powercontext/builtin/evidence/resolver.py +++ b/src/powercontext/builtin/evidence/resolver.py @@ -30,6 +30,8 @@ from powercontext.builtin.artifacts.memory import Memory, MemoryEntryVersion from powercontext.builtin.artifacts.memory.errors import InvalidMemoryCitationError, MemoryEntryNotFoundError from powercontext.builtin.artifacts.profile.models import Profile +from powercontext.builtin.artifacts.prompt.models import Prompt +from powercontext.builtin.artifacts.skill import Skill from powercontext.builtin.artifacts.topic_memory.models import TopicMemory from powercontext.builtin.evidence.models import ( EVIDENCE_TRANSFORM_VERSION, @@ -66,6 +68,8 @@ @dataclass class _Traversal: project: bool = True + prompt_target: ArtifactRef | None = None + catalog_target: ArtifactRef | None = None nodes: dict[str, EvidenceNode] = field(default_factory=dict) bodies: dict[str, str] = field(default_factory=dict) edges: set[tuple[str, str]] = field(default_factory=set) @@ -189,8 +193,14 @@ async def resolve( lock_memory: bool = False, pinned: EvidenceManifest | None = None, project: bool = True, + expose_prompt_target: bool = False, + expose_catalog_target: bool = False, ) -> ResolvedEvidence: - traversal = _Traversal(project=project) + traversal = _Traversal( + project=project, + prompt_target=target if expose_prompt_target else None, + catalog_target=target if expose_catalog_target else None, + ) refs: tuple[EvidenceReference, ...] = (*sources, *artifacts, *memory_citations) if lock_memory: # Discover immutable entry paths first; lock every owner in one order, then @@ -202,6 +212,8 @@ async def resolve( memory_citations=memory_citations, target=target, project=False, + expose_prompt_target=expose_prompt_target, + expose_catalog_target=expose_catalog_target, ) owners = tuple(citation for node in observed.manifest.nodes for citation in node.memory_citations) await self._lock_memories(connection, owners) @@ -291,7 +303,7 @@ async def _visit( raise EvidenceResolutionError("evidence_limit_exceeded") return identity - async def _read( + async def _read( # noqa: C901 - explicit typed projections for each supported evidence Family self, connection: AsyncConnection, ref: EvidenceReference, @@ -306,6 +318,20 @@ async def _read( return await self._read_memory(connection, ref, locked=locked) artifact = await self.artifacts.get(connection, self.scope_id, ref) digest = content_digest(artifact.model_dump_json().encode()) + if ref == state.catalog_target: + return ( + EvidenceNode( + evidence_id=evidence_id(ref), kind="catalog_target", artifact=ref, digest=digest, role="target" + ), + artifact.content.model_dump_json(), + (), + ) + if isinstance(artifact, Skill): + return ( + EvidenceNode(evidence_id=evidence_id(ref), kind="skill", artifact=ref, digest=digest, role="derived"), + artifact.content.model_dump_json(), + (), + ) if isinstance(artifact, Profile): return ( EvidenceNode(evidence_id=evidence_id(ref), kind="profile", artifact=ref, digest=digest, role="derived"), @@ -327,6 +353,14 @@ async def _read( (), ) if ref.family == "prompt": + if isinstance(artifact, Prompt) and ref == state.prompt_target: + return ( + EvidenceNode( + evidence_id=evidence_id(ref), kind="prompt", artifact=ref, digest=digest, role="target" + ), + artifact.content.model_dump_json(), + (), + ) return ( EvidenceNode( evidence_id=evidence_id(ref), diff --git a/src/powercontext/builtin/persistence/candidates.py b/src/powercontext/builtin/persistence/candidates.py index 46867e753c..8092f56d08 100644 --- a/src/powercontext/builtin/persistence/candidates.py +++ b/src/powercontext/builtin/persistence/candidates.py @@ -16,6 +16,7 @@ from __future__ import annotations +import json from collections.abc import Mapping from typing import Any @@ -24,6 +25,7 @@ from sqlalchemy.ext.asyncio import AsyncConnection from powercontext.artifacts import ArtifactRef, MemoryCitation +from powercontext.builtin.evidence.models import content_digest from powercontext.builtin.persistence.citation_codec import dump_memory_citations, load_memory_citations from powercontext.builtin.persistence.codec import dump_model, load_model, stored_bytes from powercontext.builtin.persistence.errors import InvalidRepositoryArgumentError @@ -41,6 +43,7 @@ MAX_CANDIDATE_PAGE_SIZE, ArtifactCandidate, ArtifactCandidatePage, + CandidateAudit, CandidateStatus, ) from powercontext.limits import MAX_SCOPE_ID_LENGTH @@ -115,11 +118,13 @@ async def get( scope_id: str, candidate_id: str, /, + *, + current: bool = False, ) -> ArtifactCandidate[Any]: """Return the current Candidate head and exact proposal version.""" _require_scope(scope_id) - row = await self._find_current(connection, scope_id, candidate_id) + row = await self._find_current(connection, scope_id, candidate_id, current=current) if row is None: raise CandidateNotFoundError(candidate_id) return self._decode_row(row) @@ -198,6 +203,15 @@ async def revise( memory_citations=memory_citations, target=target, reason=reason, + audit=None + if current.audit is None + else current.audit.model_copy( + update={ + "proposal_digest": proposal_digest(proposal), + "evidence_manifest_ref": f"candidate:{candidate_id}:{current.version + 1}", + "proposal_fingerprint": None, + } + ), ) await self._insert_version(connection, scope_id, revised) advanced = await connection.execute( @@ -262,7 +276,7 @@ async def lock_pending( ) .values(version=ARTIFACT_CANDIDATE_HEADS_TABLE.c.version) ) - current = await self.get(connection, scope_id, candidate_id) + current = await self.get(connection, scope_id, candidate_id, current=True) if locked.rowcount != 1: self._raise_stale_or_terminal(current, expected_version) return current @@ -311,7 +325,7 @@ async def _insert_version( candidate_id=candidate.candidate_id, version=candidate.version, family=candidate.family, - proposal=dump_model(candidate.proposal, kind="candidate-proposal", name=candidate.family), + proposal=_encode_proposal(candidate), source_refs=dump_model(_SourceRefs(candidate.sources), kind="candidate", name="source-refs"), artifact_refs=dump_model(_ArtifactRefs(candidate.artifacts), kind="candidate", name="artifact-refs"), memory_citations=dump_memory_citations(candidate.memory_citations), @@ -346,29 +360,23 @@ async def _find_current( connection: AsyncConnection, scope_id: str, candidate_id: str, + *, + current: bool = False, ) -> Mapping[Any, Any] | None: - return ( - ( - await connection.execute( - select(ARTIFACT_CANDIDATE_HEADS_TABLE, ARTIFACT_CANDIDATE_VERSIONS_TABLE) - .join( - ARTIFACT_CANDIDATE_VERSIONS_TABLE, - (ARTIFACT_CANDIDATE_VERSIONS_TABLE.c.scope_id == ARTIFACT_CANDIDATE_HEADS_TABLE.c.scope_id) - & ( - ARTIFACT_CANDIDATE_VERSIONS_TABLE.c.candidate_id - == ARTIFACT_CANDIDATE_HEADS_TABLE.c.candidate_id - ) - & (ARTIFACT_CANDIDATE_VERSIONS_TABLE.c.version == ARTIFACT_CANDIDATE_HEADS_TABLE.c.version), - ) - .where( - ARTIFACT_CANDIDATE_HEADS_TABLE.c.scope_id == scope_id, - ARTIFACT_CANDIDATE_HEADS_TABLE.c.candidate_id == candidate_id, - ) - ) + heads, versions = ARTIFACT_CANDIDATE_HEADS_TABLE, ARTIFACT_CANDIDATE_VERSIONS_TABLE + statement = ( + select(heads, versions) + .join( + versions, + (versions.c.scope_id == heads.c.scope_id) + & (versions.c.candidate_id == heads.c.candidate_id) + & (versions.c.version == heads.c.version), ) - .mappings() - .one_or_none() + .where(heads.c.scope_id == scope_id, heads.c.candidate_id == candidate_id) ) + if current: + statement = statement.with_for_update() + return (await connection.execute(statement)).mappings().one_or_none() def _decode_row(self, row: Mapping[Any, Any]) -> ArtifactCandidate[Any]: family = str(row["family"]) @@ -377,6 +385,7 @@ def _decode_row(self, row: Mapping[Any, Any]) -> ArtifactCandidate[Any]: raise InvalidCandidateError("family", family) target_family = row["target_family"] result_family = row["result_family"] + proposal_payload, audit = _decode_proposal(row["proposal"]) return ArtifactCandidate( candidate_id=str(row["candidate_id"]), version=int(row["version"]), @@ -385,7 +394,7 @@ def _decode_row(self, row: Mapping[Any, Any]) -> ArtifactCandidate[Any]: memory_citations=load_memory_citations(row.get("memory_citations")), proposal=load_model( proposal_type, - stored_bytes(row["proposal"], column="proposal"), + proposal_payload, kind="candidate-proposal", name=family, ), @@ -421,6 +430,7 @@ def _decode_row(self, row: Mapping[Any, Any]) -> ArtifactCandidate[Any]: ) ), decision_reason=None if row["decision_reason"] is None else str(row["decision_reason"]), + audit=audit, ) def _require_proposal(self, family: str, proposal: BaseModel) -> None: @@ -437,6 +447,50 @@ def _raise_stale_or_terminal(candidate: ArtifactCandidate[Any], expected_version raise CandidateConflictError(candidate.candidate_id, expected_version, candidate.version) +def proposal_digest(proposal: BaseModel) -> str: + return content_digest(json.dumps(proposal.model_dump(mode="json"), sort_keys=True, ensure_ascii=False).encode()) + + +def _encode_proposal(candidate: ArtifactCandidate[Any]) -> bytes: + if candidate.audit is None: + return dump_model(candidate.proposal, kind="candidate-proposal", name=candidate.family) + return json.dumps( + { + "candidate_payload_version": 1, + "proposal": candidate.proposal.model_dump(mode="json"), + "audit": candidate.audit.model_dump(mode="json"), + }, + ensure_ascii=False, + ).encode() + + +def _decode_proposal(value: object) -> tuple[bytes, CandidateAudit | None]: + payload = stored_bytes(value, column="proposal") + decoded = json.loads(payload) + if isinstance(decoded, dict) and decoded.get("candidate_payload_version") == 1: + return json.dumps(decoded["proposal"], ensure_ascii=False).encode(), CandidateAudit.model_validate( + decoded["audit"] + ) + return payload, None + + +async def attach_candidate_audit( + connection: AsyncConnection, scope_id: str, candidate: ArtifactCandidate[Any], audit: CandidateAudit +) -> None: + """Complete a newly created version inside its original uncommitted transaction.""" + if candidate.version != 1 or candidate.audit is not None or candidate.status is not CandidateStatus.PENDING: + raise InvalidCandidateError("audit", "Dream audit must be assigned at proposal creation") + await connection.execute( + update(ARTIFACT_CANDIDATE_VERSIONS_TABLE) + .where( + ARTIFACT_CANDIDATE_VERSIONS_TABLE.c.scope_id == scope_id, + ARTIFACT_CANDIDATE_VERSIONS_TABLE.c.candidate_id == candidate.candidate_id, + ARTIFACT_CANDIDATE_VERSIONS_TABLE.c.version == 1, + ) + .values(proposal=_encode_proposal(candidate.model_copy(update={"audit": audit}))) + ) + + def _require_scope(scope_id: object) -> None: if not isinstance(scope_id, str) or not scope_id.strip() or scope_id != scope_id.strip(): raise InvalidRepositoryArgumentError("scope_id", "must be a non-empty trimmed string") diff --git a/src/powercontext/builtin/persistence/catalog_changes.py b/src/powercontext/builtin/persistence/catalog_changes.py new file mode 100644 index 0000000000..29b03277e6 --- /dev/null +++ b/src/powercontext/builtin/persistence/catalog_changes.py @@ -0,0 +1,170 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Independent, versioned catalog review storage for both relational backends.""" + +from sqlalchemy import ( + CheckConstraint, + Column, + ForeignKeyConstraint, + Integer, + LargeBinary, + Table, + insert, + select, + update, +) +from sqlalchemy.dialects.mysql import MEDIUMBLOB +from sqlalchemy.ext.asyncio import AsyncConnection + +from powercontext.builtin.catalog_changes.models import CatalogChangeCandidate +from powercontext.builtin.persistence.schema import create_tables +from powercontext.builtin.persistence.tables import SHARED_METADATA, identity_string +from powercontext.builtin.review.errors import CandidateConflictError, CandidateNotFoundError, CandidateTerminalError + +CATALOG_CANDIDATE_VERSIONS = Table( + "pc_catalog_change_candidate_versions", + SHARED_METADATA, + Column("scope_id", identity_string(256), primary_key=True), + Column("candidate_id", identity_string(128), primary_key=True), + Column("version", Integer, primary_key=True), + Column("payload", LargeBinary().with_variant(MEDIUMBLOB(), "mysql"), nullable=False), + ForeignKeyConstraint(("scope_id",), ("pc_scopes.scope_id",), ondelete="CASCADE"), + CheckConstraint("version > 0", name="ck_pc_catalog_candidate_version"), +) +CATALOG_CANDIDATE_HEADS = Table( + "pc_catalog_change_candidate_heads", + SHARED_METADATA, + Column("scope_id", identity_string(256), primary_key=True), + Column("candidate_id", identity_string(128), primary_key=True), + Column("version", Integer, nullable=False), + Column("status", identity_string(16), nullable=False), + Column("payload", LargeBinary().with_variant(MEDIUMBLOB(), "mysql"), nullable=False), + ForeignKeyConstraint( + ("scope_id", "candidate_id", "version"), + ( + "pc_catalog_change_candidate_versions.scope_id", + "pc_catalog_change_candidate_versions.candidate_id", + "pc_catalog_change_candidate_versions.version", + ), + ondelete="CASCADE", + ), + CheckConstraint("status IN ('pending', 'approved', 'rejected')", name="ck_pc_catalog_candidate_status"), +) + + +async def ensure_catalog_change_schema(connection: AsyncConnection) -> None: + await create_tables(connection, (CATALOG_CANDIDATE_VERSIONS, CATALOG_CANDIDATE_HEADS)) + + +class CatalogCandidateRepository: + async def get( + self, connection: AsyncConnection, scope_id: str, candidate_id: str, *, current: bool = False + ) -> CatalogChangeCandidate: + table = CATALOG_CANDIDATE_HEADS + statement = select(table.c.payload).where(table.c.scope_id == scope_id, table.c.candidate_id == candidate_id) + if current: + statement = statement.with_for_update() + payload = await connection.scalar(statement) + if payload is None: + raise CandidateNotFoundError(candidate_id) + return CatalogChangeCandidate.model_validate_json(payload) + + async def lock_pending( + self, connection: AsyncConnection, scope_id: str, candidate_id: str, expected_version: int + ) -> CatalogChangeCandidate: + table = CATALOG_CANDIDATE_HEADS + await connection.execute( + update(table) + .where(table.c.scope_id == scope_id, table.c.candidate_id == candidate_id) + .values(version=table.c.version) + ) + current = await self.get(connection, scope_id, candidate_id, current=True) + if current.version != expected_version: + raise CandidateConflictError(candidate_id, expected_version, current.version) + if current.status != "pending": + raise CandidateTerminalError(candidate_id, current.status) + return current + + async def create( + self, connection: AsyncConnection, scope_id: str, candidate: CatalogChangeCandidate + ) -> CatalogChangeCandidate: + await self._version(connection, scope_id, candidate) + await connection.execute( + insert(CATALOG_CANDIDATE_HEADS).values( + scope_id=scope_id, + candidate_id=candidate.candidate_id, + version=candidate.version, + status=candidate.status, + payload=candidate.model_dump_json().encode(), + ) + ) + return candidate + + async def save( + self, + connection: AsyncConnection, + scope_id: str, + current: CatalogChangeCandidate, + candidate: CatalogChangeCandidate, + ) -> CatalogChangeCandidate: + if candidate.version != current.version: + await self._version(connection, scope_id, candidate) + table = CATALOG_CANDIDATE_HEADS + changed = await connection.execute( + update(table) + .where( + table.c.scope_id == scope_id, + table.c.candidate_id == current.candidate_id, + table.c.version == current.version, + table.c.status == "pending", + ) + .values(version=candidate.version, status=candidate.status, payload=candidate.model_dump_json().encode()) + ) + if changed.rowcount != 1: + raise CandidateConflictError(current.candidate_id, current.version, current.version) + return candidate + + async def _version(self, connection: AsyncConnection, scope_id: str, candidate: CatalogChangeCandidate) -> None: + await connection.execute( + insert(CATALOG_CANDIDATE_VERSIONS).values( + scope_id=scope_id, + candidate_id=candidate.candidate_id, + version=candidate.version, + payload=candidate.model_dump_json().encode(), + ) + ) + + async def history( + self, connection: AsyncConnection, scope_id: str, candidate_id: str + ) -> tuple[CatalogChangeCandidate, ...]: + current = await self.get(connection, scope_id, candidate_id, current=True) + table = CATALOG_CANDIDATE_VERSIONS + payloads = await connection.scalars( + select(table.c.payload) + .where(table.c.scope_id == scope_id, table.c.candidate_id == candidate_id) + .order_by(table.c.version) + ) + versions = tuple(CatalogChangeCandidate.model_validate_json(payload) for payload in payloads) + return (*versions[:-1], current) + + async def list( + self, connection: AsyncConnection, scope_id: str, *, status: str | None, after: str, limit: int + ) -> tuple[CatalogChangeCandidate, ...]: + table = CATALOG_CANDIDATE_HEADS + stmt = select(table.c.payload).where(table.c.scope_id == scope_id, table.c.candidate_id > after) + if status is not None: + stmt = stmt.where(table.c.status == status) + payloads = await connection.scalars(stmt.order_by(table.c.candidate_id).limit(limit)) + return tuple(CatalogChangeCandidate.model_validate_json(payload) for payload in payloads) diff --git a/src/powercontext/builtin/persistence/dream.py b/src/powercontext/builtin/persistence/dream.py index 06eb4a0ca2..297623a851 100644 --- a/src/powercontext/builtin/persistence/dream.py +++ b/src/powercontext/builtin/persistence/dream.py @@ -21,6 +21,7 @@ from sqlalchemy import and_, func, insert, or_, select, update from sqlalchemy.ext.asyncio import AsyncConnection +from powercontext.builtin.dream.bindings import binding_for_request from powercontext.builtin.dream.models import ( CreateDreamRunRequest, DreamError, @@ -91,6 +92,7 @@ async def create(self, connection: AsyncConnection, record: DreamRecord) -> Drea principal_key=content_digest(record.principal_id.encode())[7:], idempotency_key=record.request.idempotency_key, request_digest=record.request.digest(), + proposal_fingerprint=record.proposal_fingerprint, operation=run.operation, status=run.status, accepted_at=ticks(run.accepted_at), @@ -101,6 +103,23 @@ async def create(self, connection: AsyncConnection, record: DreamRecord) -> Drea ) return record + async def matching_proposals(self, connection: AsyncConnection, record: DreamRecord) -> tuple[DreamRecord, ...]: + """Read earlier results for this principal without transferring ownership.""" + if record.proposal_fingerprint is None: + return () + rows = await connection.scalars( + select(RUNS.c.payload) + .where( + RUNS.c.scope_id == record.run.scope_id, + RUNS.c.principal_key == content_digest(record.principal_id.encode())[7:], + RUNS.c.proposal_fingerprint == record.proposal_fingerprint, + RUNS.c.status == "succeeded", + RUNS.c.run_id != record.run.run_id, + ) + .order_by(RUNS.c.accepted_at.desc(), RUNS.c.run_id.desc()) + ) + return tuple(_decode(row) for row in rows) + async def get( self, connection: AsyncConnection, @@ -171,6 +190,7 @@ async def next_pending( operation: str | tuple[str, ...], *, through_generation: int | None = None, + binding: str | None = None, ) -> DreamRecord | None: operations = (operation,) if isinstance(operation, str) else operation if not operations: @@ -182,10 +202,14 @@ async def next_pending( ) if through_generation is not None: statement = statement.where(RUNS.c.request_generation <= through_generation) - payload = await connection.scalar( - statement.order_by(RUNS.c.request_generation, RUNS.c.accepted_at, RUNS.c.run_id).limit(1) - ) - return None if payload is None else _decode(payload) + # A Tag operation can belong to any existing Family binding. Pending + # admission is bounded per Scope; filter the exact owner before claiming. + rows = await connection.scalars(statement.order_by(RUNS.c.accepted_at, RUNS.c.run_id)) + for payload in rows: + record = _decode(payload) + if binding is None or binding_for_request(record.request) == binding: + return record + return None async def claim( self, @@ -254,6 +278,7 @@ async def _store(self, connection: AsyncConnection, record: DreamRecord) -> None status=record.run.status, generation=record.generation, request_generation=record.request_generation, + proposal_fingerprint=record.proposal_fingerprint, payload=_payload(record), ) ) diff --git a/src/powercontext/builtin/persistence/dream_schema.py b/src/powercontext/builtin/persistence/dream_schema.py index 51f725a243..d5426cdd60 100644 --- a/src/powercontext/builtin/persistence/dream_schema.py +++ b/src/powercontext/builtin/persistence/dream_schema.py @@ -19,19 +19,24 @@ from sqlalchemy.ext.asyncio import AsyncConnection from sqlalchemy.schema import CreateColumn +from powercontext.builtin.persistence.catalog_changes import ensure_catalog_change_schema from powercontext.builtin.persistence.tables import ( ARTIFACT_CANDIDATE_VERSIONS_TABLE, ARTIFACT_PROCESSING_INTENTS_TABLE, ARTIFACTS_TABLE, + DREAM_RUNS_TABLE, ) async def ensure_dream_schema(connection: AsyncConnection, /) -> None: for table, name in ( + (DREAM_RUNS_TABLE, "proposal_fingerprint"), (ARTIFACTS_TABLE, "memory_citations"), (ARTIFACT_CANDIDATE_VERSIONS_TABLE, "memory_citations"), (ARTIFACT_PROCESSING_INTENTS_TABLE, "consecutive_dream_attempts"), ): + if not await connection.run_sync(lambda sync, table_name=table.name: inspect(sync).has_table(table_name)): + continue column = table.c[name] if await _has_column(connection, table.name, column.name): continue @@ -43,6 +48,13 @@ async def ensure_dream_schema(connection: AsyncConnection, /) -> None: if not await _has_column(connection, table.name, column.name): raise + for index in DREAM_RUNS_TABLE.indexes: + if index.name == "ix_pc_dream_runs_proposal_fingerprint" and await connection.run_sync( + lambda sync: inspect(sync).has_table(DREAM_RUNS_TABLE.name) + ): + await connection.run_sync(lambda sync, selected=index: selected.create(sync, checkfirst=True)) + await ensure_catalog_change_schema(connection) + async def _has_column(connection: AsyncConnection, table_name: str, column_name: str) -> bool: columns = await connection.run_sync(lambda sync: inspect(sync).get_columns(table_name)) diff --git a/src/powercontext/builtin/persistence/family_management.py b/src/powercontext/builtin/persistence/family_management.py index 038be34e8f..f49e062afa 100644 --- a/src/powercontext/builtin/persistence/family_management.py +++ b/src/powercontext/builtin/persistence/family_management.py @@ -25,7 +25,7 @@ from sqlalchemy.ext.asyncio import AsyncConnection from typing_extensions import override -from powercontext.artifacts import Artifact, ArtifactLineage +from powercontext.artifacts import Artifact, ArtifactLineage, ArtifactRef, MemoryCitation from powercontext.builtin.artifacts.experience import Experience, ExperienceContent from powercontext.builtin.artifacts.handoff import Handoff, HandoffContent, HandoffService, PreparedHandoff from powercontext.builtin.artifacts.memory import ( @@ -265,6 +265,36 @@ async def replace( self._registry.validate(current.artifact_id, validated) return cast(Prompt, await self._revise_artifact(connection, scope_id, current, validated, direct_source)) + async def commit_reviewed( + self, + connection: AsyncConnection, + scope_id: str, + current: Prompt, + content: PromptContent, + *, + sources: tuple[SourceRef, ...], + artifacts: tuple[ArtifactRef, ...], + memory_citations: tuple[MemoryCitation, ...], + ) -> Prompt: + """Publish a reviewed configuration using the normal Prompt validation and Artifact CAS.""" + + self._registry.validate(current.artifact_id, content) + return cast( + Prompt, + await self._artifacts.revise( + connection, + scope_id, + current, + RepositoryArtifactDraft( + family=self.family, + content=content, + sources=sources, + artifacts=artifacts, + memory_citations=memory_citations, + ), + ), + ) + class ExperienceManagementWriter(_RepositoryFamilyWriter): family = Experience.family diff --git a/src/powercontext/builtin/persistence/tables.py b/src/powercontext/builtin/persistence/tables.py index bc1315a7ef..461ea4f1a5 100644 --- a/src/powercontext/builtin/persistence/tables.py +++ b/src/powercontext/builtin/persistence/tables.py @@ -1070,6 +1070,7 @@ def _entry_text_type(): Column("principal_key", identity_string(64), nullable=False), Column("idempotency_key", identity_string(128), nullable=False), Column("request_digest", identity_string(71), nullable=False), + Column("proposal_fingerprint", String(71), nullable=True), Column("operation", identity_string(32), nullable=False), Column("status", identity_string(16), nullable=False), Column("accepted_at", BigInteger, nullable=False), @@ -1081,6 +1082,7 @@ def _entry_text_type(): Index("ix_pc_dream_dispatch", "scope_id", "operation", "status", "request_generation"), Index("ix_pc_dream_list", "scope_id", "accepted_at", "run_id"), CheckConstraint("generation >= 0", name="ck_pc_dream_generation"), + Index("ix_pc_dream_runs_proposal_fingerprint", "scope_id", "principal_key", "proposal_fingerprint"), ) RECEIPT_MIGRATION_REVIEW_TABLE = Table( diff --git a/src/powercontext/builtin/persistence/tags.py b/src/powercontext/builtin/persistence/tags.py index 7036dd1222..7b1cb453f6 100644 --- a/src/powercontext/builtin/persistence/tags.py +++ b/src/powercontext/builtin/persistence/tags.py @@ -158,51 +158,72 @@ async def get(self, scope_id: str, target: TagTarget) -> ArtifactTagSet: async def replace( self, scope_id: str, target: TagTarget, tags: tuple[str, ...], *, expected_etag: str ) -> ArtifactTagSet: - desired = normalize_tags(tags) async with self._database.transaction() as connection: - # Acquire the database write lock before any reads. In particular, - # SELECT FOR UPDATE alone cannot serialize empty-set writes on SQLite. - locked = await connection.execute( - update(ARTIFACT_HEADS_TABLE) - .where( - ARTIFACT_HEADS_TABLE.c.scope_id == scope_id, - ARTIFACT_HEADS_TABLE.c.family == target.family, - ARTIFACT_HEADS_TABLE.c.artifact_id == target.artifact_id, - ) - .values(revision=ARTIFACT_HEADS_TABLE.c.revision) + return await self.replace_in_transaction(connection, scope_id, target, tags, expected_etag=expected_etag) + + async def lock_target(self, connection: AsyncConnection, scope_id: str, target: TagTarget) -> None: + """Serialize catalog writes with changes to the owning content head.""" + # Acquire the database write lock before any reads. In particular, + # SELECT FOR UPDATE alone cannot serialize empty-set writes on SQLite. + locked = await connection.execute( + update(ARTIFACT_HEADS_TABLE) + .where( + ARTIFACT_HEADS_TABLE.c.scope_id == scope_id, + ARTIFACT_HEADS_TABLE.c.family == target.family, + ARTIFACT_HEADS_TABLE.c.artifact_id == target.artifact_id, ) - if locked.rowcount != 1: - raise BaseValueNotFoundError("artifact", target) - await self._target_reference(connection, scope_id, target) - current = await self._read(connection, scope_id, target) - if not hmac.compare_digest(expected_etag.encode("utf-8"), current.etag.encode("utf-8")): - raise TagPreconditionError - previous = normalize_tags(current.tags) - identity = _identity(scope_id, target) - removed = previous.keys() - desired.keys() - if removed: + .values(revision=ARTIFACT_HEADS_TABLE.c.revision) + ) + if locked.rowcount != 1: + raise BaseValueNotFoundError("artifact", target) + + async def replace_in_transaction( + self, + connection: AsyncConnection, + scope_id: str, + target: TagTarget, + tags: tuple[str, ...], + *, + expected_etag: str, + ) -> ArtifactTagSet: + """Publish labels inside the caller's transaction, including its review decision.""" + desired = normalize_tags(tags) + await self.lock_target(connection, scope_id, target) + await self._target_reference(connection, scope_id, target) + current = await self._read(connection, scope_id, target, for_update=True) + if not hmac.compare_digest(expected_etag.encode("utf-8"), current.etag.encode("utf-8")): + raise TagPreconditionError + previous = normalize_tags(current.tags) + identity = _identity(scope_id, target) + removed = previous.keys() - desired.keys() + if removed: + await connection.execute( + delete(ARTIFACT_TAGS_TABLE).where(_where(identity), ARTIFACT_TAGS_TABLE.c.tag_key.in_(removed)) + ) + assigned_at = self._clock() + for key, label in desired.items(): + if key not in previous: await connection.execute( - delete(ARTIFACT_TAGS_TABLE).where(_where(identity), ARTIFACT_TAGS_TABLE.c.tag_key.in_(removed)) - ) - assigned_at = self._clock() - for key, label in desired.items(): - if key not in previous: - await connection.execute( - insert(ARTIFACT_TAGS_TABLE).values( - **identity, - tag_key=key, - tag_key_hash=sha256(key.encode("utf-8")).digest(), - tag=label, - assigned_at=assigned_at, - ) - ) - elif label != previous[key]: - await connection.execute( - update(ARTIFACT_TAGS_TABLE) - .where(_where(identity), ARTIFACT_TAGS_TABLE.c.tag_key == key) - .values(tag=label) + insert(ARTIFACT_TAGS_TABLE).values( + **identity, + tag_key=key, + tag_key_hash=sha256(key.encode("utf-8")).digest(), + tag=label, + assigned_at=assigned_at, ) - return tag_set(scope_id, target, tags) + ) + elif label != previous[key]: + await connection.execute( + update(ARTIFACT_TAGS_TABLE) + .where(_where(identity), ARTIFACT_TAGS_TABLE.c.tag_key == key) + .values(tag=label) + ) + return tag_set(scope_id, target, tags) + + async def read_in_transaction( + self, connection: AsyncConnection, scope_id: str, target: TagTarget + ) -> ArtifactTagSet: + return await self._read(connection, scope_id, target, for_update=True) async def query(self, scope_id: str, query: TagQuery, *, caller: str = "runtime") -> TagQueryPage: binding = sha256( @@ -288,8 +309,13 @@ async def query(self, scope_id: str, query: TagQuery, *, caller: str = "runtime" cursor = self._encode_cursor(keys[query.limit - 1], binding) if len(items) > query.limit else None return TagQueryPage(items=tuple(items[: query.limit]), next_cursor=cursor) - async def _read(self, connection: AsyncConnection, scope_id: str, target: TagTarget) -> ArtifactTagSet: - labels = await connection.scalars(select(ARTIFACT_TAGS_TABLE.c.tag).where(_where(_identity(scope_id, target)))) + async def _read( + self, connection: AsyncConnection, scope_id: str, target: TagTarget, *, for_update: bool = False + ) -> ArtifactTagSet: + statement = select(ARTIFACT_TAGS_TABLE.c.tag).where(_where(_identity(scope_id, target))) + if for_update: + statement = statement.with_for_update() + labels = await connection.scalars(statement) return tag_set(scope_id, target, tuple(labels)) async def _target_reference( diff --git a/src/powercontext/builtin/review/models.py b/src/powercontext/builtin/review/models.py index 9b7a14b652..b67ac457ab 100644 --- a/src/powercontext/builtin/review/models.py +++ b/src/powercontext/builtin/review/models.py @@ -17,7 +17,7 @@ from __future__ import annotations from enum import StrEnum -from typing import Generic, TypeVar +from typing import Generic, Literal, TypeVar from pydantic import BaseModel, Field, StrictInt, field_validator, model_validator @@ -42,6 +42,19 @@ class CandidateStatus(StrEnum): REJECTED = "rejected" +class CandidateAudit(BaseModel): + """Server-owned provenance pinned to one immutable Dream proposal version.""" + + origin: Literal["dream"] = "dream" + operation: str + dream_run_id: str + spec_version: str + proposal_digest: str + evidence_manifest_ref: str + validation_policy_digest: str + proposal_fingerprint: str | None = None + + class ArtifactCandidate(BaseModel, Generic[ProposalT]): """One current Candidate head with its immutable proposal version.""" @@ -57,6 +70,7 @@ class ArtifactCandidate(BaseModel, Generic[ProposalT]): reason: str | None = Field(default=None, min_length=1, max_length=MAX_CANDIDATE_REASON_LENGTH) result_artifact: ArtifactRef | None = None decision_reason: str | None = Field(default=None, min_length=1, max_length=MAX_CANDIDATE_REASON_LENGTH) + audit: CandidateAudit | None = None @field_validator("candidate_id", "family", "reason", "decision_reason") @classmethod @@ -73,7 +87,15 @@ def validate_evidence(self): raise ValueError(f"Candidate evidence must not exceed {MAX_CANDIDATE_EVIDENCE} references") # noqa: TRY003 if self.target is not None and self.target.family != self.family: raise ValueError("Candidate target must belong to the proposed family") # noqa: TRY003 - if self.memory_citations and self.family not in {"experience", "profile", "memory", "handoff", "topic-memory"}: + if self.memory_citations and self.family not in { + "experience", + "profile", + "memory", + "handoff", + "topic-memory", + "skill", + "prompt", + }: raise ValueError("this Candidate Family does not accept Memory citations") # noqa: TRY003 return self diff --git a/src/powercontext/builtin/review/service.py b/src/powercontext/builtin/review/service.py index 019581cb3a..e4546f1116 100644 --- a/src/powercontext/builtin/review/service.py +++ b/src/powercontext/builtin/review/service.py @@ -16,7 +16,7 @@ from __future__ import annotations -from collections.abc import AsyncIterator, Callable +from collections.abc import AsyncIterator, Awaitable, Callable from contextlib import AbstractAsyncContextManager, asynccontextmanager, nullcontext from typing import Any, TypeAlias, cast @@ -45,6 +45,8 @@ from powercontext.builtin.artifacts.memory.service import MemoryService from powercontext.builtin.artifacts.profile.models import ProfileCandidateProposal, ProfileWriteContent from powercontext.builtin.artifacts.profile.review import decide_profile, revise_profile +from powercontext.builtin.artifacts.prompt import Prompt, PromptContent, PromptRegistry +from powercontext.builtin.artifacts.prompt.models import PROMPT_KEYS from powercontext.builtin.artifacts.skill import Skill, SkillContent, SkillDraft, build_instruction_skill_package from powercontext.builtin.artifacts.topic_memory.models import TopicMemory, TopicMemoryContent, TopicMemoryDraft from powercontext.builtin.evidence.models import EvidenceResolutionError, unique_references @@ -55,7 +57,7 @@ from powercontext.builtin.persistence.dream import DreamRepository from powercontext.builtin.persistence.errors import RepositoryNotFoundError from powercontext.builtin.persistence.experience_index import ExperienceIndex -from powercontext.builtin.persistence.family_management import HandoffManagementWriter +from powercontext.builtin.persistence.family_management import HandoffManagementWriter, PromptManagementWriter from powercontext.builtin.persistence.generation_sources import GenerationSourceAccess from powercontext.builtin.persistence.profile import ProfilePolicyRepository from powercontext.builtin.persistence.skill_packages import SkillPackageRepository @@ -87,6 +89,7 @@ | MemoryDreamCandidateProposal | TopicMemoryContent | HandoffContent + | PromptContent ) ReviewedArtifact: TypeAlias = Experience | Skill ReviewedDraft: TypeAlias = ExperienceDraft | SkillDraft @@ -111,6 +114,7 @@ def __init__( memory: Callable[[AsyncConnection | None], MemoryService] | None = None, topic_writer: TopicMemoryManagementWriter | None = None, handoff_writer: HandoffManagementWriter | None = None, + prompt_registry: PromptRegistry | None = None, authorization_context: Callable[[], AbstractAsyncContextManager[None]] = nullcontext, connection: AsyncConnection | None = None, ) -> None: @@ -126,8 +130,10 @@ def __init__( self._memory = memory self._topic_writer = topic_writer self._handoff_writer = handoff_writer + self._prompt_registry = prompt_registry self._authorization_context = authorization_context self._bound_connection = connection + self.authorize_action: Callable[[str, str, ArtifactCandidate[Any]], Awaitable[None]] | None = None def configure_authorization(self, authorize: EvidenceAuthorizer, context: AuthorizationContext) -> None: if self._evidence is None: @@ -135,6 +141,10 @@ def configure_authorization(self, authorize: EvidenceAuthorizer, context: Author self._evidence.authorize = authorize self._authorization_context = context + async def _authorize_decision(self, action: str, candidate: ArtifactCandidate[Any]) -> None: + if self.authorize_action is not None: + await self.authorize_action(self._scope_id, action, candidate) + @asynccontextmanager async def _connection(self) -> AsyncIterator[AsyncConnection]: async with self._authorization_context(), self._database.connection(self._bound_connection) as connection: @@ -176,14 +186,19 @@ async def propose_skill( target: ArtifactRef | None, reason: str | None, candidate_id: str | None = None, + memory_citations: tuple[MemoryCitation, ...] = (), ) -> ArtifactCandidate[SkillContent]: """Persist a human or integration supplied managed Skill proposal.""" canonical_sources = _unique_sources(sources) canonical_artifacts = _unique_artifacts(artifacts) + if memory_citations and (target is None or candidate_id is None or not candidate_id.startswith("cand_dream_")): + raise InvalidCandidateError("memory_citations", "only Dream Skill revisions accept direct Memory evidence") _validate_reason(reason) async with self._connection() as connection: proposal = await self._canonical_skill_proposal(connection, proposal) + if target is not None and candidate_id is not None and candidate_id.startswith("cand_dream_"): + await self._validate_skill_revision(connection, target, proposal) candidate = await self._propose_with_connection( connection, Skill.family, @@ -193,6 +208,7 @@ async def propose_skill( target=target, reason=reason, candidate_id=candidate_id, + memory_citations=unique_references(memory_citations), ) return _skill_candidate(candidate) @@ -347,7 +363,13 @@ async def _propose_with_connection( memory_citations: tuple[MemoryCitation, ...] = (), ) -> ReviewedCandidate: sources = await self._validate_evidence( - connection, sources, artifacts, memory_citations, proposal=proposal, target=target + connection, + sources, + artifacts, + memory_citations, + proposal=proposal, + target=target, + dream_skill_target=candidate_id is not None and candidate_id.startswith("cand_dream_"), ) await self._validate_handoff_objective(connection, proposal, target) await self._validate_target(connection, family, target, artifacts) @@ -365,9 +387,9 @@ async def _propose_with_connection( ) return _reviewed_candidate(candidate) - async def get_candidate(self, candidate_id: str, /) -> ReviewedCandidate: + async def get_candidate(self, candidate_id: str, /, *, current: bool = False) -> ReviewedCandidate: async with self._connection() as connection: - candidate = await self._candidates.get(connection, self._scope_id, candidate_id) + candidate = await self._candidates.get(connection, self._scope_id, candidate_id, current=current) return _reviewed_candidate(candidate) async def inspect_evidence(self, candidate_id: str, expected_version: int) -> CandidateEvidenceView: @@ -435,27 +457,55 @@ async def revise( # noqa: C901 - Family-specific validation shares one versione _validate_reason(reason) async with self._connection() as connection: preview = await self._candidates.get(connection, self._scope_id, candidate_id) + if preview.version != expected_version: + raise CandidateConflictError(candidate_id, expected_version, preview.version) + if preview.target != target: + raise InvalidCandidateError("target", "cannot change across Candidate versions") if preview.family == "profile": await ProfilePolicyRepository().get(connection, self._scope_id, for_update=True) - if preview.family in {"memory", "topic-memory", "handoff"} and preview.target is not None: + if preview.family in {"memory", "topic-memory", "handoff", "prompt"} and preview.target is not None: await self._artifacts.latest( connection, self._scope_id, preview.family, preview.target.artifact_id, for_update=True ) + if preview.family in {Experience.family, Skill.family}: + await self._lock_generic_review_inputs( + connection, + preview, + sources=canonical_sources, + artifacts=canonical_artifacts, + memory_citations=preview.memory_citations + if memory_citations is None + else unique_references(memory_citations), + ) current = await self._candidates.lock_pending( connection, self._scope_id, candidate_id, expected_version, ) + if current != preview: + raise CandidateConflictError(candidate_id, expected_version, current.version) reviewed = _reviewed_candidate(current) if reviewed.family == "profile": proposal = revise_profile(current, proposal, canonical_sources, canonical_artifacts, target) _validate_proposal_family(reviewed.family, proposal) if isinstance(proposal, SkillContent): proposal = await self._canonical_skill_proposal(connection, proposal) + if current.target is not None and current.candidate_id.startswith("cand_dream_"): + await self._validate_skill_revision(connection, current.target, proposal) + if isinstance(proposal, PromptContent): + await self._validate_prompt_proposal(connection, current.target, proposal) if target != current.target: raise InvalidCandidateError("target", "cannot change across Candidate versions") citations = current.memory_citations if memory_citations is None else unique_references(memory_citations) + if ( + reviewed.family == Skill.family + and citations + and (current.target is None or not current.candidate_id.startswith("cand_dream_")) + ): + raise InvalidCandidateError( + "memory_citations", "only Dream Skill revisions accept direct Memory evidence" + ) if isinstance(proposal, MemoryDreamCandidateProposal): previous = current.proposal if ( @@ -480,10 +530,12 @@ async def revise( # noqa: C901 - Family-specific validation shares one versione citations, proposal=proposal if isinstance(proposal, (ExperienceContent, SkillContent)) else None, target=current.target, + dream_skill_target=current.candidate_id.startswith("cand_dream_"), ) await self._validate_handoff_objective(connection, proposal, current.target) if reviewed.family != "profile": await self._validate_target(connection, reviewed.family, target, canonical_artifacts) + await self._authorize_decision("revise", current.model_copy(update={"proposal": proposal})) revised = await self._candidates.revise( connection, self._scope_id, @@ -518,6 +570,8 @@ async def reject( reason=reason, ) ) + current = await self._candidates.lock_pending(connection, self._scope_id, candidate_id, expected_version) + await self._authorize_decision("reject", current) rejected = await self._candidates.reject( connection, self._scope_id, @@ -537,16 +591,29 @@ async def approve( # noqa: C901 - each Family keeps its own transactional publi async with self._connection() as connection: current = await self._candidates.get(connection, self._scope_id, candidate_id) + await self._authorize_decision("approve", current) if current.family == Memory.family: return await self._approve_memory_dream(candidate_id, expected_version) if current.family == TopicMemory.family: return await self._approve_topic_memory_dream(candidate_id, expected_version) if current.family == Handoff.family: return await self._approve_handoff_dream(candidate_id, expected_version) + if current.family == Prompt.family: + return await self._approve_prompt_dream(candidate_id, expected_version) async with self._connection() as connection: current = await self._candidates.get(connection, self._scope_id, candidate_id) if current.family == "profile": return _reviewed_candidate(await decide_profile(self, connection, candidate_id, expected_version)) + if current.version != expected_version: + raise CandidateConflictError(candidate_id, expected_version, current.version) + _validate_approval_lineage(_reviewed_candidate(current)) + await self._lock_generic_review_inputs( + connection, + current, + sources=current.sources, + artifacts=current.artifacts, + memory_citations=current.memory_citations, + ) candidate = _reviewed_candidate( await self._candidates.lock_pending( connection, @@ -555,6 +622,10 @@ async def approve( # noqa: C901 - each Family keeps its own transactional publi expected_version, ) ) + if candidate != current: + raise CandidateConflictError(candidate_id, expected_version, candidate.version) + await self._authorize_decision("approve", candidate) + await self._validate_candidate_audit(connection, candidate) if candidate.family == Skill.family and candidate.candidate_id.startswith("cand_dream_"): run_id = candidate.candidate_id.removeprefix("cand_dream_") run = await DreamRepository().get(connection, self._scope_id, run_id) @@ -563,9 +634,11 @@ async def approve( # noqa: C901 - each Family keeps its own transactional publi or run.run.candidate is None or run.run.candidate.candidate_id != candidate.candidate_id ): - raise InvalidCandidateError("evaluation", "Dream Skill origin is invalid") + raise InvalidCandidateError("origin", "Dream Skill origin is invalid") if run.run.operation == "revise_skill": - raise InvalidCandidateError("evaluation", "trusted_evaluation_required") + if candidate.target is None or not isinstance(candidate.proposal, SkillContent): + raise InvalidCandidateError("target", "Dream Skill revision requires its original target") + await self._validate_skill_revision(connection, candidate.target, candidate.proposal) _validate_approval_lineage(candidate) await self._validate_evidence( connection, @@ -573,6 +646,8 @@ async def approve( # noqa: C901 - each Family keeps its own transactional publi candidate.artifacts, candidate.memory_citations, proposal=candidate.proposal if isinstance(candidate.proposal, ExperienceContent) else None, + target=candidate.target, + dream_skill_target=candidate.candidate_id.startswith("cand_dream_"), ) if isinstance(candidate.proposal, SkillContent) and candidate.proposal.package is not None: await self._canonical_skill_proposal(connection, candidate.proposal) @@ -607,6 +682,161 @@ async def approve( # noqa: C901 - each Family keeps its own transactional publi ) return _reviewed_candidate(approved) + async def require_prompt_target(self, target: ArtifactRef, /) -> dict[str, object]: + """Expose schemas as task data, never the target's text as Dream instructions.""" + + async with self._connection() as connection: + await self._prompt_target(connection, target) + definition = cast(PromptRegistry, self._prompt_registry).require_customization(target.artifact_id) + return { + "key": definition.key, + "definition_version": definition.definition_version, + "input_schema": definition.input_type.model_json_schema(), + "output_schema": definition.output_type.model_json_schema(), + } + + async def _lock_generic_review_inputs( + self, + connection: AsyncConnection, + preview: ArtifactCandidate[Any], + *, + sources: tuple[SourceRef, ...], + artifacts: tuple[ArtifactRef, ...], + memory_citations: tuple[MemoryCitation, ...], + ) -> None: + """Match Dream reuse: target head, ordered evidence Memory heads, then Candidate.""" + + if preview.target is not None: + current = await self._artifacts.latest( + connection, self._scope_id, preview.family, preview.target.artifact_id, for_update=True + ) + if current.as_ref() != preview.target: + raise ArtifactTargetConflictError(preview.target, current.as_ref()) + await self._validate_evidence( + connection, + sources, + artifacts, + memory_citations, + target=preview.target, + dream_skill_target=preview.candidate_id.startswith("cand_dream_"), + ) + + async def _validate_candidate_audit(self, connection: AsyncConnection, candidate: ArtifactCandidate[Any]) -> None: + """Recheck persisted generation policy and content after acquiring the Candidate lock.""" + + from powercontext.builtin.dream.bindings import DREAM_SPECS + from powercontext.builtin.dream.provenance import validation_policy_digest + from powercontext.builtin.persistence.candidates import proposal_digest + + audit = candidate.audit + if audit is None: + return + spec = DREAM_SPECS.get(audit.operation) + if ( + spec is None + or spec.family != candidate.family + or spec.spec_version != audit.spec_version + or audit.proposal_digest != proposal_digest(candidate.proposal) + ): + raise InvalidCandidateError("audit", "Dream Candidate content or operation contract changed") + record = await DreamRepository().get(connection, self._scope_id, audit.dream_run_id) + if ( + record.run.operation != audit.operation + or record.run.target != candidate.target + or record.run.candidate is None + or record.run.candidate.candidate_id != candidate.candidate_id + or validation_policy_digest(record) != audit.validation_policy_digest + ): + raise InvalidCandidateError("audit", "Dream Candidate policy or origin changed") + + async def _prompt_target(self, connection: AsyncConnection, target: ArtifactRef | None) -> Prompt: + if ( + target is None + or target.family != Prompt.family + or target.artifact_id not in PROMPT_KEYS + or self._prompt_registry is None + ): + raise InvalidCandidateError("target", "expected an existing customizable operational Prompt") + self._prompt_registry.require_customization(target.artifact_id) + current = await self._artifacts.get(connection, self._scope_id, target) + if not isinstance(current, Prompt): + raise InvalidCandidateError("target", "expected an exact Prompt revision") + return current + + async def _validate_prompt_proposal( + self, connection: AsyncConnection, target: ArtifactRef | None, proposal: PromptContent + ) -> Prompt: + current = await self._prompt_target(connection, target) + if proposal.mode != "custom": + raise InvalidCandidateError("proposal", "Dream Prompt proposals must use custom mode") + cast(PromptRegistry, self._prompt_registry).validate(current.artifact_id, proposal) + return current + + async def propose_prompt_dream( + self, + proposal: PromptContent, + /, + *, + sources: tuple[SourceRef, ...], + artifacts: tuple[ArtifactRef, ...], + target: ArtifactRef, + reason: str | None, + candidate_id: str, + memory_citations: tuple[MemoryCitation, ...] = (), + ) -> ArtifactCandidate[PromptContent]: + _validate_reason(reason) + async with self._connection() as connection: + await self._validate_prompt_proposal(connection, target, proposal) + candidate = await self._propose_with_connection( + connection, + Prompt.family, + proposal, + sources=_unique_sources(sources), + artifacts=_unique_artifacts(artifacts), + target=target, + reason=reason, + candidate_id=candidate_id, + memory_citations=unique_references(memory_citations), + ) + return ArtifactCandidate[PromptContent].model_validate(candidate.model_dump(mode="python")) + + async def _approve_prompt_dream(self, candidate_id: str, expected_version: int) -> ReviewedCandidate: + async with self._connection() as connection: + preview = await self._candidates.get(connection, self._scope_id, candidate_id) + if preview.target is None: + raise InvalidCandidateError("target", "Dream Prompt requires an existing target") + current = await self._artifacts.latest( + connection, self._scope_id, Prompt.family, preview.target.artifact_id, for_update=True + ) + if current.as_ref() != preview.target: + raise ArtifactTargetConflictError(preview.target, current.as_ref()) + candidate = _reviewed_candidate( + await self._candidates.lock_pending(connection, self._scope_id, candidate_id, expected_version) + ) + await self._authorize_decision("approve", candidate) + await self._validate_candidate_audit(connection, candidate) + if candidate.target != preview.target or not isinstance(candidate.proposal, PromptContent): + raise InvalidCandidateError("proposal", "Prompt candidate changed during approval") + current = await self._validate_prompt_proposal(connection, candidate.target, candidate.proposal) + await self._validate_evidence( + connection, candidate.sources, candidate.artifacts, candidate.memory_citations, target=candidate.target + ) + artifact = await PromptManagementWriter( + self._artifacts, cast(PromptRegistry, self._prompt_registry) + ).commit_reviewed( + connection, + self._scope_id, + current, + candidate.proposal, + sources=candidate.sources, + artifacts=candidate.artifacts, + memory_citations=candidate.memory_citations, + ) + approved = await self._candidates.mark_approved( + connection, self._scope_id, candidate_id, expected_version, artifact.as_ref() + ) + return _reviewed_candidate(approved) + async def _approve_memory_dream( # noqa: C901 - preparation and publication have distinct transaction boundaries self, candidate_id: str, expected_version: int ) -> ReviewedCandidate: @@ -656,6 +886,8 @@ async def _approve_memory_dream( # noqa: C901 - preparation and publication hav candidate = _reviewed_candidate( await self._candidates.lock_pending(connection, self._scope_id, candidate_id, expected_version) ) + await self._authorize_decision("approve", candidate) + await self._validate_candidate_audit(connection, candidate) if candidate != preview: raise CandidateConflictError(candidate_id, expected_version, candidate.version) await self._validate_evidence( @@ -688,6 +920,8 @@ async def _approve_topic_memory_dream(self, candidate_id: str, expected_version: candidate = _reviewed_candidate( await self._candidates.lock_pending(connection, self._scope_id, candidate_id, expected_version) ) + await self._authorize_decision("approve", candidate) + await self._validate_candidate_audit(connection, candidate) if ( candidate != preview or not isinstance(current, TopicMemory) @@ -729,6 +963,8 @@ async def _approve_handoff_dream(self, candidate_id: str, expected_version: int) candidate = _reviewed_candidate( await self._candidates.lock_pending(connection, self._scope_id, candidate_id, expected_version) ) + await self._authorize_decision("approve", candidate) + await self._validate_candidate_audit(connection, candidate) if not isinstance(current, Handoff) or not isinstance(candidate.proposal, HandoffContent): raise InvalidCandidateError("family", "Handoff Dream proposal required") _validate_handoff_citations( @@ -781,9 +1017,14 @@ async def _validate_evidence( *, proposal: ReviewedProposal | None = None, target: ArtifactRef | None = None, + dream_skill_target: bool = False, ) -> tuple[SourceRef, ...]: supporting_artifacts = tuple( - ref for ref in artifacts if ref != target or ref.family not in {"profile", "topic-memory", "handoff"} + ref + for ref in artifacts + if ref != target + or ref.family + not in ({"profile", "topic-memory", "handoff", "prompt"} | ({"skill"} if dream_skill_target else set())) ) if ( not sources @@ -893,6 +1134,19 @@ async def prepare_skill_revision(self, target: ArtifactRef, proposal: SkillConte await self._skill_packages.add(connection, self._scope_id, snapshot) return snapshot.as_skill_content() + async def _validate_skill_revision( + self, connection: AsyncConnection, target: ArtifactRef, proposal: SkillContent + ) -> None: + current = await self._instruction_skill_target(connection, target) + if proposal.package is None: + raise InvalidCandidateError("package", "Dream Skill revision requires a prepared standard package") + snapshot = await self._skill_packages.get(connection, self._scope_id, proposal.package) + if len(snapshot.entries) != 1 or snapshot.entries[0].path != "SKILL.md": + raise InvalidCandidateError("package", "unsupported_target") + for field in ("name", "license", "compatibility", "metadata", "allowed_tools"): + if getattr(proposal, field) != getattr(current.content, field): + raise InvalidCandidateError("proposal", "Dream Skill revision cannot change protected package metadata") + async def require_instruction_skill_target(self, target: ArtifactRef, /) -> None: async with self._connection() as connection: await self._instruction_skill_target(connection, target) @@ -954,6 +1208,7 @@ def _validate_proposal_family(family: str, proposal: object) -> None: Memory.family: MemoryDreamCandidateProposal, TopicMemory.family: TopicMemoryContent, Handoff.family: HandoffContent, + Prompt.family: PromptContent, }.get(family) if expected is None or type(proposal) is not expected: raise InvalidCandidateError("family", family) @@ -969,19 +1224,21 @@ def _validate_handoff_citations( if proposal.generation is not None or target is None: raise InvalidCandidateError("proposal", "Dream Handoff cannot claim generated or activated content") statements = (*proposal.state, *((proposal.next_action,) if proposal.next_action is not None else ())) - for statement in statements: - for citation in statement.citations: - if isinstance(citation, HandoffSourceCitation) and citation.source_ref in sources: - continue - if ( - isinstance(citation, HandoffArtifactCitation) - and citation.artifact_ref in artifacts - and citation.artifact_ref != target - ): - continue - if isinstance(citation, HandoffMemoryCitation) and citation.memory_citation in memory_citations: - continue - raise InvalidCandidateError("proposal", "Handoff claim cites evidence outside the selected inputs") + citations = tuple(citation for statement in statements for citation in statement.citations) + tuple( + omission.citation for omission in proposal.omissions if omission.citation is not None + ) + for citation in citations: + if isinstance(citation, HandoffSourceCitation) and citation.source_ref in sources: + continue + if ( + isinstance(citation, HandoffArtifactCitation) + and citation.artifact_ref in artifacts + and citation.artifact_ref != target + ): + continue + if isinstance(citation, HandoffMemoryCitation) and citation.memory_citation in memory_citations: + continue + raise InvalidCandidateError("proposal", "Handoff claim cites evidence outside the selected inputs") async def _has_resolved_failure_evidence( # noqa: C901 - each citation kind has a distinct resolution boundary diff --git a/src/powercontext/builtin/runtime/application.py b/src/powercontext/builtin/runtime/application.py index a1c552ac0b..dac6b9b4d7 100644 --- a/src/powercontext/builtin/runtime/application.py +++ b/src/powercontext/builtin/runtime/application.py @@ -116,6 +116,9 @@ TopicMemorySearchMode, TopicMemorySearchResult, ) +from powercontext.builtin.catalog_changes.application import CatalogChangeApplication +from powercontext.builtin.catalog_changes.models import TagDreamTarget +from powercontext.builtin.catalog_changes.service import CatalogChangeService from powercontext.builtin.context import BuiltinArtifacts, BuiltinSources from powercontext.builtin.dream.application import DreamApplication from powercontext.builtin.dream.service import CandidateAttester, DreamAuthorizer, DreamService @@ -2894,6 +2897,7 @@ def __init__( scope_cache_observer: ScopeCacheObserver | None = None, scope_ids: ScopeIds | None = None, review_service: ReviewServiceFactory | None = None, + catalog_change_service: Callable[[str], CatalogChangeService] | None = None, profiles: RelationalProfileService | None = None, subject_sources: SubjectSourceService | None = None, generation_service: GenerationServiceFactory | None = None, @@ -2944,10 +2948,13 @@ def __init__( self._provider = provider self._capabilities = capabilities self._review_service = review_service + self._catalog_change_service = catalog_change_service self.profiles = profiles self.subject_sources = subject_sources self._generation_service = generation_service self._dream_service = dream_service + self._review_action_authorizer: Callable[[str, str, ReviewedCandidate], Awaitable[None]] | None = None + self._catalog_action_authorizer: Callable[[str, str, TagDreamTarget], Awaitable[None]] | None = None self._review_evidence_authorizer: ScopedEvidenceAuthorizer | None = None self._review_authorization_context: AuthorizationContext = nullcontext self._generation_slots = asyncio.Semaphore(generation_concurrency) @@ -3015,6 +3022,9 @@ def __init__( self.topic_memory = TopicMemoryApplication(self) self.records = RecordApplication(self) self.prompts = PromptApplication(self) + self.catalog_changes = ( + None if catalog_change_service is None else CatalogChangeApplication(self._catalog_review) + ) self.review = ReviewApplication(self) self.skill = SkillApplication(self) self.remote_skills = RemoteSkillApplication(self) @@ -3150,15 +3160,21 @@ def configure_evidence_authorization( review: ScopedEvidenceAuthorizer, context: AuthorizationContext, attest_candidate: CandidateAttester, + authorize_candidate: CandidateAttester | None = None, + catalog_action: Callable[[str, str, TagDreamTarget], Awaitable[None]] | None = None, + review_action: Callable[[str, str, ReviewedCandidate], Awaitable[None]] | None = None, ) -> None: """Bind a trusted Server adapter's current authorization policy.""" + self._review_action_authorizer = review_action + self._catalog_action_authorizer = catalog_action self._review_evidence_authorizer = review self._review_authorization_context = context if self._dream_service is not None: self._dream_service.authorize = dream self._dream_service.authorization_context = context self._dream_service.attest_candidate = attest_candidate + self._dream_service.authorize_candidate = authorize_candidate async def close(self) -> None: """Stop accepting work and await in-flight operations without closing the provider.""" @@ -3316,6 +3332,18 @@ def _review(self, scope_id: str) -> ReviewService: raise _RuntimeStateError("review") scope = validate_scope_id(scope_id) service = self._review_service(scope) + service.authorize_action = self._review_action_authorizer + authorizer = self._review_evidence_authorizer + if authorizer is not None: + service.configure_authorization(lambda ref: authorizer(scope, ref), self._review_authorization_context) + return service + + def _catalog_review(self, scope_id: str) -> CatalogChangeService: + if self._catalog_change_service is None: + raise _RuntimeStateError("catalog-review") + scope = validate_scope_id(scope_id) + service = self._catalog_change_service(scope) + service.authorize_action = self._catalog_action_authorizer authorizer = self._review_evidence_authorizer if authorizer is not None: service.configure_authorization(lambda ref: authorizer(scope, ref), self._review_authorization_context) diff --git a/src/powercontext/builtin/runtime/composition.py b/src/powercontext/builtin/runtime/composition.py index 789eb044b2..3a90fb2947 100644 --- a/src/powercontext/builtin/runtime/composition.py +++ b/src/powercontext/builtin/runtime/composition.py @@ -59,7 +59,7 @@ topic_memory_stage_budget, validate_topic_memory_stage_capacity, ) -from powercontext.builtin.dream.bindings import DREAM_OPERATIONS +from powercontext.builtin.dream.bindings import DREAM_OPERATIONS, operation_spec from powercontext.builtin.dream.generation import ( DREAM_INSTRUCTIONS, DreamGenerationInput, @@ -272,6 +272,7 @@ async def open_builtin_runtime( dream_authorizer: DreamAuthorizer | None = None, dream_authorization_context: AuthorizationContext = nullcontext, dream_candidate_attester: CandidateAttester | None = None, + dream_candidate_authorizer: CandidateAttester | None = None, external_skill_provider: ExternalSkillProvider | None = None, handoff_pipeline: HandoffGenerationPipeline | None = None, embedding_model: EmbeddingModel | None = None, @@ -460,7 +461,9 @@ async def run_profile(scope_id, high): if dream_generator is not None and config.runtime.dream_enabled: registered_families = {binding.artifact_family for binding in processing_bindings} configured_operations = tuple( - spec.operation for spec in DREAM_OPERATIONS if spec.family in registered_families + spec.operation + for spec in DREAM_OPERATIONS + if spec.family in registered_families or (spec.family is None and registered_families) ) topic_memory_processing_available = _topic_memory_processing_available(config, processing_bindings) runtime = await resources.enter_async_context( @@ -474,9 +477,8 @@ async def run_profile(scope_id, high): artifact_dreaming_operations=tuple( DreamOperationCapability( operation=operation, - effect="review_then_commit_without_activation" - if operation == "refresh_handoff" - else "review_then_publish", + effect=operation_spec(operation).effect, + output_kind=operation_spec(operation).output_kind, ) for operation in configured_operations ), @@ -493,6 +495,7 @@ async def run_profile(scope_id, high): scope_cache_observer=scope_cache_observer, scope_ids=contexts.scope_ids, review_service=contexts.review, + catalog_change_service=contexts.catalog_changes, profiles=contexts.profiles, subject_sources=contexts.subject_sources, generation_service=contexts.generation, @@ -504,6 +507,7 @@ async def run_profile(scope_id, high): authorize=dream_authorizer, authorization_context=dream_authorization_context, attest_candidate=dream_candidate_attester, + authorize_candidate=dream_candidate_authorizer, ), generation_concurrency=config.runtime.generation_concurrency, experience_recall=contexts.search_experience_outcome, @@ -606,6 +610,7 @@ def _artifact_processing_bindings( # noqa: C901 - validate and assemble one reg "profile": config.runtime.profile_schedule_enabled or None, "skill": None, "handoff": None, + "prompt": None, } for family, schedule in automatic.items(): if schedule is not None and family not in declared | registered: @@ -665,7 +670,7 @@ def _artifact_processing_bindings( # noqa: C901 - validate and assemble one reg else None, timezone=config.runtime.profile_timezone if family == "profile" else "Asia/Shanghai", pending_provider=None - if family in {"skill", "handoff"} + if family in {"skill", "handoff", "prompt"} else SourceProcessingPendingProvider(contexts.database, binding, family), automatic_scope_filter=enabled_profile_scopes if family == "profile" else None, ) diff --git a/src/powercontext/builtin/runtime/config.py b/src/powercontext/builtin/runtime/config.py index 76353e1c79..1f3c726bb4 100644 --- a/src/powercontext/builtin/runtime/config.py +++ b/src/powercontext/builtin/runtime/config.py @@ -124,6 +124,7 @@ def normalize_processing_aliases(cls, value: Any) -> Any: "experience_max_workers", "skill_max_workers", "handoff_max_workers", + "prompt_max_workers", "profile_max_workers", "profile_max_concurrency", "artifact_processing_max_workers", @@ -201,12 +202,14 @@ def validate_recall_gate_threshold_order(self): experience_max_workers: int = Field(default=1, ge=1) skill_max_workers: int = Field(default=1, ge=1) handoff_max_workers: int = Field(default=1, ge=1) + prompt_max_workers: int = Field(default=1, ge=1) profile_max_workers: int = Field(default=4, ge=1) memory_worker_timeout_seconds: float = Field(default=600, gt=0) topic_memory_worker_timeout_seconds: float = Field(default=600, gt=0) experience_worker_timeout_seconds: float = Field(default=600, gt=0) skill_worker_timeout_seconds: float = Field(default=600, gt=0) handoff_worker_timeout_seconds: float = Field(default=600, gt=0) + prompt_worker_timeout_seconds: float = Field(default=600, gt=0) profile_worker_timeout_seconds: float = Field(default=600, gt=0) @model_validator(mode="after") diff --git a/src/powercontext/builtin/runtime/family_processing.py b/src/powercontext/builtin/runtime/family_processing.py index 8e8d1f66dc..5143d9a149 100644 --- a/src/powercontext/builtin/runtime/family_processing.py +++ b/src/powercontext/builtin/runtime/family_processing.py @@ -28,7 +28,12 @@ from powercontext._logging import log_safely from powercontext.builtin.artifacts.experience import EXPERIENCE_INCUBATION_CURSOR_NAME from powercontext.builtin.artifacts.profile.models import PROFILE_SOURCE_WINDOW_BINDING -from powercontext.builtin.dream.bindings import HANDOFF_DREAM_BINDING, SKILL_DREAM_BINDING, operations_for_binding +from powercontext.builtin.dream.bindings import ( + HANDOFF_DREAM_BINDING, + PROMPT_DREAM_BINDING, + SKILL_DREAM_BINDING, + operations_for_binding, +) from powercontext.builtin.dream.generation import DreamGenerator from powercontext.builtin.inference.models import InferenceUsage from powercontext.builtin.inference.usage import bind_usage_reporter @@ -57,6 +62,7 @@ "profile": PROFILE_SOURCE_WINDOW_BINDING, "skill": SKILL_DREAM_BINDING, "handoff": HANDOFF_DREAM_BINDING, + "prompt": PROMPT_DREAM_BINDING, } logger = logging.getLogger(__name__) @@ -104,6 +110,15 @@ async def _run_family_worker( ("profile.generate", None, pipelines[0]), ("memory.extract", None, pipelines[1]), ("experience.incubate", None, pipelines[2]), + ("experience.generate", None, pipelines[3]), + ("skill.generate", None, pipelines[4]), + ("handoff.generate", None, pipelines[5]), + ("memory.rerank", None, pipelines[6]), + *( + (f"topic_memory.{stage}", None, object()) + for stage in ("probe", "global", "planner", "evolve", "temporary", "reduce", "reconcile") + if config.inference.generation_model is not None + ), ), ), _topic_memory_worker=True, @@ -196,7 +211,7 @@ async def _process_family_invocation( # noqa: C901 - one guarded dispatch per r contexts, assignment, config=config, generator=dream_generator, security=security ): return ArtifactProcessingWorkerCompletion() - if assignment.artifact_family in {"skill", "handoff"}: + if assignment.artifact_family in {"skill", "handoff", "prompt"}: async with contexts.database.transaction() as connection: await invocation.start(connection) await invocation.complete(connection, remaining_work=False) diff --git a/src/powercontext/builtin/runtime/models.py b/src/powercontext/builtin/runtime/models.py index 64c4eafce7..d0b2950596 100644 --- a/src/powercontext/builtin/runtime/models.py +++ b/src/powercontext/builtin/runtime/models.py @@ -37,7 +37,7 @@ MemoryUsedSearchMode, ) from powercontext.builtin.artifacts.profile.models import ProfileCandidateProposal, ProfileWriteContent -from powercontext.builtin.artifacts.prompt import PromptCapability +from powercontext.builtin.artifacts.prompt import PromptCapability, PromptContent from powercontext.builtin.artifacts.skill import ( ExternalSkillProviderScan, ExternalSkillResolution, @@ -66,6 +66,7 @@ | MemoryDreamCandidateProposal | TopicMemoryContent | HandoffContent + | PromptContent ) PREPARED_CONTEXT_SCHEMA: PreparedContextSchema = "powercontext.prepared-context.v1" @@ -114,8 +115,13 @@ class CommitConnectorCheckpoint(BaseModel): class DreamOperationCapability(BaseModel): operation: str - output_kind: Literal["artifact_candidate"] = "artifact_candidate" - effect: Literal["review_then_publish", "review_then_commit_without_activation"] = "review_then_publish" + output_kind: Literal["artifact_candidate", "catalog_change_candidate"] = "artifact_candidate" + effect: Literal[ + "review_then_publish", + "review_then_commit_without_activation", + "review_then_publish_configuration", + "review_then_replace_tags", + ] = "review_then_publish" class RuntimeCapabilities(BaseModel): @@ -423,7 +429,7 @@ class ListArtifactCandidatesRequest(BaseModel): """Filter and page the current Review Inbox.""" status: CandidateStatus = CandidateStatus.PENDING - family: Literal["experience", "skill", "profile", "memory", "topic-memory", "handoff"] | None = None + family: Literal["experience", "skill", "profile", "memory", "topic-memory", "handoff", "prompt"] | None = None cursor: str | None = None limit: Annotated[int, Field(ge=1, le=MAX_CANDIDATE_PAGE_SIZE)] = DEFAULT_CANDIDATE_PAGE_SIZE @@ -449,6 +455,7 @@ class ReviseArtifactCandidateRequest(ApproveArtifactCandidateRequest): | MemoryDreamCandidateProposal | TopicMemoryContent | HandoffContent + | PromptContent ) sources: tuple[SourceRef, ...] = () artifacts: tuple[ArtifactRef, ...] = () diff --git a/src/powercontext/builtin/runtime/processing_registry.py b/src/powercontext/builtin/runtime/processing_registry.py index c702546d09..ad8e1194bc 100644 --- a/src/powercontext/builtin/runtime/processing_registry.py +++ b/src/powercontext/builtin/runtime/processing_registry.py @@ -25,6 +25,7 @@ DREAM_OPERATIONS, DREAM_PROVIDERS, HANDOFF_DREAM_BINDING, + PROMPT_DREAM_BINDING, SKILL_DREAM_BINDING, ) from powercontext.builtin.dream.models import DreamOperation @@ -64,6 +65,7 @@ def processing_capabilities(config: BuiltinConfig) -> tuple[str, ...]: if config.runtime.dream_enabled and config.inference.generation_model.split(":", 1)[0] in DREAM_PROVIDERS: families.append("skill") families.append("handoff") + families.append("prompt") return tuple(sorted(families)) @@ -78,7 +80,9 @@ def dream_operations(config: BuiltinConfig) -> tuple[DreamOperation, ...]: ): return () families = processing_capabilities(config) - return tuple(spec.operation for spec in DREAM_OPERATIONS if spec.family in families) + return tuple( + spec.operation for spec in DREAM_OPERATIONS if spec.family in families or (spec.family is None and families) + ) def canonical_processing_manifest(config: BuiltinConfig) -> dict[str, Any]: @@ -104,6 +108,7 @@ def canonical_processing_manifest(config: BuiltinConfig) -> dict[str, Any]: PROFILE_SOURCE_WINDOW_BINDING: "profile", SKILL_DREAM_BINDING: "skill", HANDOFF_DREAM_BINDING: "handoff", + PROMPT_DREAM_BINDING: "prompt", }, "legacy_automatic_bindings": sorted(automatic), } diff --git a/src/powercontext/builtin/runtime/relational.py b/src/powercontext/builtin/runtime/relational.py index 9a565cc278..d7870afdd2 100644 --- a/src/powercontext/builtin/runtime/relational.py +++ b/src/powercontext/builtin/runtime/relational.py @@ -73,7 +73,7 @@ from powercontext.builtin.artifacts.profile.management import ProfileManagementWriter from powercontext.builtin.artifacts.profile.models import ProfileCandidateProposal from powercontext.builtin.artifacts.profile.service import RelationalProfileService -from powercontext.builtin.artifacts.prompt import Prompt, PromptRegistry +from powercontext.builtin.artifacts.prompt import Prompt, PromptContent, PromptRegistry from powercontext.builtin.artifacts.prompt.builtin import builtin_prompt_definitions from powercontext.builtin.artifacts.prompt.service import ( DemonstrationGenerator, @@ -397,6 +397,7 @@ def review(self, connection: AsyncConnection | None = None) -> ReviewService: memory=lambda bound: self.memory(self.sources(bound)[1], bound), topic_writer=self.topic_writer, handoff_writer=self.handoff_writer, + prompt_registry=self.prompts.registry, ) def recurrence_ledger(self) -> RelationalRecurrenceLedger: @@ -554,6 +555,7 @@ def __init__( Memory.family: MemoryDreamCandidateProposal, TopicMemory.family: TopicMemoryContent, Handoff.family: HandoffContent, + Prompt.family: PromptContent, }), connector_checkpoints=ConnectorCheckpointRepository(), source_definitions=SourceDefinitionManifestRepository(), @@ -708,6 +710,19 @@ def review(self, scope_id: str, /) -> ReviewService: return self._services_for(scope_id).review() + def catalog_changes(self, scope_id: str, connection: AsyncConnection | None = None): + from powercontext.builtin.catalog_changes.service import CatalogChangeService + + services = self._services_for(scope_id) + return CatalogChangeService( + database=self.database, + scope_id=scope_id, + artifacts=self.repositories.artifacts, + evidence=services.evidence(), + id_factory=services.id_factory, + connection=connection, + ) + def dream( self, generator: DreamGenerator | None, @@ -717,6 +732,7 @@ def dream( authorize: DreamAuthorizer | None = None, authorization_context: AuthorizationContext = nullcontext, attest_candidate: CandidateAttester | None = None, + authorize_candidate: CandidateAttester | None = None, operations: tuple[DreamOperation, ...] = (), processing: ScopeInvocation | None = None, ) -> DreamService: @@ -731,6 +747,8 @@ def dream( authorize=authorize, authorization_context=authorization_context, attest_candidate=attest_candidate, + authorize_candidate=authorize_candidate, + catalog_changes=self.catalog_changes, operations=operations, processing=processing, ) diff --git a/src/powercontext/client/cli.py b/src/powercontext/client/cli.py index 4c42f6cae1..e6e250d7b7 100644 --- a/src/powercontext/client/cli.py +++ b/src/powercontext/client/cli.py @@ -61,6 +61,7 @@ from powercontext.http import ( AllScopeSelection, ApproveArtifactCandidateRequest, + ApproveCatalogCandidateRequest, ArtifactCandidate, ArtifactCandidatePage, ArtifactPage, @@ -68,6 +69,9 @@ CandidateFamily, CandidateStatus, Capabilities, + CatalogCandidateHistory, + CatalogCandidatePage, + CatalogChangeCandidate, CreateDreamRunRequest, CreateRemoteSkillTargetRequest, DreamOperation, @@ -85,6 +89,7 @@ GenerateExperienceRequest, GenerateSkillRequest, GetArtifactCandidateRequest, + GetCatalogCandidateRequest, GetExperienceRequest, GetSkillPackageRequest, GetSkillRequest, @@ -93,6 +98,7 @@ ImportExternalSkillRequest, ListArtifactCandidatesRequest, ListArtifactsRequest, + ListCatalogCandidatesRequest, ListDreamRunsRequest, ListExternalSkillsRequest, ListExternalSkillsResponse, @@ -102,6 +108,7 @@ PublishRemoteSkillRequest, ReadinessResponse, RejectArtifactCandidateRequest, + RejectCatalogCandidateRequest, RemoteAgentKind, RemoteSkillPublication, RemoteSkillTarget, @@ -109,6 +116,7 @@ RenameRemoteSkillTargetRequest, ResolveExternalSkillRequest, ReviseArtifactCandidateRequest, + ReviseCatalogCandidateRequest, RevokeRemoteSkillTargetRequest, ScanExternalSkillsRequest, ScanExternalSkillsResponse, @@ -128,6 +136,9 @@ HELP_OPTION_NAMES = ("-h", "--help") _ClientResponse: TypeAlias = ( ArtifactCandidate + | CatalogChangeCandidate + | CatalogCandidatePage + | CatalogCandidateHistory | DreamRun | DreamRunPage | ArtifactCandidatePage @@ -384,6 +395,99 @@ def reject_candidate( asyncio.run(_execute(context, lambda client: client.reject_artifact_candidate(request))) +@candidate_revise_app.command("json") +def revise_candidate_json( + context: typer.Context, + request_file: Annotated[ + Path, + typer.Option( + exists=True, + dir_okay=False, + readable=True, + help="UTF-8 JSON ReviseArtifactCandidateRequest for any supported family.", + ), + ], +) -> None: + """Revise a complete proposal; the result remains pending human review.""" + try: + request = ReviseArtifactCandidateRequest.model_validate_json(request_file.read_text(encoding="utf-8")) + except ValidationError as error: + _raise_invalid_request("Candidate revision", error) + asyncio.run(_execute(context, lambda client: client.revise_artifact_candidate(request))) + + +catalog_candidate_app = typer.Typer( + name="catalog-candidate", help="Inspect and decide Tag metadata candidates.", no_args_is_help=True +) + + +@catalog_candidate_app.command("list") +def list_catalog_candidates( + context: typer.Context, + scope_id: Annotated[str, typer.Option()], + status: CandidateStatus = CandidateStatus.PENDING, + cursor: str | None = None, + limit: Annotated[int, typer.Option(min=1, max=100)] = 50, +) -> None: + request = ListCatalogCandidatesRequest(scope_id=scope_id, status=status, cursor=cursor, limit=limit) + asyncio.run(_execute(context, lambda client: client.list_catalog_candidates(request))) + + +@catalog_candidate_app.command("show") +def show_catalog_candidate( + context: typer.Context, scope_id: Annotated[str, typer.Option()], candidate_id: Annotated[str, typer.Argument()] +) -> None: + request = GetCatalogCandidateRequest(scope_id=scope_id, candidate_id=candidate_id) + asyncio.run(_execute(context, lambda client: client.get_catalog_candidate(request))) + + +@catalog_candidate_app.command("history") +def catalog_candidate_history( + context: typer.Context, scope_id: Annotated[str, typer.Option()], candidate_id: Annotated[str, typer.Argument()] +) -> None: + request = GetCatalogCandidateRequest(scope_id=scope_id, candidate_id=candidate_id) + asyncio.run(_execute(context, lambda client: client.get_catalog_candidate_history(request))) + + +@catalog_candidate_app.command("approve") +def approve_catalog_candidate( + context: typer.Context, + scope_id: Annotated[str, typer.Option()], + candidate_id: Annotated[str, typer.Argument()], + expected_version: Annotated[int, typer.Option(min=1)], +) -> None: + request = ApproveCatalogCandidateRequest( + scope_id=scope_id, candidate_id=candidate_id, expected_version=expected_version + ) + asyncio.run(_execute(context, lambda client: client.approve_catalog_candidate(request))) + + +@catalog_candidate_app.command("reject") +def reject_catalog_candidate( + context: typer.Context, + scope_id: Annotated[str, typer.Option()], + candidate_id: Annotated[str, typer.Argument()], + expected_version: Annotated[int, typer.Option(min=1)], + reason: Annotated[str, typer.Option()], +) -> None: + request = RejectCatalogCandidateRequest( + scope_id=scope_id, candidate_id=candidate_id, expected_version=expected_version, reason=reason + ) + asyncio.run(_execute(context, lambda client: client.reject_catalog_candidate(request))) + + +@catalog_candidate_app.command("revise") +def revise_catalog_candidate( + context: typer.Context, request_file: Annotated[Path, typer.Option(exists=True, dir_okay=False, readable=True)] +) -> None: + """Append a new complete Tag-set proposal for exact-version review.""" + try: + request = ReviseCatalogCandidateRequest.model_validate_json(request_file.read_text(encoding="utf-8")) + except ValidationError as error: + _raise_invalid_request("Catalog Candidate revision", error) + asyncio.run(_execute(context, lambda client: client.revise_catalog_candidate(request))) + + @candidate_revise_app.command("experience") def revise_experience_candidate( context: typer.Context, @@ -1793,6 +1897,7 @@ def register_commands(cli: typer.Typer) -> set[str]: cli.command()(ready) cli.add_typer(dream_app, name="dream") cli.add_typer(candidate_app, name="candidate") + cli.add_typer(catalog_candidate_app, name="catalog-candidate") cli.add_typer(experience_app, name="experience") cli.add_typer(skill_app, name="skill") cli.add_typer(external_skill_app, name="external-skill") diff --git a/src/powercontext/client/client.py b/src/powercontext/client/client.py index e0bde40ee5..5fd5833a0a 100644 --- a/src/powercontext/client/client.py +++ b/src/powercontext/client/client.py @@ -42,6 +42,7 @@ AcknowledgeHandoffRequest, ActivateHandoffRequest, ApproveArtifactCandidateRequest, + ApproveCatalogCandidateRequest, ArtifactCandidate, ArtifactCandidatePage, ArtifactCreated, @@ -52,6 +53,9 @@ Capabilities, CaptureContentSourceRequest, CaptureContentSourceResponse, + CatalogCandidateHistory, + CatalogCandidatePage, + CatalogChangeCandidate, ClearScopeBindingRequest, ClearScopeBindingResponse, CommitConnectorCheckpointRequest, @@ -87,6 +91,7 @@ GeneratePromptDemonstrationsRequest, GenerateSkillRequest, GetArtifactCandidateRequest, + GetCatalogCandidateRequest, GetConnectorCheckpointRequest, GetExperienceRequest, GetHandoffReportRequest, @@ -110,6 +115,7 @@ ListArtifactCandidatesRequest, ListArtifactRevisionsRequest, ListArtifactsRequest, + ListCatalogCandidatesRequest, ListDreamRunsRequest, ListExternalSkillsRequest, ListExternalSkillsResponse, @@ -147,6 +153,7 @@ RecordTaskOutcomeRequest, RegisterSourceDefinitionRequest, RejectArtifactCandidateRequest, + RejectCatalogCandidateRequest, RememberMemoryRequest, RemoteSkillPublication, RemoteSkillReceiptResponse, @@ -161,6 +168,7 @@ ResolveScopeSelectionRequest, RetireMemoryEntryRequest, ReviseArtifactCandidateRequest, + ReviseCatalogCandidateRequest, ReviseMemoryEntryRequest, RevokeAccessBindingRequest, RevokeRemoteSkillTargetRequest, @@ -202,6 +210,7 @@ ACKNOWLEDGE_HANDOFF, ACTIVATE_HANDOFF, APPROVE_ARTIFACT_CANDIDATE, + APPROVE_CATALOG_CANDIDATE, CAPTURE_CONTENT_SOURCE, CHECK_ACCESS, CLEAR_SCOPE_BINDING, @@ -232,6 +241,8 @@ GET_ARTIFACT_REVISION, GET_ARTIFACT_TAGS, GET_CAPABILITIES, + GET_CATALOG_CANDIDATE, + GET_CATALOG_CANDIDATE_HISTORY, GET_CONNECTOR_CHECKPOINT, GET_DEFAULT_SCOPE, GET_DREAM_RUN, @@ -258,6 +269,7 @@ LIST_ARTIFACT_CANDIDATES, LIST_ARTIFACT_REVISIONS, LIST_ARTIFACTS, + LIST_CATALOG_CANDIDATES, LIST_DREAM_RUNS, LIST_EXTERNAL_SKILLS, LIST_MANAGED_SKILLS, @@ -281,6 +293,7 @@ RECORD_TASK_OUTCOME, REGISTER_SOURCE_DEFINITION, REJECT_ARTIFACT_CANDIDATE, + REJECT_CATALOG_CANDIDATE, REMEMBER_MEMORY, RENAME_REMOTE_SKILL_TARGET, REPLACE_ACCESS_BINDING, @@ -292,6 +305,7 @@ RESOLVE_SCOPE_SELECTION, RETIRE_MEMORY_ENTRY, REVISE_ARTIFACT_CANDIDATE, + REVISE_CATALOG_CANDIDATE, REVISE_MEMORY_ENTRY, REVOKE_ACCESS_BINDING, REVOKE_REMOTE_SKILL_TARGET, @@ -344,7 +358,9 @@ def __init__( transport_trusted = http_client is not None and trust_transport_security if not transport_trusted and not allow_insecure_http and is_plaintext_non_loopback(self._base_url): raise ValueError("refusing to send requests over unencrypted non-loopback HTTP") # noqa: TRY003 - self._headers = {"Authorization": f"Bearer {token}"} if token else None + self._headers = {"X-PowerContext-Dream-Contract": "2"} + if token: + self._headers["Authorization"] = f"Bearer {token}" self._owned_http_client: httpx.AsyncClient | None = None if http_client is None: self._owned_http_client = httpx.AsyncClient(timeout=timeout) @@ -1121,6 +1137,36 @@ async def import_external_skill(self, request: ImportExternalSkillRequest) -> Ge return await self._request(IMPORT_EXTERNAL_SKILL, request) + async def list_catalog_candidates(self, request: ListCatalogCandidatesRequest) -> CatalogCandidatePage: + """List catalog candidates using the exact requested version.""" + + return await self._request(LIST_CATALOG_CANDIDATES, request) + + async def get_catalog_candidate(self, request: GetCatalogCandidateRequest) -> CatalogChangeCandidate: + """Get catalog candidate using the exact requested version.""" + + return await self._request(GET_CATALOG_CANDIDATE, request) + + async def get_catalog_candidate_history(self, request: GetCatalogCandidateRequest) -> CatalogCandidateHistory: + """Get catalog candidate history using the exact requested version.""" + + return await self._request(GET_CATALOG_CANDIDATE_HISTORY, request) + + async def approve_catalog_candidate(self, request: ApproveCatalogCandidateRequest) -> CatalogChangeCandidate: + """Approve catalog candidate using the exact requested version.""" + + return await self._request(APPROVE_CATALOG_CANDIDATE, request) + + async def reject_catalog_candidate(self, request: RejectCatalogCandidateRequest) -> CatalogChangeCandidate: + """Reject catalog candidate using the exact requested version.""" + + return await self._request(REJECT_CATALOG_CANDIDATE, request) + + async def revise_catalog_candidate(self, request: ReviseCatalogCandidateRequest) -> CatalogChangeCandidate: + """Revise catalog candidate using the exact requested version.""" + + return await self._request(REVISE_CATALOG_CANDIDATE, request) + async def list_artifact_candidates(self, request: ListArtifactCandidatesRequest) -> ArtifactCandidatePage: """Page current Candidate heads in the Review Inbox.""" diff --git a/src/powercontext/http/__init__.py b/src/powercontext/http/__init__.py index 060d50093f..229dd7b27e 100644 --- a/src/powercontext/http/__init__.py +++ b/src/powercontext/http/__init__.py @@ -47,6 +47,7 @@ AgentKind, AllScopeSelection, ApproveArtifactCandidateRequest, + ApproveCatalogCandidateRequest, ArtifactAccessResource, ArtifactAddress, ArtifactCandidate, @@ -69,6 +70,7 @@ AuthorizationNote, BaseArtifactFamily, BuiltinPromptInstructions, + CandidateAudit, CandidateFamily, CandidateFamilyCount, CandidateInventoryStatistics, @@ -78,6 +80,11 @@ CaptureContentSourceRequest, CaptureContentSourceResponse, CaptureStatus, + CatalogCandidateHistory, + CatalogCandidatePage, + CatalogChangeCandidate, + CatalogChangeProposal, + CatalogTagResult, ClearScopeBindingRequest, ClearScopeBindingResponse, CommitConnectorCheckpointRequest, @@ -160,6 +167,7 @@ GeneratePromptDemonstrationsRequest, GenerateSkillRequest, GetArtifactCandidateRequest, + GetCatalogCandidateRequest, GetConnectorCheckpointRequest, GetExperienceRequest, GetHandoffReportRequest, @@ -208,6 +216,7 @@ ListArtifactCandidatesRequest, ListArtifactRevisionsRequest, ListArtifactsRequest, + ListCatalogCandidatesRequest, ListDreamRunsRequest, ListExternalSkillsRequest, ListExternalSkillsResponse, @@ -286,6 +295,7 @@ RecurrenceStreak, RegisterSourceDefinitionRequest, RejectArtifactCandidateRequest, + RejectCatalogCandidateRequest, RemainingWorkItem, RememberMemoryRequest, RemoteAgentKind, @@ -323,6 +333,7 @@ ResolveScopeSelectionRequest, RetireMemoryEntryRequest, ReviseArtifactCandidateRequest, + ReviseCatalogCandidateRequest, ReviseMemoryEntryRequest, RevokeAccessBindingRequest, RevokeRemoteSkillTargetRequest, @@ -376,6 +387,7 @@ SubmitSourceObservationRequest, SubtreeScopeSelection, Tag, + TagDreamTarget, TagFilter, TaggableArtifactFamily, TaggedTarget, @@ -441,6 +453,7 @@ "AgentKind", "AllScopeSelection", "ApproveArtifactCandidateRequest", + "ApproveCatalogCandidateRequest", "ArtifactAccessResource", "ArtifactAddress", "ArtifactCandidate", @@ -463,6 +476,7 @@ "AuthorizationNote", "BaseArtifactFamily", "BuiltinPromptInstructions", + "CandidateAudit", "CandidateFamily", "CandidateFamilyCount", "CandidateInventoryStatistics", @@ -472,6 +486,11 @@ "CaptureContentSourceRequest", "CaptureContentSourceResponse", "CaptureStatus", + "CatalogCandidateHistory", + "CatalogCandidatePage", + "CatalogChangeCandidate", + "CatalogChangeProposal", + "CatalogTagResult", "ClearScopeBindingRequest", "ClearScopeBindingResponse", "CommitConnectorCheckpointRequest", @@ -554,6 +573,7 @@ "GeneratedCandidateResponse", "GeneratedCandidateStatus", "GetArtifactCandidateRequest", + "GetCatalogCandidateRequest", "GetConnectorCheckpointRequest", "GetExperienceRequest", "GetHandoffReportRequest", @@ -602,6 +622,7 @@ "ListArtifactCandidatesRequest", "ListArtifactRevisionsRequest", "ListArtifactsRequest", + "ListCatalogCandidatesRequest", "ListDreamRunsRequest", "ListExternalSkillsRequest", "ListExternalSkillsResponse", @@ -680,6 +701,7 @@ "RecurrenceStreak", "RegisterSourceDefinitionRequest", "RejectArtifactCandidateRequest", + "RejectCatalogCandidateRequest", "RemainingWorkItem", "RememberMemoryRequest", "RemoteAgentKind", @@ -717,6 +739,7 @@ "ResolvedUsagePeriod", "RetireMemoryEntryRequest", "ReviseArtifactCandidateRequest", + "ReviseCatalogCandidateRequest", "ReviseMemoryEntryRequest", "RevokeAccessBindingRequest", "RevokeRemoteSkillTargetRequest", @@ -770,6 +793,7 @@ "SubmitSourceObservationRequest", "SubtreeScopeSelection", "Tag", + "TagDreamTarget", "TagFilter", "TagMatch", "TagTarget", diff --git a/src/powercontext/http/_generated/models.py b/src/powercontext/http/_generated/models.py index 1535aec169..1384b1b548 100644 --- a/src/powercontext/http/_generated/models.py +++ b/src/powercontext/http/_generated/models.py @@ -361,6 +361,8 @@ class DreamOperation(StrEnum): REVISE_MEMORY = "revise_memory" REVISE_TOPIC_MEMORY = "revise_topic_memory" REFRESH_HANDOFF = "refresh_handoff" + REVISE_PROMPT = "revise_prompt" + REVISE_TAGS = "revise_tags" class DreamStatus(StrEnum): @@ -383,6 +385,9 @@ class DreamEvidenceKind(StrEnum): PROFILE = "profile" TOPIC_MEMORY = "topic_memory" HANDOFF = "handoff" + SKILL = "skill" + PROMPT = "prompt" + CATALOG_TARGET = "catalog_target" UNRESOLVED = "unresolved" @@ -431,10 +436,16 @@ class DreamBudget(BaseModel): timeout_seconds: Annotated[StrictFloat, Field(ge=0.0, gt=1.0, le=120.0)] = 120 +class Kind(StrEnum): + ARTIFACT = "artifact" + CATALOG_CHANGE = "catalog_change" + + class DreamCandidateRef(BaseModel): model_config = ConfigDict( extra="forbid", ) + kind: Kind = Kind.ARTIFACT candidate_id: StrictStr version: Annotated[StrictInt, Field(ge=1)] @@ -476,11 +487,14 @@ class ApproveArtifactCandidateRequest(BaseModel): class OutputKind(StrEnum): ARTIFACT_CANDIDATE = "artifact_candidate" + CATALOG_CHANGE_CANDIDATE = "catalog_change_candidate" class Effect(StrEnum): REVIEW_THEN_PUBLISH = "review_then_publish" REVIEW_THEN_COMMIT_WITHOUT_ACTIVATION = "review_then_commit_without_activation" + REVIEW_THEN_PUBLISH_CONFIGURATION = "review_then_publish_configuration" + REVIEW_THEN_REPLACE_TAGS = "review_then_replace_tags" class ArtifactDreamingOperationCapability(BaseModel): @@ -847,7 +861,7 @@ class CommitConnectorCheckpointRequest(BaseModel): checkpoint: Annotated[Any | None, Field(...)] -class Kind(StrEnum): +class Kind1(StrEnum): ARTIFACT = "artifact" @@ -859,7 +873,7 @@ class HandoffArtifactCitation(BaseModel): artifact_ref: ArtifactReference -class Kind1(StrEnum): +class Kind2(StrEnum): MEMORY = "memory" @@ -867,7 +881,7 @@ class Trust3(StrEnum): UNTRUSTED_HISTORY = "untrusted_history" -class Kind2(StrEnum): +class Kind3(StrEnum): SOURCE = "source" @@ -2059,6 +2073,7 @@ class CandidateFamily(StrEnum): MEMORY = "memory" TOPIC_MEMORY = "topic-memory" HANDOFF = "handoff" + PROMPT = "prompt" class ExternalSkillInstallationScope(StrEnum): @@ -2570,6 +2585,156 @@ class AccessAuditPage(BaseModel): next_cursor: Annotated[StrictStr | None, Field(max_length=2048)] +class TagDreamTarget(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + target: TagTarget + expected_etag: Annotated[StrictStr, Field(min_length=1)] + basis_ref: ArtifactReference | None = None + basis_citation: MemoryCitation | None = None + + +class CandidateAudit(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + origin: Annotated[StrictStr, Field(min_length=1)] + operation: Annotated[StrictStr, Field(min_length=1)] + dream_run_id: Annotated[StrictStr, Field(min_length=1)] + spec_version: Annotated[StrictStr, Field(min_length=1)] + proposal_digest: Annotated[StrictStr, Field(min_length=1)] + evidence_manifest_ref: Annotated[StrictStr, Field(min_length=1)] + validation_policy_digest: Annotated[StrictStr, Field(min_length=1)] + proposal_fingerprint: Annotated[StrictStr | None, Field(...)] + + +class BeforeTag(RootModel[StrictStr]): + root: Annotated[StrictStr, Field(max_length=64, min_length=1)] + + +class AfterTag(RootModel[StrictStr]): + root: Annotated[StrictStr, Field(max_length=64, min_length=1)] + + +class CatalogChangeProposal(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + target: TagTarget + expected_etag: Annotated[StrictStr, Field(min_length=1)] + basis_ref: ArtifactReference | None = None + basis_citation: MemoryCitation | None = None + before_tags: Annotated[list[BeforeTag], Field(max_length=32)] + after_tags: Annotated[list[AfterTag], Field(max_length=32)] + + +class CatalogTagResult(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + scope_id: Annotated[StrictStr, Field(min_length=1)] + target: TagTarget + tags: Annotated[list[Tag], Field(max_length=32)] + tag_digest: Annotated[StrictStr, Field(min_length=1)] + etag: Annotated[StrictStr, Field(min_length=1)] + + +class Operation(StrEnum): + REVISE_TAGS = "revise_tags" + + +class Origin(StrEnum): + DREAM = "dream" + MANUAL = "manual" + + +class CatalogChangeCandidate(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + audit: CandidateAudit | None = None + operation: Operation = Operation.REVISE_TAGS + origin: Origin = Origin.DREAM + candidate_id: Annotated[StrictStr, Field(min_length=1)] + version: Annotated[StrictInt, Field(ge=1)] + status: CandidateStatus + proposal: CatalogChangeProposal + sources: Annotated[list[DreamSourceReference], Field(validate_default=True)] = [] + artifacts: Annotated[list[ArtifactReference], Field(validate_default=True)] = [] + memory_citations: Annotated[list[MemoryCitation], Field(validate_default=True)] = [] + reason: Annotated[StrictStr, Field(min_length=1)] + dream_run_id: StrictStr | None = None + result: CatalogTagResult | None = None + decision_reason: StrictStr | None = None + + +class CatalogCandidatePage(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + candidates: list[CatalogChangeCandidate] + next_cursor: StrictStr | None = None + + +class CatalogCandidateHistory(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + versions: list[CatalogChangeCandidate] + + +class ListCatalogCandidatesRequest(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + scope_id: Annotated[StrictStr, Field(max_length=256, min_length=1)] + status: CandidateStatus | None = None + cursor: StrictStr | None = None + limit: Annotated[StrictInt, Field(ge=1, le=100)] = 50 + + +class GetCatalogCandidateRequest(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + scope_id: Annotated[StrictStr, Field(max_length=256, min_length=1)] + candidate_id: Annotated[StrictStr, Field(min_length=1)] + + +class ApproveCatalogCandidateRequest(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + scope_id: Annotated[StrictStr, Field(max_length=256, min_length=1)] + candidate_id: Annotated[StrictStr, Field(min_length=1)] + expected_version: Annotated[StrictInt, Field(ge=1)] + + +class RejectCatalogCandidateRequest(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + scope_id: Annotated[StrictStr, Field(max_length=256, min_length=1)] + candidate_id: Annotated[StrictStr, Field(min_length=1)] + expected_version: Annotated[StrictInt, Field(ge=1)] + reason: Annotated[StrictStr, Field(min_length=1)] + + +class ReviseCatalogCandidateRequest(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + scope_id: Annotated[StrictStr, Field(max_length=256, min_length=1)] + candidate_id: Annotated[StrictStr, Field(min_length=1)] + expected_version: Annotated[StrictInt, Field(ge=1)] + after_tags: Annotated[list[AfterTag], Field(max_length=32)] + reason: Annotated[StrictStr, Field(min_length=1)] + sources: list[DreamSourceReference] | None = None + artifacts: list[ArtifactReference] | None = None + memory_citations: list[MemoryCitation] | None = None + + class FlushProfileResponse(BaseModel): model_config = ConfigDict( extra="forbid", @@ -2668,6 +2833,7 @@ class CreateDreamRunRequest(BaseModel): model_config = ConfigDict( extra="forbid", ) + tag_target: TagDreamTarget | None = None operation: DreamOperation artifacts: Annotated[list[ArtifactReference], Field(validate_default=True)] = [] memory_citations: Annotated[list[MemoryCitation], Field(validate_default=True)] = [] @@ -3485,6 +3651,8 @@ class DreamRun(BaseModel): model_config = ConfigDict( extra="forbid", ) + tag_target: TagDreamTarget | None = None + reused: StrictBool = False scope_id: StrictStr run_id: StrictStr operation: DreamOperation @@ -3501,7 +3669,7 @@ class DreamRun(BaseModel): input_manifest: DreamInputManifest | None = None usage: DreamUsage | None = None budget: DreamBudget | None = None - prompt_version: StrictStr = "powercontext.dream.v1.1" + prompt_version: StrictStr = "powercontext.dream.v1.2" model_config_id: Annotated[StrictStr | None, Field(...)] @@ -3845,6 +4013,7 @@ class ReviseArtifactCandidateRequest(BaseModel): | MemoryDreamCandidateProposal | TopicMemoryDreamProposal | HandoffContent + | PromptContent ) source_refs: Annotated[ list[SourceReference], @@ -3891,6 +4060,7 @@ class ArtifactCandidate(BaseModel): model_config = ConfigDict( extra="forbid", ) + audit: CandidateAudit | None = None memory_citations: Annotated[ list[MemoryCitation], Field( @@ -3914,6 +4084,7 @@ class ArtifactCandidate(BaseModel): | MemoryDreamCandidateProposal | TopicMemoryDreamProposal | HandoffContent + | PromptContent ) source_refs: Annotated[ list[SourceReference], diff --git a/src/powercontext/http/_generated/operations.py b/src/powercontext/http/_generated/operations.py index d32c78fb60..dc12ae1b7f 100644 --- a/src/powercontext/http/_generated/operations.py +++ b/src/powercontext/http/_generated/operations.py @@ -19,6 +19,7 @@ AcknowledgeHandoffRequest, ActivateHandoffRequest, ApproveArtifactCandidateRequest, + ApproveCatalogCandidateRequest, ArtifactCandidate, ArtifactCandidatePage, ArtifactCreated, @@ -31,6 +32,9 @@ Capabilities, CaptureContentSourceRequest, CaptureContentSourceResponse, + CatalogCandidateHistory, + CatalogCandidatePage, + CatalogChangeCandidate, ClearScopeBindingRequest, ClearScopeBindingResponse, CommitConnectorCheckpointRequest, @@ -65,6 +69,7 @@ GeneratePromptDemonstrationsRequest, GenerateSkillRequest, GetArtifactCandidateRequest, + GetCatalogCandidateRequest, GetConnectorCheckpointRequest, GetExperienceRequest, GetHandoffReportRequest, @@ -88,6 +93,7 @@ ListArtifactCandidatesRequest, ListArtifactRevisionsRequest, ListArtifactsRequest, + ListCatalogCandidatesRequest, ListDreamRunsRequest, ListExternalSkillsRequest, ListExternalSkillsResponse, @@ -126,6 +132,7 @@ RecordTaskOutcomeRequest, RegisterSourceDefinitionRequest, RejectArtifactCandidateRequest, + RejectCatalogCandidateRequest, RememberMemoryRequest, RemoteSkillPublication, RemoteSkillReceiptResponse, @@ -141,6 +148,7 @@ ResolveScopeSelectionRequest, RetireMemoryEntryRequest, ReviseArtifactCandidateRequest, + ReviseCatalogCandidateRequest, ReviseMemoryEntryRequest, RevokeAccessBindingRequest, RevokeRemoteSkillTargetRequest, @@ -1374,6 +1382,7 @@ class AccessRequirement(BaseModel): tags=("dream",), scope_mode="none", responses={ + 426: {"$ref": "#/components/responses/ClientUpgradeRequired"}, 200: { "description": "The requested Dream state.", "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, @@ -1402,6 +1411,7 @@ class AccessRequirement(BaseModel): tags=("dream",), scope_mode="none", responses={ + 426: {"$ref": "#/components/responses/ClientUpgradeRequired"}, 202: { "description": "The accepted queued or running Dream.", "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, @@ -1438,6 +1448,7 @@ class AccessRequirement(BaseModel): tags=("dream",), scope_mode="none", responses={ + 426: {"$ref": "#/components/responses/ClientUpgradeRequired"}, 200: { "description": "The requested Dream state.", "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, @@ -2128,6 +2139,7 @@ class AccessRequirement(BaseModel): tags=("review",), scope_mode="current", responses={ + 426: {"$ref": "#/components/responses/ClientUpgradeRequired"}, 200: { "description": "The selected current Candidate heads.", "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, @@ -2154,6 +2166,7 @@ class AccessRequirement(BaseModel): tags=("review",), scope_mode="current", responses={ + 426: {"$ref": "#/components/responses/ClientUpgradeRequired"}, 200: { "description": "The current Candidate head.", "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, @@ -2181,6 +2194,7 @@ class AccessRequirement(BaseModel): tags=("review",), scope_mode="current", responses={ + 426: {"$ref": "#/components/responses/ClientUpgradeRequired"}, 200: { "description": "The approved Candidate and exact result Artifact.", "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, @@ -2209,6 +2223,7 @@ class AccessRequirement(BaseModel): tags=("review",), scope_mode="current", responses={ + 426: {"$ref": "#/components/responses/ClientUpgradeRequired"}, 200: { "description": "The rejected Candidate.", "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, @@ -2237,6 +2252,7 @@ class AccessRequirement(BaseModel): tags=("review",), scope_mode="current", responses={ + 426: {"$ref": "#/components/responses/ClientUpgradeRequired"}, 200: { "description": "The next pending Candidate version.", "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, @@ -2252,6 +2268,162 @@ class AccessRequirement(BaseModel): access=AccessRequirement(action="scope.review", resource="scope", scope_id_field="scope_id", resolver="request"), ) +LIST_CATALOG_CANDIDATES = Operation[ListCatalogCandidatesRequest, CatalogCandidatePage]( + method="POST", + path="/v1/catalog-change-candidates/list", + operation_id="list_catalog_candidates", + request_type=ListCatalogCandidatesRequest, + request_location="body", + path_parameters=(), + response_type=CatalogCandidatePage, + success_status=200, + summary="List Catalog Change Candidates", + tags=("review",), + scope_mode="current", + responses={ + 426: {"$ref": "#/components/responses/ClientUpgradeRequired"}, + 200: {"description": "Current Catalog Change Candidate state."}, + 401: {"$ref": "#/components/responses/Unauthorized"}, + 403: {"$ref": "#/components/responses/Forbidden"}, + 404: {"$ref": "#/components/responses/NotFound"}, + 409: {"$ref": "#/components/responses/Conflict"}, + 422: {"$ref": "#/components/responses/InvalidRequest"}, + 503: {"$ref": "#/components/responses/Unavailable"}, + 500: {"$ref": "#/components/responses/InternalError"}, + }, + access=AccessRequirement(action="scope.read", resource="scope", scope_id_field="scope_id", resolver="request"), +) + +GET_CATALOG_CANDIDATE = Operation[GetCatalogCandidateRequest, CatalogChangeCandidate]( + method="POST", + path="/v1/catalog-change-candidates/get", + operation_id="get_catalog_candidate", + request_type=GetCatalogCandidateRequest, + request_location="body", + path_parameters=(), + response_type=CatalogChangeCandidate, + success_status=200, + summary="Get Catalog Change Candidates", + tags=("review",), + scope_mode="current", + responses={ + 426: {"$ref": "#/components/responses/ClientUpgradeRequired"}, + 200: {"description": "Current Catalog Change Candidate state."}, + 401: {"$ref": "#/components/responses/Unauthorized"}, + 403: {"$ref": "#/components/responses/Forbidden"}, + 404: {"$ref": "#/components/responses/NotFound"}, + 409: {"$ref": "#/components/responses/Conflict"}, + 422: {"$ref": "#/components/responses/InvalidRequest"}, + 503: {"$ref": "#/components/responses/Unavailable"}, + 500: {"$ref": "#/components/responses/InternalError"}, + }, + access=AccessRequirement(action="scope.read", resource="scope", scope_id_field="scope_id", resolver="request"), +) + +GET_CATALOG_CANDIDATE_HISTORY = Operation[GetCatalogCandidateRequest, CatalogCandidateHistory]( + method="POST", + path="/v1/catalog-change-candidates/history", + operation_id="get_catalog_candidate_history", + request_type=GetCatalogCandidateRequest, + request_location="body", + path_parameters=(), + response_type=CatalogCandidateHistory, + success_status=200, + summary="History Catalog Change Candidates", + tags=("review",), + scope_mode="current", + responses={ + 426: {"$ref": "#/components/responses/ClientUpgradeRequired"}, + 200: {"description": "Current Catalog Change Candidate state."}, + 401: {"$ref": "#/components/responses/Unauthorized"}, + 403: {"$ref": "#/components/responses/Forbidden"}, + 404: {"$ref": "#/components/responses/NotFound"}, + 409: {"$ref": "#/components/responses/Conflict"}, + 422: {"$ref": "#/components/responses/InvalidRequest"}, + 503: {"$ref": "#/components/responses/Unavailable"}, + 500: {"$ref": "#/components/responses/InternalError"}, + }, + access=AccessRequirement(action="scope.read", resource="scope", scope_id_field="scope_id", resolver="request"), +) + +REVISE_CATALOG_CANDIDATE = Operation[ReviseCatalogCandidateRequest, CatalogChangeCandidate]( + method="POST", + path="/v1/catalog-change-candidates/revise", + operation_id="revise_catalog_candidate", + request_type=ReviseCatalogCandidateRequest, + request_location="body", + path_parameters=(), + response_type=CatalogChangeCandidate, + success_status=200, + summary="Revise Catalog Change Candidates", + tags=("review",), + scope_mode="current", + responses={ + 426: {"$ref": "#/components/responses/ClientUpgradeRequired"}, + 200: {"description": "Current Catalog Change Candidate state."}, + 401: {"$ref": "#/components/responses/Unauthorized"}, + 403: {"$ref": "#/components/responses/Forbidden"}, + 404: {"$ref": "#/components/responses/NotFound"}, + 409: {"$ref": "#/components/responses/Conflict"}, + 422: {"$ref": "#/components/responses/InvalidRequest"}, + 503: {"$ref": "#/components/responses/Unavailable"}, + 500: {"$ref": "#/components/responses/InternalError"}, + }, + access=AccessRequirement(action="scope.review", resource="scope", scope_id_field="scope_id", resolver="request"), +) + +APPROVE_CATALOG_CANDIDATE = Operation[ApproveCatalogCandidateRequest, CatalogChangeCandidate]( + method="POST", + path="/v1/catalog-change-candidates/approve", + operation_id="approve_catalog_candidate", + request_type=ApproveCatalogCandidateRequest, + request_location="body", + path_parameters=(), + response_type=CatalogChangeCandidate, + success_status=200, + summary="Approve Catalog Change Candidates", + tags=("review",), + scope_mode="current", + responses={ + 426: {"$ref": "#/components/responses/ClientUpgradeRequired"}, + 200: {"description": "Current Catalog Change Candidate state."}, + 401: {"$ref": "#/components/responses/Unauthorized"}, + 403: {"$ref": "#/components/responses/Forbidden"}, + 404: {"$ref": "#/components/responses/NotFound"}, + 409: {"$ref": "#/components/responses/Conflict"}, + 422: {"$ref": "#/components/responses/InvalidRequest"}, + 503: {"$ref": "#/components/responses/Unavailable"}, + 500: {"$ref": "#/components/responses/InternalError"}, + }, + access=AccessRequirement(action="scope.review", resource="scope", scope_id_field="scope_id", resolver="request"), +) + +REJECT_CATALOG_CANDIDATE = Operation[RejectCatalogCandidateRequest, CatalogChangeCandidate]( + method="POST", + path="/v1/catalog-change-candidates/reject", + operation_id="reject_catalog_candidate", + request_type=RejectCatalogCandidateRequest, + request_location="body", + path_parameters=(), + response_type=CatalogChangeCandidate, + success_status=200, + summary="Reject Catalog Change Candidates", + tags=("review",), + scope_mode="current", + responses={ + 426: {"$ref": "#/components/responses/ClientUpgradeRequired"}, + 200: {"description": "Current Catalog Change Candidate state."}, + 401: {"$ref": "#/components/responses/Unauthorized"}, + 403: {"$ref": "#/components/responses/Forbidden"}, + 404: {"$ref": "#/components/responses/NotFound"}, + 409: {"$ref": "#/components/responses/Conflict"}, + 422: {"$ref": "#/components/responses/InvalidRequest"}, + 503: {"$ref": "#/components/responses/Unavailable"}, + 500: {"$ref": "#/components/responses/InternalError"}, + }, + access=AccessRequirement(action="scope.review", resource="scope", scope_id_field="scope_id", resolver="request"), +) + GET_STATS = Operation[GetStatsRequest, ScopedStats]( method="POST", path="/v1/stats", diff --git a/src/powercontext/http/_generated/schema.py b/src/powercontext/http/_generated/schema.py index 6fd7b358c3..dddca6d579 100644 --- a/src/powercontext/http/_generated/schema.py +++ b/src/powercontext/http/_generated/schema.py @@ -1764,6 +1764,7 @@ } ], "responses": { + "426": {"$ref": "#/components/responses/ClientUpgradeRequired"}, "202": { "description": "The accepted queued or running Dream.", "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, @@ -1831,6 +1832,7 @@ }, ], "responses": { + "426": {"$ref": "#/components/responses/ClientUpgradeRequired"}, "200": { "description": "The requested Dream state.", "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, @@ -1868,6 +1870,7 @@ }, ], "responses": { + "426": {"$ref": "#/components/responses/ClientUpgradeRequired"}, "200": { "description": "The requested Dream state.", "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, @@ -2856,6 +2859,7 @@ "required": True, }, "responses": { + "426": {"$ref": "#/components/responses/ClientUpgradeRequired"}, "200": { "description": "The selected current Candidate heads.", "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, @@ -2900,6 +2904,7 @@ "required": True, }, "responses": { + "426": {"$ref": "#/components/responses/ClientUpgradeRequired"}, "200": { "description": "The current Candidate head.", "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, @@ -2949,6 +2954,7 @@ "required": True, }, "responses": { + "426": {"$ref": "#/components/responses/ClientUpgradeRequired"}, "200": { "description": "The approved Candidate and exact result Artifact.", "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, @@ -2996,6 +3002,7 @@ "required": True, }, "responses": { + "426": {"$ref": "#/components/responses/ClientUpgradeRequired"}, "200": { "description": "The rejected Candidate.", "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, @@ -3044,6 +3051,7 @@ "required": True, }, "responses": { + "426": {"$ref": "#/components/responses/ClientUpgradeRequired"}, "200": { "description": "The next pending Candidate version.", "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, @@ -3064,6 +3072,259 @@ "x-powercontext-scope-mode": "current", } }, + "/v1/catalog-change-candidates/list": { + "post": { + "tags": ["review"], + "summary": "List Catalog Change Candidates", + "description": "Review one Tag metadata " + "change. Approval checks " + "both the exact " + "candidate version and " + "current Tag/content " + "baseline; it never " + "creates an Artifact " + "revision.", + "operationId": "list_catalog_candidates", + "requestBody": { + "content": { + "application/json": {"schema": {"$ref": "#/components/schemas/ListCatalogCandidatesRequest"}} + }, + "required": True, + }, + "responses": { + "426": {"$ref": "#/components/responses/ClientUpgradeRequired"}, + "200": { + "description": "Current Catalog Change Candidate state.", + "content": { + "application/json": {"schema": {"$ref": "#/components/schemas/CatalogCandidatePage"}} + }, + }, + "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, + "404": {"$ref": "#/components/responses/NotFound"}, + "409": {"$ref": "#/components/responses/Conflict"}, + "422": {"$ref": "#/components/responses/InvalidRequest"}, + "503": {"$ref": "#/components/responses/Unavailable"}, + "500": {"$ref": "#/components/responses/InternalError"}, + }, + "x-powercontext-access": { + "action": "scope.read", + "resource": {"type": "scope", "scope-id-from": "scope_id"}, + }, + "x-powercontext-scope-mode": "current", + } + }, + "/v1/catalog-change-candidates/get": { + "post": { + "tags": ["review"], + "summary": "Get Catalog Change Candidates", + "description": "Review one Tag metadata " + "change. Approval checks " + "both the exact candidate " + "version and current " + "Tag/content baseline; it " + "never creates an " + "Artifact revision.", + "operationId": "get_catalog_candidate", + "requestBody": { + "content": { + "application/json": {"schema": {"$ref": "#/components/schemas/GetCatalogCandidateRequest"}} + }, + "required": True, + }, + "responses": { + "426": {"$ref": "#/components/responses/ClientUpgradeRequired"}, + "200": { + "description": "Current Catalog Change Candidate state.", + "content": { + "application/json": {"schema": {"$ref": "#/components/schemas/CatalogChangeCandidate"}} + }, + }, + "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, + "404": {"$ref": "#/components/responses/NotFound"}, + "409": {"$ref": "#/components/responses/Conflict"}, + "422": {"$ref": "#/components/responses/InvalidRequest"}, + "503": {"$ref": "#/components/responses/Unavailable"}, + "500": {"$ref": "#/components/responses/InternalError"}, + }, + "x-powercontext-access": { + "action": "scope.read", + "resource": {"type": "scope", "scope-id-from": "scope_id"}, + }, + "x-powercontext-scope-mode": "current", + } + }, + "/v1/catalog-change-candidates/history": { + "post": { + "tags": ["review"], + "summary": "History Catalog Change Candidates", + "description": "Review one Tag " + "metadata change. " + "Approval checks both " + "the exact candidate " + "version and current " + "Tag/content " + "baseline; it never " + "creates an Artifact " + "revision.", + "operationId": "get_catalog_candidate_history", + "requestBody": { + "content": { + "application/json": {"schema": {"$ref": "#/components/schemas/GetCatalogCandidateRequest"}} + }, + "required": True, + }, + "responses": { + "426": {"$ref": "#/components/responses/ClientUpgradeRequired"}, + "200": { + "description": "Current Catalog Change Candidate state.", + "content": { + "application/json": {"schema": {"$ref": "#/components/schemas/CatalogCandidateHistory"}} + }, + }, + "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, + "404": {"$ref": "#/components/responses/NotFound"}, + "409": {"$ref": "#/components/responses/Conflict"}, + "422": {"$ref": "#/components/responses/InvalidRequest"}, + "503": {"$ref": "#/components/responses/Unavailable"}, + "500": {"$ref": "#/components/responses/InternalError"}, + }, + "x-powercontext-access": { + "action": "scope.read", + "resource": {"type": "scope", "scope-id-from": "scope_id"}, + }, + "x-powercontext-scope-mode": "current", + } + }, + "/v1/catalog-change-candidates/revise": { + "post": { + "tags": ["review"], + "summary": "Revise Catalog Change Candidates", + "description": "Review one Tag " + "metadata change. " + "Approval checks both " + "the exact candidate " + "version and current " + "Tag/content baseline; " + "it never creates an " + "Artifact revision.", + "operationId": "revise_catalog_candidate", + "requestBody": { + "content": { + "application/json": {"schema": {"$ref": "#/components/schemas/ReviseCatalogCandidateRequest"}} + }, + "required": True, + }, + "responses": { + "426": {"$ref": "#/components/responses/ClientUpgradeRequired"}, + "200": { + "description": "Current Catalog Change Candidate state.", + "content": { + "application/json": {"schema": {"$ref": "#/components/schemas/CatalogChangeCandidate"}} + }, + }, + "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, + "404": {"$ref": "#/components/responses/NotFound"}, + "409": {"$ref": "#/components/responses/Conflict"}, + "422": {"$ref": "#/components/responses/InvalidRequest"}, + "503": {"$ref": "#/components/responses/Unavailable"}, + "500": {"$ref": "#/components/responses/InternalError"}, + }, + "x-powercontext-access": { + "action": "scope.review", + "resource": {"type": "scope", "scope-id-from": "scope_id"}, + }, + "x-powercontext-scope-mode": "current", + } + }, + "/v1/catalog-change-candidates/approve": { + "post": { + "tags": ["review"], + "summary": "Approve Catalog Change Candidates", + "description": "Review one Tag " + "metadata change. " + "Approval checks both " + "the exact candidate " + "version and current " + "Tag/content " + "baseline; it never " + "creates an Artifact " + "revision.", + "operationId": "approve_catalog_candidate", + "requestBody": { + "content": { + "application/json": {"schema": {"$ref": "#/components/schemas/ApproveCatalogCandidateRequest"}} + }, + "required": True, + }, + "responses": { + "426": {"$ref": "#/components/responses/ClientUpgradeRequired"}, + "200": { + "description": "Current Catalog Change Candidate state.", + "content": { + "application/json": {"schema": {"$ref": "#/components/schemas/CatalogChangeCandidate"}} + }, + }, + "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, + "404": {"$ref": "#/components/responses/NotFound"}, + "409": {"$ref": "#/components/responses/Conflict"}, + "422": {"$ref": "#/components/responses/InvalidRequest"}, + "503": {"$ref": "#/components/responses/Unavailable"}, + "500": {"$ref": "#/components/responses/InternalError"}, + }, + "x-powercontext-access": { + "action": "scope.review", + "resource": {"type": "scope", "scope-id-from": "scope_id"}, + }, + "x-powercontext-scope-mode": "current", + } + }, + "/v1/catalog-change-candidates/reject": { + "post": { + "tags": ["review"], + "summary": "Reject Catalog Change Candidates", + "description": "Review one Tag " + "metadata change. " + "Approval checks both " + "the exact candidate " + "version and current " + "Tag/content baseline; " + "it never creates an " + "Artifact revision.", + "operationId": "reject_catalog_candidate", + "requestBody": { + "content": { + "application/json": {"schema": {"$ref": "#/components/schemas/RejectCatalogCandidateRequest"}} + }, + "required": True, + }, + "responses": { + "426": {"$ref": "#/components/responses/ClientUpgradeRequired"}, + "200": { + "description": "Current Catalog Change Candidate state.", + "content": { + "application/json": {"schema": {"$ref": "#/components/schemas/CatalogChangeCandidate"}} + }, + }, + "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, + "404": {"$ref": "#/components/responses/NotFound"}, + "409": {"$ref": "#/components/responses/Conflict"}, + "422": {"$ref": "#/components/responses/InvalidRequest"}, + "503": {"$ref": "#/components/responses/Unavailable"}, + "500": {"$ref": "#/components/responses/InternalError"}, + }, + "x-powercontext-access": { + "action": "scope.review", + "resource": {"type": "scope", "scope-id-from": "scope_id"}, + }, + "x-powercontext-scope-mode": "current", + } + }, "/v1/stats": { "post": { "tags": ["stats"], @@ -5040,13 +5301,26 @@ "revise_memory", "revise_topic_memory", "refresh_handoff", + "revise_prompt", + "revise_tags", ], }, "DreamStatus": {"type": "string", "enum": ["queued", "running", "succeeded", "failed"]}, "DreamOutcome": {"type": "string", "enum": ["proposed", "no_change", "needs_evidence"]}, "DreamEvidenceKind": { "type": "string", - "enum": ["source", "experience", "memory", "profile", "topic_memory", "handoff", "unresolved"], + "enum": [ + "source", + "experience", + "memory", + "profile", + "topic_memory", + "handoff", + "skill", + "prompt", + "catalog_target", + "unresolved", + ], }, "DreamEvidenceRole": { "type": "string", @@ -5074,6 +5348,7 @@ }, "CreateDreamRunRequest": { "properties": { + "tag_target": {"$ref": "#/components/schemas/TagDreamTarget", "nullable": True}, "operation": {"$ref": "#/components/schemas/DreamOperation"}, "artifacts": { "items": {"$ref": "#/components/schemas/ArtifactReference"}, @@ -5131,7 +5406,11 @@ "type": "object", }, "DreamCandidateRef": { - "properties": {"candidate_id": {"type": "string"}, "version": {"type": "integer", "minimum": 1.0}}, + "properties": { + "kind": {"type": "string", "enum": ["artifact", "catalog_change"], "default": "artifact"}, + "candidate_id": {"type": "string"}, + "version": {"type": "integer", "minimum": 1.0}, + }, "additionalProperties": False, "type": "object", "required": ["candidate_id", "version"], @@ -5225,6 +5504,8 @@ }, "DreamRun": { "properties": { + "tag_target": {"$ref": "#/components/schemas/TagDreamTarget", "nullable": True}, + "reused": {"type": "boolean", "default": False}, "scope_id": {"type": "string"}, "run_id": {"type": "string"}, "operation": {"$ref": "#/components/schemas/DreamOperation"}, @@ -5241,7 +5522,7 @@ "input_manifest": {"$ref": "#/components/schemas/DreamInputManifest", "nullable": True}, "usage": {"$ref": "#/components/schemas/DreamUsage"}, "budget": {"$ref": "#/components/schemas/DreamBudget"}, - "prompt_version": {"type": "string", "default": "powercontext.dream.v1.1"}, + "prompt_version": {"type": "string", "default": "powercontext.dream.v1.2"}, "model_config_id": {"type": "string", "nullable": True}, }, "additionalProperties": False, @@ -5259,6 +5540,7 @@ }, "ArtifactCandidate": { "properties": { + "audit": {"$ref": "#/components/schemas/CandidateAudit", "nullable": True}, "memory_citations": { "items": {"$ref": "#/components/schemas/MemoryCitation"}, "type": "array", @@ -5311,6 +5593,7 @@ {"$ref": "#/components/schemas/MemoryDreamCandidateProposal"}, {"$ref": "#/components/schemas/TopicMemoryDreamProposal"}, {"$ref": "#/components/schemas/HandoffContent"}, + {"$ref": "#/components/schemas/PromptContent"}, ] }, "source_refs": { @@ -5392,10 +5675,15 @@ "ArtifactDreamingOperationCapability": { "properties": { "operation": {"$ref": "#/components/schemas/DreamOperation"}, - "output_kind": {"type": "string", "enum": ["artifact_candidate"]}, + "output_kind": {"type": "string", "enum": ["artifact_candidate", "catalog_change_candidate"]}, "effect": { "type": "string", - "enum": ["review_then_publish", "review_then_commit_without_activation"], + "enum": [ + "review_then_publish", + "review_then_commit_without_activation", + "review_then_publish_configuration", + "review_then_replace_tags", + ], }, }, "additionalProperties": False, @@ -8076,6 +8364,7 @@ {"$ref": "#/components/schemas/MemoryDreamCandidateProposal"}, {"$ref": "#/components/schemas/TopicMemoryDreamProposal"}, {"$ref": "#/components/schemas/HandoffContent"}, + {"$ref": "#/components/schemas/PromptContent"}, ] }, "source_refs": { @@ -9051,7 +9340,7 @@ "StatsPeriod": {"type": "string", "enum": ["today", "7d", "30d"]}, "CandidateFamily": { "type": "string", - "enum": ["experience", "skill", "profile", "memory", "topic-memory", "handoff"], + "enum": ["experience", "skill", "profile", "memory", "topic-memory", "handoff", "prompt"], }, "ExternalSkillInstallationScope": {"type": "string", "enum": ["user", "project", "plugin"]}, "ExternalSkillResolutionStatus": {"type": "string", "enum": ["available", "unavailable"]}, @@ -9601,8 +9890,224 @@ "type": "object", "required": ["items", "next_cursor"], }, + "TagDreamTarget": { + "properties": { + "target": {"$ref": "#/components/schemas/TagTarget"}, + "expected_etag": {"type": "string", "minLength": 1}, + "basis_ref": {"$ref": "#/components/schemas/ArtifactReference", "nullable": True}, + "basis_citation": {"$ref": "#/components/schemas/MemoryCitation", "nullable": True}, + }, + "additionalProperties": False, + "type": "object", + "required": ["target", "expected_etag"], + }, + "CandidateAudit": { + "properties": { + "origin": {"type": "string", "minLength": 1}, + "operation": {"type": "string", "minLength": 1}, + "dream_run_id": {"type": "string", "minLength": 1}, + "spec_version": {"type": "string", "minLength": 1}, + "proposal_digest": {"type": "string", "minLength": 1}, + "evidence_manifest_ref": {"type": "string", "minLength": 1}, + "validation_policy_digest": {"type": "string", "minLength": 1}, + "proposal_fingerprint": {"type": "string", "nullable": True}, + }, + "additionalProperties": False, + "type": "object", + "required": [ + "origin", + "operation", + "dream_run_id", + "spec_version", + "proposal_digest", + "evidence_manifest_ref", + "validation_policy_digest", + "proposal_fingerprint", + ], + }, + "CatalogChangeProposal": { + "properties": { + "target": {"$ref": "#/components/schemas/TagTarget"}, + "expected_etag": {"type": "string", "minLength": 1}, + "basis_ref": {"$ref": "#/components/schemas/ArtifactReference", "nullable": True}, + "basis_citation": {"$ref": "#/components/schemas/MemoryCitation", "nullable": True}, + "before_tags": { + "items": {"type": "string", "maxLength": 64, "minLength": 1}, + "type": "array", + "maxItems": 32, + }, + "after_tags": { + "items": {"type": "string", "maxLength": 64, "minLength": 1}, + "type": "array", + "maxItems": 32, + }, + }, + "additionalProperties": False, + "type": "object", + "required": ["target", "expected_etag", "before_tags", "after_tags"], + }, + "CatalogTagResult": { + "properties": { + "scope_id": {"type": "string", "minLength": 1}, + "target": {"$ref": "#/components/schemas/TagTarget"}, + "tags": { + "items": {"type": "string", "maxLength": 64, "minLength": 1}, + "type": "array", + "maxItems": 32, + }, + "tag_digest": {"type": "string", "minLength": 1}, + "etag": {"type": "string", "minLength": 1}, + }, + "additionalProperties": False, + "type": "object", + "required": ["scope_id", "target", "tags", "tag_digest", "etag"], + }, + "CatalogChangeCandidate": { + "properties": { + "audit": {"$ref": "#/components/schemas/CandidateAudit", "nullable": True}, + "operation": {"type": "string", "enum": ["revise_tags"], "default": "revise_tags"}, + "origin": {"type": "string", "enum": ["dream", "manual"], "default": "dream"}, + "candidate_id": {"type": "string", "minLength": 1}, + "version": {"type": "integer", "minimum": 1.0}, + "status": {"$ref": "#/components/schemas/CandidateStatus"}, + "proposal": {"$ref": "#/components/schemas/CatalogChangeProposal"}, + "sources": { + "items": {"$ref": "#/components/schemas/DreamSourceReference"}, + "type": "array", + "default": [], + }, + "artifacts": { + "items": {"$ref": "#/components/schemas/ArtifactReference"}, + "type": "array", + "default": [], + }, + "memory_citations": { + "items": {"$ref": "#/components/schemas/MemoryCitation"}, + "type": "array", + "default": [], + }, + "reason": {"type": "string", "minLength": 1}, + "dream_run_id": {"type": "string", "nullable": True}, + "result": {"$ref": "#/components/schemas/CatalogTagResult", "nullable": True}, + "decision_reason": {"type": "string", "nullable": True}, + }, + "additionalProperties": False, + "type": "object", + "required": ["candidate_id", "version", "status", "proposal", "reason"], + }, + "CatalogCandidatePage": { + "properties": { + "candidates": { + "items": {"$ref": "#/components/schemas/CatalogChangeCandidate"}, + "type": "array", + "default": [], + }, + "next_cursor": {"type": "string", "nullable": True}, + }, + "additionalProperties": False, + "type": "object", + "required": ["candidates"], + }, + "CatalogCandidateHistory": { + "properties": { + "versions": { + "items": {"$ref": "#/components/schemas/CatalogChangeCandidate"}, + "type": "array", + "default": [], + } + }, + "additionalProperties": False, + "type": "object", + "required": ["versions"], + }, + "ListCatalogCandidatesRequest": { + "properties": { + "scope_id": {"type": "string", "maxLength": 256, "minLength": 1}, + "status": {"$ref": "#/components/schemas/CandidateStatus", "nullable": True}, + "cursor": {"type": "string", "nullable": True}, + "limit": {"type": "integer", "maximum": 100.0, "minimum": 1.0, "default": 50}, + }, + "additionalProperties": False, + "type": "object", + "required": ["scope_id"], + }, + "GetCatalogCandidateRequest": { + "properties": { + "scope_id": {"type": "string", "maxLength": 256, "minLength": 1}, + "candidate_id": {"type": "string", "minLength": 1}, + }, + "additionalProperties": False, + "type": "object", + "required": ["scope_id", "candidate_id"], + }, + "ApproveCatalogCandidateRequest": { + "properties": { + "scope_id": {"type": "string", "maxLength": 256, "minLength": 1}, + "candidate_id": {"type": "string", "minLength": 1}, + "expected_version": {"type": "integer", "minimum": 1.0}, + }, + "additionalProperties": False, + "type": "object", + "required": ["scope_id", "candidate_id", "expected_version"], + }, + "RejectCatalogCandidateRequest": { + "properties": { + "scope_id": {"type": "string", "maxLength": 256, "minLength": 1}, + "candidate_id": {"type": "string", "minLength": 1}, + "expected_version": {"type": "integer", "minimum": 1.0}, + "reason": {"type": "string", "minLength": 1}, + }, + "additionalProperties": False, + "type": "object", + "required": ["scope_id", "candidate_id", "expected_version", "reason"], + }, + "ReviseCatalogCandidateRequest": { + "properties": { + "scope_id": {"type": "string", "maxLength": 256, "minLength": 1}, + "candidate_id": {"type": "string", "minLength": 1}, + "expected_version": {"type": "integer", "minimum": 1.0}, + "after_tags": { + "items": {"type": "string", "maxLength": 64, "minLength": 1}, + "type": "array", + "maxItems": 32, + }, + "reason": {"type": "string", "minLength": 1}, + "sources": { + "items": {"$ref": "#/components/schemas/DreamSourceReference"}, + "type": "array", + "nullable": True, + }, + "artifacts": { + "items": {"$ref": "#/components/schemas/ArtifactReference"}, + "type": "array", + "nullable": True, + }, + "memory_citations": { + "items": {"$ref": "#/components/schemas/MemoryCitation"}, + "type": "array", + "nullable": True, + }, + }, + "additionalProperties": False, + "type": "object", + "required": ["scope_id", "candidate_id", "expected_version", "after_tags", "reason"], + }, }, "responses": { + "ClientUpgradeRequired": { + "description": "This response requires a " + "coordinated client " + "supporting Dream contract " + "2. Upgrade the Python SDK, " + "CLI or integration and " + "send " + "X-PowerContext-Dream-Contract: " + "2. Original operations and " + "compatible candidate " + "shapes remain available " + "without the header.", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}, + }, "BadRequest": { "description": "The request query or pagination cursor is invalid.", "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, diff --git a/src/powercontext/server/app.py b/src/powercontext/server/app.py index 586d325ac7..04ebb8dd88 100644 --- a/src/powercontext/server/app.py +++ b/src/powercontext/server/app.py @@ -388,6 +388,7 @@ AcknowledgeHandoffRequest, ActivateHandoffRequest, ApproveArtifactCandidateRequest, + ApproveCatalogCandidateRequest, ArtifactAccessResource, ArtifactCandidate, ArtifactCandidatePage, @@ -403,6 +404,9 @@ Capabilities, CaptureContentSourceRequest, CaptureContentSourceResponse, + CatalogCandidateHistory, + CatalogCandidatePage, + CatalogChangeCandidate, ClearScopeBindingRequest, ClearScopeBindingResponse, CommitConnectorCheckpointRequest, @@ -440,6 +444,7 @@ GeneratePromptDemonstrationsRequest, GenerateSkillRequest, GetArtifactCandidateRequest, + GetCatalogCandidateRequest, GetConnectorCheckpointRequest, GetExperienceRequest, GetHandoffReportRequest, @@ -461,6 +466,7 @@ ListArtifactCandidatesRequest, ListArtifactRevisionsRequest, ListArtifactsRequest, + ListCatalogCandidatesRequest, ListDreamRunsRequest, ListExternalSkillsRequest, ListExternalSkillsResponse, @@ -500,6 +506,7 @@ RecordTaskOutcomeRequest, RegisterSourceDefinitionRequest, RejectArtifactCandidateRequest, + RejectCatalogCandidateRequest, RememberMemoryRequest, RemoteSkillAction, RemoteSkillPublication, @@ -516,6 +523,7 @@ ResolveScopeSelectionRequest, RetireMemoryEntryRequest, ReviseArtifactCandidateRequest, + ReviseCatalogCandidateRequest, ReviseMemoryEntryRequest, RevokeAccessBindingRequest, RevokeRemoteSkillTargetRequest, @@ -636,6 +644,7 @@ API_TITLE, API_VERSION, APPROVE_ARTIFACT_CANDIDATE, + APPROVE_CATALOG_CANDIDATE, CAPTURE_CONTENT_SOURCE, CHECK_ACCESS, CLEAR_SCOPE_BINDING, @@ -666,6 +675,8 @@ GET_ARTIFACT_REVISION, GET_ARTIFACT_TAGS, GET_CAPABILITIES, + GET_CATALOG_CANDIDATE, + GET_CATALOG_CANDIDATE_HISTORY, GET_CONNECTOR_CHECKPOINT, GET_DEFAULT_SCOPE, GET_DREAM_RUN, @@ -692,6 +703,7 @@ LIST_ARTIFACT_CANDIDATES, LIST_ARTIFACT_REVISIONS, LIST_ARTIFACTS, + LIST_CATALOG_CANDIDATES, LIST_DREAM_RUNS, LIST_EXTERNAL_SKILLS, LIST_MANAGED_SKILLS, @@ -716,6 +728,7 @@ RECORD_TASK_OUTCOME, REGISTER_SOURCE_DEFINITION, REJECT_ARTIFACT_CANDIDATE, + REJECT_CATALOG_CANDIDATE, REMEMBER_MEMORY, RENAME_REMOTE_SKILL_TARGET, REPLACE_ACCESS_BINDING, @@ -727,6 +740,7 @@ RESOLVE_SCOPE_SELECTION, RETIRE_MEMORY_ENTRY, REVISE_ARTIFACT_CANDIDATE, + REVISE_CATALOG_CANDIDATE, REVISE_MEMORY_ENTRY, REVOKE_ACCESS_BINDING, REVOKE_REMOTE_SKILL_TARGET, @@ -791,6 +805,12 @@ reset_request_id, ) from powercontext.server.dream_access import DreamAccess, principal_identity +from powercontext.server.dream_compatibility import ( + LEGACY_DREAM_OPERATIONS, + negotiate_dream_contract, + require_candidate_contract, + require_extended_dream, +) from powercontext.server.tracing import request_id_from_span from powercontext.sources import ConnectorBinding as RuntimeConnectorBinding from powercontext.sources import SourceDefinitionManifest as RuntimeSourceDefinitionManifest @@ -1208,6 +1228,7 @@ class ServerApplication(Protocol): profiles: Any subject_sources: Any + catalog_changes: Any dream: DreamApplication scopes: ScopeApplication | None publications: ArtifactPublicationApplication | None @@ -1287,6 +1308,8 @@ def create_app( context_versions=[], ) + app.middleware("http")(negotiate_dream_contract) + @app.middleware("http") async def attach_request_id(request: Request, call_next: RequestResponseEndpoint) -> Response: request_id = request_id_from_span() @@ -1447,6 +1470,12 @@ async def unexpected_error(request: Request, error: Exception) -> JSONResponse: _add_route(app, LIST_EXTERNAL_SKILLS, list_external_skills) _add_route(app, RESOLVE_EXTERNAL_SKILL, resolve_external_skill) _add_route(app, IMPORT_EXTERNAL_SKILL, import_external_skill) + _add_route(app, LIST_CATALOG_CANDIDATES, list_catalog_candidates) + _add_route(app, GET_CATALOG_CANDIDATE, get_catalog_candidate) + _add_route(app, GET_CATALOG_CANDIDATE_HISTORY, get_catalog_candidate_history) + _add_route(app, APPROVE_CATALOG_CANDIDATE, approve_catalog_candidate) + _add_route(app, REJECT_CATALOG_CANDIDATE, reject_catalog_candidate) + _add_route(app, REVISE_CATALOG_CANDIDATE, revise_catalog_candidate) _add_route(app, LIST_ARTIFACT_CANDIDATES, list_artifact_candidates) _add_route(app, GET_ARTIFACT_CANDIDATE, get_artifact_candidate) _add_route(app, APPROVE_ARTIFACT_CANDIDATE, approve_artifact_candidate) @@ -3134,6 +3163,8 @@ async def create_dream_run( application: Annotated[ServerApplication, Depends(_require_application)], http_request: Request, ) -> DreamRun: + if request.operation.value not in LEGACY_DREAM_OPERATIONS: + require_extended_dream(http_request) result = await application.dream.for_scope(scope_id, principal_id=_dream_principal(http_request)).create( RuntimeCreateDreamRunRequest.model_validate_json(request.model_dump_json(exclude_unset=True)), ) @@ -3882,6 +3913,7 @@ async def import_external_skill( async def _candidate_response( request: Request, scope_id: str, candidate: RuntimeArtifactCandidate[Any] ) -> ArtifactCandidate: + require_candidate_contract(request, candidate) response = mapping.candidate_response(candidate) access = access_control_for_mode(request.app.state.access_control, mode=request.app.state.access_mode) if access is None: @@ -3909,6 +3941,114 @@ async def _candidate_response( return response +def _catalog_response(candidate: Any) -> CatalogChangeCandidate: + payload = candidate.model_dump(mode="json") + if candidate.result is not None: + payload["result"]["etag"] = candidate.result.etag + return CatalogChangeCandidate.model_validate(payload) + + +async def _require_catalog_target_write(request: Request, scope_id: str, candidate: Any) -> None: + access = access_control_for_mode(request.app.state.access_control, mode=request.app.state.access_mode) + if access is None: + return + target = candidate.proposal.target + payload = {"scope_id": scope_id, **target.model_dump(mode="json")} + checks = ( + _path_memory_entry_write_access(payload, access.deployment_id) + if target.type == "memory_entry" + else _path_artifact_tags_write_access(payload, access.deployment_id) + ) + await access.require_all(_require_principal(), checks, context=_access_audit_context("review_catalog_change")) + + +async def list_catalog_candidates( + request: ListCatalogCandidatesRequest, + application: Annotated[ServerApplication, Depends(_require_application)], + http_request: Request, +) -> CatalogCandidatePage: + require_extended_dream(http_request) + page = await application.catalog_changes.for_scope(request.scope_id).list( + status=None if request.status is None else request.status.value, cursor=request.cursor, limit=request.limit + ) + return CatalogCandidatePage( + candidates=[_catalog_response(item) for item in page.candidates], next_cursor=page.next_cursor + ) + + +async def get_catalog_candidate( + request: GetCatalogCandidateRequest, + application: Annotated[ServerApplication, Depends(_require_application)], + http_request: Request, +) -> CatalogChangeCandidate: + require_extended_dream(http_request) + return _catalog_response(await application.catalog_changes.for_scope(request.scope_id).get(request.candidate_id)) + + +async def get_catalog_candidate_history( + request: GetCatalogCandidateRequest, + application: Annotated[ServerApplication, Depends(_require_application)], + http_request: Request, +) -> CatalogCandidateHistory: + require_extended_dream(http_request) + versions = await application.catalog_changes.for_scope(request.scope_id).history(request.candidate_id) + return CatalogCandidateHistory(versions=[_catalog_response(item) for item in versions]) + + +async def approve_catalog_candidate( + request: ApproveCatalogCandidateRequest, + application: Annotated[ServerApplication, Depends(_require_application)], + http_request: Request, +) -> CatalogChangeCandidate: + require_extended_dream(http_request) + service = application.catalog_changes.for_scope(request.scope_id) + candidate = await service.get(request.candidate_id) + await _require_catalog_target_write(http_request, request.scope_id, candidate) + return _catalog_response(await service.approve(request.candidate_id, request.expected_version)) + + +async def reject_catalog_candidate( + request: RejectCatalogCandidateRequest, + application: Annotated[ServerApplication, Depends(_require_application)], + http_request: Request, +) -> CatalogChangeCandidate: + require_extended_dream(http_request) + return _catalog_response( + await application.catalog_changes.for_scope(request.scope_id).reject( + request.candidate_id, request.expected_version, request.reason + ) + ) + + +async def revise_catalog_candidate( + request: ReviseCatalogCandidateRequest, + application: Annotated[ServerApplication, Depends(_require_application)], + http_request: Request, +) -> CatalogChangeCandidate: + require_extended_dream(http_request) + service = application.catalog_changes.for_scope(request.scope_id) + candidate = await service.get(request.candidate_id) + await _require_catalog_target_write(http_request, request.scope_id, candidate) + from powercontext.sources import SourceRef + + result = await service.revise( + request.candidate_id, + request.expected_version, + after_tags=tuple(tag.root for tag in request.after_tags), + reason=request.reason, + sources=None + if request.sources is None + else tuple(SourceRef(source_type=item.source_type, source_id=item.source_id) for item in request.sources), + artifacts=None + if request.artifacts is None + else tuple(mapping.runtime_artifact_reference(item) for item in request.artifacts), + memory_citations=None + if request.memory_citations is None + else tuple(mapping.runtime_citation(item) for item in request.memory_citations), + ) + return _catalog_response(result) + + async def list_artifact_candidates( request: ListArtifactCandidatesRequest, application: Annotated[ServerApplication, Depends(_require_application)], @@ -3938,6 +4078,9 @@ async def approve_artifact_candidate( http_request: Request, ) -> ArtifactCandidate: review = application.review.for_scope(request.scope_id) + current = await review.get(RuntimeGetArtifactCandidateRequest(candidate_id=request.candidate_id)) + require_candidate_contract(http_request, current) + await _require_candidate_target_write(http_request, request.scope_id, current) access = access_control_for_mode( http_request.app.state.access_control, mode=http_request.app.state.access_mode, @@ -3974,6 +4117,10 @@ async def reject_artifact_candidate( application: Annotated[ServerApplication, Depends(_require_application)], http_request: Request, ) -> ArtifactCandidate: + current = await application.review.for_scope(request.scope_id).get( + RuntimeGetArtifactCandidateRequest(candidate_id=request.candidate_id) + ) + require_candidate_contract(http_request, current) result = await application.review.for_scope(request.scope_id).reject(mapping.reject_candidate_request(request)) return await _candidate_response(http_request, request.scope_id, result) @@ -3983,6 +4130,11 @@ async def revise_artifact_candidate( application: Annotated[ServerApplication, Depends(_require_application)], http_request: Request, ) -> ArtifactCandidate: + current = await application.review.for_scope(request.scope_id).get( + RuntimeGetArtifactCandidateRequest(candidate_id=request.candidate_id) + ) + require_candidate_contract(http_request, current) + await _require_candidate_target_write(http_request, request.scope_id, current) access = access_control_for_mode( http_request.app.state.access_control, mode=http_request.app.state.access_mode, @@ -3997,6 +4149,25 @@ async def revise_artifact_candidate( return await _candidate_response(http_request, request.scope_id, result) +async def _require_candidate_target_write( + request: Request, scope_id: str, candidate: RuntimeArtifactCandidate[Any] +) -> None: + access = access_control_for_mode(request.app.state.access_control, mode=request.app.state.access_mode) + if access is None or candidate.target is None: + return + if candidate.family in {"prompt", "topic-memory"}: + checks = ((AccessAction.SCOPE_ADMIN, ResourceRef.scope(scope_id)),) + elif candidate.family == "memory": + checks = _base_memory_write_access({ + "scope_id": scope_id, + "artifact_id": candidate.target.artifact_id, + "content": {"entries": [{"entry_id": item.entry_id} for item in candidate.proposal.changes]}, + }) + else: + return + await access.require_all(_require_principal(), checks, context=_access_audit_context("review_candidate_target")) + + async def _require_candidate_artifact_owner( request: Request, scope_id: str, @@ -4004,6 +4175,9 @@ async def _require_candidate_artifact_owner( ) -> None: if candidate.status.value != "approved" or candidate.result_artifact is None: return + if candidate.result_artifact.family in {"memory", "prompt", "topic-memory"}: + # These results name aggregate or Scope-owned revisions, not separately owned content. + return access = access_control_for_mode(request.app.state.access_control, mode=request.app.state.access_mode) if access is None: return @@ -5134,11 +5308,14 @@ def _set_error_headers(response: Response, error: Exception) -> None: def _map_error(error: Exception) -> tuple[int, str, str, dict[str, Any] | None]: + if isinstance(error, DreamError) and error.code == "client_upgrade_required": + return 426, error.code, "Upgrade to a client supporting Dream contract 2.", {"required_dream_contract": 2} access_error = _map_access_error(error) if access_error is not None: return access_error if isinstance(error, (DreamError, EvidenceResolutionError)): statuses = { + "client_upgrade_required": 426, "idempotency_conflict": 409, "artifact_conflict": 409, "dream_not_found": 404, diff --git a/src/powercontext/server/dashboard/api.py b/src/powercontext/server/dashboard/api.py index 74c0331ce6..e98297dde1 100644 --- a/src/powercontext/server/dashboard/api.py +++ b/src/powercontext/server/dashboard/api.py @@ -57,6 +57,7 @@ def __init__(self, request: Request) -> None: self.app = request.app headers = authentication_headers(request.scope) headers = {key: value for key, value in headers.items() if key in {"authorization", "cookie"}} + headers["X-PowerContext-Dream-Contract"] = "2" self.client = httpx.AsyncClient( transport=httpx.ASGITransport(app=request.app, raise_app_exceptions=False), base_url=str(request.base_url), diff --git a/src/powercontext/server/dashboard/labels.en.json b/src/powercontext/server/dashboard/labels.en.json index e6d66c5269..78c728bf14 100644 --- a/src/powercontext/server/dashboard/labels.en.json +++ b/src/powercontext/server/dashboard/labels.en.json @@ -243,5 +243,34 @@ "handoff_status": "Handoff status", "return_handoff": "Return to this handoff", "error_410": "This collection position has expired. Reopen the first page.", - "error_413": "The content exceeds the download size limit. No attachment was created." + "error_413": "The content exceeds the download size limit. No attachment was created.", + "review": "Review inbox", + "profile_mode_dream_review_approved": "Dream review approved", + "review_resource": "Candidate type", + "review_all": "All", + "review_artifact": "Artifact candidates", + "review_catalog_change": "Tag change candidates", + "review_status": "Status", + "review_pending": "Pending", + "review_approved": "Approved", + "review_rejected": "Rejected", + "review_version": "Version", + "review_empty": "No candidates on this page.", + "review_next": "Next page", + "review_reason": "Reason for change", + "review_diff": "Diff against the target version", + "review_evidence": "Exact evidence", + "review_content": "Complete proposal", + "review_approve": "Approve this version", + "review_reject": "Reject this version", + "review_reject_reason": "Reason for rejection", + "review_revise": "Save a new pending version", + "review_edit": "Revise the complete proposal", + "review_effect_prompt": "Approval publishes Prompt configuration for future inference. In-flight inference keeps its frozen version. Rollback creates a higher revision.", + "review_effect_skill": "Approval saves the Skill package without installing, executing, or distributing it.", + "review_effect_handoff": "Approval commits a revision without activating the handoff or acknowledging for the receiver.", + "review_effect_tag": "Approval checks the Tag ETag and content baseline together, replacing tags without creating an Artifact revision.", + "review_history": "Candidate versions and decisions", + "review_unavailable": "Evidence unavailable; approval will recheck it.", + "review_target_unavailable": "Target content unavailable; a complete diff cannot be shown." } diff --git a/src/powercontext/server/dashboard/labels.json b/src/powercontext/server/dashboard/labels.json index 0fc89093b0..12d8bc4b50 100644 --- a/src/powercontext/server/dashboard/labels.json +++ b/src/powercontext/server/dashboard/labels.json @@ -243,5 +243,34 @@ "handoff_status": "交接状态", "return_handoff": "返回此交接", "error_410": "目录位置已失效,请重新打开第一页。", - "error_413": "内容超过下载大小限制,未生成附件。" + "error_413": "内容超过下载大小限制,未生成附件。", + "review": "审核收件箱", + "profile_mode_dream_review_approved": "Dream 审核通过", + "review_resource": "候选类型", + "review_all": "全部", + "review_artifact": "制品候选", + "review_catalog_change": "标签变更候选", + "review_status": "状态", + "review_pending": "待审核", + "review_approved": "已批准", + "review_rejected": "已拒绝", + "review_version": "版本", + "review_empty": "此页没有候选。", + "review_next": "下一页", + "review_reason": "变更依据", + "review_diff": "与目标版本的差异", + "review_evidence": "精确证据", + "review_content": "完整提案", + "review_approve": "批准此版本", + "review_reject": "拒绝此版本", + "review_reject_reason": "拒绝原因", + "review_revise": "保存新的待审版本", + "review_edit": "修改完整候选内容", + "review_effect_prompt": "批准后将发布 Prompt 配置,应用于后续推理;进行中的推理保留原版本。回滚须创建更高版本。", + "review_effect_skill": "批准后保存技能包,不会安装、执行或分发。", + "review_effect_handoff": "批准后提交新版本,不会激活交接或代表接收方确认。", + "review_effect_tag": "批准时同时校验标签 ETag 和内容版本;只替换标签,不产生制品版本。", + "review_history": "候选版本与决定记录", + "review_unavailable": "证据不可用;批准时会重新检查。", + "review_target_unavailable": "目标内容暂不可用,无法显示完整差异。" } diff --git a/src/powercontext/server/dashboard/review.py b/src/powercontext/server/dashboard/review.py new file mode 100644 index 0000000000..78504c558d --- /dev/null +++ b/src/powercontext/server/dashboard/review.py @@ -0,0 +1,202 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Unified review presentation over the two public candidate resources.""" + +from __future__ import annotations + +import difflib +import json +from typing import Any +from urllib.parse import parse_qs, urlsplit + +from fastapi import Request + +from powercontext.server.dashboard.api import DashboardAPI, ReadError, segment + +KINDS = {"artifact": "/v1/artifact-candidates", "catalog_change": "/v1/catalog-change-candidates"} + + +def formatted(value: Any) -> str: + return json.dumps(value, ensure_ascii=False, indent=2, sort_keys=True) + + +async def load_review(api: DashboardAPI, request: Request, ctx: dict[str, Any]) -> None: + kind = request.query_params.get("resource_type", "all") + status = request.query_params.get("review_status", "pending") + if kind not in {"all", *KINDS} or status not in {"pending", "approved", "rejected"}: + raise ReadError(422, "invalid_request") + view: dict[str, Any] = {"kind": kind, "status": status, "sections": [], "selected": None} + ctx["review"] = view + selected = request.query_params.get("candidate_id") + for resource, path in KINDS.items(): + if kind not in {"all", resource}: + continue + try: + result = await api.read( + path + "/list", + { + "scope_id": ctx["scope"], + "status": status, + "limit": 12, + "cursor": request.query_params.get(resource + "_cursor"), + }, + ) + view["sections"].append({"kind": resource, **result}) + except ReadError as error: + ctx["errors"][resource] = error + if selected: + selected_kind = request.query_params.get("candidate_kind", "artifact") + if selected_kind not in KINDS: + raise ReadError(422, "invalid_request") + candidate = await api.read(KINDS[selected_kind] + "/get", {"scope_id": ctx["scope"], "candidate_id": selected}) + detail: dict[str, Any] = { + "kind": selected_kind, + "candidate": candidate, + "evidence": [], + "diff": "", + "history": [], + } + view["selected"] = detail + await _detail(api, ctx["scope"], detail) + + +async def _detail(api: DashboardAPI, scope: str, detail: dict[str, Any]) -> None: + candidate, kind = detail["candidate"], detail["kind"] + proposal = candidate["proposal"] + baseline: Any = None + if kind == "catalog_change": + baseline, proposed = proposal["before_tags"], proposal["after_tags"] + detail["history"] = ( + await api.read(KINDS[kind] + "/history", {"scope_id": scope, "candidate_id": candidate["candidate_id"]}) + )["versions"] + revision = {"after_tags": proposed, "reason": candidate["reason"]} + else: + target = candidate.get("target") + baseline, proposed = await _artifact_comparison(api, scope, detail) + revision = { + "proposal": proposal, + "source_refs": candidate["source_refs"], + "artifact_refs": candidate["artifact_refs"], + "memory_citations": candidate["memory_citations"], + "target": target, + "reason": candidate["reason"], + } + # The public revision input accepts Profile content, while the stored proposal also has generation metadata. + if candidate["family"] == "profile": + revision["proposal"] = {"content": proposal["content"]} + detail["revision"] = formatted(revision) + detail["proposal"] = formatted(proposal) + detail["diff"] = "\n".join( + difflib.unified_diff( + formatted(baseline).splitlines(), + formatted(proposed).splitlines(), + fromfile="current target", + tofile="candidate", + lineterm="", + ) + ) + for source in candidate.get("sources", candidate.get("source_refs", [])): + source_type = source.get("source_type", source.get("name")) + await _evidence( + api, + detail, + source, + f"/v1/scopes/{segment(scope)}/sources/{segment(source_type)}/{segment(source['source_id'])}", + ) + for artifact in candidate.get("artifacts", candidate.get("artifact_refs", [])): + await _evidence( + api, + detail, + artifact, + f"/v1/scopes/{segment(scope)}/artifacts/{segment(artifact['family'])}/{segment(artifact['artifact_id'])}/revisions/{artifact['revision']}", + ) + for citation in candidate.get("memory_citations", []): + await _evidence(api, detail, citation, "/v1/memory/entries/get", {"scope_id": scope, "citation": citation}) + + +async def _artifact_comparison(api: DashboardAPI, scope: str, detail: dict[str, Any]) -> tuple[Any, Any]: + candidate = detail["candidate"] + proposal = candidate["proposal"] + baseline: Any = None + target = candidate.get("target") + if target and candidate["family"] != "memory": + try: + baseline = ( + await api.artifact_revision(scope, target["family"], target["artifact_id"], target["revision"]) + )["content"] + except ReadError as error: + detail["baseline_error"] = error + proposed = proposal + if candidate["family"] == "memory": + try: + baseline = [] + proposed = [] + for change in proposal["changes"]: + citation = { + "memory_ref": proposal["base"], + "entry_id": change["entry_id"], + "entry_version_id": change["entry_version_id"], + } + entry = await api.read("/v1/memory/entries/get", {"scope_id": scope, "citation": citation}) + baseline.append({"entry_id": change["entry_id"], "kind": entry["kind"], "text": entry["text"]}) + proposed.append({"entry_id": change["entry_id"], "kind": change["kind"], "text": change["text"]}) + except ReadError as error: + detail["baseline_error"] = error + elif candidate["family"] == "profile": + baseline = None if baseline is None else baseline["content"] + proposed = proposal["content"] + return baseline, proposed + + +async def _evidence( + api: DashboardAPI, detail: dict[str, Any], ref: dict[str, Any], path: str, payload: dict[str, Any] | None = None +) -> None: + item = {"reference": formatted(ref)} + try: + item["content"] = formatted(await api.read(path, payload)) + except ReadError as error: + item["error"] = error + detail["evidence"].append(item) + + +async def submit_review(api: DashboardAPI, request: Request) -> tuple[str, str, str]: + origin = request.headers.get("origin") + expected = urlsplit(str(request.base_url)) + if origin != f"{expected.scheme}://{expected.netloc}": + raise ReadError(403, "cross_origin_request") + body = await request.body() + if len(body) > 350_000: + raise ReadError(413, "invalid_request") + try: + values = parse_qs(body.decode("utf-8"), strict_parsing=True) + if any(len(items) != 1 for items in values.values()): + raise ReadError(422, "invalid_request") + form = {key: items[0] for key, items in values.items()} + scope, kind, candidate_id = form["scope"], form["candidate_kind"], form["candidate_id"] + action = form["action"] + if kind not in KINDS or action not in {"approve", "reject", "revise"}: + raise ReadError(422, "invalid_request") + payload: dict[str, Any] = {} + if action == "revise": + payload = json.loads(form["replacement"]) + if not isinstance(payload, dict): + raise ReadError(422, "invalid_request") + if action == "reject": + payload["reason"] = form["reason"] + payload.update(scope_id=scope, candidate_id=candidate_id, expected_version=int(form["expected_version"])) + except (ValueError, KeyError, UnicodeDecodeError) as error: + raise ReadError(422, "invalid_request") from error + await api.read(KINDS[kind] + "/" + action, payload) + return scope, kind, candidate_id diff --git a/src/powercontext/server/dashboard/routes.py b/src/powercontext/server/dashboard/routes.py index 930c31d6c9..2b6aa1175d 100644 --- a/src/powercontext/server/dashboard/routes.py +++ b/src/powercontext/server/dashboard/routes.py @@ -36,6 +36,7 @@ ) from powercontext.server.dashboard.preferences import CATALOGS, presentation, remember_language from powercontext.server.dashboard.presenters import source_view +from powercontext.server.dashboard.review import load_review, submit_review from powercontext.server.dashboard.session import login_response ROOT = Path(__file__).parent @@ -51,6 +52,7 @@ "profile", "handoff-download", "usage", + "review", "entry", *RECORDS, } @@ -115,6 +117,12 @@ def link(destination: str | None = None, fragment: str = "", **params: Any) -> s "skill_page", "experience_history", "handoff_history", + "resource_type", + "review_status", + "candidate_id", + "candidate_kind", + "artifact_cursor", + "catalog_change_cursor", } }) if destination == "methods" and ctx["page"] in {"experience", "skill"}: @@ -215,6 +223,7 @@ def initial_context(request: Request, page: str) -> dict[str, Any]: "selected_note": None, "requested_entry": request.query_params.get("entry"), "collections": {}, + "review": {"kind": "all", "status": "pending", "sections": [], "selected": None}, "related_sources": [], "source_record": None, "source": None, @@ -377,6 +386,25 @@ async def download_handoff(request: Request) -> Response: return render(request, ctx) +@router.post("/review/decision") +async def review_decision(request: Request) -> Response: + ctx = initial_context(request, "review") + api = DashboardAPI(request) + try: + scope, kind, candidate_id = await submit_review(api, request) + return RedirectResponse( + "/dashboard/review?" + urlencode({"scope": scope, "candidate_kind": kind, "candidate_id": candidate_id}), + status_code=303, + ) + except ReadError as error: + if error.status == 401: + return login_response(request=request) + ctx.update(page_error=error, status=error.status) + return render(request, ctx) + finally: + await api.client.aclose() + + @router.get("/{page}") async def screen(request: Request, page: str) -> HTMLResponse: ctx = initial_context(request, page if page in PAGES else "home") @@ -384,7 +412,9 @@ async def screen(request: Request, page: str) -> HTMLResponse: try: validate_selection(page, ctx) await scope_context(api, ctx) - if ctx["scope"] and page != "entry": + if ctx["scope"] and page == "review": + await load_review(api, request, ctx) + elif ctx["scope"] and page != "entry": await load_content(api, request, ctx) if ctx["errors"] and not ctx["scope_descriptor"] and not ctx["record_only"]: raise next(iter(ctx["errors"].values())) diff --git a/src/powercontext/server/dashboard/templates/components/navigation.html b/src/powercontext/server/dashboard/templates/components/navigation.html index 8762f5be58..b52a7c1422 100644 --- a/src/powercontext/server/dashboard/templates/components/navigation.html +++ b/src/powercontext/server/dashboard/templates/components/navigation.html @@ -34,7 +34,7 @@ {% if errors.get('scopes') %}

{{ t.scope_list_unavailable }}

{% endif %}