diff --git a/desktop/src-tauri/src/transport/operations.json b/desktop/src-tauri/src/transport/operations.json index 66f6f16b80..6a8f1f84e4 100644 --- a/desktop/src-tauri/src/transport/operations.json +++ b/desktop/src-tauri/src/transport/operations.json @@ -1,5 +1,5 @@ { - "contractSha256": "fdc752737cc446c96a1482debcde927860e1ce30539b7a647a78c8a64f94b112", + "contractSha256": "5c00096f12642263d6bc538f7271a1484e1f8a17660786b12563cac9f4d6b0ab", "operations": { "get_liveness": { "method": "GET", diff --git a/desktop/ui/src/generated/api.d.ts b/desktop/ui/src/generated/api.d.ts index 1ac0bc081b..293abb3fd0 100644 --- a/desktop/ui/src/generated/api.d.ts +++ b/desktop/ui/src/generated/api.d.ts @@ -2379,6 +2379,8 @@ export interface components { boundary_source: components["schemas"]["SourceReference"]; objective: string; /** @default [] */ + rollover_reasons: components["schemas"]["HandoffRolloverReason"][]; + /** @default [] */ evidence: components["schemas"]["HandoffCitation"][]; /** @default 8000 */ max_bytes: number; @@ -3241,6 +3243,8 @@ export interface components { PrepareHandoffRequest: { scope_id: string; objective: string; + /** @default [] */ + rollover_reasons: components["schemas"]["HandoffRolloverReason"][]; evidence: components["schemas"]["HandoffCitation"][]; /** @default 8000 */ max_bytes: number; @@ -4532,6 +4536,8 @@ export interface components { /** @enum {string} */ HandoffResolutionStatus: "empty" | "resolved"; /** @enum {string} */ + HandoffRolloverReason: "user_requested" | "host_context_budget" | "host_compaction" | "context_quality" | "delegation" | "manual_checkpoint"; + /** @enum {string} */ HandoffSchema: "powercontext.handoff.v1"; /** @enum {string} */ HandoffSelection: "prepared" | "exact" | "latest"; diff --git a/desktop/ui/src/generated/operations.ts b/desktop/ui/src/generated/operations.ts index 2fcb2939a4..f387adbfea 100644 --- a/desktop/ui/src/generated/operations.ts +++ b/desktop/ui/src/generated/operations.ts @@ -15,7 +15,7 @@ */ // Generated from openapi/powercontext.yaml. Do not edit. -export const contractSha256 = "fdc752737cc446c96a1482debcde927860e1ce30539b7a647a78c8a64f94b112"; +export const contractSha256 = "5c00096f12642263d6bc538f7271a1484e1f8a17660786b12563cac9f4d6b0ab"; export const operations = { "get_liveness": { "method": "GET", diff --git a/docs/en/rfcs/1783-handoff-session-rollover.md b/docs/en/rfcs/1783-handoff-session-rollover.md new file mode 100644 index 0000000000..5355580757 --- /dev/null +++ b/docs/en/rfcs/1783-handoff-session-rollover.md @@ -0,0 +1,342 @@ +- Proposal Name: `handoff_session_rollover` +- Start Date: 2026-09-29 +- Status: Draft +- RFC PR: [oceanbase/powercontext#1783](https://github.com/oceanbase/powercontext/pull/1783) +- Tracking Issue: Not assigned +- Related RFCs: [RFC 0001](0001_product_definition_and_vision.md), [RFC 0014](0014_memory_layer_design.md), [RFC 0019](0019_local_source_memory_runtime.md), [RFC 0028](0028_context_pack.md), [RFC 0048](0048_handoff_artifact.md), [RFC 0082](0082_handoff_report.md), and [RFC 1489](1489_prepared_context_text_assembly.md) + +# Summary + +This RFC defines session rollover as a Handoff lifecycle event. When a long Agent session approaches context limits or starts to accumulate stale assumptions, the host or user can ask PowerContext to prepare a Rollover Handoff: a complete, evidence-backed work checkpoint for continuing the same scope in a fresh session. A fresh session then continues from the Rollover Handoff and bounded PreparedContext instead of inheriting the old transcript or a lossy conversation summary. + +PowerContext remains an Agent memory and work-continuity system. It does not become an Agent runtime, a transcript store, or a provider-specific context-window manager. Rollover Handoff reuses the RFC 0048 Handoff content contract and commit semantics, adds advisory rollover reasons to preparation, and keeps long-term Memory promotion explicit. + +# Motivation + +Long Agent sessions fail in a different way from ordinary missing context. Even when the model still has enough tokens, a session can accumulate stale assumptions, outdated plans, redundant summaries, noisy tool output, and hidden dependency on earlier wording. Repeated compaction can preserve a session's apparent continuity while degrading the exact facts needed to continue safely. + +PowerContext already has the right durable boundary for this problem: Handoff. A Handoff captures objective, state, disposition, next action, evidence, and omissions. That is exactly the minimum state a fresh session needs to continue work without inheriting the whole old session. + +Without a rollover concept, hosts tend to choose one of two weak strategies: + +- keep extending the current session until the model or host must compact it; +- start a new session and rely on Memory, old prompts, or an informal summary to recover state. + +Both blur the distinction between current work state, long-term project knowledge, and original evidence. This RFC defines a narrower path: turn session rollover into an explicit Handoff checkpoint. The checkpoint can be inspected, committed, and later used by Continue like any other Handoff, while Source and Memory keep their existing boundaries. + +## Review focus + +Reviewers should focus on these decisions: + +- whether session rollover belongs in Handoff semantics rather than Memory, PreparedContext, or a new Notes family; +- whether the proposal preserves RFC 0048's distinction between temporary Prepared Handoff and committed Handoff Revision; +- whether the rollover quality requirements are sufficient to prevent vague summaries such as "continue the previous work"; +- whether automatic draft preparation can be allowed while automatic commit remains a separate policy decision; +- whether the proposed relationship with PreparedContext is narrow enough to avoid making Context Pack a Handoff replacement; +- whether Source capture is sufficient prior art for optional transcript evidence without making PowerContext a full transcript store. + +# Guide-level explanation + +## Rollover Handoff + +A Rollover Handoff is a Handoff prepared because the current Agent session should not be the primary carrier of work state anymore. + +Typical triggers include: + +- the user asks to continue in a new session; +- the host observes that a session is long or near its context budget; +- the Agent reports that the current context is noisy, stale, or too compressed; +- a host-specific compaction event happened and the next session needs a clean continuation point; +- a human wants to checkpoint the current state before delegating to another Agent. + +These triggers are recorded as rollover reasons supplied by the caller or host. PowerContext itself does not detect context pressure; detection belongs to the host trigger policy defined below. + +Rollover does not mean that the work is complete. It means the next participant should continue from a verified checkpoint rather than from the old conversation. + +The Handoff content still answers the RFC 0048 questions: + +| Field | Rollover requirement | +| --- | --- | +| Objective | The workstream objective being continued | +| State | The current work state that a fresh session must understand | +| Disposition | Usually `continuable`, but may be `blocked` or `complete` | +| Next action | The first action the fresh session should consider | +| Evidence | Exact references for state and next action | +| Omissions | Missing checks, unverifiable assumptions, excluded materials, or host context that was not captured | + +A Rollover Handoff is not a transcript summary. It should omit details that do not change how the next session continues. + +## Example: move a coding task to a fresh session + +Session A has been implementing a feature for several hours. It has read many files, tried two designs, fixed tests, and now the model starts repeating an older plan. The user says: + +```text +Prepare a rollover handoff so I can continue this in a new session. +``` + +PowerContext prepares a Handoff draft: + +```text +Objective: + Add session rollover support to Handoff without changing Memory semantics. + +State: + - The RFC draft exists in English and Chinese. [evidence: exact Artifact revision] + - The design deliberately treats rollover as a Handoff lifecycle event. [evidence: user prompt Source] + - No runtime implementation has been started. [evidence: Git diff Source] + +Disposition: + continuable + +Next action: + Review the RFC for consistency with RFC 0048 and RFC 0028, then open the RFC PR. + +Omissions: + - No full repository test suite has been run because this is a documentation-only change. + - Host-specific token-budget signals are not specified beyond an advisory input. +``` + +The user can inspect and correct the draft. If it is only being copied into another session, the Prepared Handoff can be transferred directly. If it is meant to become the latest workstream checkpoint, the caller explicitly commits it as a Handoff Revision. + +## Continue from rollover + +Preparing a Handoff does not open the new session. Opening, resetting, or closing the model window is a host action; PowerContext only prepares, validates, and stores the checkpoint. The host owns the following sequence: + +```text +prepare -> open/reset -> deliver -> Continue +``` + +1. `prepare`: the originating session prepares the Rollover Handoff and the caller inspects it. If the checkpoint must survive a failed delivery, the caller commits it as a Handoff Revision. +2. `open/reset`: the host opens the fresh session and binds it to the Handoff's originating scope. +3. `deliver`: the host transfers the exact Handoff into the fresh session — the Prepared Handoff value or the exact committed Revision reference. This is the explicit transfer of RFC 0048, not transcript inheritance. +4. `Continue`: the fresh session verifies the transfer (see "Receiver verification and recovery"), then continues from the Handoff under the current request. + +The scope binding matters. Continue resolves the latest committed Handoff only in the Handoff's originating scope. If the fresh session binds to a different scope, the host must deliver the exact Prepared value or exact Revision, and Continue must not treat a latest lookup as current work. + +A fresh session should not receive the full old transcript by default. After delivery, the fresh session receives: + +1. the Handoff content as untrusted historical work state; +2. exact evidence references or evidence-check results; +3. bounded related Memory, Experience, Profile, or Topic Memory when requested by the context assembly; +4. omissions explaining what was not carried forward. + +The fresh session still treats current instructions, the current request, repository rules, live workspace state, and tool results as higher priority than the Handoff. + +## Relationship to Memory + +Rollover Handoff does not automatically create or update Memory. + +During rollover, information falls into different stores: + +| Information | Destination | +| --- | --- | +| Current objective, progress, next action, blockers | Handoff | +| Original prompts, tool outputs, file snapshots, test output | Source, if the host captured them | +| Long-term project decisions or constraints | Memory, only through explicit Memory flows | +| Reusable troubleshooting lessons | Experience, only through the existing review lifecycle | +| Summarized domain knowledge | Topic Memory, through its own processing lifecycle | + +This keeps a temporary work checkpoint from polluting long-term project Memory. A Rollover Handoff may cite Memory, and later review may promote a durable decision from the Handoff into Memory, but promotion is explicit and reviewable. + +# Reference-level explanation + +## Scope + +This RFC defines: + +- Rollover Handoff as a named use of the existing Handoff lifecycle; +- rollover reasons, the host trigger policy, and host signals; +- additional quality requirements for rollover content; +- the host sequence that opens or resets a session window around a Rollover Handoff; +- receiver acknowledgement and recovery when a rollover fails; +- how a fresh session receives continuation context; +- the boundary between Handoff, Source, Memory, Experience, Topic Memory, and PreparedContext. + +This RFC does not define: + +- provider-specific APIs for opening a new model session or context window; +- full transcript storage in PowerContext; +- automatic Memory creation from every rollover; +- a new durable Notes family; +- automatic execution of the Handoff next action; +- implementation priority or rollout policy. + +## Product model + +Rollover Handoff is not a new Artifact family. It is a Handoff prepared with advisory rollover reasons supplied by a host or user. The shared content contract remains the RFC 0048 Handoff contract. + +Prepared and committed forms retain their existing lifecycle: + +```text +Draft -> Prepared Rollover Handoff -> Transfer + -> Commit -> Handoff Revision +``` + +The rollover reasons are part of the preparation context. They guide draft generation and inspection, but they are not part of Handoff content identity in the initial implementation. A committed Rollover Handoff is still a Handoff Revision in the scope's linear Handoff history. Reading the latest Handoff does not require special handling. + +## Rollover reasons + +A preparation request may include one or more advisory reasons: + +| Reason | Meaning | +| --- | --- | +| `user_requested` | The user explicitly requested a new session or checkpoint | +| `host_context_budget` | The host estimates that the session is near a context limit | +| `host_compaction` | The host reports that compaction already occurred or is imminent | +| `context_quality` | The Agent or host reports stale, noisy, or over-compressed context | +| `delegation` | Work is being transferred to another Agent or human | +| `manual_checkpoint` | The caller wants a checkpoint without claiming the session is unhealthy | + +Reasons are advisory. They help generation focus on a fresh-session checkpoint, but they do not authorize commit or execution and do not create a distinct durable Artifact kind. + +## Host trigger policy + +Rollover reasons record a caller's decision; they do not detect context pressure. Detection belongs to the host. A host that supports the Advisory or Automatic draft level defines its trigger policy explicitly: + +- which observations it uses, such as token estimates, provider usage reports, compaction events, or session age; +- the thresholds or events that turn an observation into a suggestion or a draft; +- when preparation runs, so drafts are prepared at safe boundaries rather than in the middle of an action; +- what the policy may and may not do: suggestions and drafts never commit, and they never close or reset the window by themselves. + +PowerContext supplies the checkpoint contract, not the detection logic, and treats host observations as untrusted inputs (see "Host integration"). + +When usage or compaction signals are unavailable, the trigger policy degrades to the Manual level: only `user_requested`, `delegation`, or `manual_checkpoint` triggers apply. The host must not fabricate or infer signals it does not have, and missing signals never block an explicit user request. A host that cannot detect budget pressure can still support full rollover through user-initiated preparation. + +## Quality requirements + +A Rollover Handoff must be self-contained enough for a fresh session to begin safely. In addition to RFC 0048 validation, finalization should reject or flag content that lacks: + +- a nonempty objective; +- at least one current state statement; +- a disposition; +- either a next action or a disposition that explains why no next action exists; +- evidence for state statements and next action, or an omission explaining why evidence is unavailable; +- explicit omissions for known unverified checks, missing host state, or relevant excluded material. + +The following content is invalid or should require correction: + +- "continue the previous work"; +- "see the conversation above"; +- a next action without evidence or an omission; +- a claim that tests passed without a cited test result or current verification; +- an objective rewritten by generation instead of supplied by the caller. + +These checks may start as deterministic validation over the Handoff content model. A future generation pipeline may use a model to draft content, but model output cannot satisfy the evidence requirement by itself. + +## Host integration + +A host integration can support rollover in three levels: + +| Level | Behavior | +| --- | --- | +| Manual | The user explicitly asks to prepare or commit a Rollover Handoff | +| Advisory | The host detects a long session or budget pressure and suggests preparing a Rollover Handoff | +| Automatic draft | The host prepares a draft at a safe boundary, but commit still follows configured policy | + +Automatic commit is outside this RFC. It requires a separate policy decision because committing advances the scope's Handoff history. + +Hosts may provide context-budget observations, such as model name, estimated remaining tokens, compaction count, or session age. PowerContext treats them as untrusted host observations unless backed by Source evidence. They can influence drafting and display, but they are not proof that a provider will actually open or preserve a session. + +## Continue and PreparedContext + +Continue remains the primary operation for acting on a Handoff. PreparedContext remains a bounded context package for one Agent turn. The two can work together without merging their contracts. + +A continuation-oriented `prepare_context` profile may select: + +1. the latest Handoff for the current scope, if the caller asked for Handoff-backed continuation; +2. exact evidence references or evidence check summaries from that Handoff; +3. related Memory and Experience under the existing assembly budget; +4. optional Profile or Topic Memory sections when explicitly requested. + +This RFC does not require RFC 1489 to add `handoff` as an assembly family. It defines the semantic boundary so a later implementation PR or RFC can add that family without changing Handoff meaning. + +PreparedContext must continue to mark historical material as untrusted. It must not make a historical objective current, execute a next action, or hide evidence omissions. + +## Source and transcript boundary + +Rollover can cite captured Source records, including host prompts, selected tool outputs, test results, or human-authored notes. This does not require PowerContext to ingest or retain the complete session transcript. + +Hosts decide what they are allowed to capture. If relevant transcript material was not captured, the Handoff records an omission rather than pretending the material is available. A host may include a transcript location or digest as evidence only when it is readable through an authorized Source adapter. + +The generator only sees the evidence a caller supplies. Work state that exists only in the current agent context — plans, partial reasoning, or decisions not yet written to any file, tool output, or Source — is invisible to preparation until it is captured. Hosts and Agents should capture that state explicitly with `handoff_current_work` (RFC 1223): the Agent declares its inspected facts with `basis="declared"` against a captured boundary Source, and the operation finalizes the Prepared Handoff deterministically in one step. This declared boundary is the default capture path for hosts that do not retain transcripts. State that is neither captured as Source nor declared through the boundary remains an omission. + +## Concurrency and idempotency + +Committed Rollover Handoffs use the same CAS behavior as RFC 0048. If the scope's Handoff head advanced after draft preparation, commit reports a conflict. The caller must read the new head and prepare a complete replacement or transfer the prepared value without committing it. + +Repeated commit attempts for the same finalized content must remain idempotent under the existing Handoff commit rules. A rollover reason alone must not create a new Revision when the content is otherwise identical to the current head. + +## Access and trust + +Rollover does not weaken access control. A recipient must be allowed to read the Handoff scope and the cited evidence. Missing evidence degrades only the claims that depend on it. + +All Rollover Handoff content delivered to an Agent is untrusted history. The current user request, developer and system instructions, repository instructions, live workspace, and current tool results take precedence. + +## Receiver verification and recovery + +The fresh session verifies the transfer before planning, reusing the existing acknowledgement semantics (`acknowledge_handoff`). The receiver re-resolves the exact prepared or committed Handoff, checks that cited evidence is readable, compares the claims with live state, capabilities, and authorization, and records the decision as a durable receiver acknowledgement. The receiver must not acknowledge an unresolved latest selector, and must not report acceptance while required checks are unknown. Acknowledgement does not execute the next action. + +If window creation, delivery, or validation fails, the host retains a usable recovery point: + +- The originating session remains usable until the receiver acknowledges; the host does not close or destroy it on the strength of an unacknowledged transfer. +- A committed Rollover Handoff is durable: delivery can be retried from the exact Revision in the scope's Handoff history. +- A Prepared-only Handoff has no durable identity. If its carrier is lost before acknowledgement, the host re-prepares from the still-usable originating session. +- A failed acknowledgement degrades only the claims that depend on unavailable evidence, per RFC 0048. The remaining validated content stays usable for retry. + +# Drawbacks + +This design gives Handoff another job. Reviewers and implementers must keep "work checkpoint" separate from "project milestone" so the Handoff history does not become noisy. + +Rollover preparation can produce a false sense of safety if content quality checks are weak. A bad Handoff may be worse than no Handoff because it looks deliberate. + +Hosts that do not expose context-budget or compaction signals can only support manual rollover. That is acceptable, but it limits automation. + +Adding Handoff to continuation-oriented context assembly may increase injected bytes unless hosts select tight budgets. + +# Rationale and alternatives + +## Why Handoff + +Handoff already describes continuable work state with evidence and omissions. Session rollover is a work-continuity event, not a knowledge-ingestion event. Reusing Handoff preserves the existing trust, evidence, and CAS semantics. + +## Alternative: store rollover notes in Memory + +This would pollute long-term Memory with temporary progress, stale next actions, and incomplete validation. Memory remains appropriate for durable decisions and constraints, but not for every session checkpoint. + +## Alternative: add a Notes family + +A Notes family could model session-local scratch state, but it would overlap with Handoff for this use case and introduce a second work-continuity object. This RFC leaves room for future Notes if a different use case emerges, but does not need one for rollover. + +## Alternative: store complete transcripts + +Complete transcripts are useful evidence in some hosts, but they are expensive, sensitive, and provider-specific. PowerContext can cite captured Source records without becoming a transcript archive. + +## Alternative: provider-specific `new_context` + +Some hosts may offer a model-callable tool that opens a fresh context window. PowerContext should not standardize that provider action. It should standardize the checkpoint that lets a fresh session continue safely. + +# Prior art + +RFC 0048 defines Handoff and Continue as the existing work-transfer boundary. RFC 0028 and RFC 1489 define bounded PreparedContext delivery with trust wrappers, budgets, and exact citations. RFC 0014 defines Memory as durable project knowledge rather than session state. + +OpenAI Codex introduced context-management work around token budgeting, a model-requestable fresh context window, history and notes tools, and lightweight history-note hints. The portable idea is not the provider-specific tool call, but the separation between current working context, explicit working state, and evidence retrieved on demand. + +Developer workflows already use manual handoff documents before starting fresh sessions. This RFC makes that pattern part of PowerContext's Handoff semantics, with evidence and scope boundaries. + +# Unresolved questions + +- Should later implementations persist rollover reason observations separately for diagnostics, without changing Handoff content identity? +- Should committed rollover Handoffs be visually distinguished in Handoff Report through separate observations, or should reasons remain preparation-only? +- What minimum deterministic validation should be mandatory before a Prepared Rollover Handoff can be committed? +- Should continuation-oriented PreparedContext include Handoff in RFC 1489 assembly, or should Continue remain a separate host step? +- Which host observations are safe to capture as Source by default, and which require explicit user or workspace policy? + +# Future possibilities + +Future work may add host-specific adapters that detect budget pressure and suggest rollover automatically. Those adapters can remain optional and fail open. + +PowerContext may later support a continuation profile for `prepare_context` that includes the latest Handoff, related Memory, Experience, Profile, and Topic Memory in a fixed order with one output budget. + +Handoff Report could show rollover density, stale workstreams, or long-running sessions that checkpointed without later continuation. + +Evaluation can compare long coding tasks continued through Rollover Handoff against tasks continued through transcript compaction or informal summaries, measuring task success and injected bytes separately. diff --git a/docs/zh/rfcs/1783-handoff-session-rollover.md b/docs/zh/rfcs/1783-handoff-session-rollover.md new file mode 100644 index 0000000000..3081b1701e --- /dev/null +++ b/docs/zh/rfcs/1783-handoff-session-rollover.md @@ -0,0 +1,342 @@ +- Proposal Name: `handoff_session_rollover` +- Start Date: 2026-09-29 +- Status: Draft +- RFC PR: [oceanbase/powercontext#1783](https://github.com/oceanbase/powercontext/pull/1783) +- Tracking Issue: 尚未分配 +- Related RFCs: [RFC 0001](0001_product_definition_and_vision.md)、[RFC 0014](0014_memory_layer_design.md)、[RFC 0019](0019_local_source_memory_runtime.md)、[RFC 0028](0028_context_pack.md)、[RFC 0048](0048_handoff_artifact.md)、[RFC 0082](0082_handoff_report.md)、[RFC 1489](1489_prepared_context_text_assembly.md) + +# Summary + +本 RFC 将 session rollover 定义为 Handoff 生命周期事件。当一个长 Agent 会话接近上下文上限,或开始积累过时假设、噪声和压缩损耗时,宿主或用户可以要求 PowerContext 准备一份 Rollover Handoff:一份用于在新会话中继续同一 scope 的、完整且带 evidence 的工作检查点。新会话随后从这份 Rollover Handoff 和有界 PreparedContext 继续,而不是继承旧 transcript 或有损会话摘要。 + +PowerContext 仍然是 Agent memory 与工作连续性系统。它不变成 Agent runtime、transcript store 或 provider 特定的上下文窗口管理器。Rollover Handoff 复用 RFC 0048 的 Handoff 内容契约和 commit 语义,在 prepare 阶段增加 advisory rollover reason,并保持长期 Memory 晋级必须显式发生。 + +# Motivation + +长 Agent 会话的失效方式不同于普通的上下文缺失。即使模型仍有足够 token,会话也可能积累过时假设、陈旧计划、重复摘要、噪声工具输出,以及对早期措辞的隐式依赖。反复 compaction 可以维持表面连续性,却会削弱继续工作真正需要的精确事实。 + +PowerContext 已经拥有解决这个问题的持久边界:Handoff。Handoff 捕获 objective、state、disposition、next action、evidence 和 omissions。这正是新会话在不继承整个旧会话的前提下继续工作所需的最小状态。 + +如果没有 rollover 概念,宿主通常会落入两种较弱策略: + +- 继续延长当前会话,直到模型或宿主不得不压缩它; +- 另开新会话,并依赖 Memory、旧 prompt 或非正式摘要恢复状态。 + +这两种方式都会模糊当前工作状态、长期项目知识与原始 evidence 的区别。本 RFC 定义一条更窄的路径:把 session rollover 变成显式 Handoff checkpoint。该 checkpoint 可以被检查、提交,并像其他 Handoff 一样被后续 Continue 使用;Source 和 Memory 继续保持既有边界。 + +## 评审重点 + +评审者应重点检查以下决策: + +- session rollover 是否应属于 Handoff 语义,而不是 Memory、PreparedContext 或新的 Notes family; +- 该提案是否保留 RFC 0048 中临时 Prepared Handoff 与已提交 Handoff Revision 的区别; +- rollover 质量要求是否足以避免“继续之前的工作”这类模糊摘要; +- 是否可以允许自动准备 draft,同时把自动 commit 留给独立 policy; +- 与 PreparedContext 的关系是否足够窄,避免让 Context Pack 替代 Handoff; +- Source capture 是否足以作为可选 transcript evidence 的先例,而不把 PowerContext 变成完整 transcript store。 + +# Guide-level explanation + +## Rollover Handoff + +Rollover Handoff 是一种 Handoff,其准备原因是当前 Agent 会话不应再作为工作状态的主要载体。 + +典型触发包括: + +- 用户要求在新会话中继续; +- 宿主观察到会话很长或接近上下文预算; +- Agent 报告当前上下文噪声过多、过时或压缩过重; +- 宿主已经发生或即将发生 compaction,需要给下一个会话留下干净的继续点; +- 人类希望在把工作交给另一个 Agent 前保存检查点。 + +这些触发会被记录为调用方或宿主提供的 rollover reason。PowerContext 自身不检测上下文压力;检测属于下文定义的宿主触发策略。 + +Rollover 不表示工作已经完成。它表示下一个参与者应从可验证 checkpoint 继续,而不是从旧对话继续。 + +Handoff 内容仍然回答 RFC 0048 的问题: + +| Field | Rollover 要求 | +| --- | --- | +| Objective | 要继续的 workstream objective | +| State | 新会话必须理解的当前工作状态 | +| Disposition | 通常为 `continuable`,也可以是 `blocked` 或 `complete` | +| Next action | 新会话首先应考虑的动作 | +| Evidence | 支撑 state 和 next action 的精确引用 | +| Omissions | 缺失检查、未验证假设、被排除材料,或未捕获的宿主上下文 | + +Rollover Handoff 不是 transcript summary。它应排除不会改变下一会话行动方式的细节。 + +## 示例:把编码任务移到新会话 + +会话 A 已经为一个功能实现了数小时。它读过许多文件,尝试过两个设计,修复了测试,现在模型开始重复旧计划。用户说: + +```text +准备一份 rollover handoff,我要在新会话继续。 +``` + +PowerContext 准备一份 Handoff draft: + +```text +Objective: + Add session rollover support to Handoff without changing Memory semantics. + +State: + - The RFC draft exists in English and Chinese. [evidence: exact Artifact revision] + - The design deliberately treats rollover as a Handoff lifecycle event. [evidence: user prompt Source] + - No runtime implementation has been started. [evidence: Git diff Source] + +Disposition: + continuable + +Next action: + Review the RFC for consistency with RFC 0048 and RFC 0028, then open the RFC PR. + +Omissions: + - No full repository test suite has been run because this is a documentation-only change. + - Host-specific token-budget signals are not specified beyond an advisory input. +``` + +用户可以检查并修正草稿。如果只是要复制到另一个会话,Prepared Handoff 可以直接 transfer。如果它要成为最新 workstream checkpoint,调用方必须显式 commit,把它提交为 Handoff Revision。 + +## 从 rollover 继续 + +prepare Handoff 并不会打开新会话。打开、重置或关闭模型窗口是宿主动作;PowerContext 只负责准备、校验和保存 checkpoint。宿主拥有以下序列: + +```text +prepare -> open/reset -> deliver -> Continue +``` + +1. `prepare`:originating session 准备 Rollover Handoff,调用方检查内容。如果 checkpoint 需要在 delivery 失败后仍然可用,调用方将其 commit 为 Handoff Revision。 +2. `open/reset`:宿主打开新会话,并将其绑定到 Handoff 的 originating scope。 +3. `deliver`:宿主把精确的 Handoff 交给新会话——Prepared Handoff 值,或精确的 committed Revision 引用。这是 RFC 0048 的显式 transfer,不是 transcript 继承。 +4. `Continue`:新会话先校验这次 transfer(见「接收方校验与恢复」),再在当前请求下从 Handoff 继续。 + +scope 绑定很重要。Continue 只在 Handoff 的 originating scope 内解析 latest committed Handoff。如果新会话绑定到其他 scope,宿主必须交付精确的 Prepared 值或精确 Revision,且 Continue 不得把 latest 查找当作当前工作。 + +新会话默认不应收到完整旧 transcript。delivery 完成后,新会话收到: + +1. 作为 untrusted historical work state 的 Handoff 内容; +2. 精确 evidence 引用或 evidence-check 结果; +3. 在 context assembly 请求时选择的相关 Memory、Experience、Profile 或 Topic Memory; +4. 说明哪些内容没有带过来的 omissions。 + +新会话仍然必须把当前指令、当前请求、仓库规则、实时 workspace 状态和工具结果置于 Handoff 之上。 + +## 与 Memory 的关系 + +Rollover Handoff 不会自动创建或更新 Memory。 + +Rollover 期间的信息进入不同位置: + +| 信息 | 目标位置 | +| --- | --- | +| 当前 objective、进度、next action、blocker | Handoff | +| 原始 prompt、工具输出、文件快照、测试输出 | Source,如果宿主捕获了它们 | +| 长期项目决策或约束 | Memory,只能通过显式 Memory 流程 | +| 可复用的排障经验 | Experience,只能通过既有 review lifecycle | +| 总结后的领域知识 | Topic Memory,通过自己的处理生命周期 | + +这可以防止临时工作 checkpoint 污染长期项目 Memory。Rollover Handoff 可以引用 Memory,后续 review 也可以把 Handoff 中的持久决策晋级为 Memory,但晋级必须显式且可审查。 + +# Reference-level explanation + +## 范围 + +本 RFC 定义: + +- Rollover Handoff 作为现有 Handoff 生命周期的命名用法; +- rollover reason、宿主触发策略和宿主信号; +- rollover 内容的额外质量要求; +- 围绕 Rollover Handoff 打开或重置会话窗口的宿主序列; +- rollover 失败时的接收方 acknowledgement 与恢复; +- 新会话如何接收 continuation context; +- Handoff、Source、Memory、Experience、Topic Memory 和 PreparedContext 的边界。 + +本 RFC 不定义: + +- provider 特定的 API,用于打开新模型会话或上下文窗口; +- PowerContext 中的完整 transcript 存储; +- 每次 rollover 自动创建 Memory; +- 新的持久 Notes family; +- 自动执行 Handoff next action; +- 实现优先级或上线策略。 + +## 产品模型 + +Rollover Handoff 不是新的 Artifact family。它是由宿主或用户在 prepare 时提供 advisory rollover reason 的 Handoff。共享内容契约仍然是 RFC 0048 的 Handoff 契约。 + +Prepared 与 committed 形式保持现有生命周期: + +```text +Draft -> Prepared Rollover Handoff -> Transfer + -> Commit -> Handoff Revision +``` + +rollover reason 属于 prepare 上下文。它们指导 draft generation 和 inspect,但初始实现不把它们纳入 Handoff content identity。已提交的 Rollover Handoff 仍是该 scope 线性 Handoff history 中的一个 Handoff Revision。读取 latest Handoff 不需要特殊处理。 + +## Rollover reasons + +prepare 请求可以包含一个或多个 advisory reason: + +| Reason | 含义 | +| --- | --- | +| `user_requested` | 用户明确要求新会话或 checkpoint | +| `host_context_budget` | 宿主估计会话接近上下文限制 | +| `host_compaction` | 宿主报告 compaction 已发生或即将发生 | +| `context_quality` | Agent 或宿主报告上下文陈旧、噪声大或过度压缩 | +| `delegation` | 工作将交给另一个 Agent 或人类 | +| `manual_checkpoint` | 调用方需要 checkpoint,但不声称会话不健康 | + +reason 是 advisory。它们帮助 generation 聚焦于 fresh-session checkpoint,但不授权 commit 或执行,也不会创建新的持久 Artifact 类型。 + +## 宿主触发策略 + +rollover reason 记录的是调用方做出的决定;它不负责检测上下文压力。检测属于宿主职责。支持 Advisory 或 Automatic draft 档位的宿主需要显式定义自己的触发策略: + +- 使用哪些观察,例如 token 估算、provider usage 报告、compaction 事件或会话时长; +- 哪些阈值或事件把观察变成建议或 draft; +- prepare 在什么时机运行,保证 draft 在安全边界生成,而不是在动作中途; +- 策略能做什么、不能做什么:建议和 draft 自身不得 commit,也不得关闭或重置窗口。 + +PowerContext 提供 checkpoint 契约,不提供检测逻辑,并把宿主观察视为不可信输入(见「宿主集成」)。 + +当 usage 或 compaction 信号不可用时,触发策略降级到 Manual 档位:只有 `user_requested`、`delegation` 或 `manual_checkpoint` 触发可用。宿主不得伪造或推断自己没有的信号;信号缺失也绝不阻塞用户的显式请求。无法检测预算压力的宿主仍可通过用户发起的 prepare 支持完整 rollover。 + +## 质量要求 + +Rollover Handoff 必须足够自包含,使新会话能够安全开始。除 RFC 0048 validation 外,finalization 应拒绝或标记缺少以下内容的结果: + +- 非空 objective; +- 至少一个当前 state statement; +- disposition; +- next action,或解释为什么没有 next action 的 disposition; +- state statement 与 next action 的 evidence,或说明 evidence 不可用的 omission; +- 对已知未验证检查、缺失宿主状态或相关被排除材料的 explicit omissions。 + +以下内容无效,或应要求修正: + +- “continue the previous work”; +- “see the conversation above”; +- 没有 evidence 或 omission 的 next action; +- 没有测试结果引用或当前验证,却声称测试通过; +- generation 改写 objective,而不是使用调用方提供的 objective。 + +这些检查可以先作为 Handoff content model 的确定性 validation。未来生成流水线可以用模型起草内容,但模型输出本身不能满足 evidence 要求。 + +## 宿主集成 + +宿主集成可以分三档支持 rollover: + +| Level | 行为 | +| --- | --- | +| Manual | 用户显式要求 prepare 或 commit Rollover Handoff | +| Advisory | 宿主检测到长会话或预算压力,并建议准备 Rollover Handoff | +| Automatic draft | 宿主在安全边界自动准备 draft,但 commit 仍遵循配置 policy | + +Automatic commit 不在本 RFC 范围内。它需要独立 policy 决策,因为 commit 会推进 scope 的 Handoff history。 + +宿主可以提供 context-budget observation,例如模型名、估算剩余 token、compaction 次数或会话时长。除非它们有 Source evidence 支撑,PowerContext 将其视为不可信宿主观察。它们可以影响 draft 和展示,但不能证明 provider 确实会打开或保留某个会话。 + +## Continue 与 PreparedContext + +Continue 仍然是基于 Handoff 行动的主要操作。PreparedContext 仍然是一次 Agent turn 的有界 context package。两者可以协作,但不合并契约。 + +continuation-oriented `prepare_context` profile 可以选择: + +1. 当前 scope 的 latest Handoff,如果调用方请求 Handoff-backed continuation; +2. 该 Handoff 的精确 evidence 引用或 evidence check 摘要; +3. 现有 assembly 预算内的相关 Memory 和 Experience; +4. 显式请求时的 Profile 或 Topic Memory section。 + +本 RFC 不要求 RFC 1489 立即把 `handoff` 加为 assembly family。它只定义语义边界,使后续实现 PR 或 RFC 可以加入该 family,而不改变 Handoff 含义。 + +PreparedContext 必须继续把历史材料标记为 untrusted。它不能把历史 objective 变成当前目标,不能执行 next action,也不能隐藏 evidence omissions。 + +## Source 与 transcript 边界 + +Rollover 可以引用已捕获的 Source records,包括宿主 prompt、被选择的工具输出、测试结果或人类编写的 note。这不要求 PowerContext 摄取或保留完整 session transcript。 + +宿主决定自己被允许捕获哪些内容。如果相关 transcript 材料没有被捕获,Handoff 记录 omission,而不是假装材料可用。只有当 transcript 位置或 digest 可通过授权 Source adapter 读取时,宿主才能把它作为 evidence。 + +generator 只能看到调用方提供的 evidence。只存在于当前 agent 上下文中的工作状态——计划、部分推理、尚未写入任何文件、工具输出或 Source 的决定——在被显式捕获之前对 prepare 不可见。宿主和 Agent 应使用 `handoff_current_work`(RFC 1223)显式捕获这类状态:Agent 以 `basis="declared"` 声明自己检查过的事实,并将其挂在捕获的 boundary Source 上,该操作一步完成 Prepared Handoff 的确定性 finalize。对不保留 transcript 的宿主,这个声明的 boundary 是默认捕获路径。既没有被捕获为 Source、也没有通过 boundary 声明的状态,仍然记录为 omission。 + +## 并发与幂等 + +已提交的 Rollover Handoff 使用 RFC 0048 相同的 CAS 行为。如果 draft 准备后 scope 的 Handoff head 已经推进,commit 报告 conflict。调用方必须读取新 head 并准备一份完整替代内容,或只 transfer 这份 prepared value 而不提交。 + +同一 finalized content 的重复 commit 必须遵循现有 Handoff commit 规则保持幂等。只有 rollover reason 变化,而内容与当前 head 相同,不得创建新 Revision。 + +## Access 与信任边界 + +Rollover 不削弱访问控制。接收方必须能读取 Handoff scope 和被引用的 evidence。缺失 evidence 只降级依赖它的 statement。 + +交给 Agent 的所有 Rollover Handoff 内容都是 untrusted history。当前用户请求、developer 和 system instructions、仓库指令、实时 workspace、当前工具结果拥有更高优先级。 + +## 接收方校验与恢复 + +新会话在开始规划前必须校验这次 transfer,复用现有的 acknowledgement 语义(`acknowledge_handoff`)。接收方重新解析精确的 prepared 或 committed Handoff,检查被引用 evidence 是否可读,将内容与实时状态、capability 和授权对比,并把决定记录为持久的 receiver acknowledgement。接收方不得确认未解析的 latest selector,也不得在必需检查未知时报告 accepted。acknowledgement 不执行 next action。 + +如果窗口创建、delivery 或校验失败,宿主必须保留可用的恢复点: + +- 在接收方 acknowledge 之前,originating session 保持可用;宿主不得凭一次未确认的 transfer 关闭或销毁它。 +- 已 commit 的 Rollover Handoff 是持久的:可以从 scope 的 Handoff history 中的精确 Revision 重试 delivery。 +- 仅 Prepared 的 Handoff 没有持久身份。如果它的载体在确认前丢失,宿主从仍然可用的 originating session 重新 prepare。 +- acknowledgement 失败只降级依赖不可用 evidence 的 statement,遵循 RFC 0048;其余通过校验的内容保持可用,用于重试。 + +# Drawbacks + +该设计给 Handoff 增加了一种职责。评审者和实现者必须保持“工作 checkpoint”和“项目里程碑”的区别,避免 Handoff history 变得嘈杂。 + +如果内容质量检查太弱,rollover preparation 可能制造虚假的安全感。糟糕的 Handoff 可能比没有 Handoff 更危险,因为它看起来像经过审查的状态。 + +不暴露 context-budget 或 compaction 信号的宿主只能支持 manual rollover。这可以接受,但会限制自动化程度。 + +把 Handoff 加入 continuation-oriented context assembly 可能增加注入字节数,除非宿主选择紧预算。 + +# Rationale and alternatives + +## 为什么选择 Handoff + +Handoff 已经用 evidence 和 omissions 描述可继续工作状态。Session rollover 是工作连续性事件,不是知识摄取事件。复用 Handoff 可以保留现有 trust、evidence 和 CAS 语义。 + +## 替代方案:把 rollover notes 存进 Memory + +这会用临时进度、过时 next action 和不完整验证污染长期 Memory。Memory 适合持久决策与约束,但不适合每个 session checkpoint。 + +## 替代方案:新增 Notes family + +Notes family 可以表达 session-local scratch state,但它会在本用例中与 Handoff 重叠,并引入第二个工作连续性对象。本 RFC 为未来不同用例保留 Notes 空间,但 rollover 不需要它。 + +## 替代方案:存储完整 transcript + +完整 transcript 在某些宿主中是有用 evidence,但成本高、敏感且 provider-specific。PowerContext 可以引用已捕获 Source records,而不成为 transcript archive。 + +## 替代方案:provider-specific `new_context` + +某些宿主可能提供模型可调用的工具来打开 fresh context window。PowerContext 不应标准化该 provider action。它应标准化能让新会话安全继续的 checkpoint。 + +# Prior art + +RFC 0048 将 Handoff 和 Continue 定义为现有工作交接边界。RFC 0028 与 RFC 1489 定义了带 trust wrapper、预算和精确引用的有界 PreparedContext 交付。RFC 0014 将 Memory 定义为持久项目知识,而不是 session state。 + +OpenAI Codex 围绕 token budgeting、模型可请求 fresh context window、history 与 notes tools,以及轻量 history-note hints 引入了上下文管理工作。可移植的思想不是 provider-specific tool call,而是当前工作上下文、显式工作状态和按需检索 evidence 之间的分离。 + +开发工作流中已经存在在新会话前手写 handoff 文档的实践。本 RFC 将该模式纳入 PowerContext 的 Handoff 语义,并加上 evidence 与 scope 边界。 + +# Unresolved questions + +- 后续实现是否应把 rollover reason 作为独立 observation 持久化用于诊断,同时不改变 Handoff content identity? +- 已提交的 rollover Handoff 是否应通过独立 observation 在 Handoff Report 中视觉区分,还是 reason 保持为 preparation-only? +- Prepared Rollover Handoff commit 前必须通过哪些最小确定性 validation? +- continuation-oriented PreparedContext 应在 RFC 1489 assembly 中包含 Handoff,还是 Continue 应保持独立宿主步骤? +- 哪些宿主观察可以默认捕获为 Source,哪些需要显式用户或 workspace policy? + +# Future possibilities + +后续工作可以增加宿主特定 adapter,自动检测预算压力并建议 rollover。这些 adapter 可以保持可选且 fail open。 + +PowerContext 以后可以支持一个 continuation profile for `prepare_context`,用一个输出预算按固定顺序包含 latest Handoff、相关 Memory、Experience、Profile 和 Topic Memory。 + +Handoff Report 可以展示 rollover 密度、过时 workstream,或已经 checkpoint 但没有后续 continuation 的长会话。 + +评测可以比较通过 Rollover Handoff 继续的长编码任务,以及通过 transcript compaction 或非正式摘要继续的任务,分别衡量任务成功率与注入字节数。 diff --git a/evaluation/skills/skill-up/evals/fixtures/repos/powercontext-tool-contract/CLAUDE.md b/evaluation/skills/skill-up/evals/fixtures/repos/powercontext-tool-contract/CLAUDE.md index 2f518c1ca6..f6574f1b91 100644 --- a/evaluation/skills/skill-up/evals/fixtures/repos/powercontext-tool-contract/CLAUDE.md +++ b/evaluation/skills/skill-up/evals/fixtures/repos/powercontext-tool-contract/CLAUDE.md @@ -18,7 +18,7 @@ not replace those schemas or enforce their constraints. It contains no scenario ## mcp__powercontext__activate_handoff - Required: `boundary_source: SourceReference`, `objective: string`, `scope_id: string`. -- Optional: `evidence: array = []`, `max_bytes: integer = 8000`. +- Optional: `evidence: array = []`, `max_bytes: integer = 8000`, `rollover_reasons: array<"user_requested" | "host_context_budget" | "host_compaction" | "context_quality" | "delegation" | "manual_checkpoint"> = []`. ## mcp__powercontext__approve_artifact_candidate diff --git a/integrations/dify/generate_contract.py b/integrations/dify/generate_contract.py index 4b14189a78..f61058261c 100644 --- a/integrations/dify/generate_contract.py +++ b/integrations/dify/generate_contract.py @@ -74,6 +74,10 @@ "family": ("候选类型", "Optional experience/skill filter; omission keeps the Server's unfiltered listing."), "cursor": ("分页游标", "Complete next_cursor from the previous candidate page; do not invent or rewrite it."), "candidate_id": ("候选 ID", "Exact candidate ID returned by generation or candidate listing."), + "rollover_reasons": ( + "轮换原因", + "Optional advisory rollover reasons for this preparation; they do not authorize commit or execution.", + ), } OUTPUT: dict[str, Any] = { "type": "object", diff --git a/integrations/dify/plugin/powercontext_dify/contract.json b/integrations/dify/plugin/powercontext_dify/contract.json index 041e16d87a..093ea09a80 100644 --- a/integrations/dify/plugin/powercontext_dify/contract.json +++ b/integrations/dify/plugin/powercontext_dify/contract.json @@ -562,9 +562,11 @@ ], "activate_handoff": [ "boundary_source", + "rollover_reasons", "evidence" ], "prepare_handoff": [ + "rollover_reasons", "evidence" ], "finalize_handoff": [ @@ -846,6 +848,15 @@ "maxLength": 8192, "pattern": ".*\\S.*" }, + "rollover_reasons": { + "type": "array", + "maxItems": 6, + "uniqueItems": true, + "items": { + "$ref": "#/components/schemas/HandoffRolloverReason" + }, + "default": [] + }, "evidence": { "type": "array", "maxItems": 32, @@ -2500,6 +2511,17 @@ "resolved" ] }, + "HandoffRolloverReason": { + "type": "string", + "enum": [ + "user_requested", + "host_context_budget", + "host_compaction", + "context_quality", + "delegation", + "manual_checkpoint" + ] + }, "HandoffSchema": { "type": "string", "enum": [ @@ -2881,6 +2903,15 @@ "maxLength": 8192, "pattern": ".*\\S.*" }, + "rollover_reasons": { + "type": "array", + "maxItems": 6, + "uniqueItems": true, + "items": { + "$ref": "#/components/schemas/HandoffRolloverReason" + }, + "default": [] + }, "evidence": { "type": "array", "minItems": 1, diff --git a/integrations/dify/plugin/tools/pc_handoff_activate.yaml b/integrations/dify/plugin/tools/pc_handoff_activate.yaml index 546bad0fd1..79917501ed 100644 --- a/integrations/dify/plugin/tools/pc_handoff_activate.yaml +++ b/integrations/dify/plugin/tools/pc_handoff_activate.yaml @@ -55,6 +55,25 @@ parameters: en_US: The work objective to continue with the selected evidence. zh_Hans: 交接目标;按工具契约保留完整值。 llm_description: The work objective to continue with the selected evidence. +- name: rollover_reasons + type: string + required: false + form: llm + label: + en_US: rollover_reasons + zh_Hans: 轮换原因 + human_description: + en_US: Optional advisory rollover reasons for this preparation; they do not authorize + commit or execution. Supply one JSON-encoded text value. Strings include JSON + quotes; null is the text null. Omit unused optional inputs and preserve every + field. + zh_Hans: 轮换原因;填写一次 JSON 序列化后的文本;字符串须带 JSON 双引号,空值填 null;可选参数不用时省略,保留所有字段。 + llm_description: 'Optional advisory rollover reasons for this preparation; they + do not authorize commit or execution. Pass one JSON-encoded value as text, never + a native object/array/null. Encode strings with JSON quotes; use the text null + for null; omit optional inputs when unused. Decode exactly once and preserve every + field. Decoded JSON Schema: {"type":"array","maxItems":6,"uniqueItems":true,"items":{"type":"string","enum":["user_requested","host_context_budget","host_compaction","context_quality","delegation","manual_checkpoint"]},"default":[]}' + default: '[]' - name: evidence type: string required: false diff --git a/integrations/dify/plugin/tools/pc_handoff_prepare.yaml b/integrations/dify/plugin/tools/pc_handoff_prepare.yaml index fa65535ed9..516c7da64b 100644 --- a/integrations/dify/plugin/tools/pc_handoff_prepare.yaml +++ b/integrations/dify/plugin/tools/pc_handoff_prepare.yaml @@ -37,6 +37,25 @@ parameters: en_US: The work objective to continue with the selected evidence. zh_Hans: 交接目标;按工具契约保留完整值。 llm_description: The work objective to continue with the selected evidence. +- name: rollover_reasons + type: string + required: false + form: llm + label: + en_US: rollover_reasons + zh_Hans: 轮换原因 + human_description: + en_US: Optional advisory rollover reasons for this preparation; they do not authorize + commit or execution. Supply one JSON-encoded text value. Strings include JSON + quotes; null is the text null. Omit unused optional inputs and preserve every + field. + zh_Hans: 轮换原因;填写一次 JSON 序列化后的文本;字符串须带 JSON 双引号,空值填 null;可选参数不用时省略,保留所有字段。 + llm_description: 'Optional advisory rollover reasons for this preparation; they + do not authorize commit or execution. Pass one JSON-encoded value as text, never + a native object/array/null. Encode strings with JSON quotes; use the text null + for null; omit optional inputs when unused. Decode exactly once and preserve every + field. Decoded JSON Schema: {"type":"array","maxItems":6,"uniqueItems":true,"items":{"type":"string","enum":["user_requested","host_context_budget","host_compaction","context_quality","delegation","manual_checkpoint"]},"default":[]}' + default: '[]' - name: evidence type: string required: true diff --git a/integrations/dify/tests/test_contract_generation.py b/integrations/dify/tests/test_contract_generation.py index 160c58aa00..ee7b579f7d 100644 --- a/integrations/dify/tests/test_contract_generation.py +++ b/integrations/dify/tests/test_contract_generation.py @@ -77,6 +77,16 @@ def test_unrelated_openapi_changes_leave_all_dify_outputs_unchanged(repository: assert generated_files(repository) == before +def test_rollover_reasons_has_a_chinese_parameter_label(repository: Path) -> None: + declaration = yaml.safe_load( + (repository / "integrations/dify/plugin/tools/pc_handoff_prepare.yaml").read_text(encoding="utf-8") + ) + + parameter = next(item for item in declaration["parameters"] if item["name"] == "rollover_reasons") + + assert parameter["label"]["zh_Hans"] == "轮换原因" + + def test_consumed_schema_changes_fail_stale_checks_and_update_outputs(repository: Path) -> None: before = generated_files(repository) contract = repository / "openapi/powercontext.yaml" diff --git a/integrations/dsh/plugins/powercontext/lib/index.js b/integrations/dsh/plugins/powercontext/lib/index.js index 5c527a7a21..4808b1f571 100644 --- a/integrations/dsh/plugins/powercontext/lib/index.js +++ b/integrations/dsh/plugins/powercontext/lib/index.js @@ -4041,6 +4041,20 @@ const HANDOFF_EVIDENCE = { }, description: "For captured evidence use {kind: \"source\", source_ref: data.source}, copying the exact result. No raw facts." }; +const HANDOFF_ROLLOVER_REASONS = { + type: "array", + items: { + type: "string", + enum: [ + "user_requested", + "host_context_budget", + "host_compaction", + "context_quality", + "delegation", + "manual_checkpoint" + ] + } +}; function handoffTools(runtime, defineTool) { return [ pcTool(defineTool, { @@ -4059,12 +4073,14 @@ function handoffTools(runtime, defineTool) { evidence: { type: "array", items: HANDOFF_EVIDENCE - } + }, + rollover_reasons: HANDOFF_ROLLOVER_REASONS }, execute: (args, exec) => run(runtime, exec, "activate_handoff", { boundary_source: args.boundary_source, objective: args.objective, - evidence: args.evidence ?? [] + evidence: args.evidence ?? [], + ...args.rollover_reasons === void 0 ? {} : { rollover_reasons: args.rollover_reasons } }) }), pcTool(defineTool, { @@ -4080,11 +4096,13 @@ function handoffTools(runtime, defineTool) { type: "array", required: true, items: HANDOFF_EVIDENCE - } + }, + rollover_reasons: HANDOFF_ROLLOVER_REASONS }, execute: (args, exec) => run(runtime, exec, "prepare_handoff", { objective: args.objective, - evidence: args.evidence + evidence: args.evidence, + ...args.rollover_reasons === void 0 ? {} : { rollover_reasons: args.rollover_reasons } }) }), pcTool(defineTool, { diff --git a/integrations/dsh/plugins/powercontext/src/tools.ts b/integrations/dsh/plugins/powercontext/src/tools.ts index d6e9e16739..aa5176b3c6 100644 --- a/integrations/dsh/plugins/powercontext/src/tools.ts +++ b/integrations/dsh/plugins/powercontext/src/tools.ts @@ -251,6 +251,13 @@ const HANDOFF_EVIDENCE = { }, description: 'For captured evidence use {kind: "source", source_ref: data.source}, copying the exact result. No raw facts.', } +const HANDOFF_ROLLOVER_REASONS = { + type: 'array', + items: { + type: 'string', + enum: ['user_requested', 'host_context_budget', 'host_compaction', 'context_quality', 'delegation', 'manual_checkpoint'], + }, +} function handoffTools(runtime: PluginRuntime, defineTool: DefineTool): unknown[] { return [ @@ -268,9 +275,11 @@ function handoffTools(runtime: PluginRuntime, defineTool: DefineTool): unknown[] boundary_source: { ...SOURCE_REFERENCE, required: true }, objective: { type: 'string', required: true }, evidence: { type: 'array', items: HANDOFF_EVIDENCE }, + rollover_reasons: HANDOFF_ROLLOVER_REASONS, }, execute: (args, exec) => run(runtime, exec, 'activate_handoff', { boundary_source: args.boundary_source, objective: args.objective, evidence: args.evidence ?? [], + ...(args.rollover_reasons === undefined ? {} : { rollover_reasons: args.rollover_reasons }), }), }), pcTool(defineTool, { @@ -285,8 +294,13 @@ function handoffTools(runtime: PluginRuntime, defineTool: DefineTool): unknown[] parameters: { objective: { type: 'string', required: true }, evidence: { type: 'array', required: true, items: HANDOFF_EVIDENCE }, + rollover_reasons: HANDOFF_ROLLOVER_REASONS, }, - execute: (args, exec) => run(runtime, exec, 'prepare_handoff', { objective: args.objective, evidence: args.evidence }), + execute: (args, exec) => run(runtime, exec, 'prepare_handoff', { + objective: args.objective, + evidence: args.evidence, + ...(args.rollover_reasons === undefined ? {} : { rollover_reasons: args.rollover_reasons }), + }), }), pcTool(defineTool, { name: 'pc_handoff_finalize', diff --git a/integrations/dsh/plugins/powercontext/tests/tools.spec.ts b/integrations/dsh/plugins/powercontext/tests/tools.spec.ts index 5007788ae4..5d931dc6f1 100644 --- a/integrations/dsh/plugins/powercontext/tests/tools.spec.ts +++ b/integrations/dsh/plugins/powercontext/tests/tools.spec.ts @@ -15,6 +15,7 @@ */ import { describe, expect, it, vi } from 'vitest' +import { PowerContextClient } from '../src/client.ts' import { registerTools } from '../src/tools.ts' import type { PluginRuntime } from '../src/invoke.ts' @@ -127,4 +128,72 @@ describe('agent tool surface', () => { code: 'unscoped', }) }) + + it('forwards advisory rollover reasons through both Handoff draft operations', async () => { + const registered: Array> = [] + const requests: Array<{ url: string; body: Record }> = [] + const runtime = { + client: new PowerContextClient({ + baseUrl: 'http://127.0.0.1:8000', + allowInsecureHttp: true, + requestTimeoutMs: 1000, + fetch: async (url, init) => { + requests.push({ url, body: JSON.parse(String(init.body)) }) + return Response.json({ ok: true }) + }, + }), + config: { maxBytes: 8000 }, + resolveScope: vi.fn(async () => 'project:demo'), + log: vi.fn(), + } as unknown as PluginRuntime + registerTools( + { tools: { register: (tool) => registered.push(tool as Record) }, on: () => undefined }, + runtime, + (definition) => definition, + ) + const activate = registered.find((tool) => tool.name === 'pc_handoff_activate') as { + parameters: Record + execute: (args: Record, exec: { signal: AbortSignal }) => Promise + } + const prepare = registered.find((tool) => tool.name === 'pc_handoff_prepare') as typeof activate + const boundarySource = { name: 'content', source_id: 'turn-1' } + const reasons = ['host_context_budget', 'context_quality'] + + expect(activate.parameters.rollover_reasons).toMatchObject({ + type: 'array', + items: { type: 'string', enum: ['user_requested', 'host_context_budget', 'host_compaction', 'context_quality', 'delegation', 'manual_checkpoint'] }, + }) + expect(prepare.parameters.rollover_reasons).toMatchObject({ + type: 'array', + items: { type: 'string', enum: ['user_requested', 'host_context_budget', 'host_compaction', 'context_quality', 'delegation', 'manual_checkpoint'] }, + }) + expect(activate.parameters.rollover_reasons).not.toHaveProperty('maxItems') + expect(prepare.parameters.rollover_reasons).not.toHaveProperty('maxItems') + await activate.execute({ + boundary_source: boundarySource, + objective: 'Continue in a fresh session.', + rollover_reasons: reasons, + }, { signal: new AbortController().signal }) + await prepare.execute({ + objective: 'Continue in a fresh session.', + evidence: [{ kind: 'source', source_ref: boundarySource }], + rollover_reasons: reasons, + }, { signal: new AbortController().signal }) + + expect(requests).toEqual([ + { url: 'http://127.0.0.1:8000/v1/handoff/activate', body: { + boundary_source: boundarySource, + objective: 'Continue in a fresh session.', + evidence: [], + rollover_reasons: reasons, + scope_id: 'project:demo', + } }, + { url: 'http://127.0.0.1:8000/v1/handoff/prepare', body: { + objective: 'Continue in a fresh session.', + evidence: [{ kind: 'source', source_ref: boundarySource }], + rollover_reasons: reasons, + scope_id: 'project:demo', + } }, + ]) + }) }) diff --git a/integrations/hermes/plugins/powercontext/commands.py b/integrations/hermes/plugins/powercontext/commands.py index dc15155721..f1304dc672 100644 --- a/integrations/hermes/plugins/powercontext/commands.py +++ b/integrations/hermes/plugins/powercontext/commands.py @@ -614,6 +614,21 @@ def get_tool_schemas() -> list[dict[str, Any]]: json_object = {"type": "object", "additionalProperties": True} json_array = {"type": "array", "items": json_object} + handoff_rollover_reasons = { + "type": "array", + "items": { + "type": "string", + "enum": [ + "user_requested", + "host_context_budget", + "host_compaction", + "context_quality", + "delegation", + "manual_checkpoint", + ], + }, + "maxItems": 6, + } schemas.extend([ _operation_schema( "powercontext_prepare_context", @@ -760,6 +775,7 @@ def get_tool_schemas() -> list[dict[str, Any]]: "boundary_source": json_object, "objective": {"type": "string"}, "evidence": json_array, + "rollover_reasons": handoff_rollover_reasons, "max_bytes": {"type": "integer", "minimum": 512, "maximum": 32768}, }, ("boundary_source", "objective"), @@ -776,6 +792,7 @@ def get_tool_schemas() -> list[dict[str, Any]]: { "objective": {"type": "string"}, "evidence": json_array, + "rollover_reasons": handoff_rollover_reasons, "max_bytes": {"type": "integer", "minimum": 512, "maximum": 32768}, }, ("objective", "evidence"), diff --git a/integrations/opencode/plugins/powercontext/lib/index.js b/integrations/opencode/plugins/powercontext/lib/index.js index 3c3c10a282..95255c3456 100644 --- a/integrations/opencode/plugins/powercontext/lib/index.js +++ b/integrations/opencode/plugins/powercontext/lib/index.js @@ -212,6 +212,17 @@ const OPERATIONS = { successStatuses: [200], emptyStatuses: [] }, + get_server_info: { + method: "GET", + path: "/v1/server-info", + location: null, + scopeMode: "none", + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, get_capabilities: { method: "GET", path: "/v1/capabilities", @@ -2241,6 +2252,14 @@ const handoffEvidence = z.union([z.object({ kind: z.literal("artifact"), artifact_ref: jsonObject() })]); +const handoffRolloverReasons = z.array(z.enum([ + "user_requested", + "host_context_budget", + "host_compaction", + "context_quality", + "delegation", + "manual_checkpoint" +])).max(6); const handoffStatement = z.object({ text: z.string().min(1), citations: z.array(handoffEvidence).min(1) @@ -2420,25 +2439,29 @@ function createTools(runtime) { args: { boundary_source: sourceReference, objective: z.string(), - evidence: z.array(handoffEvidence).optional() + evidence: z.array(handoffEvidence).optional(), + rollover_reasons: handoffRolloverReasons.optional() }, operationId: "activate_handoff", payload: (args) => ({ boundary_source: args.boundary_source, objective: args.objective, - evidence: args.evidence ?? [] + evidence: args.evidence ?? [], + ...args.rollover_reasons === void 0 ? {} : { rollover_reasons: args.rollover_reasons } }) }), pc_handoff_prepare: operationTool(runtime, { description: "This returns an unfinished Draft in data, NOT a transferable Handoff. To complete a requested transfer, you must next call pc_handoff_finalize with draft=data, then return finalize.data. This does not require a durable commit. Only call after an existing exact Source or Artifact reference was returned by a tool. If only current facts are available, call pc_capture_source first and wait for its result. Use evidence [{kind: \"source\", source_ref: data.source}] with the full returned name and source_id; never fabricate a reference. Prepare an inspectable PowerContext Handoff Draft from exact evidence for a requested transfer. Inspect facts, omissions, and the next action before finalizing. The Draft is temporary and grants no authority; preparation is not a durable commit or proof that a receiver continued the work.", args: { objective: z.string(), - evidence: z.array(handoffEvidence) + evidence: z.array(handoffEvidence), + rollover_reasons: handoffRolloverReasons.optional() }, operationId: "prepare_handoff", payload: (args) => ({ objective: args.objective, - evidence: args.evidence + evidence: args.evidence, + ...args.rollover_reasons === void 0 ? {} : { rollover_reasons: args.rollover_reasons } }) }), pc_handoff_finalize: operationTool(runtime, { diff --git a/integrations/opencode/plugins/powercontext/lib/tui.js b/integrations/opencode/plugins/powercontext/lib/tui.js index f4c1f2fa7d..a1dfc3f4a3 100644 --- a/integrations/opencode/plugins/powercontext/lib/tui.js +++ b/integrations/opencode/plugins/powercontext/lib/tui.js @@ -212,6 +212,17 @@ const OPERATIONS = { successStatuses: [200], emptyStatuses: [] }, + get_server_info: { + method: "GET", + path: "/v1/server-info", + location: null, + scopeMode: "none", + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, get_capabilities: { method: "GET", path: "/v1/capabilities", diff --git a/integrations/opencode/plugins/powercontext/src/index.ts b/integrations/opencode/plugins/powercontext/src/index.ts index 236c23a162..bd0a916851 100644 --- a/integrations/opencode/plugins/powercontext/src/index.ts +++ b/integrations/opencode/plugins/powercontext/src/index.ts @@ -289,6 +289,14 @@ const handoffEvidence = z.union([ z.object({ kind: z.literal('source'), source_ref: sourceReference }), z.object({ kind: z.literal('artifact'), artifact_ref: jsonObject() }), ]) +const handoffRolloverReasons = z.array(z.enum([ + 'user_requested', + 'host_context_budget', + 'host_compaction', + 'context_quality', + 'delegation', + 'manual_checkpoint', +])).max(6) const handoffStatement = z.object({ text: z.string().min(1), citations: z.array(handoffEvidence).min(1) }) const handoffDraft = z.object({ objective: z.string().min(1), @@ -447,9 +455,19 @@ function createTools(runtime: Runtime) { 'a generated Draft before finalizing it. An ignored boundary does not establish a new handoff; ' + 'do not claim a committed milestone. Conceptual or preview-only requests do not authorize this ' + 'write.', - args: { boundary_source: sourceReference, objective: z.string(), evidence: z.array(handoffEvidence).optional() }, + args: { + boundary_source: sourceReference, + objective: z.string(), + evidence: z.array(handoffEvidence).optional(), + rollover_reasons: handoffRolloverReasons.optional(), + }, operationId: 'activate_handoff', - payload: (args) => ({ boundary_source: args.boundary_source, objective: args.objective, evidence: args.evidence ?? [] }), + payload: (args) => ({ + boundary_source: args.boundary_source, + objective: args.objective, + evidence: args.evidence ?? [], + ...(args.rollover_reasons === undefined ? {} : { rollover_reasons: args.rollover_reasons }), + }), }), pc_handoff_prepare: operationTool(runtime, { description: @@ -458,9 +476,17 @@ function createTools(runtime: Runtime) { 'transfer. Inspect facts, omissions, and the next action before finalizing. The Draft is ' + 'temporary and grants no authority; preparation is not a durable commit or proof that a ' + 'receiver continued the work.', - args: { objective: z.string(), evidence: z.array(handoffEvidence) }, + args: { + objective: z.string(), + evidence: z.array(handoffEvidence), + rollover_reasons: handoffRolloverReasons.optional(), + }, operationId: 'prepare_handoff', - payload: (args) => ({ objective: args.objective, evidence: args.evidence }), + payload: (args) => ({ + objective: args.objective, + evidence: args.evidence, + ...(args.rollover_reasons === undefined ? {} : { rollover_reasons: args.rollover_reasons }), + }), }), pc_handoff_finalize: operationTool(runtime, { description: diff --git a/integrations/opencode/plugins/powercontext/tests/plugin.spec.ts b/integrations/opencode/plugins/powercontext/tests/plugin.spec.ts index ef65c265b9..8a65ecec4e 100644 --- a/integrations/opencode/plugins/powercontext/tests/plugin.spec.ts +++ b/integrations/opencode/plugins/powercontext/tests/plugin.spec.ts @@ -228,6 +228,56 @@ describe('PowerContextPlugin', () => { expect(JSON.parse(String(result))).toMatchObject({ ok: true }) }) + it('forwards advisory rollover reasons through both Handoff draft tools', async () => { + process.env.POWERCONTEXT_OPENCODE_SCOPE_ID = 'project:test' + const calls: Array<{ url: string; body: any }> = [] + vi.stubGlobal('fetch', vi.fn(async (url: string, init: RequestInit) => { + const body = JSON.parse(String(init.body)) + calls.push({ url, body }) + return bindingResponse(url, body) ?? Response.json({ ok: true }) + })) + const hooks = await PowerContextPlugin(pluginInput()) + const boundarySource = { name: 'content', source_id: 'turn-1' } + const reasons = ['host_context_budget', 'context_quality'] + const context = { + sessionID: 'session-1', + messageID: 'msg-1', + agent: 'build', + directory: '/tmp/project', + worktree: '/tmp/project', + abort: new AbortController().signal, + metadata: vi.fn(), + ask: vi.fn(async () => undefined), + } + + await hooks.tool?.pc_handoff_activate?.execute({ + boundary_source: boundarySource, + objective: 'Continue in a fresh session.', + rollover_reasons: reasons, + }, context) + await hooks.tool?.pc_handoff_prepare?.execute({ + objective: 'Continue in a fresh session.', + evidence: [{ kind: 'source', source_ref: boundarySource }], + rollover_reasons: reasons, + }, context) + + expect(calls.filter((call) => call.url.includes('/v1/handoff/'))).toEqual([ + { url: 'http://127.0.0.1:8000/v1/handoff/activate', body: { + boundary_source: boundarySource, + objective: 'Continue in a fresh session.', + evidence: [], + rollover_reasons: reasons, + scope_id: 'project:test', + } }, + { url: 'http://127.0.0.1:8000/v1/handoff/prepare', body: { + objective: 'Continue in a fresh session.', + evidence: [{ kind: 'source', source_ref: boundarySource }], + rollover_reasons: reasons, + scope_id: 'project:test', + } }, + ]) + }) + it('does not send secret-shaped prompts', async () => { process.env.POWERCONTEXT_OPENCODE_SCOPE_ID = 'project:test' const fetchMock = vi.fn(async () => Response.json({ diff --git a/integrations/pi/plugins/powercontext/src/tools.ts b/integrations/pi/plugins/powercontext/src/tools.ts index 303a833f51..154196a18e 100644 --- a/integrations/pi/plugins/powercontext/src/tools.ts +++ b/integrations/pi/plugins/powercontext/src/tools.ts @@ -87,6 +87,14 @@ const HANDOFF_CITATION = Type.Union([ Type.Object({ kind: Type.Literal('source'), source_ref: SOURCE_REFERENCE }), Type.Object({ kind: Type.Literal('artifact'), artifact_ref: ARTIFACT_REFERENCE }), ]) +const HANDOFF_ROLLOVER_REASONS = Type.Array(Type.Union([ + Type.Literal('user_requested'), + Type.Literal('host_context_budget'), + Type.Literal('host_compaction'), + Type.Literal('context_quality'), + Type.Literal('delegation'), + Type.Literal('manual_checkpoint'), +]), { maxItems: 6 }) const WORK_CLAIM = Type.Object({ text: NON_EMPTY_STRING, basis: Type.Union([Type.Literal('declared'), Type.Literal('verified')], { @@ -519,12 +527,14 @@ export function registerTools(pi: ExtensionAPI, runtime: PluginRuntime): void { boundary_source: SOURCE_REFERENCE, objective: Type.String(), evidence: Type.Optional(Type.Array(HANDOFF_CITATION)), + rollover_reasons: Type.Optional(HANDOFF_ROLLOVER_REASONS), }), operationId: 'activate_handoff', payload: (params) => ({ boundary_source: params.boundary_source, objective: params.objective, evidence: params.evidence ?? [], + ...(params.rollover_reasons === undefined ? {} : { rollover_reasons: params.rollover_reasons }), }), mutates: true, }) @@ -618,9 +628,14 @@ export function registerTools(pi: ExtensionAPI, runtime: PluginRuntime): void { parameters: Type.Object({ objective: Type.String(), evidence: Type.Array(HANDOFF_CITATION), + rollover_reasons: Type.Optional(HANDOFF_ROLLOVER_REASONS), }), operationId: 'prepare_handoff', - payload: (params) => ({ objective: params.objective, evidence: params.evidence }), + payload: (params) => ({ + objective: params.objective, + evidence: params.evidence, + ...(params.rollover_reasons === undefined ? {} : { rollover_reasons: params.rollover_reasons }), + }), }) registerOperationTool(pi, runtime, { diff --git a/integrations/pi/plugins/powercontext/tests/tools.spec.ts b/integrations/pi/plugins/powercontext/tests/tools.spec.ts index 2caffae4d5..b38d3d9c0c 100644 --- a/integrations/pi/plugins/powercontext/tests/tools.spec.ts +++ b/integrations/pi/plugins/powercontext/tests/tools.spec.ts @@ -614,6 +614,57 @@ describe('Pi native tool surface', () => { ]) }) + it('accepts and forwards advisory rollover reasons for Handoff preparation', async () => { + const registered: Array> = [] + const fetch = vi.fn(async (_url: string, _init?: RequestInit) => new Response(JSON.stringify({ ok: true }))) + const runtime = createRuntime(fetch) + registerTools({ registerTool: (tool: Record) => registered.push(tool) } as never, runtime) + const activate = registeredTool>(registered, 'pc_handoff_activate') + const prepare = registeredTool>(registered, 'pc_handoff_prepare') + const boundarySource = { name: 'content', source_id: 'turn-1' } + const reasons = ['host_context_budget', 'context_quality'] + const context = { cwd: '/workspace/repo', hasUI: true, ui: { confirm: vi.fn(async () => true) } } + const signal = new AbortController().signal + + expect(Value.Check(activate.parameters!, { + boundary_source: boundarySource, + objective: 'Continue in a fresh session.', + rollover_reasons: reasons, + })).toBe(true) + expect(Value.Check(prepare.parameters!, { + objective: 'Continue in a fresh session.', + evidence: [{ kind: 'source', source_ref: boundarySource }], + rollover_reasons: reasons, + })).toBe(true) + + await activate.execute('call-activate', { + boundary_source: boundarySource, + objective: 'Continue in a fresh session.', + rollover_reasons: reasons, + }, signal, () => undefined, context) + await prepare.execute('call-prepare', { + objective: 'Continue in a fresh session.', + evidence: [{ kind: 'source', source_ref: boundarySource }], + rollover_reasons: reasons, + }, signal, () => undefined, context) + + expect(fetch.mock.calls.map(([url, init]) => [url, JSON.parse(String(init?.body))])).toEqual([ + ['http://127.0.0.1:8000/v1/handoff/activate', { + boundary_source: boundarySource, + objective: 'Continue in a fresh session.', + evidence: [], + rollover_reasons: reasons, + scope_id: 'project:demo', + }], + ['http://127.0.0.1:8000/v1/handoff/prepare', { + objective: 'Continue in a fresh session.', + evidence: [{ kind: 'source', source_ref: boundarySource }], + rollover_reasons: reasons, + scope_id: 'project:demo', + }], + ]) + }) + it('accepts the check results the OpenAPI contract marks optional', () => { const registered: Array> = [] registerTools({ registerTool: (tool: Record) => registered.push(tool) } as never, createRuntime(vi.fn())) diff --git a/openapi/powercontext.yaml b/openapi/powercontext.yaml index e5e6427ced..92055b2780 100644 --- a/openapi/powercontext.yaml +++ b/openapi/powercontext.yaml @@ -5572,6 +5572,13 @@ components: minLength: 1 maxLength: 8192 pattern: '.*\S.*' + rollover_reasons: + type: array + maxItems: 6 + uniqueItems: true + items: + $ref: "#/components/schemas/HandoffRolloverReason" + default: [] evidence: type: array maxItems: 32 @@ -7869,6 +7876,13 @@ components: minLength: 1 maxLength: 8192 pattern: '.*\S.*' + rollover_reasons: + type: array + maxItems: 6 + uniqueItems: true + items: + $ref: "#/components/schemas/HandoffRolloverReason" + default: [] evidence: type: array minItems: 1 @@ -11143,6 +11157,9 @@ components: HandoffResolutionStatus: type: string enum: [empty, resolved] + HandoffRolloverReason: + type: string + enum: [user_requested, host_context_budget, host_compaction, context_quality, delegation, manual_checkpoint] HandoffSchema: type: string enum: [powercontext.handoff.v1] diff --git a/src/powercontext/builtin/artifacts/handoff/__init__.py b/src/powercontext/builtin/artifacts/handoff/__init__.py index dddd095396..c29802f0d7 100644 --- a/src/powercontext/builtin/artifacts/handoff/__init__.py +++ b/src/powercontext/builtin/artifacts/handoff/__init__.py @@ -57,6 +57,7 @@ HandoffResolution, HandoffResolutionSelection, HandoffResolutionStatus, + HandoffRolloverReason, HandoffSourceCitation, HandoffSourceEvidence, HandoffStatement, @@ -119,6 +120,7 @@ "HandoffResolution", "HandoffResolutionSelection", "HandoffResolutionStatus", + "HandoffRolloverReason", "HandoffScopeMismatchError", "HandoffService", "HandoffSourceCitation", diff --git a/src/powercontext/builtin/artifacts/handoff/errors.py b/src/powercontext/builtin/artifacts/handoff/errors.py index 5d1d1afafe..f7c8226b63 100644 --- a/src/powercontext/builtin/artifacts/handoff/errors.py +++ b/src/powercontext/builtin/artifacts/handoff/errors.py @@ -48,6 +48,8 @@ def __init__(self, code: str) -> None: "objective": "generated Handoff changed the caller-owned objective", "evidence": "generated Handoff cited evidence outside the preparation action", "budget": "generated Handoff exceeds the preparation byte budget", + "rollover_next_action": "rollover Handoff draft without a next action must use disposition complete", + "rollover_quality": "rollover Handoff draft contains transcript-dependent wording", } super().__init__(messages.get(code, f"invalid Handoff generation: {code}")) diff --git a/src/powercontext/builtin/artifacts/handoff/generation.py b/src/powercontext/builtin/artifacts/handoff/generation.py index ff0ab5e665..8d941151d5 100644 --- a/src/powercontext/builtin/artifacts/handoff/generation.py +++ b/src/powercontext/builtin/artifacts/handoff/generation.py @@ -16,10 +16,11 @@ from __future__ import annotations +import re from collections.abc import Mapping -from typing import Literal, Protocol, TypeAlias +from typing import Annotated, Literal, Protocol, TypeAlias -from pydantic import BaseModel, JsonValue, TypeAdapter, ValidationError +from pydantic import BaseModel, Field, JsonValue, TypeAdapter, ValidationError, model_validator from powercontext.artifacts import Artifact from powercontext.builtin.artifacts.handoff.models import ( @@ -30,6 +31,7 @@ HandoffGenerationEvidence, HandoffGenerationRequest, HandoffOmission, + HandoffRolloverReason, HandoffSourceEvidence, HandoffStatement, ) @@ -53,9 +55,20 @@ class HandoffGenerationInput(BaseModel): """Caller-owned objective, bounded evidence, and output budget.""" objective: str + rollover_reasons: Annotated[ + tuple[HandoffRolloverReason, ...], + Field(max_length=6), + ] = () evidence: tuple[HandoffGenerationEvidenceInput, ...] max_bytes: int + @model_validator(mode="after") + def require_unique_rollover_reasons(self) -> HandoffGenerationInput: + for index, reason in enumerate(self.rollover_reasons): + if reason in self.rollover_reasons[:index]: + raise ValueError("Handoff rollover reasons must be unique") # noqa: TRY003 + return self + class HandoffGenerationStatement(BaseModel): """One generated statement with operation-local citations.""" @@ -148,6 +161,32 @@ def _validated_output(result: GenerationResult[HandoffGenerationOutput]) -> Hand raise InvalidInferenceOutputError("handoff-generate", "generator returned an invalid output tree") from error +_ROLLOVER_TRANSCRIPT_INSTRUCTION = re.compile( + r"\b(?:continue\s+(?:the\s+previous\s+work|above)|see\s+the\s+conversation\s+above)\s*[.!?]*\s*\Z", + re.IGNORECASE, +) + + +def rollover_draft_violation(draft: HandoffDraft) -> str | None: + """Return the deterministic RFC 1783 rollover violation, if any. + + The caller owns ``objective`` and the generator must preserve it exactly, so this checks + only generated checkpoint content. It rejects a direct instruction to rely on an old + transcript, not a descriptive or quoted mention of that instruction. A blocked checkpoint + may have no next action because the blocker itself explains why the fresh session cannot + continue safely. + """ + + if draft.next_action is None and draft.disposition == "continuable": + return "rollover_next_action" + texts = [*(statement.text for statement in draft.state)] + if draft.next_action is not None: + texts.append(draft.next_action.text) + if any(_ROLLOVER_TRANSCRIPT_INSTRUCTION.search(text) for text in texts): + return "rollover_quality" + return None + + def _generation_input( request: HandoffGenerationRequest, projector: HandoffEvidenceProjector, @@ -167,6 +206,7 @@ def _generation_input( return ( HandoffGenerationInput( objective=request.objective, + rollover_reasons=request.rollover_reasons, evidence=tuple(values), max_bytes=request.max_bytes, ), diff --git a/src/powercontext/builtin/artifacts/handoff/models.py b/src/powercontext/builtin/artifacts/handoff/models.py index 8778a32986..1ce578857a 100644 --- a/src/powercontext/builtin/artifacts/handoff/models.py +++ b/src/powercontext/builtin/artifacts/handoff/models.py @@ -46,6 +46,14 @@ HandoffResolutionSelection: TypeAlias = Literal["prepared", "exact", "latest"] HandoffResolutionStatus: TypeAlias = Literal["empty", "resolved"] HandoffActivationStatus: TypeAlias = Literal["generated", "ignored"] +HandoffRolloverReason: TypeAlias = Literal[ + "user_requested", + "host_context_budget", + "host_compaction", + "context_quality", + "delegation", + "manual_checkpoint", +] class _HandoffValue(BaseModel): @@ -76,6 +84,10 @@ class PrepareHandoff(_HandoffValue): """Standard action for generating one bounded, inspectable Handoff.""" objective: Annotated[str, Field(max_length=MAX_HANDOFF_TEXT_LENGTH)] + rollover_reasons: Annotated[ + tuple[HandoffRolloverReason, ...], + Field(max_length=6), + ] = () evidence: Annotated[ tuple[HandoffCitation, ...], Field(min_length=1, max_length=MAX_HANDOFF_CITATIONS), @@ -88,7 +100,10 @@ def require_objective(cls, value: str) -> str: return _require_text("objective", value) @model_validator(mode="after") - def require_unique_evidence(self) -> PrepareHandoff: + def require_unique_inputs(self) -> PrepareHandoff: + for index, reason in enumerate(self.rollover_reasons): + if reason in self.rollover_reasons[:index]: + raise ValueError("Handoff rollover reasons must be unique") # noqa: TRY003 for index, citation in enumerate(self.evidence): if citation in self.evidence[:index]: raise ValueError("Handoff preparation evidence must be unique") # noqa: TRY003 @@ -100,6 +115,10 @@ class ActivateHandoff(_HandoffValue): boundary_source: SourceRef objective: Annotated[str, Field(max_length=MAX_HANDOFF_TEXT_LENGTH)] + rollover_reasons: Annotated[ + tuple[HandoffRolloverReason, ...], + Field(max_length=6), + ] = () evidence: Annotated[ tuple[HandoffCitation, ...], Field(max_length=MAX_HANDOFF_CITATIONS), @@ -113,6 +132,9 @@ def require_objective(cls, value: str) -> str: @model_validator(mode="after") def require_bounded_unique_evidence(self) -> ActivateHandoff: + for index, reason in enumerate(self.rollover_reasons): + if reason in self.rollover_reasons[:index]: + raise ValueError("Handoff rollover reasons must be unique") # noqa: TRY003 citations = self.action_evidence() if len(citations) > MAX_HANDOFF_CITATIONS: raise ValueError("Handoff activation evidence exceeds the citation limit") # noqa: TRY003 @@ -155,6 +177,10 @@ class HandoffGenerationRequest(_HandoffValue): """Canonical bounded evidence offered to a Handoff generation pipeline.""" objective: Annotated[str, Field(max_length=MAX_HANDOFF_TEXT_LENGTH)] + rollover_reasons: Annotated[ + tuple[HandoffRolloverReason, ...], + Field(max_length=6), + ] = () evidence: Annotated[ tuple[HandoffGenerationEvidence, ...], Field(min_length=1, max_length=MAX_HANDOFF_CITATIONS), @@ -166,6 +192,13 @@ class HandoffGenerationRequest(_HandoffValue): def require_objective(cls, value: str) -> str: return _require_text("objective", value) + @model_validator(mode="after") + def require_unique_rollover_reasons(self) -> HandoffGenerationRequest: + for index, reason in enumerate(self.rollover_reasons): + if reason in self.rollover_reasons[:index]: + raise ValueError("Handoff rollover reasons must be unique") # noqa: TRY003 + return self + class HandoffStatement(_HandoffValue): """One current claim and the evidence that supports it.""" diff --git a/src/powercontext/builtin/artifacts/handoff/prompts.py b/src/powercontext/builtin/artifacts/handoff/prompts.py index 943532552d..103231b543 100644 --- a/src/powercontext/builtin/artifacts/handoff/prompts.py +++ b/src/powercontext/builtin/artifacts/handoff/prompts.py @@ -14,7 +14,7 @@ """Versioned instructions owned by the Handoff Artifact Family.""" -HANDOFF_GENERATION_INSTRUCTIONS_VERSION = "powercontext.handoff.generate.v1" +HANDOFF_GENERATION_INSTRUCTIONS_VERSION = "powercontext.handoff.generate.v2" HANDOFF_GENERATION_INSTRUCTIONS = f""" You generate a concise Handoff for the next participant from bounded evidence. @@ -26,6 +26,9 @@ - Use only facts present in the supplied evidence. - Every state statement and next action must cite one or more supplied evidence IDs. - Describe the current work state needed to continue the caller-owned objective. +- Treat rollover_reasons as advisory preparation context only; do not claim they are committed Handoff content. +- When rollover_reasons is non-empty, draft a fresh-session checkpoint: do not depend on the old transcript, + avoid "continue above" wording, and make the next participant's first safe action explicit. - Keep observed state separate from the proposed next action. - Use disposition "continuable" when work can proceed, "blocked" when progress requires an external change, and "complete" when the objective has been achieved. diff --git a/src/powercontext/builtin/artifacts/handoff/service.py b/src/powercontext/builtin/artifacts/handoff/service.py index 5d579d1aa0..a076134dfb 100644 --- a/src/powercontext/builtin/artifacts/handoff/service.py +++ b/src/powercontext/builtin/artifacts/handoff/service.py @@ -27,6 +27,7 @@ InvalidHandoffGenerationError, InvalidHandoffReferenceError, ) +from powercontext.builtin.artifacts.handoff.generation import rollover_draft_violation from powercontext.builtin.artifacts.handoff.generation_metadata import HandoffGenerationReceipts from powercontext.builtin.artifacts.handoff.hints import render_handoff_hint from powercontext.builtin.artifacts.handoff.models import ( @@ -109,6 +110,7 @@ async def prepare(self, action: PrepareHandoff, /) -> HandoffDraft: draft = await self._generation_pipeline.generate( HandoffGenerationRequest( objective=action.objective, + rollover_reasons=action.rollover_reasons, evidence=evidence, max_bytes=action.max_bytes, ) @@ -400,6 +402,8 @@ def _validate_generated_draft(action: PrepareHandoff, draft: object) -> None: content_bytes = len(draft.as_content().model_dump_json(by_alias=True, indent=2).encode()) if content_bytes > action.max_bytes: raise InvalidHandoffGenerationError("budget") + if action.rollover_reasons and (violation := rollover_draft_violation(draft)): + raise InvalidHandoffGenerationError(violation) async def _evidence_checks( self, diff --git a/src/powercontext/builtin/artifacts/prompt/builtin.py b/src/powercontext/builtin/artifacts/prompt/builtin.py index 60e43bf27e..6a0828394f 100644 --- a/src/powercontext/builtin/artifacts/prompt/builtin.py +++ b/src/powercontext/builtin/artifacts/prompt/builtin.py @@ -203,6 +203,9 @@ def builtin_prompt_definitions( invariant_instructions=_COMMON_INVARIANTS + """ Generate a Handoff for the supplied objective. Every state and next-action statement must cite supplied evidence IDs. +Treat rollover_reasons as advisory preparation context only; do not claim they are committed Handoff content. +When rollover_reasons is non-empty, draft a fresh-session checkpoint: do not depend on the old transcript, +avoid "continue above" wording, and make the next participant's first safe action explicit. Separate observed state from proposed next actions. Use continuable, blocked, or complete accurately. Omit next_action when complete. Record uncertainty as omissions and stay within max_bytes. Do not change the objective or claim the draft is committed. diff --git a/src/powercontext/builtin/artifacts/prompt/validation.py b/src/powercontext/builtin/artifacts/prompt/validation.py index 080c0c697f..4063adf042 100644 --- a/src/powercontext/builtin/artifacts/prompt/validation.py +++ b/src/powercontext/builtin/artifacts/prompt/validation.py @@ -42,7 +42,7 @@ HandoffStatement, PrepareHandoff, ) -from powercontext.builtin.artifacts.handoff.generation import HandoffGenerationStatement +from powercontext.builtin.artifacts.handoff.generation import HandoffGenerationStatement, rollover_draft_violation from powercontext.builtin.artifacts.memory import ( MemoryExtractionInput, MemoryExtractionOutput, @@ -156,7 +156,12 @@ def _handoff(value: HandoffGenerationInput, output: HandoffGenerationOutput) -> evidence_id: HandoffSourceCitation(source_ref=SourceRef(source_type="content", source_id=f"demo-{index}")) for index, evidence_id in enumerate(sorted(evidence)) } - PrepareHandoff(objective=value.objective, evidence=tuple(citations.values()), max_bytes=value.max_bytes) + PrepareHandoff( + objective=value.objective, + rollover_reasons=value.rollover_reasons, + evidence=tuple(citations.values()), + max_bytes=value.max_bytes, + ) def statement(item: HandoffGenerationStatement) -> HandoffStatement: _require(bool(item.evidence_ids) and set(item.evidence_ids) <= evidence) @@ -170,13 +175,14 @@ def statement(item: HandoffGenerationStatement) -> HandoffStatement: omissions.append( HandoffOmission(text=item.text, citation=None if item.evidence_id is None else citations[item.evidence_id]) ) - HandoffDraft( + draft = HandoffDraft( objective=value.objective, state=tuple(statement(item) for item in output.state), disposition=output.disposition, next_action=None if output.next_action is None else statement(output.next_action), omissions=tuple(omissions), ) + _require(not value.rollover_reasons or rollover_draft_violation(draft) is None) def _topic_memory_probe(value: TopicMemoryProbeInput, output: TopicMemoryProbeOutput) -> None: diff --git a/src/powercontext/builtin/triggers/handoff.py b/src/powercontext/builtin/triggers/handoff.py index 1d8edd8bc8..815b79db8a 100644 --- a/src/powercontext/builtin/triggers/handoff.py +++ b/src/powercontext/builtin/triggers/handoff.py @@ -52,6 +52,7 @@ def activate( actions=( PrepareHandoff( objective=activation.objective, + rollover_reasons=activation.rollover_reasons, evidence=activation.action_evidence(), max_bytes=activation.max_bytes, ), diff --git a/src/powercontext/http/__init__.py b/src/powercontext/http/__init__.py index 11a64d8793..9d810d3b6f 100644 --- a/src/powercontext/http/__init__.py +++ b/src/powercontext/http/__init__.py @@ -219,6 +219,7 @@ HandoffReportResponse, HandoffResolution, HandoffResolutionStatus, + HandoffRolloverReason, HandoffSchema, HandoffSelection, HandoffSourceCitation, @@ -648,6 +649,7 @@ "HandoffReportResponse", "HandoffResolution", "HandoffResolutionStatus", + "HandoffRolloverReason", "HandoffSchema", "HandoffSelection", "HandoffSourceCitation", diff --git a/src/powercontext/http/_generated/models.py b/src/powercontext/http/_generated/models.py index 07143c90c0..fca09f4893 100644 --- a/src/powercontext/http/_generated/models.py +++ b/src/powercontext/http/_generated/models.py @@ -2780,6 +2780,15 @@ class HandoffResolutionStatus(StrEnum): RESOLVED = "resolved" +class HandoffRolloverReason(StrEnum): + USER_REQUESTED = "user_requested" + HOST_CONTEXT_BUDGET = "host_context_budget" + HOST_COMPACTION = "host_compaction" + CONTEXT_QUALITY = "context_quality" + DELEGATION = "delegation" + MANUAL_CHECKPOINT = "manual_checkpoint" + + class HandoffSchema(StrEnum): POWERCONTEXT_HANDOFF_V1 = "powercontext.handoff.v1" @@ -4401,6 +4410,7 @@ class PrepareHandoffRequest(BaseModel): ) scope_id: Annotated[StrictStr, Field(max_length=256, min_length=1, pattern=".*\\S.*")] objective: Annotated[StrictStr, Field(max_length=8192, min_length=1, pattern=".*\\S.*")] + rollover_reasons: Annotated[list[HandoffRolloverReason], Field(max_length=6)] = [] evidence: Annotated[list[HandoffCitation], Field(max_length=32, min_length=1)] max_bytes: Annotated[StrictInt, Field(ge=512, le=32768)] = 8000 @@ -4511,6 +4521,7 @@ class ActivateHandoffRequest(BaseModel): scope_id: Annotated[StrictStr, Field(max_length=256, min_length=1, pattern=".*\\S.*")] boundary_source: SourceReference objective: Annotated[StrictStr, Field(max_length=8192, min_length=1, pattern=".*\\S.*")] + rollover_reasons: Annotated[list[HandoffRolloverReason], Field(max_length=6)] = [] evidence: Annotated[list[HandoffCitation], Field(max_length=32, validate_default=True)] = [] max_bytes: Annotated[StrictInt, Field(ge=512, le=32768)] = 8000 diff --git a/src/powercontext/http/_generated/schema.py b/src/powercontext/http/_generated/schema.py index 181424e848..19170765df 100644 --- a/src/powercontext/http/_generated/schema.py +++ b/src/powercontext/http/_generated/schema.py @@ -5466,6 +5466,13 @@ "scope_id": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": ".*\\S.*"}, "boundary_source": {"$ref": "#/components/schemas/SourceReference"}, "objective": {"type": "string", "maxLength": 8192, "minLength": 1, "pattern": ".*\\S.*"}, + "rollover_reasons": { + "items": {"$ref": "#/components/schemas/HandoffRolloverReason"}, + "type": "array", + "maxItems": 6, + "uniqueItems": True, + "default": [], + }, "evidence": { "items": {"$ref": "#/components/schemas/HandoffCitation"}, "type": "array", @@ -7565,6 +7572,13 @@ "properties": { "scope_id": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": ".*\\S.*"}, "objective": {"type": "string", "maxLength": 8192, "minLength": 1, "pattern": ".*\\S.*"}, + "rollover_reasons": { + "items": {"$ref": "#/components/schemas/HandoffRolloverReason"}, + "type": "array", + "maxItems": 6, + "uniqueItems": True, + "default": [], + }, "evidence": { "items": {"$ref": "#/components/schemas/HandoffCitation"}, "type": "array", @@ -10597,6 +10611,17 @@ "HandoffDisposition": {"type": "string", "enum": ["continuable", "blocked", "complete"]}, "HandoffEvidenceStatus": {"type": "string", "enum": ["available", "unavailable"]}, "HandoffResolutionStatus": {"type": "string", "enum": ["empty", "resolved"]}, + "HandoffRolloverReason": { + "type": "string", + "enum": [ + "user_requested", + "host_context_budget", + "host_compaction", + "context_quality", + "delegation", + "manual_checkpoint", + ], + }, "HandoffSchema": {"type": "string", "enum": ["powercontext.handoff.v1"]}, "HandoffSelection": {"type": "string", "enum": ["prepared", "exact", "latest"]}, "PreparedHandoffSchema": {"type": "string", "enum": ["powercontext.prepared-handoff.v1"]}, diff --git a/src/powercontext/server/mapping.py b/src/powercontext/server/mapping.py index 803e6dec9b..4a709c02cd 100644 --- a/src/powercontext/server/mapping.py +++ b/src/powercontext/server/mapping.py @@ -655,6 +655,7 @@ def activate_handoff_request(value: ActivateHandoffRequest) -> ActivateHandoff: return ActivateHandoff( boundary_source=runtime_source_reference(value.boundary_source), objective=value.objective, + rollover_reasons=tuple(reason.value for reason in value.rollover_reasons), evidence=tuple(runtime_handoff_citation(citation) for citation in value.evidence), max_bytes=value.max_bytes, ) @@ -673,6 +674,7 @@ def handoff_activation_response(value: HandoffActivation) -> TransportHandoffAct def prepare_handoff_request(value: PrepareHandoffRequest) -> PrepareHandoff: return PrepareHandoff( objective=value.objective, + rollover_reasons=tuple(reason.value for reason in value.rollover_reasons), evidence=tuple(runtime_handoff_citation(citation) for citation in value.evidence), max_bytes=value.max_bytes, ) diff --git a/tests/builtin/artifacts/handoff/test_generation.py b/tests/builtin/artifacts/handoff/test_generation.py index 7de5d89346..6c11460176 100644 --- a/tests/builtin/artifacts/handoff/test_generation.py +++ b/tests/builtin/artifacts/handoff/test_generation.py @@ -79,6 +79,7 @@ async def scenario() -> None: draft = await pipeline.generate( HandoffGenerationRequest( objective="Complete parser error handling.", + rollover_reasons=("host_context_budget", "context_quality"), evidence=(evidence,), max_bytes=4096, ) @@ -90,8 +91,10 @@ async def scenario() -> None: assert draft.next_action.citations == (evidence.citation,) assert generator.input is not None assert generator.input.objective == draft.objective + assert generator.input.rollover_reasons == ("host_context_budget", "context_quality") assert generator.input.max_bytes == 4096 assert generator.input.evidence[0].evidence_id == "source:0" + assert "rollover_reasons" not in draft.as_content().model_dump(mode="json") asyncio.run(scenario()) diff --git a/tests/builtin/artifacts/handoff/test_models.py b/tests/builtin/artifacts/handoff/test_models.py index 823206d0a1..e7a73881ae 100644 --- a/tests/builtin/artifacts/handoff/test_models.py +++ b/tests/builtin/artifacts/handoff/test_models.py @@ -128,11 +128,24 @@ def test_handoff_content_is_bounded() -> None: def test_prepare_action_requires_unique_bounded_evidence() -> None: citation = _statement().citations[0] + action = PrepareHandoff( + objective="Complete parser error handling.", + rollover_reasons=("user_requested", "host_context_budget"), + evidence=(citation,), + ) + + assert action.rollover_reasons == ("user_requested", "host_context_budget") with pytest.raises(ValidationError): PrepareHandoff( objective="Complete parser error handling.", evidence=(citation, citation), ) + with pytest.raises(ValidationError): + PrepareHandoff( + objective="Complete parser error handling.", + rollover_reasons=("user_requested", "user_requested"), + evidence=(citation,), + ) with pytest.raises(ValidationError): PrepareHandoff( objective="Complete parser error handling.", @@ -148,10 +161,18 @@ def test_activation_adds_the_boundary_source_once_and_rejects_duplicate_evidence activation = ActivateHandoff( boundary_source=citation.source_ref, objective="Complete parser error handling.", + rollover_reasons=("host_compaction",), evidence=(citation,), ) + assert activation.rollover_reasons == ("host_compaction",) assert activation.action_evidence() == (citation,) + with pytest.raises(ValidationError): + ActivateHandoff( + boundary_source=SourceRef(source_type="content", source_id="boundary"), + objective="Complete parser error handling.", + rollover_reasons=("manual_checkpoint", "manual_checkpoint"), + ) with pytest.raises(ValidationError): ActivateHandoff( boundary_source=SourceRef(source_type="content", source_id="boundary"), diff --git a/tests/builtin/artifacts/handoff/test_service.py b/tests/builtin/artifacts/handoff/test_service.py index a317277772..14c42542f7 100644 --- a/tests/builtin/artifacts/handoff/test_service.py +++ b/tests/builtin/artifacts/handoff/test_service.py @@ -29,6 +29,7 @@ HandoffEvidenceUnavailableError, HandoffGenerationUnavailableError, HandoffOmission, + HandoffRolloverReason, HandoffService, HandoffSourceCitation, HandoffSourceEvidence, @@ -71,8 +72,10 @@ async def resolve(self, citation, /): class _GenerationPipeline: def __init__(self, draft: object) -> None: self.draft = draft + self.requests = [] async def generate(self, request, /): + self.requests.append(request) return self.draft @@ -278,6 +281,7 @@ async def scenario() -> None: service, _, _ = _service(pipeline) action = PrepareHandoff( objective=generated.objective, + rollover_reasons=("user_requested", "context_quality"), evidence=(_citation(),), max_bytes=4096, ) @@ -285,6 +289,7 @@ async def scenario() -> None: draft = await service.prepare(action) assert draft == generated + assert pipeline.requests[0].rollover_reasons == ("user_requested", "context_quality") assert await service.latest() is None asyncio.run(scenario()) @@ -306,7 +311,7 @@ async def scenario() -> None: @pytest.mark.parametrize( - ("draft", "max_bytes", "code"), + ("draft", "max_bytes", "code", "rollover_reasons"), [ ( HandoffDraft( @@ -316,6 +321,7 @@ async def scenario() -> None: ), 4096, "objective", + (), ), ( HandoffDraft( @@ -325,14 +331,40 @@ async def scenario() -> None: ), 4096, "evidence", + (), + ), + (_draft("x" * 1000), 512, "budget", ()), + ( + _draft("Blocked on review.", next_action=None), + 4096, + "rollover_next_action", + ("user_requested",), + ), + ( + _draft("Let us continue the previous work."), + 4096, + "rollover_quality", + ("host_compaction",), + ), + ( + _draft("Error mapping changed.", next_action="Continue above."), + 4096, + "rollover_quality", + ("user_requested",), + ), + ( + _draft("Error mapping changed.", next_action="Run tests, then continue above."), + 4096, + "rollover_quality", + ("user_requested",), ), - (_draft("x" * 1000), 512, "budget"), ], ) def test_prepare_rejects_pipeline_contract_violations( draft: HandoffDraft, max_bytes: int, code: str, + rollover_reasons: tuple[HandoffRolloverReason, ...], ) -> None: async def scenario() -> None: service, _, _ = _service(_GenerationPipeline(draft)) @@ -341,6 +373,7 @@ async def scenario() -> None: await service.prepare( PrepareHandoff( objective="Complete parser error handling.", + rollover_reasons=rollover_reasons, evidence=(_citation(),), max_bytes=max_bytes, ) @@ -351,6 +384,111 @@ async def scenario() -> None: asyncio.run(scenario()) +def test_prepare_allows_transcript_free_rollover_and_unaffected_plain_drafts() -> None: + async def scenario() -> None: + rollover = _draft("Error mapping changed.") + plain = _draft("Blocked on review.", disposition="blocked", next_action=None) + checkpoint = _draft("Error mapping changed.", disposition="complete", next_action=None) + blocked_rollover = _draft( + "Repository access must be granted before work can continue.", disposition="blocked", next_action=None + ) + quoted_objective = 'Reject "continue above" wording in rollover handoffs.' + quoted_rollover = _draft("The rollover validator needs a quoted-phrase regression.").model_copy( + update={"objective": quoted_objective} + ) + descriptive_state_rollover = _draft( + 'The validator now rejects "continue above" wording; focused tests passed.', next_action="Run tests." + ) + quoted_next_action_rollover = _draft( + "The rollover validator needs a descriptive-action regression.", + next_action='Document why "continue above" wording is invalid, then run tests.', + ) + + prepared = await _service(_GenerationPipeline(rollover))[0].prepare( + PrepareHandoff( + objective=rollover.objective, + rollover_reasons=("user_requested",), + evidence=(_citation(),), + max_bytes=4096, + ) + ) + plain_draft = await _service(_GenerationPipeline(plain))[0].prepare( + PrepareHandoff(objective=plain.objective, evidence=(_citation(),), max_bytes=4096) + ) + checkpoint_draft = await _service(_GenerationPipeline(checkpoint))[0].prepare( + PrepareHandoff( + objective=checkpoint.objective, + rollover_reasons=("manual_checkpoint",), + evidence=(_citation(),), + max_bytes=4096, + ) + ) + blocked_rollover_draft = await _service(_GenerationPipeline(blocked_rollover))[0].prepare( + PrepareHandoff( + objective=blocked_rollover.objective, + rollover_reasons=("user_requested",), + evidence=(_citation(),), + max_bytes=4096, + ) + ) + quoted_rollover_draft = await _service(_GenerationPipeline(quoted_rollover))[0].prepare( + PrepareHandoff( + objective=quoted_objective, + rollover_reasons=("user_requested",), + evidence=(_citation(),), + max_bytes=4096, + ) + ) + descriptive_state_rollover_draft = await _service(_GenerationPipeline(descriptive_state_rollover))[0].prepare( + PrepareHandoff( + objective=descriptive_state_rollover.objective, + rollover_reasons=("user_requested",), + evidence=(_citation(),), + max_bytes=4096, + ) + ) + quoted_next_action_rollover_draft = await _service(_GenerationPipeline(quoted_next_action_rollover))[0].prepare( + PrepareHandoff( + objective=quoted_next_action_rollover.objective, + rollover_reasons=("user_requested",), + evidence=(_citation(),), + max_bytes=4096, + ) + ) + + assert prepared == rollover + assert plain_draft == plain + assert checkpoint_draft == checkpoint + assert blocked_rollover_draft == blocked_rollover + assert quoted_rollover_draft == quoted_rollover + assert descriptive_state_rollover_draft == descriptive_state_rollover + assert quoted_next_action_rollover_draft == quoted_next_action_rollover + + asyncio.run(scenario()) + + +def test_rollover_reasons_do_not_change_content_identity() -> None: + async def scenario() -> None: + pipeline = _GenerationPipeline(_draft("Error mapping changed.")) + service, _, _ = _service(pipeline) + + manual = await service.prepare( + PrepareHandoff(objective="Complete parser error handling.", evidence=(_citation(),), max_bytes=4096) + ) + rollover = await service.prepare( + PrepareHandoff( + objective="Complete parser error handling.", + rollover_reasons=("user_requested", "delegation"), + evidence=(_citation(),), + max_bytes=4096, + ) + ) + + assert rollover.as_content() == manual.as_content() + + asyncio.run(scenario()) + + def test_draft_can_be_corrected_before_temporary_handoff_is_finalized() -> None: async def scenario() -> None: service, _, _ = _service() diff --git a/tests/builtin/artifacts/prompt/test_prompt_content.py b/tests/builtin/artifacts/prompt/test_prompt_content.py index a3c19b3293..6fc78a5a2b 100644 --- a/tests/builtin/artifacts/prompt/test_prompt_content.py +++ b/tests/builtin/artifacts/prompt/test_prompt_content.py @@ -82,6 +82,30 @@ def test_custom_selection_freezes_exact_revision_and_keeps_scope_identity() -> N assert definition.resolve("scope-b", original).scope_id == "scope-b" +def test_custom_handoff_prompt_retains_rollover_requirements() -> None: + definition = PromptRegistry(builtin_prompt_definitions()).get("handoff.generate") + prompt = Prompt( + artifact_id="handoff.generate", + revision=1, + content=_content(instructions="Summarize the supplied evidence."), + ) + + resolved = definition.resolve("scope-a", prompt) + + assert "fresh-session checkpoint" in resolved.compiled_instructions + assert "do not depend on the old transcript" in resolved.compiled_instructions + assert "first safe action explicit" in resolved.compiled_instructions + + +def test_auto_handoff_prompt_marks_rollover_reasons_as_advisory() -> None: + definition = PromptRegistry(builtin_prompt_definitions()).get("handoff.generate") + + resolved = definition.resolve("scope-a", None) + + assert "advisory preparation context only" in resolved.compiled_instructions + assert "do not claim they are committed Handoff content" in resolved.compiled_instructions + + def test_demonstrations_validate_without_inserting_defaults() -> None: registry = PromptRegistry(builtin_prompt_definitions(), supported=frozenset({"memory.extract"})) content = _with_demonstration({ diff --git a/tests/builtin/artifacts/prompt/test_prompt_validation.py b/tests/builtin/artifacts/prompt/test_prompt_validation.py index c72b1bae05..1d0149d93d 100644 --- a/tests/builtin/artifacts/prompt/test_prompt_validation.py +++ b/tests/builtin/artifacts/prompt/test_prompt_validation.py @@ -294,6 +294,64 @@ def test_atomic_extraction_demonstration_rejects_published_artifact_evidence() - assert caught.value.code == "prompt_definition_incompatible" +@pytest.mark.parametrize( + "rollover_reasons", + [ + ["user_requested", "user_requested"], + ["manual_checkpoint"] * 7, + ], +) +def test_custom_handoff_demonstrations_reject_invalid_rollover_reasons( + rollover_reasons: list[str], +) -> None: + case = deepcopy(_case("handoff.generate")) + case["input"]["rollover_reasons"] = rollover_reasons + definition = PromptRegistry(builtin_prompt_definitions()).get("handoff.generate") + + with pytest.raises(PromptError) as caught: + definition.validate(_content(case)) + + assert caught.value.code == "prompt_definition_incompatible" + assert not caught.value.during_inference + + +def test_custom_handoff_demonstrations_reject_rollover_outputs_that_prepare_would_reject() -> None: + case = deepcopy(_case("handoff.generate")) + case["input"]["rollover_reasons"] = ["user_requested"] + case["expected_output"] = { + "state": [{"text": "The smoke tests passed.", "evidence_ids": ["source:1"]}], + "disposition": "continuable", + "next_action": {"text": "Continue above.", "evidence_ids": ["source:1"]}, + } + definition = PromptRegistry(builtin_prompt_definitions()).get("handoff.generate") + + with pytest.raises(PromptError) as caught: + definition.validate(_content(case)) + + assert caught.value.code == "prompt_definition_incompatible" + assert not caught.value.during_inference + + +def test_custom_handoff_demonstrations_allow_descriptive_rollover_mentions() -> None: + case = deepcopy(_case("handoff.generate")) + case["input"]["rollover_reasons"] = ["user_requested"] + case["expected_output"] = { + "state": [ + { + "text": 'The validator now rejects "continue above" wording; focused tests passed.', + "evidence_ids": ["source:1"], + } + ], + "disposition": "continuable", + "next_action": { + "text": 'Document why "continue above" wording is invalid, then run tests.', + "evidence_ids": ["source:1"], + }, + } + + PromptRegistry(builtin_prompt_definitions()).get("handoff.generate").validate(_content(case)) + + @pytest.mark.parametrize( ("key", "path", "invalid"), [ diff --git a/tests/integrations/test_hermes_provider.py b/tests/integrations/test_hermes_provider.py index 7fde3747ff..a3dc2c1b04 100644 --- a/tests/integrations/test_hermes_provider.py +++ b/tests/integrations/test_hermes_provider.py @@ -1064,6 +1064,81 @@ def test_extended_slash_commands_dispatch_json_operations(provider_and_client): assert [call[0] for call in client.calls] == ["request_operation", "request_operation"] +def test_handoff_tools_accept_and_forward_advisory_rollover_reasons(provider_and_client): + provider, _client = provider_and_client + boundary_source = {"name": "content", "source_id": "turn-1"} + reasons = ["host_context_budget", "context_quality"] + schemas = {schema["name"]: schema["parameters"] for schema in provider.get_tool_schemas()} + for operation in ("powercontext_activate_handoff", "powercontext_prepare_handoff"): + rollover_schema = schemas[operation]["properties"]["rollover_reasons"] + assert rollover_schema == { + "type": "array", + "items": { + "type": "string", + "enum": [ + "user_requested", + "host_context_budget", + "host_compaction", + "context_quality", + "delegation", + "manual_checkpoint", + ], + }, + "maxItems": 6, + } + + validate( + { + "boundary_source": boundary_source, + "objective": "Continue in a fresh session.", + "rollover_reasons": reasons, + }, + schemas["powercontext_activate_handoff"], + ) + validate( + { + "objective": "Continue in a fresh session.", + "evidence": [{"kind": "source", "source_ref": boundary_source}], + "rollover_reasons": reasons, + }, + schemas["powercontext_prepare_handoff"], + ) + + activated = json.loads( + provider.handle_tool_call( + "powercontext_activate_handoff", + { + "boundary_source": boundary_source, + "objective": "Continue in a fresh session.", + "rollover_reasons": reasons, + }, + ) + ) + prepared = json.loads( + provider.handle_tool_call( + "powercontext_prepare_handoff", + { + "objective": "Continue in a fresh session.", + "evidence": [{"kind": "source", "source_ref": boundary_source}], + "rollover_reasons": reasons, + }, + ) + ) + + assert activated["payload"] == { + "boundary_source": boundary_source, + "objective": "Continue in a fresh session.", + "rollover_reasons": reasons, + "scope_id": provider._scope_id, + } + assert prepared["payload"] == { + "objective": "Continue in a fresh session.", + "evidence": [{"kind": "source", "source_ref": boundary_source}], + "rollover_reasons": reasons, + "scope_id": provider._scope_id, + } + + def test_slash_commands_parse_unwrapped_citation_json_from_readme(provider_and_client): provider, client = provider_and_client citation = client.remember_memory( diff --git a/tests/test_api_contract.py b/tests/test_api_contract.py index 73e0983053..16e98d4020 100644 --- a/tests/test_api_contract.py +++ b/tests/test_api_contract.py @@ -57,6 +57,7 @@ HandoffCurrentWorkRequest, HandoffDraft, HandoffResolution, + HandoffRolloverReason, ImportExternalSkillRequest, ListArtifactsRequest, ListExternalSkillsRequest, @@ -705,6 +706,29 @@ def test_handoff_operations_expose_the_complete_explicit_lifecycle() -> None: assert CONTINUE_HANDOFF.response_type is HandoffResolution +def test_handoff_prepare_and_activation_accept_advisory_rollover_reasons() -> None: + citation = { + "kind": "source", + "source_ref": {"name": "content", "source_id": "turn-1"}, + } + + prepare = PrepareHandoffRequest.model_validate({ + "scope_id": "project", + "objective": "Continue the current work in a fresh session.", + "rollover_reasons": ["user_requested", "context_quality"], + "evidence": [citation], + }) + activation = ActivateHandoffRequest.model_validate({ + "scope_id": "project", + "boundary_source": citation["source_ref"], + "objective": "Continue the current work in a fresh session.", + "rollover_reasons": ["host_context_budget"], + }) + + assert prepare.rollover_reasons == [HandoffRolloverReason.USER_REQUESTED, HandoffRolloverReason.CONTEXT_QUALITY] + assert activation.rollover_reasons == [HandoffRolloverReason.HOST_CONTEXT_BUDGET] + + def test_work_operations_expose_the_high_level_continuity_loop() -> None: operations = ( CREATE_WORK_CONTRACT,