Skip to content

Commit 77fa0dd

Browse files
authored
fix(memory): accept bound selection receipts in turn recall (#7098)
1 parent 2aa12dd commit 77fa0dd

4 files changed

Lines changed: 323 additions & 20 deletions

File tree

‎loopx/capabilities/agent_turn_recall/README.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,13 @@ keeps turn admission bounded and ensures the recall query uses the same selected
7070
Todo and interaction state that the host is following. `-` may be used to read
7171
the packet from stdin when the host owns a safe pipeline.
7272

73+
An explicit selection or requalification may return an `upgraded` committed
74+
receipt. Recall accepts that packet with its exact settlement identity, checking
75+
the Goal, Agent, Turn, selected Todo or replan obligation, and canonical effect
76+
identity. A missing or inconsistent binding is rejected before provider access.
77+
Legacy `committed` and `replayed` packets without a settlement binding remain
78+
valid; all existing conflict, freshness, scope and deduplication checks still apply.
79+
7380
The result carries a private `context.guidance` list for agent reasoning. It is
7481
not action authority. The agent must still obey the current interaction
7582
contract, capability gates, write scopes, and user gates.

‎loopx/capabilities/agent_turn_recall/cli.py‎

Lines changed: 62 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@
1010

1111
from ...history import load_registry
1212
from ...materials import find_registry_goal, goal_repo
13+
from ...control_plane.quota.effect_program import SettlementIdentity
14+
from ...control_plane.work_items.autonomous_replan_obligation import (
15+
replan_obligation_id_from_packet,
16+
)
1317
from ..reward_memory.experiment import resolve_reward_memory_experiment
1418
from .core import (
1519
AGENT_TURN_RECALL_SCHEMA_VERSION,
@@ -51,6 +55,22 @@ def _render(payload: dict[str, object]) -> str:
5155
return "\n".join(lines) + "\n"
5256

5357

58+
def _runtime_unavailable_payload(goal_id: str, agent_id: str) -> dict[str, Any]:
59+
return {
60+
"ok": False,
61+
"schema_version": AGENT_TURN_RECALL_SCHEMA_VERSION,
62+
"status": "runtime_unavailable",
63+
"reason_code": "automatic_recall_runtime_failed",
64+
"goal_id": goal_id,
65+
"agent_id": agent_id,
66+
"provider_call_count": 0,
67+
"grants_new_action_authority": False,
68+
"quota_spend_performed": False,
69+
"external_writes_performed": False,
70+
"suppress_external_sinks": True,
71+
}
72+
73+
5474
def register_agent_turn_recall_commands(
5575
subparsers: argparse._SubParsersAction[argparse.ArgumentParser],
5676
add_subcommand_format: Callable[[argparse.ArgumentParser], None],
@@ -132,8 +152,37 @@ def _validate_quota_identity(
132152
raise ValueError(
133153
"quota decision turn_instance_id does not match --turn-instance-id"
134154
)
135-
if receipt.get("status") not in {"committed", "replayed"}:
155+
if receipt.get("status") not in {"committed", "replayed", "upgraded"}:
136156
raise ValueError("quota decision heartbeat receipt is not committed")
157+
binding = receipt.get("settlement_identity")
158+
if binding is None and receipt.get("status") != "upgraded":
159+
return
160+
try:
161+
identity = SettlementIdentity.from_runtime_payload(binding)
162+
except RuntimeError:
163+
raise ValueError("quota decision settlement identity is invalid") from None
164+
if (
165+
identity.goal_id != goal_id
166+
or identity.agent_id != agent_id
167+
or identity.turn_instance_id != turn_instance_id
168+
):
169+
raise ValueError("quota decision settlement identity does not match this turn")
170+
if identity.todo_id:
171+
if identity.todo_id != _mapping(quota_decision.get("selected_todo")).get("todo_id"):
172+
raise ValueError("quota decision settlement identity does not match selected Todo")
173+
elif identity.replan_obligation_id != replan_obligation_id_from_packet(
174+
quota_decision.get("autonomous_replan_obligation")
175+
):
176+
raise ValueError("quota decision settlement identity does not match replan obligation")
177+
expected = SettlementIdentity(
178+
goal_id=goal_id,
179+
agent_id=agent_id,
180+
todo_id=identity.todo_id,
181+
turn_instance_id=turn_instance_id,
182+
replan_obligation_id=identity.replan_obligation_id,
183+
)
184+
if identity.effect_id != expected.effect_id:
185+
raise ValueError("quota decision settlement effect identity is invalid")
137186

138187

139188
def _load_receipt(path: Path) -> dict[str, Any] | None:
@@ -207,12 +256,17 @@ def handle_agent_turn_recall_command(
207256
else:
208257
identity = _identity_scope(config)
209258
quota_decision = _quota_decision(args.quota_decision_json)
210-
_validate_quota_identity(
211-
quota_decision,
212-
goal_id=args.goal_id,
213-
agent_id=args.agent_id,
214-
turn_instance_id=args.turn_instance_id,
215-
)
259+
try:
260+
_validate_quota_identity(
261+
quota_decision,
262+
goal_id=args.goal_id,
263+
agent_id=args.agent_id,
264+
turn_instance_id=args.turn_instance_id,
265+
)
266+
except RuntimeError:
267+
payload = _runtime_unavailable_payload(args.goal_id, args.agent_id)
268+
print_payload(payload, output_format(args), _render)
269+
return 2
216270
situation = build_agent_turn_situation(
217271
quota_decision,
218272
goal_id=args.goal_id,
@@ -250,19 +304,7 @@ def handle_agent_turn_recall_command(
250304
except RuntimeError:
251305
# This failure precedes the provider; do not fabricate a
252306
# zero-call receipt for errors after recall has begun.
253-
payload = {
254-
"ok": False,
255-
"schema_version": AGENT_TURN_RECALL_SCHEMA_VERSION,
256-
"status": "runtime_unavailable",
257-
"reason_code": "automatic_recall_runtime_failed",
258-
"goal_id": args.goal_id,
259-
"agent_id": args.agent_id,
260-
"provider_call_count": 0,
261-
"grants_new_action_authority": False,
262-
"quota_spend_performed": False,
263-
"external_writes_performed": False,
264-
"suppress_external_sinks": True,
265-
}
307+
payload = _runtime_unavailable_payload(args.goal_id, args.agent_id)
266308
print_payload(payload, output_format(args), _render)
267309
return 2
268310
payload = run_agent_turn_recall(

‎tests/capabilities/test_agent_turn_recall.py‎

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
load_reward_memory_experiment_config,
4444
resolve_reward_memory_surface_config,
4545
)
46+
from loopx.control_plane.quota.effect_program import SettlementIdentity
4647

4748

4849
SURFACE = "agent_workflow.turn_admission"
@@ -436,6 +437,75 @@ def test_configured_session_scope_is_canonical() -> None:
436437
_resolve_session_ref(identity, "session:other")
437438

438439

440+
def bound_quota_decision(*, status: str = "upgraded") -> dict[str, Any]:
441+
turn = "turn-selected-recall"
442+
return quota_decision() | {
443+
"goal_id": "goal",
444+
"agent_identity": {"agent_id": "pilot"},
445+
"heartbeat_receipt": {
446+
"turn_instance_id": turn,
447+
"status": status,
448+
"settlement_identity": SettlementIdentity(
449+
goal_id="goal", agent_id="pilot", todo_id="todo_123",
450+
turn_instance_id=turn,
451+
).as_dict(),
452+
},
453+
}
454+
455+
456+
@pytest.mark.parametrize("status", ["committed", "replayed", "upgraded"])
457+
def test_bound_quota_receipt_matches_selected_work(status: str) -> None:
458+
_validate_quota_identity(
459+
bound_quota_decision(status=status), goal_id="goal", agent_id="pilot",
460+
turn_instance_id="turn-selected-recall",
461+
)
462+
463+
464+
@pytest.mark.parametrize(
465+
("field", "value"),
466+
[("goal_id", "other"), ("agent_id", "other"),
467+
("turn_instance_id", "other"), ("todo_id", "todo_other"),
468+
("effect_id", "unrelated-effect"), ("schema_version", "unknown")],
469+
)
470+
def test_selected_quota_receipt_rejects_inconsistent_binding(field: str, value: str) -> None:
471+
decision = bound_quota_decision()
472+
decision["heartbeat_receipt"]["settlement_identity"][field] = value
473+
with pytest.raises(ValueError, match="settlement"):
474+
_validate_quota_identity(
475+
decision, goal_id="goal", agent_id="pilot",
476+
turn_instance_id="turn-selected-recall",
477+
)
478+
479+
480+
def test_upgraded_quota_receipt_requires_committed_binding() -> None:
481+
decision = bound_quota_decision()
482+
del decision["heartbeat_receipt"]["settlement_identity"]
483+
with pytest.raises(ValueError, match="settlement identity"):
484+
_validate_quota_identity(
485+
decision, goal_id="goal", agent_id="pilot",
486+
turn_instance_id="turn-selected-recall",
487+
)
488+
489+
490+
def test_upgraded_replan_receipt_uses_its_own_binding() -> None:
491+
decision = bound_quota_decision()
492+
decision["heartbeat_receipt"]["settlement_identity"] = SettlementIdentity(
493+
goal_id="goal", agent_id="pilot", todo_id=None,
494+
turn_instance_id="turn-selected-recall", replan_obligation_id="replan-0123456789abcdef",
495+
).as_dict()
496+
decision["autonomous_replan_obligation"] = {"obligation_id": "replan-0123456789abcdef"}
497+
_validate_quota_identity(
498+
decision, goal_id="goal", agent_id="pilot",
499+
turn_instance_id="turn-selected-recall",
500+
)
501+
decision["autonomous_replan_obligation"]["obligation_id"] = "replan-fedcba9876543210"
502+
with pytest.raises(ValueError, match="replan obligation"):
503+
_validate_quota_identity(
504+
decision, goal_id="goal", agent_id="pilot",
505+
turn_instance_id="turn-selected-recall",
506+
)
507+
508+
439509
def test_same_turn_receipt_round_trip_reuses_private_context(tmp_path: Path) -> None:
440510
receipt_path = tmp_path / "pilot.json"
441511
receipt = {
@@ -613,6 +683,37 @@ def unavailable(operation: str, _params: Any) -> Any:
613683
assert not agent_turn_recall_receipt_path(tmp_path, goal_id="goal", agent_id="pilot").exists()
614684

615685

686+
def test_selected_receipt_identity_transport_failure_stops_before_provider(
687+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch,
688+
) -> None:
689+
from loopx.control_plane import effect_program
690+
691+
config = normalized_config(tmp_path)
692+
decision = bound_quota_decision()
693+
monkeypatch.setattr(recall_cli, "resolve_reward_memory_experiment", lambda **_kwargs: ({"status": "available"}, config))
694+
monkeypatch.setattr(recall_cli, "_goal_repo", lambda *_args: tmp_path)
695+
monkeypatch.setattr(recall_cli, "_quota_decision", lambda *_args: decision)
696+
697+
def unavailable(*_args: Any, **_kwargs: Any) -> Any:
698+
raise RuntimeError("private runtime startup detail")
699+
700+
monkeypatch.setattr(effect_program, "effect_runtime_result", unavailable)
701+
effect_program._settlement_identity_full.cache_clear()
702+
monkeypatch.setattr(recall_cli, "run_agent_turn_recall", lambda *_args, **_kwargs: pytest.fail("provider boundary reached"))
703+
payloads: list[dict[str, Any]] = []
704+
code = recall_cli.handle_agent_turn_recall_command(
705+
argparse.Namespace(command="agent-turn-recall", goal_id="goal", agent_id="pilot",
706+
turn_instance_id="turn-selected-recall", quota_decision_json="unused",
707+
session_ref=None, force_refresh=False, execute=True),
708+
registry_path=tmp_path / "registry.json", output_format=lambda *_args: "json",
709+
print_payload=lambda payload, *_args: payloads.append(payload),
710+
)
711+
assert code == 2 and payloads[0]["status"] == "runtime_unavailable"
712+
assert payloads[0]["provider_call_count"] == 0
713+
assert "private runtime" not in json.dumps(payloads[0])
714+
assert not agent_turn_recall_receipt_path(tmp_path, goal_id="goal", agent_id="pilot").exists()
715+
716+
616717
def test_expiry_survives_candidate_review_and_activation(tmp_path: Path) -> None:
617718
config = normalized_config(tmp_path)
618719
corpus = resolve_reward_memory_surface_config(config, SURFACE)["corpus"]

0 commit comments

Comments
 (0)