Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 15 additions & 4 deletions loopx/control_plane/turn_driver/capability_context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,15 @@ const RESERVED = new Set([
]);
const FIELD = /^[a-z][a-z0-9_]{2,95}$/;

function contextSource(value: unknown): JsonObject {
function contextSource(value: unknown, bindReaderLocation = false): JsonObject {
const source = requireJsonObject(value, "context source");
// Reuse the governing owner. Binding the original observation adds detail
// locations/measurements without changing the authorized decision.
// The replan reader generates commands that carry the capture's own hash.
// Bind the governing action and settlement rather than these read addresses.
const {replan_read_path: _readerPath, ...beforeReaderBinding} = source;
return {goal_id: source.goal_id, agent_id: source.agent_id,
governing: "heartbeat_receipt" in source
? quotaActionSignatureDocument(source) : turnEnvelopeActionSignatureDocument(source)};
? quotaActionSignatureDocument(bindReaderLocation ? source : beforeReaderBinding)
: turnEnvelopeActionSignatureDocument(source)};
}

function fields(value: unknown, label: string): JsonObject {
Expand Down Expand Up @@ -82,6 +84,15 @@ export function projectTurnContextContribution(input: JsonObject): JsonObject {
result: {context_fields: contribution.context_fields, instructions: contribution.instructions,
result_fields: contribution.result_fields, readback}});
if (contribution.basis_digest !== normalized.basis_digest) {
const source = requireJsonObject(input.source, "context source");
// An existing unversioned quota observation may already have been bound
// to its exact reader. Preserve that basis only on an exact legacy match.
if ("heartbeat_receipt" in source && !Object.hasOwn(source, "contract_binding") &&
typeof source.replan_read_path === "string") {
const {basis_digest: _currentBasis, ...value} = normalized;
const legacyBasis = canonicalAuthoritySha256({source: contextSource(source, true), ...value});
if (contribution.basis_digest === legacyBasis) return {...normalized, basis_digest: legacyBasis};
}
throw new Error("context source or contribution changed after registration");
}
return normalized;
Expand Down
51 changes: 51 additions & 0 deletions tests/control_plane/test_replan_progress_native_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,57 @@ def test_progress_cli_reads_policy_with_context_delivery(
restart_effect_runtime(wait_for_exit=True)


@pytest.mark.parametrize("provider", ["file", "sqlite"])
def test_captured_replan_preserves_scoped_memory_without_trusting_reader_location(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture, provider: str,
) -> None:
from copy import deepcopy

from loopx.cli import main
from loopx.capabilities.agent_turn_recall.runtime import run_configured_agent_turn_recall_fail_open
from loopx.capabilities.reward_memory.experiment import resolve_reward_memory_experiment
from loopx.control_plane.capability_hooks import project_turn_context
from tests.capabilities.test_agent_turn_recall import RecallProvider, active_record
from tests.control_plane.reward_memory_host_fixture import enable_live_memory

tmp_path = tmp_path.resolve()
isolate_sqlite_runtime(tmp_path, monkeypatch)
fixture = make_progress_cli_fixture(tmp_path, provider, "advanced")
registry, runtime = fixture["registry"], fixture["runtime"]
enable_live_memory(registry)
_, config = resolve_reward_memory_experiment(registry_path=registry, goal_id=GOAL, agent_id=AGENT)
memory = RecallProvider(active_record(config, expires_at="2099-01-01T00:00:00+00:00"))
monkeypatch.setattr("loopx.capabilities.agent_turn_recall.run_configured_agent_turn_recall_fail_open",
lambda **kw: run_configured_agent_turn_recall_fail_open(**kw, provider=memory))
monkeypatch.setenv("CODEX_THREAD_ID", "replan-memory-fixture")
monkeypatch.chdir(fixture["project"])
capture, journal = tmp_path / "capture", tmp_path / "native"
journal.mkdir(mode=0o700)
try:
code = main(["--registry", str(registry), "--runtime-root", str(runtime), "--format", "json",
"quota", "should-run", "--codex-app", "--goal-id", GOAL, "--agent-id", AGENT,
"--todo-id", TODO, "--turn-instance-id", TURN, "--decision-output-dir", str(capture),
"--context-delivery", "on", "--delta-delivery", "auto", "--codex-context-dir", str(journal),
"--prepared-capture-reference", "--defer-context-presentation"])
output = capsys.readouterr().out
assert code == 0, output
captured = (capture / "decision.json").read_bytes()
assert json.loads(output)["capture_sha256"] == hashlib.sha256(captured).hexdigest()
source = json.loads(captured)
assert source["replan_action_packet"]["decision"] == "replan_required"
assert source["reward_memory_recall"]["status"] == "applied" and memory.calls == 1
projected = project_turn_context(source)
assert projected["reward_memory_recall"]["context"]["guidance"]
relocated = {**source, "replan_read_path": str(tmp_path / "other-location.json")}
assert project_turn_context(relocated) == projected
changed = deepcopy(relocated)
plan = changed["interaction_contract"]["cli_channel"]["settlement_plan"]
plan["ordered_steps"][1]["command_template"] += " --evidence substituted"
assert project_turn_context(changed) == {}
finally:
restart_effect_runtime(wait_for_exit=True)


@pytest.mark.parametrize("provider", ["file", "sqlite"])
@pytest.mark.parametrize("context_args,navigation", [
((), False),
Expand Down
16 changes: 16 additions & 0 deletions tests/control_plane_ts/turn_capability_context.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import assert from "node:assert/strict";
import test from "node:test";
import {canonicalAuthoritySha256} from "../../loopx/control_plane/coordination/authority_store_codec.ts";
import {quotaActionSignatureDocument} from "../../loopx/control_plane/quota/turn_envelope.ts";
import {validateTurnContextRegistration, validateTurnContextContribution,
projectTurnContextContribution} from "../../loopx/control_plane/turn_driver/capability_context.ts";

Expand Down Expand Up @@ -27,6 +29,20 @@ const quotaSource = {goal_id: "fixture-goal", agent_id: "fixture-agent",
enabled: true, configured_for_agent: true, experiment_available: true,
automatic_recall: true, automatic_ingest: false}}}};

test("an existing quota contribution can replay with its exact legacy reader binding", () => {
const source = {...quotaSource, replan_read_path: "fixture/decision.json",
replan_action_packet: {obligation_id: "fixture-replan", decision: "replan_required"}};
const current = validateTurnContextContribution({registration, source, result});
const {basis_digest: currentBasis, ...value} = current;
const legacyBasis = canonicalAuthoritySha256({source: {goal_id: source.goal_id, agent_id: source.agent_id,
governing: quotaActionSignatureDocument(source)}, ...value});
assert.notEqual(legacyBasis, currentBasis);
const contribution = {...value, basis_digest: legacyBasis};
assert.deepEqual(projectTurnContextContribution({contribution, source, readback: result.readback}), contribution);
assert.throws(() => projectTurnContextContribution({contribution, readback: result.readback,
source: {...source, replan_read_path: "fixture/other.json"}}));
});

test("native quota context survives location-only capture binding", () => {
const contribution = validateTurnContextContribution({registration, source: quotaSource, result});
assert.deepEqual(projectTurnContextContribution({contribution, readback: result.readback,
Expand Down
Loading