diff --git a/docs/reference/required-work-context.md b/docs/reference/required-work-context.md index cf07e55b8a..67ad1f0bc5 100644 --- a/docs/reference/required-work-context.md +++ b/docs/reference/required-work-context.md @@ -187,6 +187,17 @@ conclusion, basis currency, independent verification, revisions and unknown fiel remain literal current input. Changed, removed or reordered evidence reconstructs the complete current source; failed or incomplete sources retain full delivery. +The registered preference owner permits exact reuse of its complete instructions, +statements, source quotes and source references. Each read still supplies the +current revision, membership, keys, lifecycle state, expiry, operation lineage +and unknown fields literally; an empty view replaces earlier preferences. +The existing scope-less preference hook uses the validated capture's exact +Goal/Agent binding, without adding fields to ordinary/default-off producer output +or parsing its read command. Explore retains its explicit scoped hook identity. +Unknown, failed, incompatible or ambiguous hook views stay complete. A changed +scope or lost baseline still selects full delivery; reuse never makes a retired +or expired preference actionable or grants authority. + Object edits may group complete current fields in `values`, alongside recursive `changed` edits and `removed` keys. Array edits use exact indices in `items` and `values`, with `length` for resizing. These literals are present in the current diff --git a/loopx/control_plane/capabilities/agent_preferences.ts b/loopx/control_plane/capabilities/agent_preferences.ts index 20ec90fb36..6443ab161f 100644 --- a/loopx/control_plane/capabilities/agent_preferences.ts +++ b/loopx/control_plane/capabilities/agent_preferences.ts @@ -8,7 +8,7 @@ import type {JsonObject} from "../effect_program.ts"; import type {AuthorityStore} from "../coordination/authority_store.ts"; import {FileAuthorityStore} from "../coordination/file_authority_store.ts"; import {canonicalAuthorityBytes} from "../coordination/authority_store_codec.ts"; -import {requireJsonObject, requireNonEmptyString, requireStringArray} from "../runtime_decode.ts"; +import {jsonObject, requireJsonObject, requireNonEmptyString, requireStringArray} from "../runtime_decode.ts"; type PreferenceSource = { key: string; source_kind: "user_instruction"; source_ref: string; source_quote: string; @@ -20,6 +20,27 @@ type Preference = PreferenceSource & ( ); const SCHEMA = "agent_preferences_v1"; const MAX_KEYS = 64; + +/** Exact prose/provenance reuse does not cache the current preference decision. + * Revision, membership, keys, state, expiry, operations and unknown fields stay + * current on every guard, including retirements and an empty replacement view. */ +export function agentPreferenceReusableContextPaths(content: JsonObject): (string | number)[][] { + const current = jsonObject(content.current); + if (content.ok !== true || content.status !== "read" || current?.schema_version !== SCHEMA + || current.authority !== "advisory_only" || typeof current.instructions !== "string" + || !Array.isArray(current.items) || current.items.length > MAX_KEYS) return []; + const paths: (string | number)[][] = [["current", "instructions"]], keys = new Set(); + for (const [index, raw] of current.items.entries()) { + const item = jsonObject(raw); + if (!item || typeof item.key !== "string" || !item.key || keys.has(item.key) + || !["active", "retired", "expired"].includes(String(item.state))) return []; + keys.add(item.key); + for (const field of ["statement", "source_ref", "source_quote"]) + if (typeof item[field] === "string") paths.push(["current", "items", index, field]); + } + return paths; +} + const hash = (value: JsonObject) => createHash("sha256").update(canonicalAuthorityBytes(value)).digest("hex"); function text(value: unknown, field: string, limit: number): string { const result = requireNonEmptyString(value, field); diff --git a/loopx/control_plane/capability_hooks.ts b/loopx/control_plane/capability_hooks.ts index 7f2fe2fdfa..e7cc90a1bb 100644 --- a/loopx/control_plane/capability_hooks.ts +++ b/loopx/control_plane/capability_hooks.ts @@ -13,6 +13,7 @@ import { import { requireBoolean, requireInteger, + jsonObject, requireJsonObject as requiredObject, requireNonEmptyString as requiredString, requireStringArray, @@ -22,13 +23,33 @@ import { projectPendingCapabilityIntent } from "./work_items/pending_capability_ import {exploreResultWritebackAffordance} from "./capabilities/explore_result_writeback.ts"; import {exploreReusableContextPaths} from "./capabilities/explore_turn_context.ts"; +import {agentPreferenceReusableContextPaths} from "./capabilities/agent_preferences.ts"; /** The registered owner selects reusable read-model subtrees. Unknown hooks * remain complete; payloads cannot nominate their own execution fields. */ -export function reusableTurnContextPaths(source: JsonObject): (string | number)[][] { - return source.source === "turn_start_capability_hook" && source.hook_id === "explore.turn_context" - && source.capability_id === "explore" && source.kind === "explore_turn_context" - ? exploreReusableContextPaths(source.content as JsonObject | undefined) : []; +export function reusableTurnContext(source: JsonObject, binding: JsonObject): { + identity: unknown[]; paths: (string | number)[][]; +} | null { + const content = jsonObject(source.content); + if (source.source !== "turn_start_capability_hook" || content?.ok !== true) return null; + let paths: (string | number)[][], goal: unknown, agent: unknown; + if (source.hook_id === "explore.turn_context" && source.capability_id === "explore" + && source.kind === "explore_turn_context") { + paths = exploreReusableContextPaths(content); + goal = content.goal_id; agent = content.agent_id; + } else if (source.hook_id === "semantic_preference.agent_context" && source.capability_id === "semantic-preference" + && source.kind === "agent_preferences") { + // This existing hook reads the guard's exact Goal/Agent but has no scope + // fields in its body. The validated capture already owns that binding; + // keep ordinary/default-off producer output unchanged. Never infer scope + // from prose, commands or an unregistered hook. + goal = binding.goal_id; agent = binding.agent_id; + if ((content.goal_id !== undefined && content.goal_id !== goal) + || (content.agent_id !== undefined && content.agent_id !== agent)) return null; + paths = agentPreferenceReusableContextPaths(content); + } else return null; + return paths.length && typeof goal === "string" && typeof agent === "string" + ? {identity: [source.source, source.hook_id, source.capability_id, goal, agent, content.todo_id ?? null], paths} : null; } export const CAPABILITY_HOOK_REGISTRATION_SCHEMA_VERSION = diff --git a/loopx/control_plane/quota/context_delivery.ts b/loopx/control_plane/quota/context_delivery.ts index 38a0068586..2e76385dac 100644 --- a/loopx/control_plane/quota/context_delivery.ts +++ b/loopx/control_plane/quota/context_delivery.ts @@ -4,7 +4,7 @@ import type {JsonObject} from "../effect_program.ts"; import {jsonObject, requireJsonObject, requireNonEmptyString} from "../runtime_decode.ts"; import {canonicalAuthorityJson, canonicalAuthoritySha256} from "../coordination/authority_store_codec.ts"; -import {reusableTurnContextPaths} from "../capability_hooks.ts"; +import {reusableTurnContext} from "../capability_hooks.ts"; import {COORDINATION_STATE_CONTRACT} from "../coordination/coordination_state_contract.generated.ts"; const VERSION = "context_delivery_v1"; @@ -53,7 +53,7 @@ function parentAt(root: JsonObject, path: Path): Record(); let valid = context.complete === true; const add = (identity: string, path: Path, structured = false) => { @@ -76,7 +76,8 @@ function splitContext(context: JsonObject): Split { if (!source || !content || content.ok === false || content.read_complete === false) continue; const prefix: Path = ["sources", i, "content"]; const kind = source.source; - const known = ["goal_state", "selected_todo", "goal_acceptance"].includes(String(kind)) || reusableTurnContextPaths(source).length > 0; + const reuse = reusableTurnContext(source, binding); + const known = ["goal_state", "selected_todo", "goal_acceptance"].includes(String(kind)) || reuse !== null; if (known && typeof source.reason === "string") add(`source_reason:${i}`, ["sources", i, "reason"]); const readModel = jsonObject(jsonObject(content.authority_read)?.todo_read_model); if (kind === "selected_todo" && Array.isArray(readModel?.contract_fields) @@ -143,10 +144,8 @@ function splitContext(context: JsonObject): Split { } } } else { - const paths = reusableTurnContextPaths(source); - if (!paths.length) continue; - if (content.ok !== true || typeof content.goal_id !== "string" || typeof content.agent_id !== "string") continue; - const identity = [kind, source.hook_id, source.capability_id, content.goal_id, content.agent_id, content.todo_id ?? null]; + if (!reuse) continue; + const {identity, paths} = reuse; const id = JSON.stringify(identity); if (sourceIds.has(id)) { valid = false; continue; } sourceIds.add(id); @@ -189,7 +188,7 @@ const REUSABLE_PACKET_FIELDS = new Set([ /** Current controls are forced into the change tree even when equal. This avoids * duplicating them in both a fresh frame and a patch. Static authoring guidance * remains binding via exact reuse; commands, conditions and identity stay fresh. */ -function freshPaths(context: JsonObject, surface: Surface, legacyQuotaFields: readonly string[]): {paths: Path[]; valid: boolean} { +function freshPaths(context: JsonObject, surface: Surface, legacyQuotaFields: readonly string[], binding: JsonObject): {paths: Path[]; valid: boolean} { const paths: Path[] = []; let valid = true; const leaves = (value: unknown, path: Path) => { @@ -201,7 +200,7 @@ function freshPaths(context: JsonObject, surface: Surface, legacyQuotaFields: re const work = (value: unknown, path: Path) => { const source = jsonObject(value); if (!source) { paths.push(path); return; } - const split = splitContext(source); valid &&= split.valid; + const split = splitContext(source, binding); valid &&= split.valid; leaves(split.fresh, path); }; const vision = (value: unknown, path: Path) => { @@ -485,7 +484,7 @@ export function prepareContextDelivery(request: JsonObject, legacyQuotaFields: r || binding.context_digest !== digest(prior)) return full("baseline_unavailable"); const depth = baseline?.ancestry; if (!Number.isSafeInteger(depth) || Number(depth) < 0 || Number(depth) >= MAX_ANCESTRY) return full("full_refresh_due"); - const before = freshPaths(prior, surface, legacyQuotaFields), after = freshPaths(context, surface, legacyQuotaFields); + const before = freshPaths(prior, surface, legacyQuotaFields, binding), after = freshPaths(context, surface, legacyQuotaFields, target); if (!before.valid || !after.valid) return full("source_not_reusable"); const changes = changeTree(prior, context, after.paths); const delta: JsonObject = {schema_version: VERSION, mode: "delta", target, base: binding, @@ -527,7 +526,7 @@ export function restoreContextDelivery(request: JsonObject, legacyQuotaFields: r || !equal(delivery.base, base) || !compatible(base, target) || base.context_digest !== digest(context) || !Number.isSafeInteger(baseline.ancestry) || Number(baseline.ancestry) < 0 || delivery.ancestry !== Number(baseline.ancestry) + 1 || Number(delivery.ancestry) > MAX_ANCESTRY) return fallback(); - const before = freshPaths(context, surface, legacyQuotaFields), after = freshPaths(currentContext, surface, legacyQuotaFields); + const before = freshPaths(context, surface, legacyQuotaFields, base), after = freshPaths(currentContext, surface, legacyQuotaFields, target); if (!before.valid || !after.valid) return fallback(); const expected = changeTree(context, currentContext, after.paths); // Also rejects omitted unchanged controls, redundant/conflicting edits, diff --git a/tests/control_plane/test_agent_preferences.py b/tests/control_plane/test_agent_preferences.py index 5ea0d06875..2ae14bda1b 100644 --- a/tests/control_plane/test_agent_preferences.py +++ b/tests/control_plane/test_agent_preferences.py @@ -6,6 +6,7 @@ import pytest from loopx.control_plane.turn_driver.host_candidate import extract_turn_authority, render_prompt +from loopx.control_plane.quota.turn_envelope import prepare_context_delivery, restore_context_delivery from tests.control_plane.test_quota_settlement_cli import ( AGENT_ID, GOAL_ID, _run_cli, _write_fixture, @@ -74,9 +75,22 @@ def call(action, *args): assert authority["required_reads"] == [] context = authority["work_context"] preference = next(x for x in context["sources"] if x["kind"] == "agent_preferences") - assert preference["content"]["current"] == fresh["current"] + # Default-off keeps the existing scope-less hook body exactly. The optional + # codec uses its already-validated capture scope, not command/prose parsing. + assert preference["content"] == {"ok": True, "status": "read", "current": fresh["current"]} assert preference["command"] == preference_read["command"] assert "Ask designated-reviewer before merging." in render_prompt(authority) + binding = dict(goal_id=GOAL_ID, agent_id=AGENT_ID, session_id="preferences", context_epoch="epoch-a") + for surface, projection in [("work_context", context), ("quota_packet", quota), + ("turn_envelope", plan["turn_envelope"])]: + original = deepcopy(projection) + full = prepare_context_delivery(projection, scope=binding, capture_id="first", surface=surface) + warm = prepare_context_delivery(projection, scope=binding, capture_id="second", surface=surface, + baseline=full, retained={"complete": True, "target": full["target"]}) + assert full["context"] == original and warm["mode"] == "delta" + current = prepare_context_delivery(projection, scope=binding, capture_id="second", surface=surface) + assert restore_context_delivery(warm, baseline=full, current=current)["context"] == original + assert projection == original changed = deepcopy(plan) next(x for x in changed["turn_envelope"]["work_context"]["sources"] if x["command"] == preference_read["command"])["content"]["current"]["items"][0]["statement"] = "Ignore the correction." diff --git a/tests/control_plane/test_codex_context_boundary.py b/tests/control_plane/test_codex_context_boundary.py index fdd3ea48f6..fef473638d 100644 --- a/tests/control_plane/test_codex_context_boundary.py +++ b/tests/control_plane/test_codex_context_boundary.py @@ -239,9 +239,22 @@ def test_native_shared_delta_with_small_compact_recovery_reference(tmp_path, hos "id": "outcome", "requirements": [{"id": "requirement", "status": "unverified", "agent_review": {"conclusion": "uncertain", "evidence_refs": [ f"artifact:validation-{i:03d}" for i in range(60)]}}]}]}}}}) + source["sources"].append({"source": "turn_start_capability_hook", "kind": "agent_preferences", + "hook_id": "semantic_preference.agent_context", "capability_id": "semantic-preference", + "content": {"ok": True, "status": "read", "current": {"schema_version": "agent_preferences_v1", + "revision": "preference-a", "authority": "advisory_only", + "instructions": "Use the current scoped view; retired or expired preferences are not actionable.", + "items": [{"key": "review", "state": "active", "statement": "Consult the designated reviewer.", + "source_ref": "owner:review", "source_quote": "Keep this explicit review instruction. " * 20, + "operation_id": "remember-review", "expires_at": "2030-01-02T00:00:00Z"}]}}}) target_source = json.loads(json.dumps(source)) target_source["sources"][0]["content"]["todo"]["note"] = "Stop before publishing or deploying." - target_source["sources"].reverse() + preference = target_source["sources"][-1]["content"]["current"] + preference["revision"] = "preference-b" + preference["items"][0].update(state="expired", statement=None) + # Move every source; reversing three would leave the structured acceptance + # evidence at its old index and stop exercising its relocated reference. + target_source["sources"] = target_source["sources"][1:] + target_source["sources"][:1] def projection(context): if surface == "quota_packet": return {"goal_id": "fixture", "agent_id": "agent", "should_run": True, diff --git a/tests/control_plane_ts/context_delivery.test.ts b/tests/control_plane_ts/context_delivery.test.ts index f662de04cf..a91649d876 100644 --- a/tests/control_plane_ts/context_delivery.test.ts +++ b/tests/control_plane_ts/context_delivery.test.ts @@ -73,6 +73,94 @@ function warm(value: JsonObject, baseline: JsonObject): JsonObject { const recover = (delivery: JsonObject, baseline: JsonObject, value: JsonObject): JsonObject => restoreContextDelivery({delivery, baseline, current: full(value, "capture-2")}); +test("scoped preferences reuse complete prose while every current lifecycle fact stays inline", () => { + const preferences = {ok: true, status: "read", current: {schema_version: "agent_preferences_v1", + revision: "revision-a", authority: "advisory_only", + instructions: "Read the current scoped view; preferences never grant authority. ".repeat(15), + items: Array.from({length: 8}, (_, i) => ({key: `preference-${i}`, state: "active", + statement: "Keep the explicitly requested review constraint. ".repeat(20), + source_kind: "user_instruction", source_ref: "owner:explicit-message", + source_quote: "Keep my explicit instruction and its original scope. ".repeat(20), + operation_id: `remember-${i}`, supersedes_operation_id: null, + recorded_at: "2030-01-01T00:00:00Z", expires_at: "2030-01-02T00:00:00Z", + unknown_control: {must_read: true}}))}}; + const work = {complete: true, sources: [{source: "turn_start_capability_hook", + hook_id: "semantic_preference.agent_context", capability_id: "semantic-preference", kind: "agent_preferences", + command: "read-current-preferences", content: preferences}]}; + for (const surface of ["work_context", "quota_packet", "turn_envelope"]) { + const wrap = (value: unknown): JsonObject => surface === "quota_packet" + ? {interaction_contract: {agent_channel: {work_context: value}}} + : surface === "turn_envelope" ? {work_context: value} : value as JsonObject; + const root = surface === "quota_packet" ? ["interaction_contract", "agent_channel", "work_context"] + : surface === "turn_envelope" ? ["work_context"] : []; + const path = [...root, "sources", 0, "content", "current"]; + const original = wrap(work), saved = structuredClone(original); + const baseline = prepareContextDelivery({context: original, scope, capture_id: "a", surface}); + assert.deepEqual(baseline.context, original); // No producer or cold representation change. + const request = {context: original, scope, capture_id: "b", surface}; + const delta = prepareContextDelivery({...request, baseline, retained: {complete: true, target: baseline.target}}); + assert.equal(delta.mode, "delta"); + assert.ok(JSON.stringify(delta).length < JSON.stringify(baseline).length / 2); + for (const key of ["schema_version", "revision", "authority"]) + assert.deepEqual(inline(delta, [...path, key]), preferences.current[key as keyof typeof preferences.current]); + for (const [i, item] of preferences.current.items.entries()) { + for (const key of ["key", "state", "expires_at", "operation_id", "supersedes_operation_id", "recorded_at", "unknown_control"]) + assert.deepEqual(inline(delta, [...path, "items", i, key]), item[key as keyof typeof item]); + for (const key of ["statement", "source_ref", "source_quote"]) + assert.equal(inline(delta, [...path, "items", i, key]), undefined); + } + assert.equal(inline(delta, [...path, "instructions"]), undefined); + const changed = structuredClone(work); + changed.sources[0].content.current.revision = "revision-b"; + changed.sources[0].content.current.items[0].statement = "Use the corrected constraint."; + changed.sources[0].content.current.items[0].source_quote = "The owner corrected the earlier instruction."; + const cases: JsonObject[] = [changed]; + for (const state of ["retired", "expired"]) { + // Expiry can change the current view without a journal revision change. + const next = structuredClone(work) as JsonObject; + const current = source(next, 0).current as JsonObject; + (current.items as JsonObject[])[0] = {...(current.items as JsonObject[])[0], state, statement: null}; + cases.push(next); + } + const reordered = structuredClone(work); reordered.sources[0].content.current.items.reverse(); cases.push(reordered); + const removed = structuredClone(work); removed.sources[0].content.current.items.splice(2, 1); cases.push(removed); + const empty = structuredClone(work); empty.sources[0].content.current.items = []; cases.push(empty); + for (const next of cases) { + const args = {...request, context: wrap(next)}; + const wire = prepareContextDelivery({...args, baseline, retained: {complete: true, target: baseline.target}}); + const capture = prepareContextDelivery(args); + assert.deepEqual(restoreContextDelivery({delivery: wire, baseline, current: capture}).context, wrap(next)); + if (wire.mode === "delta") { + const view = source(next, 0).current as typeof preferences.current; + assert.equal(inline(wire, [...path, "revision"]), view.revision); + if (view.items.length) assert.equal(inline(wire, [...path, "items", 0, "state"]), view.items[0].state); + else assert.deepEqual(inline(wire, [...path, "items"]), []); + } + } + for (const field of ["goal_id", "agent_id", "context_epoch"]) { + assert.equal(prepareContextDelivery({...request, scope: {...scope, [field]: "different"}, baseline, + retained: {complete: true, target: baseline.target}}).mode, "full"); + } + for (const field of ["hook_id", "capability_id", "kind"]) { + const unknown = structuredClone(work); (unknown.sources[0] as JsonObject)[field] = "unknown"; + const wire = prepareContextDelivery({...request, context: wrap(unknown), baseline, + retained: {complete: true, target: baseline.target}}); + assert.equal(wire.mode, "full"); + } + for (const content of [{...preferences, ok: false}, {...preferences, status: "unavailable"}, + {...preferences, goal_id: "other-goal"}, {...preferences, agent_id: "other-agent"}, + {...preferences, current: {...preferences.current, schema_version: "unknown"}}, + {...preferences, current: {...preferences.current, authority: "execution_grant"}}, + {...preferences, current: {...preferences.current, items: [preferences.current.items[0], preferences.current.items[0]]}}]) { + const failed = structuredClone(work); failed.sources[0].content = content; + const wire = prepareContextDelivery({...request, context: wrap(failed), baseline, + retained: {complete: true, target: baseline.target}}); + assert.equal(wire.mode, "full"); + } + assert.deepEqual(original, saved); + } +}); + // Read an inline value from the wire, without borrowing any baseline content. // Tests use this independent walker to verify controls really reach each input. function inline(delivery: JsonObject, path: (string | number)[]): unknown {