From c8df5e5bb8947c0edfb9ec80282d0e05136c41df Mon Sep 17 00:00:00 2001 From: huangruiteng Date: Sun, 6 Sep 2026 18:29:55 +0800 Subject: [PATCH 01/10] feat(periodic-report): add agent-authorized typed requests Signed-off-by: huangruiteng --- loopx/capabilities/periodic_report/cli.py | 70 ++- .../periodic_report/pending_intent.py | 181 +++++-- .../periodic_report/post_writeback_hook.py | 74 ++- .../periodic_report/request_action.py | 495 ++++++++++++++++++ loopx/extensions/hook_adapters.py | 175 +++++++ loopx/extensions/lark/event_inbox.py | 61 ++- loopx/extensions/lark/extension.toml | 14 +- loopx/extensions/lark/goal_topic_runtime.py | 2 + loopx/extensions/lark/group_history.py | 3 + .../lark/periodic_report_request.py | 413 +++++++++++++++ loopx/extensions/lark/provider.py | 5 + loopx/extensions/manifest.py | 100 ++++ 12 files changed, 1548 insertions(+), 45 deletions(-) create mode 100644 loopx/capabilities/periodic_report/request_action.py create mode 100644 loopx/extensions/hook_adapters.py create mode 100644 loopx/extensions/lark/periodic_report_request.py diff --git a/loopx/capabilities/periodic_report/cli.py b/loopx/capabilities/periodic_report/cli.py index 7903d6d2de..68dd1c3212 100644 --- a/loopx/capabilities/periodic_report/cli.py +++ b/loopx/capabilities/periodic_report/cli.py @@ -31,6 +31,10 @@ from .runtime_producer import build_periodic_report_runtime_trigger_decision from .triggers import build_periodic_report_trigger_decision from .pending_intent import consume_pending_periodic_report_intent +from .request_action import ( + discover_periodic_report_request_ports, + record_periodic_report_request, +) from ...paths import resolve_runtime_root from ...registry import read_json @@ -98,6 +102,22 @@ def register_periodic_report_commands( required=True, help="Path to periodic_report_runtime_trigger_request_v0 JSON.", ) + request = commands.add_parser( + "request", + help=( + "Record an Agent-authorized typed report request for one exact " + "provider source item." + ), + ) + add_subcommand_format(request) + request.add_argument("--goal-id", required=True) + request.add_argument("--agent-id", required=True) + request.add_argument("--source-ref", required=True) + request.add_argument("--execute", action="store_true") + request.add_argument( + "--extension-state-file", + help="Override local extension activation state for this action.", + ) consume_pending = commands.add_parser( "consume-pending", help=( @@ -109,6 +129,10 @@ def register_periodic_report_commands( consume_pending.add_argument("--goal-id", required=True) consume_pending.add_argument("--agent-id", required=True) consume_pending.add_argument("--execute", action="store_true") + consume_pending.add_argument( + "--extension-state-file", + help="Override local extension activation state for source settlement.", + ) configure_machine_defaults = commands.add_parser( "configure-machine-defaults", help="Preview or apply the runtime-root machine periodic-report policy.", @@ -424,16 +448,56 @@ def handle_periodic_report_command( strict=True, ), ) + elif args.periodic_report_command == "request": + registry = read_json(registry_path) + runtime_root = resolve_runtime_root( + registry, runtime_root_arg, registry_path=registry_path + ) + ports = discover_periodic_report_request_ports( + registry_path=registry_path, + runtime_root=runtime_root, + goal_id=args.goal_id, + agent_id=args.agent_id, + extension_state_file=( + Path(args.extension_state_file).expanduser() + if args.extension_state_file + else None + ), + ) + payload = record_periodic_report_request( + registry_path=registry_path, + runtime_root=runtime_root, + goal_id=args.goal_id, + agent_id=args.agent_id, + source_ref=args.source_ref, + bind_source=ports.bind_source, + adapter_id=ports.adapter_id, + execute=bool(args.execute), + ) elif args.periodic_report_command == "consume-pending": registry = read_json(registry_path) - payload = consume_pending_periodic_report_intent( + runtime_root = resolve_runtime_root( + registry, runtime_root_arg, registry_path=registry_path + ) + ports = discover_periodic_report_request_ports( registry_path=registry_path, - runtime_root=resolve_runtime_root( - registry, runtime_root_arg, registry_path=registry_path + runtime_root=runtime_root, + goal_id=args.goal_id, + agent_id=args.agent_id, + extension_state_file=( + Path(args.extension_state_file).expanduser() + if args.extension_state_file + else None ), + ) + payload = consume_pending_periodic_report_intent( + registry_path=registry_path, + runtime_root=runtime_root, goal_id=args.goal_id, agent_id=args.agent_id, execute=bool(args.execute), + provider_request_settler=ports.settle_source, + provider_request_adapter_id=ports.adapter_id, ) elif args.periodic_report_command in { "configure-machine-defaults", diff --git a/loopx/capabilities/periodic_report/pending_intent.py b/loopx/capabilities/periodic_report/pending_intent.py index d79a131611..9155d692a0 100644 --- a/loopx/capabilities/periodic_report/pending_intent.py +++ b/loopx/capabilities/periodic_report/pending_intent.py @@ -38,6 +38,12 @@ PERIODIC_REPORT_TRIGGER_EVALUATION_INTENT, evaluate_periodic_report_trigger_evaluation_intent, ) +from .request_action import ( + SourceSettler, + periodic_report_request_intents, + request_entry_for_intent, + settle_periodic_report_request, +) from .project_progress_snapshot import build_project_progress_snapshot from .incremental import ( build_periodic_report_publication_candidate, @@ -354,24 +360,17 @@ def pending_periodic_report_intents( if not _IDENTITY_RE.fullmatch(goal_id) or not _IDENTITY_RE.fullmatch(agent_id): return [] - sidecars = runtime_root / "goals" / goal_id / "post_writeback_hooks" - if not sidecars.is_dir(): - return [] pending: list[dict[str, Any]] = [] - for path in sorted(sidecars.iterdir()): - if not path.is_file() or not _DISPATCH_RE.fullmatch(path.name): - continue + for intent in periodic_report_request_intents( + runtime_root=runtime_root, + goal_id=goal_id, + agent_id=agent_id, + ): try: - value = json.loads(path.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError): + decision = evaluate_periodic_report_trigger_evaluation_intent(intent) + except ValueError: continue - intent = _valid_sidecar_intent( - value, - dispatch_id=path.stem, - goal_id=goal_id, - agent_id=agent_id, - ) - if intent is None: + if decision.get("eligible") is not True: continue actionable, _revision = _next_attempt_revision( registry_path=registry_path, @@ -380,10 +379,45 @@ def pending_periodic_report_intents( agent_id=agent_id, intent=intent, ) - if not actionable: - continue - pending.append(intent) - return pending + receipt = _load_consumption_receipt( + _receipt_path(runtime_root, goal_id, intent), + intent_digest=_canonical_digest(intent), + ) + if actionable or ( + isinstance(receipt, Mapping) and receipt.get("status") == "delivery_ready" + ): + pending.append(intent) + + sidecars = runtime_root / "goals" / goal_id / "post_writeback_hooks" + if sidecars.is_dir(): + for path in sorted(sidecars.iterdir()): + if not path.is_file() or not _DISPATCH_RE.fullmatch(path.name): + continue + try: + value = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + continue + sidecar_intent = _valid_sidecar_intent( + value, + dispatch_id=path.stem, + goal_id=goal_id, + agent_id=agent_id, + ) + if sidecar_intent is None: + continue + actionable, _revision = _next_attempt_revision( + registry_path=registry_path, + runtime_root=runtime_root, + goal_id=goal_id, + agent_id=agent_id, + intent=sidecar_intent, + ) + if actionable: + pending.append(sidecar_intent) + deduplicated: dict[str, dict[str, Any]] = {} + for intent in pending: + deduplicated.setdefault(str(intent.get("idempotency_key") or ""), intent) + return list(deduplicated.values()) def periodic_report_pending_intent_interaction_hook( @@ -457,7 +491,10 @@ def produce() -> Mapping[str, Any]: hook_id=HOOK_ID, capability_id=CAPABILITY_ID, projection_slots=("pending_capability_intent",), - requested_read_scope=("post_writeback_intent_journal",), + requested_read_scope=( + "periodic_report_request_journal", + "post_writeback_intent_journal", + ), producer=produce, ) @@ -957,6 +994,8 @@ def consume_pending_periodic_report_intent( goal_id: str, agent_id: str, execute: bool, + provider_request_settler: SourceSettler | None = None, + provider_request_adapter_id: str | None = None, ) -> dict[str, Any]: intents = pending_periodic_report_intents( registry_path=registry_path, @@ -986,12 +1025,83 @@ def consume_pending_periodic_report_intent( intent = intents[0] trigger = evaluate_periodic_report_trigger_evaluation_intent(intent) payload = intent["payload"] - stage = payload["stage_completion"] - completed_at = str(stage["completed_at"]) + report_request = payload.get("report_request") + request_entry = ( + request_entry_for_intent( + runtime_root=runtime_root, + goal_id=goal_id, + agent_id=agent_id, + intent=intent, + ) + if isinstance(report_request, Mapping) + else None + ) + stage = payload.get("stage_completion") + completed_at = str( + report_request.get("requested_at") + if isinstance(report_request, Mapping) + else stage.get("completed_at") + if isinstance(stage, Mapping) + else "" + ) project_progress = payload.get("project_progress") fallback_capabilities = payload.get("available_capabilities") if fallback_capabilities is None and isinstance(payload.get("turn"), Mapping): fallback_capabilities = payload["turn"].get("available_capabilities") + actionable, rejection_revision = _next_attempt_revision( + registry_path=registry_path, + runtime_root=runtime_root, + goal_id=goal_id, + agent_id=agent_id, + intent=intent, + ) + if not actionable: + durable = _load_consumption_receipt( + _receipt_path( + runtime_root, + goal_id, + intent, + rejection_revision=rejection_revision, + ), + intent_digest=_canonical_digest(intent), + ) + if ( + request_entry is not None + and isinstance(durable, Mapping) + and durable.get("status") == "delivery_ready" + ): + settlement = settle_periodic_report_request( + registry_path=registry_path, + runtime_root=runtime_root, + goal_id=goal_id, + agent_id=agent_id, + intent=intent, + settle_source=provider_request_settler, + adapter_id=provider_request_adapter_id, + execute=execute, + ) + result = { + **dict(durable), + "source_settlement": settlement, + "settlement_only_retry": True, + } + if execute: + atomic_write_json( + _receipt_path( + runtime_root, + goal_id, + intent, + rejection_revision=rejection_revision, + ), + result, + ) + return result + return { + "ok": True, + "schema_version": CONSUMPTION_RECEIPT_SCHEMA, + "status": "no_pending_intent", + "external_writes_performed": False, + } facts = ( _progress_facts_from_snapshot( project_progress, @@ -1008,20 +1118,6 @@ def consume_pending_periodic_report_intent( available_capabilities=fallback_capabilities, ) ) - actionable, rejection_revision = _next_attempt_revision( - registry_path=registry_path, - runtime_root=runtime_root, - goal_id=goal_id, - agent_id=agent_id, - intent=intent, - ) - if not actionable: - return { - "ok": True, - "schema_version": CONSUMPTION_RECEIPT_SCHEMA, - "status": "no_pending_intent", - "external_writes_performed": False, - } request_path = _editorial_request_path( runtime_root, goal_id, @@ -1220,6 +1316,19 @@ def consume_pending_periodic_report_intent( "incremental_baseline": publication_candidate.get("incremental_baseline"), } atomic_write_json(receipt_path, durable) + if request_entry is not None: + settlement = settle_periodic_report_request( + registry_path=registry_path, + runtime_root=runtime_root, + goal_id=goal_id, + agent_id=agent_id, + intent=intent, + settle_source=provider_request_settler, + adapter_id=provider_request_adapter_id, + execute=True, + ) + durable["source_settlement"] = settlement + atomic_write_json(receipt_path, durable) return durable diff --git a/loopx/capabilities/periodic_report/post_writeback_hook.py b/loopx/capabilities/periodic_report/post_writeback_hook.py index e439769558..b4da2ed45d 100644 --- a/loopx/capabilities/periodic_report/post_writeback_hook.py +++ b/loopx/capabilities/periodic_report/post_writeback_hook.py @@ -29,6 +29,7 @@ from .machine_defaults import resolve_goal_periodic_report_subscription from .machine_store import read_periodic_report_machine_defaults from .triggers import build_periodic_report_trigger_decision +from .request_action import REQUEST_ACTION_SCHEMA PERIODIC_REPORT_POST_WRITEBACK_HOOK_ID = "periodic_report.runtime_trigger" @@ -419,13 +420,80 @@ def evaluate_periodic_report_trigger_evaluation_intent( or payload.get("external_delivery_authorized") is not False ): raise ValueError("periodic-report trigger intent authority is invalid") - stage = payload.get("stage_completion") profile_ref = payload.get("profile_ref") trigger_policy = payload.get("trigger_policy") - if not all( - isinstance(value, Mapping) for value in (stage, profile_ref, trigger_policy) + if not isinstance(profile_ref, Mapping) or not isinstance( + trigger_policy, Mapping ): raise ValueError("periodic-report trigger intent is missing typed facts") + report_request = payload.get("report_request") + if isinstance(report_request, Mapping): + expected = { + "schema_version", + "request_id", + "goal_id", + "agent_id", + "requested_at", + "source_digest", + "requester_kind", + "addressing_source", + } + if ( + set(report_request) != expected + or report_request.get("schema_version") != REQUEST_ACTION_SCHEMA + or report_request.get("requester_kind") != "user" + or report_request.get("addressing_source") + not in {"provider_mention", "verified_reply"} + or any( + not str(report_request.get(field) or "").strip() + for field in ( + "request_id", + "goal_id", + "agent_id", + "requested_at", + "source_digest", + ) + ) + ): + raise ValueError("periodic-report typed request is invalid") + requested_at = str(report_request["requested_at"]) + request_id = str(report_request["request_id"]) + evidence_digest = "sha256:" + hashlib.sha256( + json.dumps( + { + "request_id": request_id, + "goal_id": report_request["goal_id"], + "agent_id": report_request["agent_id"], + "source_digest": report_request["source_digest"], + }, + sort_keys=True, + separators=(",", ":"), + ).encode() + ).hexdigest() + return build_periodic_report_trigger_decision( + { + "schema_version": "periodic_report_trigger_request_v0", + "evaluated_at": requested_at, + "profile": { + "profile_id": profile_ref.get("profile_id"), + "profile_version": profile_ref.get("profile_version"), + }, + "trigger_policy": dict(trigger_policy), + "candidates": [ + { + "trigger_kind": "manual", + "observed_at": requested_at, + "source_ref": f"request:{request_id}", + "evidence_digest": evidence_digest, + "facts": {"authorized": True}, + } + ], + } + ) + + stage = payload.get("stage_completion") + if not isinstance(stage, Mapping): + raise ValueError("periodic-report trigger intent is missing typed facts") required_stage_fields = ( "stage_identity", "closed_vision_revision", diff --git a/loopx/capabilities/periodic_report/request_action.py b/loopx/capabilities/periodic_report/request_action.py new file mode 100644 index 0000000000..27eed4e8b8 --- /dev/null +++ b/loopx/capabilities/periodic_report/request_action.py @@ -0,0 +1,495 @@ +from __future__ import annotations + +import hashlib +import json +import re +from collections.abc import Callable, Mapping +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from ...agent_registry import registered_agent_ids_for_goal +from ...extensions.hook_adapters import discover_extension_hook_adapters +from ...extensions.runtime import default_extension_state_file +from ...file_lock import exclusive_file_lock +from ...registry import atomic_write_json, find_registry_goal, read_json +from .machine_defaults import resolve_goal_periodic_report_subscription +from .machine_store import read_periodic_report_machine_defaults +from .presets import build_periodic_report_preset_activation + + +REQUEST_ACTION_SCHEMA = "periodic_report_request_action_v0" +REQUEST_JOURNAL_ENTRY_SCHEMA = "periodic_report_request_journal_entry_v0" +REQUEST_RECEIPT_SCHEMA = "periodic_report_request_receipt_v0" +SOURCE_BINDING_RECEIPT_SCHEMA = "periodic_report_source_binding_receipt_v0" +SOURCE_SETTLEMENT_RECEIPT_SCHEMA = "periodic_report_source_settlement_receipt_v0" +REQUEST_HOOK_ID = "periodic_report.request" +REQUEST_BIND_PORT = "periodic_report.request.bind_source" +REQUEST_SETTLE_PORT = "periodic_report.request.settle_source" +REQUEST_ADAPTER_PHASE = "capability_action" +_REQUEST_FILE_RE = re.compile(r"^prq_[0-9a-f]{64}\.json$") +_IDENTITY_RE = re.compile(r"^[a-z][a-z0-9_.:-]{2,127}$") + +SourceBinder = Callable[..., Mapping[str, Any]] +SourceSettler = Callable[..., Mapping[str, Any]] + + +@dataclass(frozen=True, slots=True) +class PeriodicReportRequestPorts: + adapter_id: str | None + bind_source: SourceBinder | None + settle_source: SourceSettler | None + failure_count: int = 0 + + +def _digest(value: object) -> str: + encoded = json.dumps( + value, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ).encode("utf-8") + return "sha256:" + hashlib.sha256(encoded).hexdigest() + + +def _timestamp(value: object, label: str) -> str: + raw = str(value or "").strip() + try: + parsed = datetime.fromisoformat(raw.replace("Z", "+00:00")) + except ValueError as exc: + raise ValueError(f"{label} must be an ISO timestamp") from exc + if parsed.tzinfo is None: + raise ValueError(f"{label} must include a timezone") + return parsed.astimezone(timezone.utc).isoformat().replace("+00:00", "Z") + + +def _source_ref(value: object) -> str: + ref = str(value or "").strip() + if not ref or len(ref) > 256 or "\x00" in ref or "\n" in ref or "\r" in ref: + raise ValueError("periodic-report source-ref must be a bounded opaque value") + return ref + + +def _request_id(*, goal_id: str, agent_id: str, source_ref: str) -> str: + digest = hashlib.sha256( + f"{goal_id}\0{agent_id}\0{source_ref}".encode("utf-8") + ).hexdigest() + return f"prq_{digest}" + + +def _request_dir(runtime_root: Path, goal_id: str) -> Path: + return runtime_root / "goals" / goal_id / "periodic_report_requests" + + +def _request_path(runtime_root: Path, goal_id: str, request_id: str) -> Path: + if not _REQUEST_FILE_RE.fullmatch(f"{request_id}.json"): + raise ValueError("periodic-report request id is invalid") + return _request_dir(runtime_root, goal_id) / f"{request_id}.json" + + +def discover_periodic_report_request_ports( + *, + registry_path: Path, + runtime_root: Path, + goal_id: str, + agent_id: str, + extension_state_file: Path | None = None, +) -> PeriodicReportRequestPorts: + discovery = discover_extension_hook_adapters( + state_file=( + extension_state_file or default_extension_state_file(runtime_root) + ), + phase=REQUEST_ADAPTER_PHASE, + capability_id="periodic-report", + target_hook_id=REQUEST_HOOK_ID, + registry_path=registry_path, + runtime_root=runtime_root, + goal_id=goal_id, + agent_id=agent_id, + ) + adapters: dict[str, dict[str, SourceBinder | SourceSettler]] = {} + for binding in discovery.ports: + adapters.setdefault(binding.adapter_id, {})[binding.port_name] = binding.handler + complete = [ + (adapter_id, ports) + for adapter_id, ports in adapters.items() + if set(ports) == {REQUEST_BIND_PORT, REQUEST_SETTLE_PORT} + ] + if len(complete) != 1: + return PeriodicReportRequestPorts( + adapter_id=None, + bind_source=None, + settle_source=None, + failure_count=len(discovery.failures), + ) + adapter_id, ports = complete[0] + return PeriodicReportRequestPorts( + adapter_id=adapter_id, + bind_source=ports[REQUEST_BIND_PORT], + settle_source=ports[REQUEST_SETTLE_PORT], + failure_count=len(discovery.failures), + ) + + +def _normalize_source_receipt( + value: object, + *, + goal_id: str, + agent_id: str, + source_ref: str, +) -> dict[str, Any]: + if not isinstance(value, Mapping): + raise ValueError("periodic-report source binding returned no receipt") + expected = { + "schema_version", + "provider", + "goal_id", + "agent_id", + "source_ref", + "source_digest", + "observed_at", + "requester_kind", + "addressing_source", + "binding_revision", + "raw_content_returned", + "external_writes_performed", + } + if set(value) != expected: + raise ValueError("periodic-report source binding receipt fields are invalid") + if ( + value.get("schema_version") != SOURCE_BINDING_RECEIPT_SCHEMA + or value.get("goal_id") != goal_id + or value.get("agent_id") != agent_id + or value.get("source_ref") != source_ref + or value.get("requester_kind") != "user" + or value.get("addressing_source") + not in {"provider_mention", "verified_reply"} + or value.get("raw_content_returned") is not False + or value.get("external_writes_performed") is not False + ): + raise ValueError("periodic-report source binding receipt is invalid") + for key in ("provider", "source_digest", "binding_revision"): + if not str(value.get(key) or "").strip(): + raise ValueError(f"periodic-report source binding receipt requires {key}") + normalized = dict(value) + normalized["observed_at"] = _timestamp(value.get("observed_at"), "observed_at") + expected_digest = _digest( + { + "provider": normalized["provider"], + "goal_id": goal_id, + "agent_id": agent_id, + "source_ref": source_ref, + "observed_at": normalized["observed_at"], + "requester_kind": "user", + "addressing_source": normalized["addressing_source"], + "binding_revision": normalized["binding_revision"], + } + ) + if normalized["source_digest"] != expected_digest: + raise ValueError("periodic-report source binding digest is invalid") + return normalized + + +def _request_profile( + *, registry_path: Path, runtime_root: Path, goal_id: str, agent_id: str +) -> tuple[dict[str, str], dict[str, Any]]: + registry = read_json(registry_path) + goal = find_registry_goal(registry, goal_id) + if not isinstance(goal, Mapping): + raise ValueError("periodic-report Goal is not registered") + if agent_id not in registered_agent_ids_for_goal(goal): + raise ValueError("periodic-report request Agent is not registered") + subscription = resolve_goal_periodic_report_subscription( + goal, + read_periodic_report_machine_defaults(runtime_root), + ) + if subscription.get("enabled") is not True: + raise ValueError("periodic-report subscription is disabled") + activation = build_periodic_report_preset_activation( + str(subscription.get("profile_preset") or "") + ) + profile = activation.get("profile") + if activation.get("active") is not True or not isinstance(profile, Mapping): + raise ValueError("periodic-report profile is inactive") + trigger_policy = profile.get("trigger_policy") + if not isinstance(trigger_policy, Mapping) or "manual" not in set( + trigger_policy.get("enabled_kinds") or [] + ): + raise ValueError("periodic-report profile does not allow typed requests") + return ( + { + "profile_id": str(profile.get("profile_id") or ""), + "profile_version": str(profile.get("profile_version") or ""), + "profile_digest": str(activation.get("profile_digest") or ""), + }, + dict(trigger_policy), + ) + + +def _load_request_entry(path: Path) -> dict[str, Any] | None: + try: + value = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return None + if ( + not isinstance(value, dict) + or value.get("schema_version") != REQUEST_JOURNAL_ENTRY_SCHEMA + or value.get("status") not in {"pending", "settled"} + or value.get("request_id") != path.stem + or not isinstance(value.get("source_receipt"), Mapping) + or not isinstance(value.get("profile_ref"), Mapping) + or not isinstance(value.get("trigger_policy"), Mapping) + ): + return None + return value + + +def record_periodic_report_request( + *, + registry_path: Path, + runtime_root: Path, + goal_id: str, + agent_id: str, + source_ref: str, + bind_source: SourceBinder | None, + adapter_id: str | None, + execute: bool, +) -> dict[str, Any]: + if not _IDENTITY_RE.fullmatch(goal_id) or not _IDENTITY_RE.fullmatch(agent_id): + raise ValueError("periodic-report request Goal/Agent identity is invalid") + opaque_ref = _source_ref(source_ref) + request_id = _request_id( + goal_id=goal_id, + agent_id=agent_id, + source_ref=opaque_ref, + ) + path = _request_path(runtime_root, goal_id, request_id) + existing = _load_request_entry(path) if path.is_file() else None + if path.is_file() and existing is None: + raise ValueError("periodic-report request journal entry is invalid") + if existing is not None: + if existing.get("goal_id") != goal_id or existing.get("agent_id") != agent_id: + raise ValueError("periodic-report request journal identity drifted") + return { + "ok": True, + "schema_version": REQUEST_RECEIPT_SCHEMA, + "status": "already_requested", + "request_id": request_id, + "goal_id": goal_id, + "agent_id": agent_id, + "journal_status": existing["status"], + "write_performed": False, + "raw_content_returned": False, + "external_writes_performed": False, + } + if bind_source is None or not adapter_id: + raise ValueError("periodic-report request source adapter is unavailable") + profile_ref, trigger_policy = _request_profile( + registry_path=registry_path, + runtime_root=runtime_root, + goal_id=goal_id, + agent_id=agent_id, + ) + source_receipt = _normalize_source_receipt( + bind_source( + registry_path=registry_path, + runtime_root=runtime_root, + goal_id=goal_id, + agent_id=agent_id, + source_ref=opaque_ref, + ), + goal_id=goal_id, + agent_id=agent_id, + source_ref=opaque_ref, + ) + entry = { + "schema_version": REQUEST_JOURNAL_ENTRY_SCHEMA, + "request_id": request_id, + "status": "pending", + "goal_id": goal_id, + "agent_id": agent_id, + "requested_at": source_receipt["observed_at"], + "adapter_id": adapter_id, + "source_receipt": source_receipt, + "profile_ref": profile_ref, + "trigger_policy": trigger_policy, + } + write_performed = False + if execute: + with exclusive_file_lock(path, operation="periodic_report_request"): + concurrent = _load_request_entry(path) if path.is_file() else None + if path.is_file() and concurrent is None: + raise ValueError("periodic-report request journal entry is invalid") + if concurrent is None: + atomic_write_json(path, entry) + write_performed = True + return { + "ok": True, + "schema_version": REQUEST_RECEIPT_SCHEMA, + "status": "accepted" if execute else "preview", + "request_id": request_id, + "goal_id": goal_id, + "agent_id": agent_id, + "journal_status": "pending", + "write_performed": write_performed, + "raw_content_returned": False, + "external_writes_performed": False, + } + + +def periodic_report_request_intents( + *, runtime_root: Path, goal_id: str, agent_id: str +) -> list[dict[str, Any]]: + directory = _request_dir(runtime_root, goal_id) + if not directory.is_dir(): + return [] + intents: list[dict[str, Any]] = [] + for path in sorted(directory.iterdir()): + if not path.is_file() or not _REQUEST_FILE_RE.fullmatch(path.name): + continue + entry = _load_request_entry(path) + if ( + entry is None + or entry.get("status") != "pending" + or entry.get("goal_id") != goal_id + or entry.get("agent_id") != agent_id + ): + continue + source = entry["source_receipt"] + request = { + "schema_version": REQUEST_ACTION_SCHEMA, + "request_id": entry["request_id"], + "goal_id": goal_id, + "agent_id": agent_id, + "requested_at": entry["requested_at"], + "source_digest": source["source_digest"], + "requester_kind": source["requester_kind"], + "addressing_source": source["addressing_source"], + } + intents.append( + { + "schema_version": "loopx_capability_intent_v0", + "intent_kind": "periodic_report.trigger_evaluation", + "idempotency_key": f"periodic-report:request:{entry['request_id']}", + "source_receipt_id": entry["request_id"], + "payload": { + "schema_version": "periodic_report_trigger_evaluation_intent_v0", + "report_request": request, + "profile_ref": dict(entry["profile_ref"]), + "trigger_policy": dict(entry["trigger_policy"]), + "generation_authorized": False, + "external_delivery_authorized": False, + }, + "requested_write_scope": [], + } + ) + return intents + + +def request_entry_for_intent( + *, runtime_root: Path, goal_id: str, agent_id: str, intent: Mapping[str, Any] +) -> dict[str, Any] | None: + payload = intent.get("payload") + request = payload.get("report_request") if isinstance(payload, Mapping) else None + if not isinstance(request, Mapping): + return None + request_id = str(request.get("request_id") or "") + try: + path = _request_path(runtime_root, goal_id, request_id) + except ValueError: + return None + entry = _load_request_entry(path) + if ( + entry is None + or entry.get("goal_id") != goal_id + or entry.get("agent_id") != agent_id + or entry.get("status") != "pending" + ): + return None + return entry + + +def settle_periodic_report_request( + *, + registry_path: Path, + runtime_root: Path, + goal_id: str, + agent_id: str, + intent: Mapping[str, Any], + settle_source: SourceSettler | None, + adapter_id: str | None, + execute: bool, +) -> dict[str, Any]: + entry = request_entry_for_intent( + runtime_root=runtime_root, + goal_id=goal_id, + agent_id=agent_id, + intent=intent, + ) + if entry is None: + return { + "ok": True, + "schema_version": SOURCE_SETTLEMENT_RECEIPT_SCHEMA, + "status": "not_applicable", + "write_performed": False, + "external_writes_performed": False, + } + if settle_source is None or adapter_id != entry.get("adapter_id"): + return { + "ok": False, + "schema_version": SOURCE_SETTLEMENT_RECEIPT_SCHEMA, + "status": "adapter_unavailable", + "write_performed": False, + "external_writes_performed": False, + } + result = settle_source( + registry_path=registry_path, + runtime_root=runtime_root, + goal_id=goal_id, + agent_id=agent_id, + source_receipt=dict(entry["source_receipt"]), + execute=execute, + ) + if ( + not isinstance(result, Mapping) + or result.get("schema_version") != SOURCE_SETTLEMENT_RECEIPT_SCHEMA + or result.get("external_writes_performed") is not False + or result.get("raw_content_returned") is not False + ): + raise ValueError("periodic-report source settlement receipt is invalid") + normalized = dict(result) + if execute and normalized.get("ok") is True and normalized.get("status") == "settled": + request_id = str(entry["request_id"]) + path = _request_path(runtime_root, goal_id, request_id) + with exclusive_file_lock(path, operation="periodic_report_request_settlement"): + current = _load_request_entry(path) + if current is not None and current.get("status") == "pending": + current["status"] = "settled" + current["settlement"] = { + key: value + for key, value in normalized.items() + if key not in {"source_ref", "content"} + } + atomic_write_json(path, current) + normalized["write_performed"] = True + return normalized + + +__all__ = [ + "PeriodicReportRequestPorts", + "REQUEST_ACTION_SCHEMA", + "REQUEST_ADAPTER_PHASE", + "REQUEST_BIND_PORT", + "REQUEST_HOOK_ID", + "REQUEST_JOURNAL_ENTRY_SCHEMA", + "REQUEST_RECEIPT_SCHEMA", + "REQUEST_SETTLE_PORT", + "SOURCE_BINDING_RECEIPT_SCHEMA", + "SOURCE_SETTLEMENT_RECEIPT_SCHEMA", + "discover_periodic_report_request_ports", + "periodic_report_request_intents", + "record_periodic_report_request", + "request_entry_for_intent", + "settle_periodic_report_request", +] diff --git a/loopx/extensions/hook_adapters.py b/loopx/extensions/hook_adapters.py new file mode 100644 index 0000000000..717ccd0309 --- /dev/null +++ b/loopx/extensions/hook_adapters.py @@ -0,0 +1,175 @@ +from __future__ import annotations + +from collections.abc import Callable, Mapping +from dataclasses import dataclass +from importlib import import_module +from pathlib import Path +from typing import Any, cast + +from .runtime import extension_catalog_entries, resolve_extension_activation + + +HOOK_ADAPTER_FACTORY_CONTEXT_SCHEMA_VERSION = ( + "loopx_extension_hook_adapter_factory_context_v0" +) + + +@dataclass(frozen=True, slots=True) +class ExtensionHookAdapterPortBinding: + extension_id: str + adapter_id: str + capability_id: str + target_hook_id: str + phase: str + port_name: str + handler: Callable[..., Any] + + +@dataclass(frozen=True, slots=True) +class ExtensionHookAdapterFailure: + """Content-free failure for one optional manifest adapter.""" + + extension_id: str + adapter_id: str + capability_id: str + target_hook_id: str + phase: str + error_code: str = "extension_hook_adapter_unavailable" + + +@dataclass(frozen=True, slots=True) +class ExtensionHookAdapterDiscovery: + """Activated provider ports plus isolated content-free failures.""" + + ports: tuple[ExtensionHookAdapterPortBinding, ...] + failures: tuple[ExtensionHookAdapterFailure, ...] + + def handlers(self, port_name: str) -> tuple[Callable[..., Any], ...]: + return tuple( + binding.handler + for binding in self.ports + if binding.port_name == port_name + ) + + +def _load_factory(reference: str) -> Callable[..., Mapping[str, Callable[..., Any]]]: + module_name, separator, callable_name = reference.partition(":") + if not separator: + raise ValueError("hook adapter factory reference is invalid") + factory = getattr(import_module(module_name), callable_name, None) + if not callable(factory): + raise ValueError("hook adapter factory is unavailable") + return cast(Callable[..., Mapping[str, Callable[..., Any]]], factory) + + +def _failure( + *, extension_id: str, adapter: Mapping[str, Any] +) -> ExtensionHookAdapterFailure: + return ExtensionHookAdapterFailure( + extension_id=extension_id, + adapter_id=str(adapter["id"]), + capability_id=str(adapter["capability_id"]), + target_hook_id=str(adapter["target_hook_id"]), + phase=str(adapter["phase"]), + ) + + +def discover_extension_hook_adapters( + *, + state_file: str | Path, + phase: str, + capability_id: str, + target_hook_id: str, + registry_path: Path, + runtime_root: Path, + goal_id: str, + agent_id: str | None, +) -> ExtensionHookAdapterDiscovery: + """Discover ready manifest-declared adapters without provider imports. + + Factories are imported only after lifecycle and permission activation. + Factory/import/activation failures remain ordinary data so an optional + provider cannot alter the capability or control-plane kernel. + """ + + try: + catalog = extension_catalog_entries(state_file=state_file) + except (OSError, TypeError, ValueError): + return ExtensionHookAdapterDiscovery(ports=(), failures=()) + + ports: list[ExtensionHookAdapterPortBinding] = [] + failures: list[ExtensionHookAdapterFailure] = [] + for manifest in catalog: + provider = manifest.get("provider") + if not isinstance(provider, Mapping) or provider.get("ready") is not True: + continue + extension_id = str(provider.get("id") or "") + adapters = manifest.get("hook_adapters") + if not isinstance(adapters, list): + continue + for adapter in adapters: + if ( + not isinstance(adapter, Mapping) + or adapter.get("phase") != phase + or adapter.get("capability_id") != capability_id + or adapter.get("target_hook_id") != target_hook_id + ): + continue + try: + activation = resolve_extension_activation( + extension_id, + state_file=state_file, + required_permissions=tuple(adapter["required_permissions"]), + ) + factory = _load_factory(str(adapter["factory"])) + produced = factory( + { + "schema_version": ( + HOOK_ADAPTER_FACTORY_CONTEXT_SCHEMA_VERSION + ), + "extension_id": extension_id, + "adapter_id": str(adapter["id"]), + "capability_id": str(adapter["capability_id"]), + "target_hook_id": str(adapter["target_hook_id"]), + "phase": phase, + "activation": activation, + "registry_path": registry_path, + "runtime_root": runtime_root, + "goal_id": goal_id, + "agent_id": str(agent_id or ""), + } + ) + if not isinstance(produced, Mapping) or set(produced) != set( + adapter["ports"] + ): + raise ValueError("hook adapter factory ports do not match manifest") + if any(not callable(handler) for handler in produced.values()): + raise ValueError("hook adapter factory returned a non-callable port") + except Exception: # Optional provider discovery is fail-closed and isolated. + failures.append(_failure(extension_id=extension_id, adapter=adapter)) + continue + ports.extend( + ExtensionHookAdapterPortBinding( + extension_id=extension_id, + adapter_id=str(adapter["id"]), + capability_id=str(adapter["capability_id"]), + target_hook_id=target_hook_id, + phase=phase, + port_name=str(port_name), + handler=handler, + ) + for port_name, handler in produced.items() + ) + return ExtensionHookAdapterDiscovery( + ports=tuple(ports), + failures=tuple(failures), + ) + + +__all__ = [ + "ExtensionHookAdapterDiscovery", + "ExtensionHookAdapterFailure", + "ExtensionHookAdapterPortBinding", + "HOOK_ADAPTER_FACTORY_CONTEXT_SCHEMA_VERSION", + "discover_extension_hook_adapters", +] diff --git a/loopx/extensions/lark/event_inbox.py b/loopx/extensions/lark/event_inbox.py index 85a171d986..0c527f09da 100644 --- a/loopx/extensions/lark/event_inbox.py +++ b/loopx/extensions/lark/event_inbox.py @@ -37,6 +37,8 @@ REPLY_PLACEMENT_POLICIES = {"source_thread", "source_context"} REPLY_EDITORIAL_STYLES = {"concise", "bullet_points_preferred"} ROUTE_KEY_PATTERN = re.compile(r"[a-z0-9][a-z0-9._-]{0,79}") +SENDER_TYPE_PATTERN = re.compile(r"[a-z][a-z0-9_-]{0,31}") +ADDRESSING_SOURCES = {"provider_mention", "verified_reply", "legacy_text"} LARK_OPERATOR_INBOX_SOURCE_CONTRACT = OperatorInboxSourceContract( config_schema_version=CONFIG_SCHEMA_VERSION, event_schema_version=EVENT_SCHEMA_VERSION, @@ -259,6 +261,11 @@ def _event_from_payload( "content": content, "attachment_count": raw_attachment_count, } + sender_type = str(payload.get("sender_type") or "").strip().lower() + if sender_type: + if not SENDER_TYPE_PATTERN.fullmatch(sender_type): + return None + event["sender_type"] = sender_type if "route_key" in payload: route_key = str(payload.get("route_key") or "").strip() if not ROUTE_KEY_PATTERN.fullmatch(route_key): @@ -277,7 +284,7 @@ def _event_from_payload( and "parent_id" in event and payload.get("reply_to_bot") is True ) - event["addressed_to_bot"] = bool( + addressed_to_bot = bool( event["reply_to_bot"] or ( bot_display_name is not None @@ -289,6 +296,52 @@ def _event_from_payload( ) or (bot_display_name is None and payload.get("addressed_to_bot") is True) ) + event["addressed_to_bot"] = addressed_to_bot + + mentions = payload.get("mentions") + provider_mention_count = 0 + target_mention_count = 0 + if isinstance(mentions, list): + provider_mentions = [item for item in mentions if isinstance(item, Mapping)] + provider_mention_count = len(provider_mentions) + expected = _normalized_mention_name(bot_display_name) + if expected: + target_mention_count = sum( + _normalized_mention_name(item.get("name")) == expected + for item in provider_mentions + ) + elif payload.get("mentioned") is True and provider_mention_count == 1: + # Provider history may identify the current Bot with a typed + # `mentioned` flag while an inbox route intentionally has no + # reply/display-name configuration. Preserve the exact single- + # mention proof without persisting the provider identity. + target_mention_count = 1 + else: + raw_provider_count = payload.get("provider_mention_count") + raw_target_count = payload.get("target_mention_count") + if ( + type(raw_provider_count) is int + and type(raw_target_count) is int + and 0 <= raw_target_count <= raw_provider_count <= 50 + ): + provider_mention_count = raw_provider_count + target_mention_count = raw_target_count + event["provider_mention_count"] = provider_mention_count + event["target_mention_count"] = target_mention_count + + stored_addressing_source = str(payload.get("addressing_source") or "").strip() + if stored_addressing_source and stored_addressing_source not in ADDRESSING_SOURCES: + return None + if event["reply_to_bot"]: + addressing_source = "verified_reply" + elif target_mention_count: + addressing_source = "provider_mention" + elif addressed_to_bot: + addressing_source = stored_addressing_source or "legacy_text" + else: + addressing_source = "" + if addressing_source: + event["addressing_source"] = addressing_source return event @@ -526,7 +579,11 @@ def inspect_lark_event_inbox( "instruction": ( "For an actionable item, first run `loopx lark-inbox processing` for " "its message_id, then translate it into a todo, vision correction, PR " - "update, or no-follow-up rationale. Send and verify any required reply " + "update, or no-follow-up rationale. If the message semantically asks " + "this Agent for a periodic report, invoke `loopx periodic-report " + "request --goal-id --agent-id --source-ref " + " --execute`; the Agent owns that semantic decision and " + "must not delegate it to keyword matching. Send and verify any required reply " "before acknowledging the message_id. Follow reply_guidance for " "placement and editorial style. If no reply is required, run " "`loopx lark-inbox material-review` with a committed effect receipt " diff --git a/loopx/extensions/lark/extension.toml b/loopx/extensions/lark/extension.toml index bd889633ce..eadff25a03 100644 --- a/loopx/extensions/lark/extension.toml +++ b/loopx/extensions/lark/extension.toml @@ -1,6 +1,6 @@ schema_version = "loopx_extension_manifest_v0" id = "loopx-lark" -version = "1.5.0" +version = "1.6.0" requires_loopx_api = ">=1,<2" permissions = [ "lark.inbox.read", @@ -21,6 +21,18 @@ doctor_args = ["--doctor"] required_permissions = [] timeout_seconds = 30 +[[hook_adapters]] +id = "lark-periodic-report-source" +capability_id = "periodic-report" +target_hook_id = "periodic_report.request" +phase = "capability_action" +factory = "loopx.extensions.lark.periodic_report_request:build_lark_periodic_report_hook_adapter" +required_permissions = ["lark.inbox.read", "lark.inbox.write"] +ports = [ + "periodic_report.request.bind_source", + "periodic_report.request.settle_source", +] + [documentation] source_root = "loopx/extensions/lark" site_root = "integrations/lark-provider" diff --git a/loopx/extensions/lark/goal_topic_runtime.py b/loopx/extensions/lark/goal_topic_runtime.py index 60b46a15c1..f808ac16ee 100644 --- a/loopx/extensions/lark/goal_topic_runtime.py +++ b/loopx/extensions/lark/goal_topic_runtime.py @@ -46,6 +46,7 @@ "event_id:(.event_id // .message_id // .id)," "message_id:(.message_id // .id)," "create_time:.create_time,content:.content,sender_id:.sender_id," + "sender_type:(.sender_type // .sender.sender_type // .event.sender.sender_type)," "chat_id:.chat_id," "root_id:(.root_id // .message.root_id // .event.message.root_id)," "parent_id:(.parent_id // .reply_to // .message.parent_id // .message.reply_to " @@ -852,6 +853,7 @@ def process_lark_goal_topic_event( "message_id": str(event.get("message_id") or ""), "create_time": str(event.get("create_time") or ""), "content": str(event.get("content") or ""), + "sender_type": str(event.get("sender_type") or ""), "root_id": str(event.get("root_id") or ""), "parent_id": str(event.get("parent_id") or ""), "mentions": event.get("mentions") diff --git a/loopx/extensions/lark/group_history.py b/loopx/extensions/lark/group_history.py index 2cef691a76..408f1bb261 100644 --- a/loopx/extensions/lark/group_history.py +++ b/loopx/extensions/lark/group_history.py @@ -219,6 +219,9 @@ def _canonical_events( "content": content, "chat_id": chat_id, } + sender_type, _sender_id = _sender_identity(message) + if sender_type: + event["sender_type"] = sender_type for field in ("parent_id", "root_id"): if message.get(field) not in (None, ""): event[field] = message[field] diff --git a/loopx/extensions/lark/periodic_report_request.py b/loopx/extensions/lark/periodic_report_request.py new file mode 100644 index 0000000000..2bf32d74dd --- /dev/null +++ b/loopx/extensions/lark/periodic_report_request.py @@ -0,0 +1,413 @@ +"""Bind an Agent-selected Lark inbox item to the periodic-report action. + +This adapter validates provider identity, addressing, and Goal/Agent routing. +It deliberately does not inspect message text or decide report intent; the +Agent has already made that semantic decision before invoking the typed action. +""" + +from __future__ import annotations + +import hashlib +import json +from collections.abc import Mapping +from datetime import UTC, datetime +from pathlib import Path +from typing import Any + +from ...agent_registry import registered_agent_ids_for_goal +from ...capabilities.periodic_report.request_action import ( + REQUEST_ADAPTER_PHASE, + REQUEST_BIND_PORT, + REQUEST_HOOK_ID, + REQUEST_SETTLE_PORT, + SOURCE_BINDING_RECEIPT_SCHEMA, + SOURCE_SETTLEMENT_RECEIPT_SCHEMA, +) +from ...control_plane.runtime.goal_project_route import resolve_goal_project_route +from ..hook_adapters import HOOK_ADAPTER_FACTORY_CONTEXT_SCHEMA_VERSION +from .event_collector import load_lark_event_collector_config +from .event_inbox import ( + MESSAGE_ID_PATTERN, + _event_from_file, + _load_processed, + acknowledge_lark_event_inbox, + load_lark_event_inbox_config, +) +from .goal_channel_contracts import ( + binding_for_goal, + bindings_for_goal, + default_goal_channel_binding_path, + read_goal_channel_binding, +) +from .goal_channel_targets import ( + default_goal_channel_target_path, + goal_channel_target_for_name, + read_goal_channel_targets, +) +from .routed_inbox import lark_inbox_config_kind + + +LARK_REQUEST_ADAPTER_ID = "lark-periodic-report-source" + + +def _digest(value: object) -> str: + encoded = json.dumps( + value, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ).encode("utf-8") + return "sha256:" + hashlib.sha256(encoded).hexdigest() + + +def _observed_at(value: object) -> str: + raw = str(value or "").strip() + try: + if raw.isdigit(): + number = int(raw) + seconds = number / 1000 if number >= 10_000_000_000 else number + parsed = datetime.fromtimestamp(seconds, tz=UTC) + else: + parsed = datetime.fromisoformat(raw.replace("Z", "+00:00")) + if parsed.tzinfo is None: + raise ValueError + parsed = parsed.astimezone(UTC) + except (OSError, OverflowError, ValueError) as exc: + raise ValueError("Lark periodic-report source timestamp is invalid") from exc + return parsed.isoformat().replace("+00:00", "Z") + + +def _goal_agent_inbox_config(goal: Mapping[str, Any], agent_id: str) -> str: + control_plane = goal.get("control_plane") + control_plane = control_plane if isinstance(control_plane, Mapping) else {} + inboxes = control_plane.get("lark_event_inboxes") + inboxes = inboxes if isinstance(inboxes, Mapping) else {} + inbox = inboxes.get(agent_id) + if not isinstance(inbox, Mapping) or inbox.get("enabled") is not True: + raise ValueError("Agent-scoped Lark inbox is not enabled") + config_ref = str(inbox.get("config_path") or "").strip() + if not config_ref: + raise ValueError("Agent-scoped Lark inbox config is missing") + return config_ref + + +def _resolved_request_context( + *, registry_path: Path, runtime_root: Path, goal_id: str, agent_id: str +) -> dict[str, Any]: + goal, project, route = resolve_goal_project_route( + registry_path=registry_path, + goal_id=goal_id, + ) + if agent_id not in registered_agent_ids_for_goal(goal): + raise ValueError("periodic-report request Agent is not registered") + config_ref = _goal_agent_inbox_config(goal, agent_id) + source_registry = Path(str(route["source_registry"])).expanduser().resolve() + binding_payload = read_goal_channel_binding( + default_goal_channel_binding_path(source_registry) + ) + matches = [ + binding + for binding in bindings_for_goal(binding_payload, goal_id) + if binding.get("enabled") is True + and str(binding.get("agent_id") or "") == agent_id + ] + if len(matches) != 1: + raise ValueError( + "periodic-report request requires one Agent Goal Channel binding" + ) + raw_binding = matches[0] + routing = raw_binding.get("routing") + routing = routing if isinstance(routing, Mapping) else {} + if ( + raw_binding.get("provider") != "lark" + or routing.get("ingress_mode") != "async_inbox" + or str(routing.get("inbox_config_ref") or "") != config_ref + ): + raise ValueError("Agent Goal Channel inbox binding is inconsistent") + + target_ref = str(raw_binding.get("target_ref") or "").strip() + target = goal_channel_target_for_name( + read_goal_channel_targets(default_goal_channel_target_path(runtime_root)), + target_ref, + ) + if ( + target is None + or target.get("enabled") is not True + or target.get("provider") != "lark" + ): + raise ValueError("Agent Goal Channel Lark target is unavailable") + binding = binding_for_goal( + binding_payload, + goal_id, + provider_target=target, + connection_id=str(raw_binding.get("connection_id") or ""), + ) + if binding is None: + raise ValueError("Agent Goal Channel binding is incomplete") + + channel = binding.get("channel") + channel = channel if isinstance(channel, Mapping) else {} + identity = binding.get("identity") + identity = identity if isinstance(identity, Mapping) else {} + config_kind = lark_inbox_config_kind(project=project, config_path=config_ref) + selected_route_key = "default" + selected_config_ref = config_ref + if config_kind == "collector": + collector = load_lark_event_collector_config( + project=project, + config_path=config_ref, + ) + routes = [ + route + for route in collector["routes"] + if str(route.get("chat_id") or "") == str(channel.get("chat_id") or "") + ] + if ( + collector.get("enabled") is not True + or collector.get("identity") != "bot" + or str(collector.get("profile") or "") + != str(identity.get("sender_profile") or "") + or len(routes) != 1 + ): + raise ValueError("Agent inbox collector does not match its Goal Channel") + route = routes[0] + selected_route_key = str(route.get("route_key") or "") + selected_config_ref = str(route.get("event_inbox_config_ref") or "") + config = dict(route["inbox"]) + else: + config = load_lark_event_inbox_config( + project=project, + config_path=config_ref, + ) + reply = config.get("reply") + reply = reply if isinstance(reply, Mapping) else {} + if ( + config.get("enabled") is not True + or identity.get("mode") != "project_bot" + or identity.get("sender_identity") != "bot" + or ( + config_kind == "inbox" + and ( + reply.get("enabled") is not True + or reply.get("sender_identity") != "bot" + or str(reply.get("sender_profile") or "") + != str(identity.get("sender_profile") or "") + or str(reply.get("bot_display_name") or "") + != str(identity.get("bot_display_name") or "") + or str(reply.get("chat_id") or "") + != str(channel.get("chat_id") or "") + ) + ) + ): + raise ValueError("Agent inbox identity does not match its Goal Channel") + + binding_revision = _digest( + { + "goal_id": goal_id, + "agent_id": agent_id, + "connection_id": binding.get("connection_id"), + "target_ref": target_ref, + "config_ref": config_ref, + "selected_route_key": selected_route_key, + "selected_config_ref": selected_config_ref, + "capture_scope": routing.get("capture_scope"), + "chat_id": channel.get("chat_id"), + "sender_profile": identity.get("sender_profile"), + "bot_display_name": identity.get("bot_display_name"), + } + ) + return { + "project": project, + "selected_config_ref": selected_config_ref, + "config": config, + "binding_revision": binding_revision, + } + + +def _addressing_source(event: Mapping[str, Any]) -> str: + source = str(event.get("addressing_source") or "") + mention_count = event.get("provider_mention_count") + target_count = event.get("target_mention_count") + if ( + event.get("sender_type") != "user" + or event.get("addressed_to_bot") is not True + or type(mention_count) is not int + or type(target_count) is not int + ): + raise ValueError("Lark periodic-report source is not an addressed user item") + if source == "provider_mention" and mention_count == 1 and target_count == 1: + return source + if ( + source == "verified_reply" + and event.get("reply_to_bot") is True + and (mention_count == 0 or (mention_count == 1 and target_count == 1)) + ): + return source + raise ValueError("Lark periodic-report source addressing is ambiguous") + + +def _source_receipt( + event: Mapping[str, Any], *, goal_id: str, agent_id: str, binding_revision: str +) -> dict[str, Any]: + source_ref = str(event.get("message_id") or "") + if not MESSAGE_ID_PATTERN.fullmatch(source_ref): + raise ValueError("Lark periodic-report source reference is invalid") + observed_at = _observed_at(event.get("create_time")) + addressing_source = _addressing_source(event) + identity = { + "provider": "lark", + "goal_id": goal_id, + "agent_id": agent_id, + "source_ref": source_ref, + "observed_at": observed_at, + "requester_kind": "user", + "addressing_source": addressing_source, + "binding_revision": binding_revision, + } + return { + "schema_version": SOURCE_BINDING_RECEIPT_SCHEMA, + **identity, + "source_digest": _digest(identity), + "raw_content_returned": False, + "external_writes_performed": False, + } + + +def bind_lark_periodic_report_source( + *, + registry_path: Path, + runtime_root: Path, + goal_id: str, + agent_id: str, + source_ref: str, +) -> dict[str, Any]: + """Bind one exact pending source item without reading its semantic content.""" + + if not MESSAGE_ID_PATTERN.fullmatch(str(source_ref or "")): + raise ValueError("Lark periodic-report source reference is invalid") + context = _resolved_request_context( + registry_path=registry_path, + runtime_root=runtime_root, + goal_id=goal_id, + agent_id=agent_id, + ) + inbox = context["config"]["inbox_path"] + if source_ref in _load_processed(inbox / "processed.json"): + raise ValueError("Lark periodic-report source is already settled") + event = _event_from_file(inbox / f"{source_ref}.json") + if not isinstance(event, Mapping) or event.get("message_id") != source_ref: + raise ValueError("Lark periodic-report source is unavailable") + return _source_receipt( + event, + goal_id=goal_id, + agent_id=agent_id, + binding_revision=str(context["binding_revision"]), + ) + + +def settle_lark_periodic_report_source( + *, + registry_path: Path, + runtime_root: Path, + goal_id: str, + agent_id: str, + source_receipt: Mapping[str, Any], + execute: bool, +) -> dict[str, Any]: + """ACK the exact source only after capability durability is established.""" + + context = _resolved_request_context( + registry_path=registry_path, + runtime_root=runtime_root, + goal_id=goal_id, + agent_id=agent_id, + ) + source_ref = str(source_receipt.get("source_ref") or "") + if ( + source_receipt.get("schema_version") != SOURCE_BINDING_RECEIPT_SCHEMA + or source_receipt.get("goal_id") != goal_id + or source_receipt.get("agent_id") != agent_id + or source_receipt.get("binding_revision") != context["binding_revision"] + or not MESSAGE_ID_PATTERN.fullmatch(source_ref) + ): + raise ValueError("Lark periodic-report source binding drifted") + inbox = context["config"]["inbox_path"] + event = _event_from_file(inbox / f"{source_ref}.json") + if not isinstance(event, Mapping): + raise ValueError("Lark periodic-report source is unavailable") + current = _source_receipt( + event, + goal_id=goal_id, + agent_id=agent_id, + binding_revision=str(context["binding_revision"]), + ) + if current != dict(source_receipt): + raise ValueError("Lark periodic-report source receipt drifted") + receipt = acknowledge_lark_event_inbox( + project=context["project"], + config_path=context["selected_config_ref"], + message_ids=[source_ref], + execute=execute, + ) + settled = execute and ( + int(receipt.get("new_count") or 0) == 1 + or int(receipt.get("already_acknowledged_count") or 0) == 1 + ) + return { + "ok": receipt.get("ok") is True, + "schema_version": SOURCE_SETTLEMENT_RECEIPT_SCHEMA, + "status": "settled" if settled else "preview" if not execute else "failed", + "write_performed": receipt.get("write_performed") is True, + "raw_content_returned": False, + "external_writes_performed": False, + } + + +def build_lark_periodic_report_hook_adapter( + context: Mapping[str, Any], +) -> dict[str, Any]: + """Build typed source ports from one manifest-discovered activation.""" + + required = { + "schema_version", + "extension_id", + "adapter_id", + "capability_id", + "target_hook_id", + "phase", + "activation", + "registry_path", + "runtime_root", + "goal_id", + "agent_id", + } + activation = context.get("activation") + if ( + set(context) != required + or context.get("schema_version") + != HOOK_ADAPTER_FACTORY_CONTEXT_SCHEMA_VERSION + or context.get("extension_id") != "loopx-lark" + or context.get("adapter_id") != LARK_REQUEST_ADAPTER_ID + or context.get("capability_id") != "periodic-report" + or context.get("target_hook_id") != REQUEST_HOOK_ID + or context.get("phase") != REQUEST_ADAPTER_PHASE + or not isinstance(activation, Mapping) + or activation.get("extension_id") != "loopx-lark" + or activation.get("enabled") is not True + or activation.get("doctor_verified") is not True + or set(activation.get("required_permissions") or []) + != {"lark.inbox.read", "lark.inbox.write"} + ): + raise ValueError("Lark periodic-report hook adapter context is invalid") + return { + REQUEST_BIND_PORT: bind_lark_periodic_report_source, + REQUEST_SETTLE_PORT: settle_lark_periodic_report_source, + } + + +__all__ = [ + "LARK_REQUEST_ADAPTER_ID", + "bind_lark_periodic_report_source", + "build_lark_periodic_report_hook_adapter", + "settle_lark_periodic_report_source", +] diff --git a/loopx/extensions/lark/provider.py b/loopx/extensions/lark/provider.py index 93f578a30f..55bccc9b01 100644 --- a/loopx/extensions/lark/provider.py +++ b/loopx/extensions/lark/provider.py @@ -40,6 +40,11 @@ "loopx.extensions.lark.periodic_report_delivery": ( "deliver_periodic_report_to_goal_channel", ), + "loopx.extensions.lark.periodic_report_request": ( + "bind_lark_periodic_report_source", + "build_lark_periodic_report_hook_adapter", + "settle_lark_periodic_report_source", + ), "loopx.extensions.lark.presentation.kanban": ( "lark_kanban_doctor", "sync_loopx_projection_to_lark_kanban", diff --git a/loopx/extensions/manifest.py b/loopx/extensions/manifest.py index 7ef559a316..0d7a9810ac 100644 --- a/loopx/extensions/manifest.py +++ b/loopx/extensions/manifest.py @@ -29,6 +29,8 @@ "continuous_monitor_complete", } _PYTHON_CALLABLE_RE = re.compile(r"^[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*:[A-Za-z_]\w*$") +_HOOK_TOKEN_RE = re.compile(r"^[a-z][a-z0-9_.:-]{2,95}$") +_HOOK_ADAPTER_PHASE = "capability_action" _SURFACE_ID_RE = re.compile(r"^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$") _SURFACE_KIND_RE = re.compile(r"^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$") _PRESENTATION_SURFACE_KEYS = { @@ -192,6 +194,97 @@ def _presentation_surfaces( return surfaces +def _hook_adapters( + raw: Mapping[str, Any], + *, + permissions: list[str], + runtime: Mapping[str, Any] | None, + context: str, +) -> list[dict[str, Any]]: + """Normalize manifest-declared adapters for capability-owned hooks.""" + + value = raw.get("hook_adapters", []) + if not isinstance(value, list): + raise ValueError(f"{context} requires `hook_adapters` to contain TOML tables") + if value and runtime is None: + raise ValueError(f"{context} hook adapters require an executable runtime") + + adapters: list[dict[str, Any]] = [] + seen_ids: set[str] = set() + for index, item in enumerate(value): + item_context = f"{context} hook_adapters[{index}]" + if not isinstance(item, Mapping): + raise ValueError(f"{item_context} must be a TOML table") + expected = { + "id", + "capability_id", + "target_hook_id", + "phase", + "factory", + "required_permissions", + "ports", + } + unsupported = sorted(set(item) - expected) + if unsupported: + raise ValueError(f"{item_context} contains unsupported keys {unsupported}") + + adapter_id = _required_string(item, "id", context=item_context) + capability_id = _required_string(item, "capability_id", context=item_context) + target_hook_id = _required_string(item, "target_hook_id", context=item_context) + phase = _required_string(item, "phase", context=item_context) + factory = _required_string(item, "factory", context=item_context) + if adapter_id in seen_ids: + raise ValueError(f"{context} has duplicate hook adapter id `{adapter_id}`") + seen_ids.add(adapter_id) + for label, token in ( + ("id", adapter_id), + ("capability_id", capability_id), + ("target_hook_id", target_hook_id), + ): + if _HOOK_TOKEN_RE.fullmatch(token) is None: + raise ValueError(f"{item_context} {label} must be a bounded token") + if phase != _HOOK_ADAPTER_PHASE: + raise ValueError( + f"{item_context} phase must be `{_HOOK_ADAPTER_PHASE}`" + ) + if _PYTHON_CALLABLE_RE.fullmatch(factory) is None: + raise ValueError( + f"{item_context} factory must be a `:` reference" + ) + + required_permissions = _string_list( + item, + "required_permissions", + context=item_context, + ) + if len(required_permissions) != len(set(required_permissions)): + raise ValueError(f"{item_context} required_permissions contains duplicates") + undeclared = sorted(set(required_permissions) - set(permissions)) + if undeclared: + raise ValueError( + f"{item_context} requires undeclared permissions {undeclared}" + ) + ports = _string_list(item, "ports", context=item_context) + if ( + not 1 <= len(ports) <= 16 + or len(ports) != len(set(ports)) + or any(_HOOK_TOKEN_RE.fullmatch(port) is None for port in ports) + ): + raise ValueError(f"{item_context} ports must contain unique bounded tokens") + adapters.append( + { + "id": adapter_id, + "capability_id": capability_id, + "target_hook_id": target_hook_id, + "phase": phase, + "factory": factory, + "required_permissions": required_permissions, + "ports": ports, + } + ) + return adapters + + def _runtime_contract( raw: Mapping[str, Any], *, @@ -628,6 +721,12 @@ def load_extension_manifest(path: str | Path) -> dict[str, Any]: _require_compatible_loopx_api(requires_loopx_api, context=context) permissions = _string_list(raw, "permissions", context=context) runtime = _runtime_contract(raw, permissions=permissions, context=context) + hook_adapters = _hook_adapters( + raw, + permissions=permissions, + runtime=runtime, + context=context, + ) presentation_surfaces = _presentation_surfaces( raw, runtime=runtime, @@ -725,6 +824,7 @@ def load_extension_manifest(path: str | Path) -> dict[str, Any]: "capabilities": capabilities, "implementations": implementations, "runtime": runtime, + "hook_adapters": hook_adapters, "presentation_surfaces": presentation_surfaces, "documentation": documentation, } From 82387bcd9ac7413876d016fb49ddfd5c848f26c7 Mon Sep 17 00:00:00 2001 From: huangruiteng Date: Sun, 6 Sep 2026 18:30:04 +0800 Subject: [PATCH 02/10] test(periodic-report): cover typed source request lifecycle Signed-off-by: huangruiteng --- .../test_control_plane_import_boundaries.py | 23 +++ .../test_periodic_report_pending_intent.py | 165 ++++++++++++++++++ tests/extensions/test_extension_runtime.py | 86 +++++++++ tests/extensions/test_lark_group_history.py | 3 +- .../test_lark_periodic_report_request.py | 116 ++++++++++++ ...t_periodic_report_goal_channel_delivery.py | 2 +- .../extensions/test_periodic_report_miaoda.py | 6 +- 7 files changed, 396 insertions(+), 5 deletions(-) create mode 100644 tests/extensions/test_lark_periodic_report_request.py diff --git a/tests/architecture/test_control_plane_import_boundaries.py b/tests/architecture/test_control_plane_import_boundaries.py index 32531fbb24..1a9418e0ac 100644 --- a/tests/architecture/test_control_plane_import_boundaries.py +++ b/tests/architecture/test_control_plane_import_boundaries.py @@ -31,6 +31,10 @@ QUOTA_CLI_MODULE = PACKAGE_ROOT / "cli_commands" / "quota.py" TURN_CLI_MODULE = PACKAGE_ROOT / "cli_commands" / "turn.py" LARK_INBOX_CLI_MODULE = PACKAGE_ROOT / "cli_commands" / "lark_inbox.py" +PERIODIC_REPORT_REQUEST_ACTION_MODULE = ( + PACKAGE_ROOT / "capabilities" / "periodic_report" / "request_action.py" +) +EXTENSION_HOOK_ADAPTERS_MODULE = PACKAGE_ROOT / "extensions" / "hook_adapters.py" ISSUE_FIX_REVIEWER_CLI_MODULE = ( PACKAGE_ROOT / "capabilities" / "issue_fix" / "reviewer_cli.py" ) @@ -347,6 +351,25 @@ def test_quota_receives_lark_urgency_only_through_cli_composition() -> None: assert "loopx.cli_commands.lark_inbox" in _resolved_imports(TURN_CLI_MODULE) +def test_periodic_report_request_ports_are_manifest_discovered_outside_kernel() -> None: + generic_modules = ( + PERIODIC_REPORT_REQUEST_ACTION_MODULE, + EXTENSION_HOOK_ADAPTERS_MODULE, + QUOTA_CLI_MODULE, + ) + for module in generic_modules: + assert not any( + dependency == "loopx.extensions.lark" + or dependency.startswith("loopx.extensions.lark.") + for dependency in _resolved_imports(module) + ) + assert not any( + dependency == "loopx.control_plane" + or dependency.startswith("loopx.control_plane.") + for dependency in _resolved_imports(EXTENSION_HOOK_ADAPTERS_MODULE) + ) + + def test_lark_operator_inbox_contract_is_extension_owned() -> None: core_source = OPERATOR_INBOX_MODULE.read_text(encoding="utf-8") extension_source = (LARK_EXTENSION_ROOT / "event_inbox.py").read_text( diff --git a/tests/capabilities/test_periodic_report_pending_intent.py b/tests/capabilities/test_periodic_report_pending_intent.py index 2ada5cd46f..396c2325e4 100644 --- a/tests/capabilities/test_periodic_report_pending_intent.py +++ b/tests/capabilities/test_periodic_report_pending_intent.py @@ -2,6 +2,7 @@ import json import os +import hashlib from pathlib import Path import pytest @@ -13,6 +14,13 @@ pending_periodic_report_intents, periodic_report_pending_intent_interaction_hook, ) +from loopx.capabilities.periodic_report.request_action import ( + SOURCE_BINDING_RECEIPT_SCHEMA, + SOURCE_SETTLEMENT_RECEIPT_SCHEMA, + periodic_report_request_intents, + record_periodic_report_request, + settle_periodic_report_request, +) from loopx.capabilities.periodic_report.incremental import ( build_periodic_report_publication_candidate, commit_periodic_report_publication_cursor, @@ -458,6 +466,163 @@ def test_consumption_queues_authorized_delivery_and_exact_replay_does_not_duplic assert quota["user_todo_summary"]["open_count"] == 0 +def test_agent_typed_request_is_replay_safe_and_settlement_only_retry_deduplicates( + tmp_path: Path, +) -> None: + registry, runtime = _fixture(tmp_path) + sidecars = runtime / "goals" / GOAL_ID / "post_writeback_hooks" + for path in sidecars.iterdir(): + path.unlink() + source_ref = "om_discussion_is_semantically_selected_by_agent" + observed_at = "2026-08-30T09:00:00Z" + source_identity = { + "provider": "fixture", + "goal_id": GOAL_ID, + "agent_id": AGENT_ID, + "source_ref": source_ref, + "observed_at": observed_at, + "requester_kind": "user", + "addressing_source": "provider_mention", + "binding_revision": "sha256:" + "b" * 64, + } + source_digest = "sha256:" + hashlib.sha256( + json.dumps( + source_identity, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ).encode("utf-8") + ).hexdigest() + bind_calls: list[str] = [] + + def bind_source(**kwargs: object) -> dict[str, object]: + bind_calls.append(str(kwargs["source_ref"])) + return { + "schema_version": SOURCE_BINDING_RECEIPT_SCHEMA, + **source_identity, + "source_digest": source_digest, + "raw_content_returned": False, + "external_writes_performed": False, + } + + accepted = record_periodic_report_request( + registry_path=registry, + runtime_root=runtime, + goal_id=GOAL_ID, + agent_id=AGENT_ID, + source_ref=source_ref, + bind_source=bind_source, + adapter_id="fixture-periodic-report-source", + execute=True, + ) + replay = record_periodic_report_request( + registry_path=registry, + runtime_root=runtime, + goal_id=GOAL_ID, + agent_id=AGENT_ID, + source_ref=source_ref, + bind_source=lambda **_kwargs: (_ for _ in ()).throw( + AssertionError("replay must not rebind provider source") + ), + adapter_id="fixture-periodic-report-source", + execute=True, + ) + + assert accepted["status"] == "accepted" + assert replay["status"] == "already_requested" + assert bind_calls == [source_ref] + intents = periodic_report_request_intents( + runtime_root=runtime, + goal_id=GOAL_ID, + agent_id=AGENT_ID, + ) + assert len(intents) == 1 + assert source_ref not in json.dumps(intents[0], ensure_ascii=False) + mismatch_calls: list[bool] = [] + mismatch = settle_periodic_report_request( + registry_path=registry, + runtime_root=runtime, + goal_id=GOAL_ID, + agent_id=AGENT_ID, + intent=intents[0], + settle_source=lambda **_kwargs: mismatch_calls.append(True) or {}, + adapter_id="different-periodic-report-source", + execute=True, + ) + assert mismatch["status"] == "adapter_unavailable" + assert mismatch_calls == [] + + editorial_required = consume_pending_periodic_report_intent( + registry_path=registry, + runtime_root=runtime, + goal_id=GOAL_ID, + agent_id=AGENT_ID, + execute=True, + ) + assert editorial_required["status"] == "editorial_required" + _write_editorial_response(editorial_required) + settlement_calls: list[bool] = [] + + def settle_source(**kwargs: object) -> dict[str, object]: + receipts = list( + (runtime / "goals" / GOAL_ID / "periodic_reports").glob( + "*/receipt.json" + ) + ) + assert len(receipts) == 1 + assert json.loads(receipts[0].read_text(encoding="utf-8"))["status"] == ( + "delivery_ready" + ) + settlement_calls.append(bool(kwargs["execute"])) + settled = len(settlement_calls) > 1 + return { + "ok": settled, + "schema_version": SOURCE_SETTLEMENT_RECEIPT_SCHEMA, + "status": "settled" if settled else "failed", + "write_performed": False, + "raw_content_returned": False, + "external_writes_performed": False, + } + + first = consume_pending_periodic_report_intent( + registry_path=registry, + runtime_root=runtime, + goal_id=GOAL_ID, + agent_id=AGENT_ID, + execute=True, + provider_request_settler=settle_source, + provider_request_adapter_id="fixture-periodic-report-source", + ) + retry = consume_pending_periodic_report_intent( + registry_path=registry, + runtime_root=runtime, + goal_id=GOAL_ID, + agent_id=AGENT_ID, + execute=True, + provider_request_settler=settle_source, + provider_request_adapter_id="fixture-periodic-report-source", + ) + + assert first["status"] == "delivery_ready" + assert first["source_settlement"]["status"] == "failed" + assert retry["settlement_only_retry"] is True + assert retry["source_settlement"]["status"] == "settled" + assert settlement_calls == [True, True] + durable = next( + (runtime / "goals" / GOAL_ID / "periodic_reports").glob("*/receipt.json") + ) + persisted = json.loads(durable.read_text(encoding="utf-8")) + assert persisted["source_settlement"]["status"] == "settled" + assert persisted["settlement_only_retry"] is True + state = (registry.parent / "ACTIVE_GOAL_STATE.md").read_text(encoding="utf-8") + assert state.count("action_kind=deliver_periodic_report_goal_channel") == 1 + assert periodic_report_request_intents( + runtime_root=runtime, + goal_id=GOAL_ID, + agent_id=AGENT_ID, + ) == [] + + def test_report_artifacts_leave_no_temp_residue(tmp_path: Path) -> None: registry, runtime = _fixture(tmp_path) diff --git a/tests/extensions/test_extension_runtime.py b/tests/extensions/test_extension_runtime.py index 5b589b7282..8625bd7a17 100644 --- a/tests/extensions/test_extension_runtime.py +++ b/tests/extensions/test_extension_runtime.py @@ -23,6 +23,7 @@ ) from loopx.capabilities.semantic_preference.contract import provider_doctor, recall from loopx.cli import main +from loopx.extensions.hook_adapters import discover_extension_hook_adapters from loopx.extensions.manifest import load_extension_manifest from loopx.extensions.openviking_semantic_preference.provider import ( register_openviking_provider_arguments, @@ -199,6 +200,91 @@ def test_presentation_surface_manifest_is_normalized(tmp_path: Path) -> None: ] +def test_capability_action_hook_adapter_manifest_is_normalized(tmp_path: Path) -> None: + provider = _provider(tmp_path / "provider") + manifest_path = _standalone_manifest( + tmp_path / "extension.toml", + entrypoint=provider, + permission="semantic_preference.read", + ) + manifest_path.write_text( + manifest_path.read_text(encoding="utf-8") + + """ + +[[hook_adapters]] +id = "sample-report-source" +capability_id = "sample-report" +target_hook_id = "sample_report.request" +phase = "capability_action" +factory = "sample_extension.hooks:build_adapter" +required_permissions = ["semantic_preference.read"] +ports = ["sample_report.request.bind_source", "sample_report.request.settle_source"] +""", + encoding="utf-8", + ) + + manifest = load_extension_manifest(manifest_path) + + assert manifest["hook_adapters"] == [ + { + "id": "sample-report-source", + "capability_id": "sample-report", + "target_hook_id": "sample_report.request", + "phase": "capability_action", + "factory": "sample_extension.hooks:build_adapter", + "required_permissions": ["semantic_preference.read"], + "ports": [ + "sample_report.request.bind_source", + "sample_report.request.settle_source", + ], + } + ] + + +def test_capability_action_factory_failure_is_content_free_and_kernel_independent( + tmp_path: Path, +) -> None: + provider = _provider(tmp_path / "provider") + manifest_path = _standalone_manifest( + tmp_path / "extension.toml", + entrypoint=provider, + permission="semantic_preference.read", + ) + manifest_path.write_text( + manifest_path.read_text(encoding="utf-8") + + """ + +[[hook_adapters]] +id = "sample-report-source" +capability_id = "sample-report" +target_hook_id = "sample_report.request" +phase = "capability_action" +factory = "missing_extension.hooks:build_adapter" +required_permissions = ["semantic_preference.read"] +ports = ["sample_report.request.bind_source"] +""", + encoding="utf-8", + ) + state_file = tmp_path / "runtime" / "extensions" / "state.json" + install_extension(manifest_path, state_file=state_file, execute=True) + + discovery = discover_extension_hook_adapters( + state_file=state_file, + phase="capability_action", + capability_id="sample-report", + target_hook_id="sample_report.request", + registry_path=tmp_path / "registry.json", + runtime_root=tmp_path / "runtime", + goal_id="sample-goal", + agent_id="sample-agent", + ) + + assert discovery.ports == () + assert len(discovery.failures) == 1 + assert discovery.failures[0].adapter_id == "sample-report-source" + assert discovery.failures[0].error_code == "extension_hook_adapter_unavailable" + + @pytest.mark.parametrize( "extension_id", [ diff --git a/tests/extensions/test_lark_group_history.py b/tests/extensions/test_lark_group_history.py index c2e3cf1f12..6a0cf89cc5 100644 --- a/tests/extensions/test_lark_group_history.py +++ b/tests/extensions/test_lark_group_history.py @@ -139,7 +139,7 @@ def _message( "create_time": create_time, "content": content, "deleted": False, - "sender": {"name": "Fixture User"}, + "sender": {"name": "Fixture User", "sender_type": "user"}, } @@ -331,6 +331,7 @@ def test_history_preserves_structured_negative_mention_evidence( ) assert receipt["ok"] is True + assert stored["sender_type"] == "user" assert stored["addressed_to_bot"] is False assert "mentions" not in stored assert "mentioned" not in stored diff --git a/tests/extensions/test_lark_periodic_report_request.py b/tests/extensions/test_lark_periodic_report_request.py new file mode 100644 index 0000000000..5d38a3abe4 --- /dev/null +++ b/tests/extensions/test_lark_periodic_report_request.py @@ -0,0 +1,116 @@ +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from loopx.capabilities.periodic_report.request_action import ( + discover_periodic_report_request_ports, +) +from loopx.extensions.runtime import install_extension +from loopx.extensions.lark import periodic_report_request + + +def _event(message_id: str, content: str) -> dict[str, object]: + return { + "schema_version": "lark_event_inbox_event_v0", + "event_id": f"evt_{message_id}", + "message_id": message_id, + "create_time": "2026-09-06T05:00:00Z", + "content": content, + "attachment_count": 0, + "sender_type": "user", + "addressed_to_bot": True, + "addressing_source": "provider_mention", + "provider_mention_count": 1, + "target_mention_count": 1, + "reply_context_verified": False, + "reply_to_bot": False, + } + + +def test_lark_adapter_binds_only_agent_selected_source_without_text_classification( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + inbox = tmp_path / "inbox" + inbox.mkdir() + selected = "om_selected" + (inbox / f"{selected}.json").write_text( + json.dumps( + _event(selected, "I wrote a weekly report; let's discuss its format.") + ), + encoding="utf-8", + ) + (inbox / "om_other.json").write_text( + json.dumps(_event("om_other", "请生成周报")), + encoding="utf-8", + ) + context = { + "project": tmp_path, + "selected_config_ref": ".loopx/inbox.json", + "config": {"inbox_path": inbox}, + "binding_revision": "sha256:" + "a" * 64, + } + monkeypatch.setattr( + periodic_report_request, + "_resolved_request_context", + lambda **_kwargs: context, + ) + ack_calls: list[list[str]] = [] + monkeypatch.setattr( + periodic_report_request, + "acknowledge_lark_event_inbox", + lambda **kwargs: ( + ack_calls.append(list(kwargs["message_ids"])) + or { + "ok": True, + "new_count": 1, + "already_acknowledged_count": 0, + "write_performed": True, + } + ), + ) + + receipt = periodic_report_request.bind_lark_periodic_report_source( + registry_path=tmp_path / "registry.json", + runtime_root=tmp_path / "runtime", + goal_id="goal-alpha", + agent_id="agent-alpha", + source_ref=selected, + ) + + assert receipt["source_ref"] == selected + assert receipt["raw_content_returned"] is False + assert "content" not in receipt + assert ack_calls == [] + settled = periodic_report_request.settle_lark_periodic_report_source( + registry_path=tmp_path / "registry.json", + runtime_root=tmp_path / "runtime", + goal_id="goal-alpha", + agent_id="agent-alpha", + source_receipt=receipt, + execute=True, + ) + assert settled["status"] == "settled" + assert ack_calls == [[selected]] + + +def test_lark_request_ports_are_discovered_from_manifest(tmp_path: Path) -> None: + state_file = tmp_path / "runtime" / "extensions" / "state.json" + manifest = Path(periodic_report_request.__file__).with_name("extension.toml") + installed = install_extension(manifest, state_file=state_file, execute=True) + assert installed["doctor"]["verified"] is True + + ports = discover_periodic_report_request_ports( + registry_path=tmp_path / "registry.json", + runtime_root=tmp_path / "runtime", + goal_id="goal-alpha", + agent_id="agent-alpha", + extension_state_file=state_file, + ) + + assert ports.adapter_id == "lark-periodic-report-source" + assert callable(ports.bind_source) + assert callable(ports.settle_source) diff --git a/tests/extensions/test_periodic_report_goal_channel_delivery.py b/tests/extensions/test_periodic_report_goal_channel_delivery.py index 6a60ab7798..9a45dda180 100644 --- a/tests/extensions/test_periodic_report_goal_channel_delivery.py +++ b/tests/extensions/test_periodic_report_goal_channel_delivery.py @@ -141,7 +141,7 @@ def _extension_activation() -> dict[str, Any]: return { "schema_version": "loopx_extension_activation_v0", "extension_id": "loopx-lark", - "provider_version": "1.5.0", + "provider_version": "1.6.0", "revision": "publicfixture123", "enabled": True, "doctor_verified": True, diff --git a/tests/extensions/test_periodic_report_miaoda.py b/tests/extensions/test_periodic_report_miaoda.py index ca7c8f6fb1..b8d0498245 100644 --- a/tests/extensions/test_periodic_report_miaoda.py +++ b/tests/extensions/test_periodic_report_miaoda.py @@ -105,7 +105,7 @@ def _delivery_request() -> dict[str, Any]: }, "extension": { "extension_id": "loopx-lark", - "extension_version": "1.5.0", + "extension_version": "1.6.0", "protocol": "periodic_report_sink_v0", }, } @@ -127,7 +127,7 @@ def _extension_activation() -> dict[str, Any]: return { "schema_version": "loopx_extension_activation_v0", "extension_id": "loopx-lark", - "provider_version": "1.5.0", + "provider_version": "1.6.0", "revision": "publicfixture123", "enabled": True, "doctor_verified": True, @@ -147,7 +147,7 @@ def _sent_miaoda_delivery_receipt_inputs() -> tuple[ extension_receipts=[ { "extension_id": "loopx-lark", - "extension_version": "1.5.0", + "extension_version": "1.6.0", "protocol": "periodic_report_sink_v0", "status": "ready", "readback_verified": True, From b66edaf4848f63f989deb22f5c966af5621f4e68 Mon Sep 17 00:00:00 2001 From: huangruiteng Date: Sun, 6 Sep 2026 18:30:04 +0800 Subject: [PATCH 03/10] docs(periodic-report): document agent semantic requests Signed-off-by: huangruiteng --- docs/reference/extensions.md | 25 +++++++++++ .../reference/protocols/periodic-report-v0.md | 44 +++++++++++++++++++ loopx/capabilities/periodic_report/README.md | 29 +++++++++++- loopx/extensions/lark/README.md | 9 ++++ .../extensions/lark/docs/lark-event-inbox.md | 5 +++ 5 files changed, 110 insertions(+), 2 deletions(-) diff --git a/docs/reference/extensions.md b/docs/reference/extensions.md index d466a20f2a..bc0ea51f0d 100644 --- a/docs/reference/extensions.md +++ b/docs/reference/extensions.md @@ -757,6 +757,19 @@ table solely to make a runtime installable. The v0 runtime exposes integer extension API version `1` and accepts bounded integer constraints such as `>=1,<2`; incompatible manifests fail closed. +`[[hook_adapters]]` lets an extension contribute provider ports to an existing +capability-owned hook point without adding provider branches to the capability +composition root or control-plane kernel. Discovery reads only installed +manifest declarations and admits a factory only when the extension is enabled, +doctor-ready, and authorized for every declared adapter permission. The +factory must return exactly the declared callable ports. Import, activation, +and factory failures become content-free optional-adapter failures; they do not +execute or replace kernel logic. + +`phase = "capability_action"` is for an explicit typed action whose semantics +have already been decided by the Agent or caller. The adapter may bind and +settle provider evidence, but it must not infer the action from provider text. + ```toml schema_version = "loopx_extension_manifest_v0" id = "loopx-lark" @@ -781,6 +794,18 @@ real_world_anchor = "operator-facing Lark Base projection" user_value = "Project public-safe LoopX status and todo rows into Lark." entry_command = "loopx lark-kanban sync" next_real_step = "Validate one explicitly enabled owner-approved sink." + +[[hook_adapters]] +id = "lark-periodic-report-source" +capability_id = "periodic-report" +target_hook_id = "periodic_report.request" +phase = "capability_action" +factory = "loopx.extensions.lark.periodic_report_request:build_lark_periodic_report_hook_adapter" +required_permissions = ["lark.inbox.read", "lark.inbox.write"] +ports = [ + "periodic_report.request.bind_source", + "periodic_report.request.settle_source", +] ``` The bundled OpenViking pilot uses `[[implements]]` instead: diff --git a/docs/reference/protocols/periodic-report-v0.md b/docs/reference/protocols/periodic-report-v0.md index 498c08ee9c..7fdee49b20 100644 --- a/docs/reference/protocols/periodic-report-v0.md +++ b/docs/reference/protocols/periodic-report-v0.md @@ -277,6 +277,50 @@ An eligible decision may be embedded as `trigger_receipt` in a participate in run identity, so a milestone update and a scheduled digest over the same evidence window cannot collide. +### Agent-semantic Goal Channel requests + +An Agent that has read and semantically interpreted one Goal Channel inbox item +may explicitly invoke the provider-neutral action: + +```text +loopx periodic-report request \ + --goal-id \ + --agent-id \ + --source-ref \ + --execute +``` + +This action is the report-intent decision. Neither LoopX Core nor the provider +adapter classifies message strings, searches for report keywords, or scans the +inbox for candidate requests. The provider adapter receives the exact opaque +source reference selected by the Agent and verifies only source existence, +user authorship, provider-native addressing, the registered Goal/Agent +connection, the current provider target, and inbox identity. It returns a +content-free `periodic_report_source_binding_receipt_v0`; raw message content +never enters the capability intent or public output. + +An executed request writes one replay-safe local-private +`periodic_report_request_journal_entry_v0`. The pending-intent projection reads +that typed journal and existing post-writeback intents only; it never calls a +provider reader. The request becomes an authorized `manual` trigger and then +reuses the existing editorial, generation bundle, Workspace projection, +publication candidate, and delivery-Todo pipeline. Repeating the same +Goal/Agent/source action returns the existing request and cannot create another +journal entry. + +Source binding and settlement ports are dynamically discovered from enabled, +doctor-ready extension manifests through a `capability_action` +`[[hook_adapters]]` declaration. Generic discovery and the periodic-report +composition import no Lark implementation. The bundled Lark adapter is +therefore optional provider code, not a quota or decision-kernel branch. + +The adapter ACKs the selected inbox item only after the `delivery_ready` +receipt and delivery Todo are durable. If ACK fails or the adapter is +temporarily unavailable, the typed request stays pending. A later +`consume-pending` call loads the durable receipt, retries settlement only, and +does not regenerate artifacts or add another delivery Todo. Exact ACK replay +is idempotent. + ### Post-writeback hook boundary The optional automatic path uses the provider-neutral TypeScript diff --git a/loopx/capabilities/periodic_report/README.md b/loopx/capabilities/periodic_report/README.md index 61064bbb4b..8108238898 100644 --- a/loopx/capabilities/periodic_report/README.md +++ b/loopx/capabilities/periodic_report/README.md @@ -6,7 +6,7 @@ presentation, and destinations to profiles and adapters. | Surface | Value | | --- | --- | -| CLI | `loopx periodic-report inspect-profile --preset weekly`, custom `--profile-json `, `evaluate-trigger`, `evaluate-runtime-trigger`, `compose-run`, and optional `archive-openviking` | +| CLI | `loopx periodic-report inspect-profile --preset weekly`, `request`, `consume-pending`, custom `--profile-json `, `evaluate-trigger`, `evaluate-runtime-trigger`, `compose-run`, and optional `archive-openviking` | | Protocol | [`periodic_report_v0`](../../../docs/reference/protocols/periodic-report-v0.md) | | Smokes | `python3 examples/periodic-report-smoke.py`, `periodic-report-profile-smoke.py`, `periodic-report-html-smoke.py`, `periodic-report-bindings-smoke.py`, and `openviking-periodic-report-extension-smoke.py` | @@ -56,6 +56,31 @@ research, operations, and other domains may supply peer source adapters when their richer semantics are useful; none is required by the built-in weekly profile. +## Request from a Goal Channel + +After an Agent reads one addressed Goal Channel item and semantically decides +that the user is asking it for a report, it records that decision explicitly: + +```bash +loopx periodic-report request \ + --goal-id \ + --agent-id \ + --source-ref \ + --execute +``` + +There is no keyword or regular-expression classifier. The provider adapter +binds only the exact source selected by the Agent and checks authorship, +addressing, Goal/Agent connection, target, and inbox identity. A manifest- +discovered `capability_action` hook supplies the content-free bind and settle +ports, so this capability and quota import no Lark implementation. + +The command persists a replay-safe typed request journal. `consume-pending` +uses the normal manual trigger, editorial, frozen artifact, Workspace, and +delivery-Todo pipeline. It acknowledges the provider source only after +`delivery_ready` durability; failed ACKs become settlement-only retries and do +not duplicate delivery work. + ## Customize or schedule The capability remains **inactive for background work and external writes by @@ -402,7 +427,7 @@ external writes remain disabled by default: }, "extension": { "extension_id": "loopx-lark", - "extension_version": "1.5.0", + "extension_version": "1.6.0", "protocol": "periodic_report_sink_v0" } } diff --git a/loopx/extensions/lark/README.md b/loopx/extensions/lark/README.md index a5dc76b8a7..bf462a9099 100644 --- a/loopx/extensions/lark/README.md +++ b/loopx/extensions/lark/README.md @@ -14,6 +14,7 @@ evidence, or recovery authority. | `lark-goal-channel` | Bind one verified Lark group and projection surface to one LoopX goal | [`goal_channel.py`](goal_channel.py), [`goal_channel_setup.py`](goal_channel_setup.py) | | `lark-explore-projection` | Project canonical Explore results into Lark tables, cards, and whiteboards | [`presentation/explore_results.py`](presentation/explore_results.py) | | `lark-periodic-report-announcement` | Deliver a periodic report through the current Goal Channel's verified project Bot while mentioning only recipients selected by its typed audience plan | [`periodic_report_delivery.py`](periodic_report_delivery.py) | +| `lark-periodic-report-source` | Bind and settle one exact Agent-selected Goal Channel source for a typed report action without classifying message text | [`periodic_report_request.py`](periodic_report_request.py) | | `lark-miaoda-html-report` | Publish an already-rendered periodic report to an operator-selected existing Miaoda app | [`presentation/periodic_report.py`](presentation/periodic_report.py) | Mention-bearing text delivery is owned by the extension's shared outbound @@ -53,6 +54,14 @@ created message back. It returns no profile, chat id, message body, or raw provider payload. Installation and the command itself grant no new Lark scope or external-write authority. +When an Agent semantically interprets one inbox item as a report request, it +passes that item's exact `message_id` to `loopx periodic-report request`. +`periodic_report_request.py` validates only user authorship, provider-native +addressing, the selected Goal/Agent binding, provider target, and inbox +identity. It neither scans other inbox items nor inspects text for report +keywords. Its bind/settle ports are discovered from `extension.toml`; source +ACK happens only after the capability has persisted `delivery_ready` state. + The [event inbox guide](docs/lark-event-inbox.md) documents the complete collector, processing, reply, reaction, and acknowledgement lifecycle. The [Lark Kanban integration guide](../../../docs/integrations/lark-kanban-control-plane-adapter.md) diff --git a/loopx/extensions/lark/docs/lark-event-inbox.md b/loopx/extensions/lark/docs/lark-event-inbox.md index 31329a1ff4..33ff6943a2 100644 --- a/loopx/extensions/lark/docs/lark-event-inbox.md +++ b/loopx/extensions/lark/docs/lark-event-inbox.md @@ -132,6 +132,11 @@ the reply relation through message readback before scheduling a reply. Full-chat capture is not full-chat activation; unrelated conversation remains available to domain interpretation without being treated as addressed to the bot. +For a periodic-report request, semantic activation belongs to the Agent. After +reading an exact item, the Agent calls `loopx periodic-report request` with its +`message_id`. The Lark adapter validates binding and addressing evidence only; +it never classifies the text or searches the inbox for weekly-report strings. + ## Activate the provider Install and explicitly activate the bundled provider once in the LoopX runtime From d3d00a698150bb6adbf480b5ff60f76e88769eb5 Mon Sep 17 00:00:00 2001 From: huangruiteng Date: Sun, 6 Sep 2026 18:34:14 +0800 Subject: [PATCH 04/10] refactor(periodic-report): remove unused adapter projections Signed-off-by: huangruiteng --- loopx/capabilities/periodic_report/request_action.py | 3 --- loopx/extensions/hook_adapters.py | 7 ------- 2 files changed, 10 deletions(-) diff --git a/loopx/capabilities/periodic_report/request_action.py b/loopx/capabilities/periodic_report/request_action.py index 27eed4e8b8..b2576aab75 100644 --- a/loopx/capabilities/periodic_report/request_action.py +++ b/loopx/capabilities/periodic_report/request_action.py @@ -40,7 +40,6 @@ class PeriodicReportRequestPorts: adapter_id: str | None bind_source: SourceBinder | None settle_source: SourceSettler | None - failure_count: int = 0 def _digest(value: object) -> str: @@ -121,14 +120,12 @@ def discover_periodic_report_request_ports( adapter_id=None, bind_source=None, settle_source=None, - failure_count=len(discovery.failures), ) adapter_id, ports = complete[0] return PeriodicReportRequestPorts( adapter_id=adapter_id, bind_source=ports[REQUEST_BIND_PORT], settle_source=ports[REQUEST_SETTLE_PORT], - failure_count=len(discovery.failures), ) diff --git a/loopx/extensions/hook_adapters.py b/loopx/extensions/hook_adapters.py index 717ccd0309..5b321d5841 100644 --- a/loopx/extensions/hook_adapters.py +++ b/loopx/extensions/hook_adapters.py @@ -44,13 +44,6 @@ class ExtensionHookAdapterDiscovery: ports: tuple[ExtensionHookAdapterPortBinding, ...] failures: tuple[ExtensionHookAdapterFailure, ...] - def handlers(self, port_name: str) -> tuple[Callable[..., Any], ...]: - return tuple( - binding.handler - for binding in self.ports - if binding.port_name == port_name - ) - def _load_factory(reference: str) -> Callable[..., Mapping[str, Callable[..., Any]]]: module_name, separator, callable_name = reference.partition(":") From 0bd1fc8b8c39d5132eda7e951afae91dcc85f886 Mon Sep 17 00:00:00 2001 From: huangruiteng Date: Sun, 6 Sep 2026 19:13:03 +0800 Subject: [PATCH 05/10] test: cover periodic report adapter resolution Signed-off-by: huangruiteng --- .../test_lark_periodic_report_request.py | 115 ++++++++++++++++++ 1 file changed, 115 insertions(+) diff --git a/tests/extensions/test_lark_periodic_report_request.py b/tests/extensions/test_lark_periodic_report_request.py index 5d38a3abe4..7fc821f25f 100644 --- a/tests/extensions/test_lark_periodic_report_request.py +++ b/tests/extensions/test_lark_periodic_report_request.py @@ -8,6 +8,11 @@ from loopx.capabilities.periodic_report.request_action import ( discover_periodic_report_request_ports, ) +from loopx.extensions.lark.goal_channel_contracts import ( + GOAL_CHANNEL_BINDING_SCHEMA_VERSION, + write_goal_channel_binding, +) +from loopx.extensions.lark.goal_channel_targets import add_lark_goal_channel_target from loopx.extensions.runtime import install_extension from loopx.extensions.lark import periodic_report_request @@ -114,3 +119,113 @@ def test_lark_request_ports_are_discovered_from_manifest(tmp_path: Path) -> None assert ports.adapter_id == "lark-periodic-report-source" assert callable(ports.bind_source) assert callable(ports.settle_source) + + +def test_lark_request_context_resolves_registered_agent_inbox( + tmp_path: Path, +) -> None: + project = tmp_path / "project" + registry_path = project / ".loopx" / "registry.json" + registry_path.parent.mkdir(parents=True) + config_ref = ".loopx/config/lark/agent-alpha.json" + registry_path.write_text( + json.dumps( + { + "goals": [ + { + "id": "goal-alpha", + "repo": str(project), + "objective": "Deliver a synthetic weekly report.", + "coordination": { + "registered_agents": ["agent-alpha"], + }, + "control_plane": { + "lark_event_inboxes": { + "agent-alpha": { + "enabled": True, + "config_path": config_ref, + } + } + }, + } + ] + } + ), + encoding="utf-8", + ) + config_path = project / config_ref + config_path.parent.mkdir(parents=True) + config_path.write_text( + json.dumps( + { + "schema_version": "lark_event_inbox_config_v0", + "enabled": True, + "inbox_dir": ".loopx/inbox/agent-alpha", + "capture_scope": "addressed_only", + "reply": { + "enabled": True, + "sender_profile": "agent-alpha-bot", + "sender_identity": "bot", + "bot_display_name": "Agent Alpha", + "chat_id": "oc_public_fixture", + }, + } + ), + encoding="utf-8", + ) + runtime_root = tmp_path / "runtime" + add_lark_goal_channel_target( + target_path=runtime_root / "goal-channel-targets.json", + target_name="agent-alpha-channel", + chat_id="oc_public_fixture", + chat_name="Synthetic Goal Channel", + identity_mode="project_bot", + sender_profile="agent-alpha-bot", + sender_identity="bot", + bot_app_id="cli_public_fixture", + bot_display_name="Agent Alpha", + cli_bin="lark-cli", + execute=True, + ) + write_goal_channel_binding( + project / ".loopx" / "goal-channel.json", + { + "schema_version": GOAL_CHANNEL_BINDING_SCHEMA_VERSION, + "bindings": { + "goal-alpha": { + "goal_id": "goal-alpha", + "agent_id": "agent-alpha", + "provider": "lark", + "enabled": True, + "target_ref": "agent-alpha-channel", + "routing": { + "capture_scope": "addressed_only", + "ingress_mode": "async_inbox", + "inbox_config_ref": config_ref, + }, + } + }, + }, + ) + + context = periodic_report_request._resolved_request_context( + registry_path=registry_path, + runtime_root=runtime_root, + goal_id="goal-alpha", + agent_id="agent-alpha", + ) + + assert context["project"] == project + assert context["selected_config_ref"] == config_ref + assert context["config"]["inbox_path"] == ( + project / ".loopx" / "inbox" / "agent-alpha" + ) + assert str(context["binding_revision"]).startswith("sha256:") + + +def test_lark_request_timestamp_accepts_provider_epoch_and_rejects_naive() -> None: + assert periodic_report_request._observed_at("1788670800000") == ( + "2026-09-06T05:00:00Z" + ) + with pytest.raises(ValueError, match="source timestamp is invalid"): + periodic_report_request._observed_at("2026-09-06T05:00:00") From 9fad17014ac97cc5a4a7a44a65afdcd42743a7fe Mon Sep 17 00:00:00 2001 From: huangruiteng Date: Sun, 6 Sep 2026 19:48:12 +0800 Subject: [PATCH 06/10] fix(lark): keep inbox guidance capability neutral Signed-off-by: huangruiteng --- loopx/extensions/lark/event_inbox.py | 6 +----- tests/extensions/test_lark_goal_topic_runtime.py | 1 + 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/loopx/extensions/lark/event_inbox.py b/loopx/extensions/lark/event_inbox.py index 0c527f09da..6980cc33cd 100644 --- a/loopx/extensions/lark/event_inbox.py +++ b/loopx/extensions/lark/event_inbox.py @@ -579,11 +579,7 @@ def inspect_lark_event_inbox( "instruction": ( "For an actionable item, first run `loopx lark-inbox processing` for " "its message_id, then translate it into a todo, vision correction, PR " - "update, or no-follow-up rationale. If the message semantically asks " - "this Agent for a periodic report, invoke `loopx periodic-report " - "request --goal-id --agent-id --source-ref " - " --execute`; the Agent owns that semantic decision and " - "must not delegate it to keyword matching. Send and verify any required reply " + "update, or no-follow-up rationale. Send and verify any required reply " "before acknowledging the message_id. Follow reply_guidance for " "placement and editorial style. If no reply is required, run " "`loopx lark-inbox material-review` with a committed effect receipt " diff --git a/tests/extensions/test_lark_goal_topic_runtime.py b/tests/extensions/test_lark_goal_topic_runtime.py index e4a0b7c54e..2a04c2aba7 100644 --- a/tests/extensions/test_lark_goal_topic_runtime.py +++ b/tests/extensions/test_lark_goal_topic_runtime.py @@ -408,6 +408,7 @@ def test_agent_scoped_async_inbox_queues_without_chat_reply_or_ack( assert projection["processed_count"] == 0 assert projection["thread_complete"] is False assert projection["coverage_warning"] + assert "periodic-report" not in projection["instruction"] def test_invalid_persisted_routing_state_never_answers_replies_or_acknowledges( From b67fe5ba5d4231a87767e131d3fe6d742bcf1bac Mon Sep 17 00:00:00 2001 From: huangruiteng Date: Sun, 6 Sep 2026 19:57:27 +0800 Subject: [PATCH 07/10] fix(lark): bind report requests to bot identity Signed-off-by: huangruiteng --- loopx/extensions/lark/event_inbox.py | 84 +++++++++++++++---- .../extensions/lark/goal_channel_transport.py | 19 +++++ .../extensions/lark/goal_topic_connections.py | 22 ++--- loopx/extensions/lark/goal_topic_runtime.py | 2 + .../lark/periodic_report_request.py | 6 ++ .../test_lark_goal_topic_connections.py | 23 ++++- .../test_lark_periodic_report_request.py | 30 +++++++ 7 files changed, 155 insertions(+), 31 deletions(-) diff --git a/loopx/extensions/lark/event_inbox.py b/loopx/extensions/lark/event_inbox.py index 6980cc33cd..2b411532fa 100644 --- a/loopx/extensions/lark/event_inbox.py +++ b/loopx/extensions/lark/event_inbox.py @@ -23,6 +23,11 @@ decide_external_event_ack, external_event_ref, ) +from .goal_channel_transport import ( + APP_ID_PATTERN, + OPEN_ID_PATTERN, + lark_provider_mention_identities, +) EVENT_SCHEMA_VERSION = "lark_event_inbox_event_v0" CONFIG_SCHEMA_VERSION = "lark_event_inbox_config_v0" @@ -110,6 +115,8 @@ def load_lark_event_inbox_config( bot_display_name = " ".join( str(reply_payload.get("bot_display_name") or "").split() )[:100] + bot_app_id = str(reply_payload.get("bot_app_id") or "").strip() + bot_open_id = str(reply_payload.get("bot_open_id") or "").strip() chat_id = str(reply_payload.get("chat_id") or "").strip() placement_policy = str( reply_payload.get("placement_policy") or "source_thread" @@ -166,6 +173,10 @@ def load_lark_event_inbox_config( "enabled lark inbox reply requires an explicit non-default " "sender_profile, bot identity, bot_display_name, and chat_id" ) + if bot_app_id and not APP_ID_PATTERN.fullmatch(bot_app_id): + raise ValueError("lark inbox bot_app_id is invalid") + if bot_open_id and not OPEN_ID_PATTERN.fullmatch(bot_open_id): + raise ValueError("lark inbox bot_open_id is invalid") material_review_payload = payload.get("material_review") if material_review_payload is not None and not isinstance( material_review_payload, Mapping @@ -197,6 +208,8 @@ def load_lark_event_inbox_config( "sender_profile": sender_profile, "sender_identity": sender_identity, "bot_display_name": bot_display_name, + "bot_app_id": bot_app_id, + "bot_open_id": bot_open_id, "chat_id": chat_id, "placement_policy": placement_policy, "editorial_style": editorial_style, @@ -231,6 +244,8 @@ def _event_from_payload( payload: object, *, bot_display_name: str | None = None, + bot_app_id: str | None = None, + bot_open_id: str | None = None, allow_text_addressing: bool = False, ) -> dict[str, Any] | None: if ( @@ -291,6 +306,8 @@ def _event_from_payload( and lark_event_mentions_bot( payload, bot_display_name=bot_display_name, + bot_app_id=bot_app_id, + bot_open_id=bot_open_id, allow_text_fallback=allow_text_addressing, ) ) @@ -304,10 +321,27 @@ def _event_from_payload( if isinstance(mentions, list): provider_mentions = [item for item in mentions if isinstance(item, Mapping)] provider_mention_count = len(provider_mentions) - expected = _normalized_mention_name(bot_display_name) - if expected: + expected_identities = { + value + for value in ( + str(bot_app_id or "").strip(), + str(bot_open_id or "").strip(), + ) + if value + } + expected_name = _normalized_mention_name(bot_display_name) + if expected_identities: + target_mention_count = sum( + bool( + lark_provider_mention_identities(item).intersection( + expected_identities + ) + ) + for item in provider_mentions + ) + elif expected_name: target_mention_count = sum( - _normalized_mention_name(item.get("name")) == expected + _normalized_mention_name(item.get("name")) == expected_name for item in provider_mentions ) elif payload.get("mentioned") is True and provider_mention_count == 1: @@ -422,31 +456,47 @@ def lark_event_mentions_bot( event: Mapping[str, Any], *, bot_display_name: str, + bot_app_id: str | None = None, + bot_open_id: str | None = None, allow_text_fallback: bool = True, ) -> bool: """Recognize one provider-native or exact legacy Bot mention.""" - if event.get("mentioned") is True: - return True - expected = _normalized_mention_name(bot_display_name) mentions = event.get("mentions") - provider_mention = bool( - expected - and isinstance(mentions, list) - and any( - isinstance(mention, Mapping) - and _normalized_mention_name(mention.get("name")) == expected - for mention in mentions + expected_identities = { + value + for value in ( + str(bot_app_id or "").strip(), + str(bot_open_id or "").strip(), ) - ) - if provider_mention: + if value + } + expected_name = _normalized_mention_name(bot_display_name) + if isinstance(mentions, list): + provider_mentions = [ + mention for mention in mentions if isinstance(mention, Mapping) + ] + if expected_identities: + return any( + lark_provider_mention_identities(mention).intersection( + expected_identities + ) + for mention in provider_mentions + ) + if expected_name: + return any( + _normalized_mention_name(mention.get("name")) == expected_name + for mention in provider_mentions + ) + return event.get("mentioned") is True and len(provider_mentions) == 1 + if event.get("mentioned") is True: return True # A provider-supplied structured negative is authoritative. In # particular, do not reinterpret an @mention of somebody else because the # surrounding message also discusses LoopX. if "mentions" in event or "mentioned" in event: return False - if not expected or not allow_text_fallback: + if not expected_name or not allow_text_fallback: return False content = str(event.get("content") or "") escaped = re.escape(" ".join(str(bot_display_name).strip().lstrip("@").split())) @@ -487,6 +537,8 @@ def ingest_lark_event_inbox( event = _event_from_payload( payload, bot_display_name=str(config["reply"].get("bot_display_name") or ""), + bot_app_id=str(config["reply"].get("bot_app_id") or ""), + bot_open_id=str(config["reply"].get("bot_open_id") or ""), allow_text_addressing=config["capture_scope"] == "addressed_only", ) if event is None: diff --git a/loopx/extensions/lark/goal_channel_transport.py b/loopx/extensions/lark/goal_channel_transport.py index 872380333c..052dc45685 100644 --- a/loopx/extensions/lark/goal_channel_transport.py +++ b/loopx/extensions/lark/goal_channel_transport.py @@ -69,6 +69,25 @@ def find_first_string( return None +def lark_provider_mention_identities(mention: Mapping[str, Any]) -> set[str]: + """Return provider-stable identities carried by one structured mention.""" + + identity_keys = ("user_id", "open_id", "union_id", "app_id", "bot_id") + identities = { + str(mention.get(key) or "").strip() + for key in identity_keys + if str(mention.get(key) or "").strip() + } + raw_id = mention.get("id") + if isinstance(raw_id, Mapping): + identities.update( + str(value).strip() for value in raw_id.values() if str(value).strip() + ) + elif str(raw_id or "").strip(): + identities.add(str(raw_id).strip()) + return identities + + def contains_exact_field( payload: Any, key: str, diff --git a/loopx/extensions/lark/goal_topic_connections.py b/loopx/extensions/lark/goal_topic_connections.py index 10d6914078..995fa8d08e 100644 --- a/loopx/extensions/lark/goal_topic_connections.py +++ b/loopx/extensions/lark/goal_topic_connections.py @@ -66,6 +66,7 @@ chat_verified, find_first_string, json_payload, + lark_provider_mention_identities, lark_args, message_readback_verified, ) @@ -495,6 +496,9 @@ def connect_lark_goal_topic( reply = inbox_payload.get("reply") if isinstance(reply, dict): reply["bot_display_name"] = str(identity["label"]) + reply["bot_app_id"] = str(identity["app_id"]) + if identity.get("open_id"): + reply["bot_open_id"] = str(identity["open_id"]) _write_agent_inbox_config( config_path=config_path, config_ref=preflight_config_ref, @@ -1043,23 +1047,15 @@ def is_event_addressed_to_bot( if "mentions" in event: mentions = event.get("mentions") if isinstance(mentions, list): + expected_ids = {value for value in (bot_app_id, bot_open_id) if value} for item in mentions: if not isinstance(item, Mapping): continue - raw_id = item.get("id") - candidate_ids: list[str] = [] - if isinstance(raw_id, Mapping): - candidate_ids.extend([str(v) for v in raw_id.values() if v]) - elif raw_id is not None: - candidate_ids.append(str(raw_id)) - for key in ("user_id", "open_id", "union_id", "app_id", "bot_id"): - val = item.get(key) - if val: - candidate_ids.append(str(val)) - if bot_app_id and any(c == bot_app_id for c in candidate_ids): - return True - if bot_open_id and any(c == bot_open_id for c in candidate_ids): + candidate_ids = lark_provider_mention_identities(item) + if expected_ids and candidate_ids.intersection(expected_ids): return True + if expected_ids: + continue norm_item_name = _normalize_mention_name(str(item.get("name") or "")) if norm_bot_display_name and norm_item_name == norm_bot_display_name: return True diff --git a/loopx/extensions/lark/goal_topic_runtime.py b/loopx/extensions/lark/goal_topic_runtime.py index f808ac16ee..963f13f7c2 100644 --- a/loopx/extensions/lark/goal_topic_runtime.py +++ b/loopx/extensions/lark/goal_topic_runtime.py @@ -782,6 +782,8 @@ def _inbox_config( "sender_profile": profile, "sender_identity": "bot", "bot_display_name": bot_display_name, + "bot_app_id": str(identity.get("bot_app_id") or ""), + "bot_open_id": str(identity.get("bot_open_id") or ""), "chat_id": chat_id, "placement_policy": "source_context", "editorial_style": "bullet_points_preferred", diff --git a/loopx/extensions/lark/periodic_report_request.py b/loopx/extensions/lark/periodic_report_request.py index 2bf32d74dd..89ec53e82a 100644 --- a/loopx/extensions/lark/periodic_report_request.py +++ b/loopx/extensions/lark/periodic_report_request.py @@ -194,6 +194,10 @@ def _resolved_request_context( != str(identity.get("sender_profile") or "") or str(reply.get("bot_display_name") or "") != str(identity.get("bot_display_name") or "") + or str(reply.get("bot_app_id") or "") + != str(identity.get("bot_app_id") or "") + or str(reply.get("bot_open_id") or "") + != str(identity.get("bot_open_id") or "") or str(reply.get("chat_id") or "") != str(channel.get("chat_id") or "") ) @@ -214,6 +218,8 @@ def _resolved_request_context( "chat_id": channel.get("chat_id"), "sender_profile": identity.get("sender_profile"), "bot_display_name": identity.get("bot_display_name"), + "bot_app_id": identity.get("bot_app_id"), + "bot_open_id": identity.get("bot_open_id"), } ) return { diff --git a/tests/extensions/test_lark_goal_topic_connections.py b/tests/extensions/test_lark_goal_topic_connections.py index 4180a26803..e931620083 100644 --- a/tests/extensions/test_lark_goal_topic_connections.py +++ b/tests/extensions/test_lark_goal_topic_connections.py @@ -1019,7 +1019,7 @@ def test_routes_bound_topic_messages_and_replies_in_thread(tmp_path: Path) -> No "root_id": "om_topic_alpha", "message_id": "om_incoming", "content": "@LoopX Mew hello", - "mentions": [{"name": "LoopX Mew", "id": "ou_mew_bot"}], + "mentions": [{"name": "LoopX Mew", "id": APP_ID}], }, ) assert route == { @@ -1149,6 +1149,25 @@ def runner(args: list[str], cwd: object, timeout: object) -> dict[str, Any]: assert decision["matched"] is True assert decision["reason"] == "matched" + same_name_wrong_identity = decide_lark_topic_event( + target_payload=targets, + binding_payloads={"goal-alpha": read_goal_channel_binding(binding_path)}, + event={ + "chat_id": CHAT_ID, + "root_id": "om_topic_alpha", + "message_id": "om_same_name_wrong_identity", + "content": "@_user_1 请处理", + "mentions": [ + { + "id": {"open_id": "ou_different_bot"}, + "name": str(target["identity"]["bot_display_name"]), + } + ], + }, + ) + assert same_name_wrong_identity["matched"] is False + assert same_name_wrong_identity["reason"] == "not_addressed" + def test_topic_route_decision_reports_safe_reason_codes(tmp_path: Path) -> None: state: dict[str, Any] = {} @@ -1264,7 +1283,7 @@ def test_topic_route_decision_reports_safe_reason_codes(tmp_path: Path) -> None: "mentions": [ { "key": "@_user_1", - "id": {"open_id": "ou_public_fixture"}, + "id": {"app_id": APP_ID}, "name": " @LoopX Mew ", } ], diff --git a/tests/extensions/test_lark_periodic_report_request.py b/tests/extensions/test_lark_periodic_report_request.py index 7fc821f25f..74eba1331b 100644 --- a/tests/extensions/test_lark_periodic_report_request.py +++ b/tests/extensions/test_lark_periodic_report_request.py @@ -13,6 +13,7 @@ write_goal_channel_binding, ) from loopx.extensions.lark.goal_channel_targets import add_lark_goal_channel_target +from loopx.extensions.lark.event_inbox import _event_from_payload from loopx.extensions.runtime import install_extension from loopx.extensions.lark import periodic_report_request @@ -121,6 +122,34 @@ def test_lark_request_ports_are_discovered_from_manifest(tmp_path: Path) -> None assert callable(ports.settle_source) +def test_lark_inbox_rejects_same_name_mention_with_different_provider_identity() -> ( + None +): + event = _event_from_payload( + { + "schema_version": "lark_event_inbox_event_v0", + "event_id": "evt_same_name_wrong_identity", + "message_id": "om_same_name_wrong_identity", + "content": "@_user_1 请生成周报", + "sender_type": "user", + "mentions": [ + { + "id": {"open_id": "ou_different_bot"}, + "name": "Agent Alpha", + } + ], + }, + bot_display_name="Agent Alpha", + bot_app_id="cli_public_fixture", + bot_open_id="ou_agent_alpha", + ) + + assert event is not None + assert event["addressed_to_bot"] is False + assert event["provider_mention_count"] == 1 + assert event["target_mention_count"] == 0 + + def test_lark_request_context_resolves_registered_agent_inbox( tmp_path: Path, ) -> None: @@ -167,6 +196,7 @@ def test_lark_request_context_resolves_registered_agent_inbox( "sender_profile": "agent-alpha-bot", "sender_identity": "bot", "bot_display_name": "Agent Alpha", + "bot_app_id": "cli_public_fixture", "chat_id": "oc_public_fixture", }, } From c2c065aa8a124f6cb329769d325d8ade6e29c594 Mon Sep 17 00:00:00 2001 From: huangruiteng Date: Sun, 6 Sep 2026 20:22:27 +0800 Subject: [PATCH 08/10] fix(periodic-report): route settlement by adapter provenance Signed-off-by: huangruiteng --- .../reference/protocols/periodic-report-v0.md | 7 + loopx/capabilities/periodic_report/README.md | 7 + loopx/capabilities/periodic_report/cli.py | 14 +- .../periodic_report/pending_intent.py | 11 +- .../periodic_report/request_action.py | 113 +++++++++----- .../test_periodic_report_pending_intent.py | 140 ++++++++++++------ .../test_lark_periodic_report_request.py | 63 +++++++- 7 files changed, 256 insertions(+), 99 deletions(-) diff --git a/docs/reference/protocols/periodic-report-v0.md b/docs/reference/protocols/periodic-report-v0.md index 7fdee49b20..78c7c7b0df 100644 --- a/docs/reference/protocols/periodic-report-v0.md +++ b/docs/reference/protocols/periodic-report-v0.md @@ -290,6 +290,13 @@ loopx periodic-report request \ --execute ``` +One complete active source adapter is selected automatically. If several +provider adapters are active, a new request must include +`--source-adapter-id `. Replay of an already journaled request does +not require the selector. Settlement reads the owner `adapter_id` from that +journal and resolves only the matching discovered settler; discovery order +cannot change ownership, and another provider is never used as a fallback. + This action is the report-intent decision. Neither LoopX Core nor the provider adapter classifies message strings, searches for report keywords, or scans the inbox for candidate requests. The provider adapter receives the exact opaque diff --git a/loopx/capabilities/periodic_report/README.md b/loopx/capabilities/periodic_report/README.md index 8108238898..3941f89231 100644 --- a/loopx/capabilities/periodic_report/README.md +++ b/loopx/capabilities/periodic_report/README.md @@ -69,6 +69,13 @@ loopx periodic-report request \ --execute ``` +When exactly one complete source adapter is active, the command selects it +automatically. With multiple active providers, the Agent selects the provider +explicitly with `--source-adapter-id `. The journal retains that +adapter identity, and later settlement resolves only that owner regardless of +extension discovery order. A temporarily unavailable owner leaves the request +pending; LoopX never falls through to another provider. + There is no keyword or regular-expression classifier. The provider adapter binds only the exact source selected by the Agent and checks authorship, addressing, Goal/Agent connection, target, and inbox identity. A manifest- diff --git a/loopx/capabilities/periodic_report/cli.py b/loopx/capabilities/periodic_report/cli.py index 68dd1c3212..028f796c27 100644 --- a/loopx/capabilities/periodic_report/cli.py +++ b/loopx/capabilities/periodic_report/cli.py @@ -113,6 +113,13 @@ def register_periodic_report_commands( request.add_argument("--goal-id", required=True) request.add_argument("--agent-id", required=True) request.add_argument("--source-ref", required=True) + request.add_argument( + "--source-adapter-id", + help=( + "Select the manifest-discovered source adapter. Required when more " + "than one complete adapter is active." + ), + ) request.add_argument("--execute", action="store_true") request.add_argument( "--extension-state-file", @@ -470,8 +477,8 @@ def handle_periodic_report_command( goal_id=args.goal_id, agent_id=args.agent_id, source_ref=args.source_ref, - bind_source=ports.bind_source, - adapter_id=ports.adapter_id, + request_ports=ports, + source_adapter_id=args.source_adapter_id, execute=bool(args.execute), ) elif args.periodic_report_command == "consume-pending": @@ -496,8 +503,7 @@ def handle_periodic_report_command( goal_id=args.goal_id, agent_id=args.agent_id, execute=bool(args.execute), - provider_request_settler=ports.settle_source, - provider_request_adapter_id=ports.adapter_id, + provider_request_ports=ports, ) elif args.periodic_report_command in { "configure-machine-defaults", diff --git a/loopx/capabilities/periodic_report/pending_intent.py b/loopx/capabilities/periodic_report/pending_intent.py index 9155d692a0..07b1251654 100644 --- a/loopx/capabilities/periodic_report/pending_intent.py +++ b/loopx/capabilities/periodic_report/pending_intent.py @@ -39,7 +39,7 @@ evaluate_periodic_report_trigger_evaluation_intent, ) from .request_action import ( - SourceSettler, + PeriodicReportRequestPorts, periodic_report_request_intents, request_entry_for_intent, settle_periodic_report_request, @@ -994,8 +994,7 @@ def consume_pending_periodic_report_intent( goal_id: str, agent_id: str, execute: bool, - provider_request_settler: SourceSettler | None = None, - provider_request_adapter_id: str | None = None, + provider_request_ports: PeriodicReportRequestPorts | None = None, ) -> dict[str, Any]: intents = pending_periodic_report_intents( registry_path=registry_path, @@ -1076,8 +1075,7 @@ def consume_pending_periodic_report_intent( goal_id=goal_id, agent_id=agent_id, intent=intent, - settle_source=provider_request_settler, - adapter_id=provider_request_adapter_id, + request_ports=provider_request_ports, execute=execute, ) result = { @@ -1323,8 +1321,7 @@ def consume_pending_periodic_report_intent( goal_id=goal_id, agent_id=agent_id, intent=intent, - settle_source=provider_request_settler, - adapter_id=provider_request_adapter_id, + request_ports=provider_request_ports, execute=True, ) durable["source_settlement"] = settlement diff --git a/loopx/capabilities/periodic_report/request_action.py b/loopx/capabilities/periodic_report/request_action.py index b2576aab75..a4fb260bde 100644 --- a/loopx/capabilities/periodic_report/request_action.py +++ b/loopx/capabilities/periodic_report/request_action.py @@ -35,11 +35,47 @@ SourceSettler = Callable[..., Mapping[str, Any]] +@dataclass(frozen=True, slots=True) +class PeriodicReportRequestAdapter: + adapter_id: str + bind_source: SourceBinder + settle_source: SourceSettler + + @dataclass(frozen=True, slots=True) class PeriodicReportRequestPorts: - adapter_id: str | None - bind_source: SourceBinder | None - settle_source: SourceSettler | None + adapters: Mapping[str, PeriodicReportRequestAdapter] + + @property + def adapter_ids(self) -> tuple[str, ...]: + return tuple(sorted(self.adapters)) + + def select_source_adapter( + self, adapter_id: str | None + ) -> PeriodicReportRequestAdapter: + selected_id = str(adapter_id or "").strip() + if selected_id: + selected = self.adapters.get(selected_id) + if selected is None: + raise ValueError( + "periodic-report request source adapter is unavailable: " + f"{selected_id}" + ) + return selected + if len(self.adapters) == 1: + return next(iter(self.adapters.values())) + if not self.adapters: + raise ValueError("periodic-report request source adapter is unavailable") + raise ValueError( + "periodic-report request source adapter is ambiguous; " + "select one with --source-adapter-id" + ) + + def settlement_adapter( + self, adapter_id: object + ) -> PeriodicReportRequestAdapter | None: + owner_id = str(adapter_id or "").strip() + return self.adapters.get(owner_id) if owner_id else None def _digest(value: object) -> str: @@ -96,9 +132,7 @@ def discover_periodic_report_request_ports( extension_state_file: Path | None = None, ) -> PeriodicReportRequestPorts: discovery = discover_extension_hook_adapters( - state_file=( - extension_state_file or default_extension_state_file(runtime_root) - ), + state_file=(extension_state_file or default_extension_state_file(runtime_root)), phase=REQUEST_ADAPTER_PHASE, capability_id="periodic-report", target_hook_id=REQUEST_HOOK_ID, @@ -107,26 +141,24 @@ def discover_periodic_report_request_ports( goal_id=goal_id, agent_id=agent_id, ) - adapters: dict[str, dict[str, SourceBinder | SourceSettler]] = {} + adapter_ports: dict[str, dict[str, SourceBinder | SourceSettler]] = {} + ambiguous_adapter_ids: set[str] = set() for binding in discovery.ports: - adapters.setdefault(binding.adapter_id, {})[binding.port_name] = binding.handler - complete = [ - (adapter_id, ports) - for adapter_id, ports in adapters.items() - if set(ports) == {REQUEST_BIND_PORT, REQUEST_SETTLE_PORT} - ] - if len(complete) != 1: - return PeriodicReportRequestPorts( - adapter_id=None, - bind_source=None, - settle_source=None, + ports = adapter_ports.setdefault(binding.adapter_id, {}) + if binding.port_name in ports: + ambiguous_adapter_ids.add(binding.adapter_id) + ports[binding.port_name] = binding.handler + complete = { + adapter_id: PeriodicReportRequestAdapter( + adapter_id=adapter_id, + bind_source=ports[REQUEST_BIND_PORT], + settle_source=ports[REQUEST_SETTLE_PORT], ) - adapter_id, ports = complete[0] - return PeriodicReportRequestPorts( - adapter_id=adapter_id, - bind_source=ports[REQUEST_BIND_PORT], - settle_source=ports[REQUEST_SETTLE_PORT], - ) + for adapter_id, ports in sorted(adapter_ports.items()) + if adapter_id not in ambiguous_adapter_ids + and set(ports) == {REQUEST_BIND_PORT, REQUEST_SETTLE_PORT} + } + return PeriodicReportRequestPorts(adapters=complete) def _normalize_source_receipt( @@ -160,8 +192,7 @@ def _normalize_source_receipt( or value.get("agent_id") != agent_id or value.get("source_ref") != source_ref or value.get("requester_kind") != "user" - or value.get("addressing_source") - not in {"provider_mention", "verified_reply"} + or value.get("addressing_source") not in {"provider_mention", "verified_reply"} or value.get("raw_content_returned") is not False or value.get("external_writes_performed") is not False ): @@ -249,8 +280,8 @@ def record_periodic_report_request( goal_id: str, agent_id: str, source_ref: str, - bind_source: SourceBinder | None, - adapter_id: str | None, + request_ports: PeriodicReportRequestPorts | None, + source_adapter_id: str | None, execute: bool, ) -> dict[str, Any]: if not _IDENTITY_RE.fullmatch(goal_id) or not _IDENTITY_RE.fullmatch(agent_id): @@ -280,8 +311,9 @@ def record_periodic_report_request( "raw_content_returned": False, "external_writes_performed": False, } - if bind_source is None or not adapter_id: + if request_ports is None: raise ValueError("periodic-report request source adapter is unavailable") + source_adapter = request_ports.select_source_adapter(source_adapter_id) profile_ref, trigger_policy = _request_profile( registry_path=registry_path, runtime_root=runtime_root, @@ -289,7 +321,7 @@ def record_periodic_report_request( agent_id=agent_id, ) source_receipt = _normalize_source_receipt( - bind_source( + source_adapter.bind_source( registry_path=registry_path, runtime_root=runtime_root, goal_id=goal_id, @@ -307,7 +339,7 @@ def record_periodic_report_request( "goal_id": goal_id, "agent_id": agent_id, "requested_at": source_receipt["observed_at"], - "adapter_id": adapter_id, + "adapter_id": source_adapter.adapter_id, "source_receipt": source_receipt, "profile_ref": profile_ref, "trigger_policy": trigger_policy, @@ -414,8 +446,7 @@ def settle_periodic_report_request( goal_id: str, agent_id: str, intent: Mapping[str, Any], - settle_source: SourceSettler | None, - adapter_id: str | None, + request_ports: PeriodicReportRequestPorts | None, execute: bool, ) -> dict[str, Any]: entry = request_entry_for_intent( @@ -432,7 +463,12 @@ def settle_periodic_report_request( "write_performed": False, "external_writes_performed": False, } - if settle_source is None or adapter_id != entry.get("adapter_id"): + source_adapter = ( + request_ports.settlement_adapter(entry.get("adapter_id")) + if request_ports is not None + else None + ) + if source_adapter is None: return { "ok": False, "schema_version": SOURCE_SETTLEMENT_RECEIPT_SCHEMA, @@ -440,7 +476,7 @@ def settle_periodic_report_request( "write_performed": False, "external_writes_performed": False, } - result = settle_source( + result = source_adapter.settle_source( registry_path=registry_path, runtime_root=runtime_root, goal_id=goal_id, @@ -456,7 +492,11 @@ def settle_periodic_report_request( ): raise ValueError("periodic-report source settlement receipt is invalid") normalized = dict(result) - if execute and normalized.get("ok") is True and normalized.get("status") == "settled": + if ( + execute + and normalized.get("ok") is True + and normalized.get("status") == "settled" + ): request_id = str(entry["request_id"]) path = _request_path(runtime_root, goal_id, request_id) with exclusive_file_lock(path, operation="periodic_report_request_settlement"): @@ -474,6 +514,7 @@ def settle_periodic_report_request( __all__ = [ + "PeriodicReportRequestAdapter", "PeriodicReportRequestPorts", "REQUEST_ACTION_SCHEMA", "REQUEST_ADAPTER_PHASE", diff --git a/tests/capabilities/test_periodic_report_pending_intent.py b/tests/capabilities/test_periodic_report_pending_intent.py index 396c2325e4..9efff4583e 100644 --- a/tests/capabilities/test_periodic_report_pending_intent.py +++ b/tests/capabilities/test_periodic_report_pending_intent.py @@ -15,6 +15,8 @@ periodic_report_pending_intent_interaction_hook, ) from loopx.capabilities.periodic_report.request_action import ( + PeriodicReportRequestAdapter, + PeriodicReportRequestPorts, SOURCE_BINDING_RECEIPT_SCHEMA, SOURCE_SETTLEMENT_RECEIPT_SCHEMA, periodic_report_request_intents, @@ -485,14 +487,17 @@ def test_agent_typed_request_is_replay_safe_and_settlement_only_retry_deduplicat "addressing_source": "provider_mention", "binding_revision": "sha256:" + "b" * 64, } - source_digest = "sha256:" + hashlib.sha256( - json.dumps( - source_identity, - ensure_ascii=False, - sort_keys=True, - separators=(",", ":"), - ).encode("utf-8") - ).hexdigest() + source_digest = ( + "sha256:" + + hashlib.sha256( + json.dumps( + source_identity, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ).encode("utf-8") + ).hexdigest() + ) bind_calls: list[str] = [] def bind_source(**kwargs: object) -> dict[str, object]: @@ -505,14 +510,63 @@ def bind_source(**kwargs: object) -> dict[str, object]: "external_writes_performed": False, } + settlement_calls: list[bool] = [] + other_settlement_calls: list[bool] = [] + + def settle_source(**kwargs: object) -> dict[str, object]: + receipts = list( + (runtime / "goals" / GOAL_ID / "periodic_reports").glob("*/receipt.json") + ) + assert len(receipts) == 1 + assert json.loads(receipts[0].read_text(encoding="utf-8"))["status"] == ( + "delivery_ready" + ) + settlement_calls.append(bool(kwargs["execute"])) + settled = len(settlement_calls) > 1 + return { + "ok": settled, + "schema_version": SOURCE_SETTLEMENT_RECEIPT_SCHEMA, + "status": "settled" if settled else "failed", + "write_performed": False, + "raw_content_returned": False, + "external_writes_performed": False, + } + + def settle_other(**kwargs: object) -> dict[str, object]: + other_settlement_calls.append(bool(kwargs["execute"])) + raise AssertionError("non-owner adapter must not receive settlement") + + owner_adapter = PeriodicReportRequestAdapter( + adapter_id="fixture-periodic-report-source", + bind_source=bind_source, + settle_source=settle_source, + ) + other_adapter = PeriodicReportRequestAdapter( + adapter_id="different-periodic-report-source", + bind_source=lambda **_kwargs: {}, + settle_source=settle_other, + ) + ports_forward = PeriodicReportRequestPorts( + adapters={ + owner_adapter.adapter_id: owner_adapter, + other_adapter.adapter_id: other_adapter, + } + ) + ports_reverse = PeriodicReportRequestPorts( + adapters={ + other_adapter.adapter_id: other_adapter, + owner_adapter.adapter_id: owner_adapter, + } + ) + accepted = record_periodic_report_request( registry_path=registry, runtime_root=runtime, goal_id=GOAL_ID, agent_id=AGENT_ID, source_ref=source_ref, - bind_source=bind_source, - adapter_id="fixture-periodic-report-source", + request_ports=ports_forward, + source_adapter_id=owner_adapter.adapter_id, execute=True, ) replay = record_periodic_report_request( @@ -521,10 +575,8 @@ def bind_source(**kwargs: object) -> dict[str, object]: goal_id=GOAL_ID, agent_id=AGENT_ID, source_ref=source_ref, - bind_source=lambda **_kwargs: (_ for _ in ()).throw( - AssertionError("replay must not rebind provider source") - ), - adapter_id="fixture-periodic-report-source", + request_ports=ports_reverse, + source_adapter_id=None, execute=True, ) @@ -538,19 +590,29 @@ def bind_source(**kwargs: object) -> dict[str, object]: ) assert len(intents) == 1 assert source_ref not in json.dumps(intents[0], ensure_ascii=False) - mismatch_calls: list[bool] = [] mismatch = settle_periodic_report_request( registry_path=registry, runtime_root=runtime, goal_id=GOAL_ID, agent_id=AGENT_ID, intent=intents[0], - settle_source=lambda **_kwargs: mismatch_calls.append(True) or {}, - adapter_id="different-periodic-report-source", + request_ports=PeriodicReportRequestPorts( + adapters={other_adapter.adapter_id: other_adapter} + ), execute=True, ) assert mismatch["status"] == "adapter_unavailable" - assert mismatch_calls == [] + assert other_settlement_calls == [] + assert ( + len( + periodic_report_request_intents( + runtime_root=runtime, + goal_id=GOAL_ID, + agent_id=AGENT_ID, + ) + ) + == 1 + ) editorial_required = consume_pending_periodic_report_intent( registry_path=registry, @@ -561,28 +623,6 @@ def bind_source(**kwargs: object) -> dict[str, object]: ) assert editorial_required["status"] == "editorial_required" _write_editorial_response(editorial_required) - settlement_calls: list[bool] = [] - - def settle_source(**kwargs: object) -> dict[str, object]: - receipts = list( - (runtime / "goals" / GOAL_ID / "periodic_reports").glob( - "*/receipt.json" - ) - ) - assert len(receipts) == 1 - assert json.loads(receipts[0].read_text(encoding="utf-8"))["status"] == ( - "delivery_ready" - ) - settlement_calls.append(bool(kwargs["execute"])) - settled = len(settlement_calls) > 1 - return { - "ok": settled, - "schema_version": SOURCE_SETTLEMENT_RECEIPT_SCHEMA, - "status": "settled" if settled else "failed", - "write_performed": False, - "raw_content_returned": False, - "external_writes_performed": False, - } first = consume_pending_periodic_report_intent( registry_path=registry, @@ -590,8 +630,7 @@ def settle_source(**kwargs: object) -> dict[str, object]: goal_id=GOAL_ID, agent_id=AGENT_ID, execute=True, - provider_request_settler=settle_source, - provider_request_adapter_id="fixture-periodic-report-source", + provider_request_ports=ports_reverse, ) retry = consume_pending_periodic_report_intent( registry_path=registry, @@ -599,8 +638,7 @@ def settle_source(**kwargs: object) -> dict[str, object]: goal_id=GOAL_ID, agent_id=AGENT_ID, execute=True, - provider_request_settler=settle_source, - provider_request_adapter_id="fixture-periodic-report-source", + provider_request_ports=ports_forward, ) assert first["status"] == "delivery_ready" @@ -608,6 +646,7 @@ def settle_source(**kwargs: object) -> dict[str, object]: assert retry["settlement_only_retry"] is True assert retry["source_settlement"]["status"] == "settled" assert settlement_calls == [True, True] + assert other_settlement_calls == [] durable = next( (runtime / "goals" / GOAL_ID / "periodic_reports").glob("*/receipt.json") ) @@ -616,11 +655,14 @@ def settle_source(**kwargs: object) -> dict[str, object]: assert persisted["settlement_only_retry"] is True state = (registry.parent / "ACTIVE_GOAL_STATE.md").read_text(encoding="utf-8") assert state.count("action_kind=deliver_periodic_report_goal_channel") == 1 - assert periodic_report_request_intents( - runtime_root=runtime, - goal_id=GOAL_ID, - agent_id=AGENT_ID, - ) == [] + assert ( + periodic_report_request_intents( + runtime_root=runtime, + goal_id=GOAL_ID, + agent_id=AGENT_ID, + ) + == [] + ) def test_report_artifacts_leave_no_temp_residue(tmp_path: Path) -> None: diff --git a/tests/extensions/test_lark_periodic_report_request.py b/tests/extensions/test_lark_periodic_report_request.py index 74eba1331b..6068b3f621 100644 --- a/tests/extensions/test_lark_periodic_report_request.py +++ b/tests/extensions/test_lark_periodic_report_request.py @@ -5,9 +5,18 @@ import pytest +from loopx.capabilities.periodic_report import request_action from loopx.capabilities.periodic_report.request_action import ( + REQUEST_ADAPTER_PHASE, + REQUEST_BIND_PORT, + REQUEST_HOOK_ID, + REQUEST_SETTLE_PORT, discover_periodic_report_request_ports, ) +from loopx.extensions.hook_adapters import ( + ExtensionHookAdapterDiscovery, + ExtensionHookAdapterPortBinding, +) from loopx.extensions.lark.goal_channel_contracts import ( GOAL_CHANNEL_BINDING_SCHEMA_VERSION, write_goal_channel_binding, @@ -117,9 +126,57 @@ def test_lark_request_ports_are_discovered_from_manifest(tmp_path: Path) -> None extension_state_file=state_file, ) - assert ports.adapter_id == "lark-periodic-report-source" - assert callable(ports.bind_source) - assert callable(ports.settle_source) + assert ports.adapter_ids == ("lark-periodic-report-source",) + adapter = ports.adapters["lark-periodic-report-source"] + assert callable(adapter.bind_source) + assert callable(adapter.settle_source) + + +def test_request_port_discovery_preserves_multiple_complete_adapters( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + def bind_source(**_kwargs: object) -> dict[str, object]: + return {} + + def settle_source(**_kwargs: object) -> dict[str, object]: + return {} + + bindings = tuple( + ExtensionHookAdapterPortBinding( + extension_id=f"fixture-{adapter_id}", + adapter_id=adapter_id, + capability_id="periodic-report", + target_hook_id=REQUEST_HOOK_ID, + phase=REQUEST_ADAPTER_PHASE, + port_name=port_name, + handler=handler, + ) + for adapter_id in ("source-b", "source-a") + for port_name, handler in ( + (REQUEST_BIND_PORT, bind_source), + (REQUEST_SETTLE_PORT, settle_source), + ) + ) + monkeypatch.setattr( + request_action, + "discover_extension_hook_adapters", + lambda **_kwargs: ExtensionHookAdapterDiscovery( + ports=tuple(reversed(bindings)), failures=() + ), + ) + + ports = discover_periodic_report_request_ports( + registry_path=tmp_path / "registry.json", + runtime_root=tmp_path / "runtime", + goal_id="goal-alpha", + agent_id="agent-alpha", + ) + + assert ports.adapter_ids == ("source-a", "source-b") + assert ports.select_source_adapter("source-b").adapter_id == "source-b" + with pytest.raises(ValueError, match="ambiguous"): + ports.select_source_adapter(None) def test_lark_inbox_rejects_same_name_mention_with_different_provider_identity() -> ( From a53d9cb49456ba54209d648d09c999b046ca8493 Mon Sep 17 00:00:00 2001 From: huangruiteng Date: Sun, 6 Sep 2026 20:31:52 +0800 Subject: [PATCH 09/10] fix(periodic-report): terminalize source identity drift Signed-off-by: huangruiteng --- .../reference/protocols/periodic-report-v0.md | 7 +- .../periodic_report/request_action.py | 37 ++++++- loopx/extensions/lark/README.md | 4 + .../lark/periodic_report_request.py | 49 ++++++--- .../test_periodic_report_pending_intent.py | 100 ++++++++++++++++++ .../test_lark_periodic_report_request.py | 91 ++++++++++++++++ 6 files changed, 268 insertions(+), 20 deletions(-) diff --git a/docs/reference/protocols/periodic-report-v0.md b/docs/reference/protocols/periodic-report-v0.md index 78c7c7b0df..af182edbb0 100644 --- a/docs/reference/protocols/periodic-report-v0.md +++ b/docs/reference/protocols/periodic-report-v0.md @@ -326,7 +326,12 @@ receipt and delivery Todo are durable. If ACK fails or the adapter is temporarily unavailable, the typed request stays pending. A later `consume-pending` call loads the durable receipt, retries settlement only, and does not regenerate artifacts or add another delivery Todo. Exact ACK replay -is idempotent. +is idempotent. A provider may instead return a typed `terminal_failure` when +the bound source is gone or its binding/receipt identity has drifted. LoopX +then records `settlement_failed`, leaves the provider source un-ACKed, and +removes that request from automatic retry projection. After repairing the +provider binding or retention problem, the operator must obtain a new provider +event and issue a new typed request with that event's opaque source reference. ### Post-writeback hook boundary diff --git a/loopx/capabilities/periodic_report/request_action.py b/loopx/capabilities/periodic_report/request_action.py index a4fb260bde..44f2a60112 100644 --- a/loopx/capabilities/periodic_report/request_action.py +++ b/loopx/capabilities/periodic_report/request_action.py @@ -24,6 +24,7 @@ REQUEST_RECEIPT_SCHEMA = "periodic_report_request_receipt_v0" SOURCE_BINDING_RECEIPT_SCHEMA = "periodic_report_source_binding_receipt_v0" SOURCE_SETTLEMENT_RECEIPT_SCHEMA = "periodic_report_source_settlement_receipt_v0" +SOURCE_SETTLEMENT_TERMINAL_STATUS = "terminal_failure" REQUEST_HOOK_ID = "periodic_report.request" REQUEST_BIND_PORT = "periodic_report.request.bind_source" REQUEST_SETTLE_PORT = "periodic_report.request.settle_source" @@ -263,11 +264,15 @@ def _load_request_entry(path: Path) -> dict[str, Any] | None: if ( not isinstance(value, dict) or value.get("schema_version") != REQUEST_JOURNAL_ENTRY_SCHEMA - or value.get("status") not in {"pending", "settled"} + or value.get("status") not in {"pending", "settled", "settlement_failed"} or value.get("request_id") != path.stem or not isinstance(value.get("source_receipt"), Mapping) or not isinstance(value.get("profile_ref"), Mapping) or not isinstance(value.get("trigger_policy"), Mapping) + or ( + value.get("status") != "pending" + and not isinstance(value.get("settlement"), Mapping) + ) ): return None return value @@ -487,22 +492,45 @@ def settle_periodic_report_request( if ( not isinstance(result, Mapping) or result.get("schema_version") != SOURCE_SETTLEMENT_RECEIPT_SCHEMA + or result.get("status") + not in {"preview", "failed", "settled", SOURCE_SETTLEMENT_TERMINAL_STATUS} + or ( + result.get("status") in {"preview", "settled"} + and result.get("ok") is not True + ) + or ( + result.get("status") in {"failed", SOURCE_SETTLEMENT_TERMINAL_STATUS} + and result.get("ok") is not False + ) or result.get("external_writes_performed") is not False or result.get("raw_content_returned") is not False + or ( + result.get("status") == SOURCE_SETTLEMENT_TERMINAL_STATUS + and not _IDENTITY_RE.fullmatch(str(result.get("failure_code") or "")) + ) ): raise ValueError("periodic-report source settlement receipt is invalid") normalized = dict(result) if ( execute - and normalized.get("ok") is True - and normalized.get("status") == "settled" + and ( + (normalized.get("ok") is True and normalized.get("status") == "settled") + or ( + normalized.get("ok") is False + and normalized.get("status") == SOURCE_SETTLEMENT_TERMINAL_STATUS + ) + ) ): request_id = str(entry["request_id"]) path = _request_path(runtime_root, goal_id, request_id) with exclusive_file_lock(path, operation="periodic_report_request_settlement"): current = _load_request_entry(path) if current is not None and current.get("status") == "pending": - current["status"] = "settled" + current["status"] = ( + "settled" + if normalized.get("status") == "settled" + else "settlement_failed" + ) current["settlement"] = { key: value for key, value in normalized.items() @@ -525,6 +553,7 @@ def settle_periodic_report_request( "REQUEST_SETTLE_PORT", "SOURCE_BINDING_RECEIPT_SCHEMA", "SOURCE_SETTLEMENT_RECEIPT_SCHEMA", + "SOURCE_SETTLEMENT_TERMINAL_STATUS", "discover_periodic_report_request_ports", "periodic_report_request_intents", "record_periodic_report_request", diff --git a/loopx/extensions/lark/README.md b/loopx/extensions/lark/README.md index bf462a9099..796efcc23d 100644 --- a/loopx/extensions/lark/README.md +++ b/loopx/extensions/lark/README.md @@ -61,6 +61,10 @@ addressing, the selected Goal/Agent binding, provider target, and inbox identity. It neither scans other inbox items nor inspects text for report keywords. Its bind/settle ports are discovered from `extension.toml`; source ACK happens only after the capability has persisted `delivery_ready` state. +Transient ACK failures remain replayable. A missing source or binding/receipt +identity drift is recorded as a terminal settlement failure and left un-ACKed; +after correcting the configuration or retention issue, re-deliver the request +as a new Lark message and invoke the typed action with its new `message_id`. The [event inbox guide](docs/lark-event-inbox.md) documents the complete collector, processing, reply, reaction, and acknowledgement lifecycle. The diff --git a/loopx/extensions/lark/periodic_report_request.py b/loopx/extensions/lark/periodic_report_request.py index 89ec53e82a..c25de0a242 100644 --- a/loopx/extensions/lark/periodic_report_request.py +++ b/loopx/extensions/lark/periodic_report_request.py @@ -22,6 +22,7 @@ REQUEST_SETTLE_PORT, SOURCE_BINDING_RECEIPT_SCHEMA, SOURCE_SETTLEMENT_RECEIPT_SCHEMA, + SOURCE_SETTLEMENT_TERMINAL_STATUS, ) from ...control_plane.runtime.goal_project_route import resolve_goal_project_route from ..hook_adapters import HOOK_ADAPTER_FACTORY_CONTEXT_SCHEMA_VERSION @@ -50,6 +51,18 @@ LARK_REQUEST_ADAPTER_ID = "lark-periodic-report-source" +def _terminal_settlement_failure(failure_code: str) -> dict[str, Any]: + return { + "ok": False, + "schema_version": SOURCE_SETTLEMENT_RECEIPT_SCHEMA, + "status": SOURCE_SETTLEMENT_TERMINAL_STATUS, + "failure_code": failure_code, + "write_performed": False, + "raw_content_returned": False, + "external_writes_performed": False, + } + + def _digest(value: object) -> str: encoded = json.dumps( value, @@ -322,12 +335,15 @@ def settle_lark_periodic_report_source( ) -> dict[str, Any]: """ACK the exact source only after capability durability is established.""" - context = _resolved_request_context( - registry_path=registry_path, - runtime_root=runtime_root, - goal_id=goal_id, - agent_id=agent_id, - ) + try: + context = _resolved_request_context( + registry_path=registry_path, + runtime_root=runtime_root, + goal_id=goal_id, + agent_id=agent_id, + ) + except ValueError: + return _terminal_settlement_failure("source_binding_drift") source_ref = str(source_receipt.get("source_ref") or "") if ( source_receipt.get("schema_version") != SOURCE_BINDING_RECEIPT_SCHEMA @@ -336,19 +352,22 @@ def settle_lark_periodic_report_source( or source_receipt.get("binding_revision") != context["binding_revision"] or not MESSAGE_ID_PATTERN.fullmatch(source_ref) ): - raise ValueError("Lark periodic-report source binding drifted") + return _terminal_settlement_failure("source_binding_drift") inbox = context["config"]["inbox_path"] event = _event_from_file(inbox / f"{source_ref}.json") if not isinstance(event, Mapping): - raise ValueError("Lark periodic-report source is unavailable") - current = _source_receipt( - event, - goal_id=goal_id, - agent_id=agent_id, - binding_revision=str(context["binding_revision"]), - ) + return _terminal_settlement_failure("source_unavailable") + try: + current = _source_receipt( + event, + goal_id=goal_id, + agent_id=agent_id, + binding_revision=str(context["binding_revision"]), + ) + except ValueError: + return _terminal_settlement_failure("source_receipt_drift") if current != dict(source_receipt): - raise ValueError("Lark periodic-report source receipt drifted") + return _terminal_settlement_failure("source_receipt_drift") receipt = acknowledge_lark_event_inbox( project=context["project"], config_path=context["selected_config_ref"], diff --git a/tests/capabilities/test_periodic_report_pending_intent.py b/tests/capabilities/test_periodic_report_pending_intent.py index 9efff4583e..4c73fab89d 100644 --- a/tests/capabilities/test_periodic_report_pending_intent.py +++ b/tests/capabilities/test_periodic_report_pending_intent.py @@ -19,6 +19,7 @@ PeriodicReportRequestPorts, SOURCE_BINDING_RECEIPT_SCHEMA, SOURCE_SETTLEMENT_RECEIPT_SCHEMA, + SOURCE_SETTLEMENT_TERMINAL_STATUS, periodic_report_request_intents, record_periodic_report_request, settle_periodic_report_request, @@ -665,6 +666,105 @@ def settle_other(**kwargs: object) -> dict[str, object]: ) +def test_terminal_source_settlement_is_durable_and_not_retried( + tmp_path: Path, +) -> None: + registry, runtime = _fixture(tmp_path) + source_ref = "om_terminal_source" + source_identity = { + "provider": "fixture", + "goal_id": GOAL_ID, + "agent_id": AGENT_ID, + "source_ref": source_ref, + "observed_at": "2026-08-30T09:00:00Z", + "requester_kind": "user", + "addressing_source": "provider_mention", + "binding_revision": "sha256:" + "d" * 64, + } + source_digest = "sha256:" + hashlib.sha256( + json.dumps( + source_identity, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ).encode("utf-8") + ).hexdigest() + settlement_calls: list[bool] = [] + + def bind_source(**_kwargs: object) -> dict[str, object]: + return { + "schema_version": SOURCE_BINDING_RECEIPT_SCHEMA, + **source_identity, + "source_digest": source_digest, + "raw_content_returned": False, + "external_writes_performed": False, + } + + def settle_source(**kwargs: object) -> dict[str, object]: + settlement_calls.append(bool(kwargs["execute"])) + return { + "ok": False, + "schema_version": SOURCE_SETTLEMENT_RECEIPT_SCHEMA, + "status": SOURCE_SETTLEMENT_TERMINAL_STATUS, + "failure_code": "source_receipt_drift", + "write_performed": False, + "raw_content_returned": False, + "external_writes_performed": False, + } + + adapter = PeriodicReportRequestAdapter( + adapter_id="fixture-periodic-report-source", + bind_source=bind_source, + settle_source=settle_source, + ) + ports = PeriodicReportRequestPorts(adapters={adapter.adapter_id: adapter}) + accepted = record_periodic_report_request( + registry_path=registry, + runtime_root=runtime, + goal_id=GOAL_ID, + agent_id=AGENT_ID, + source_ref=source_ref, + request_ports=ports, + source_adapter_id=None, + execute=True, + ) + intents = periodic_report_request_intents( + runtime_root=runtime, + goal_id=GOAL_ID, + agent_id=AGENT_ID, + ) + + settlement = settle_periodic_report_request( + registry_path=registry, + runtime_root=runtime, + goal_id=GOAL_ID, + agent_id=AGENT_ID, + intent=intents[0], + request_ports=ports, + execute=True, + ) + + assert settlement["status"] == SOURCE_SETTLEMENT_TERMINAL_STATUS + assert settlement["failure_code"] == "source_receipt_drift" + assert settlement["write_performed"] is True + request_path = ( + runtime + / "goals" + / GOAL_ID + / "periodic_report_requests" + / f"{accepted['request_id']}.json" + ) + journal = json.loads(request_path.read_text(encoding="utf-8")) + assert journal["status"] == "settlement_failed" + assert journal["settlement"]["failure_code"] == "source_receipt_drift" + assert periodic_report_request_intents( + runtime_root=runtime, + goal_id=GOAL_ID, + agent_id=AGENT_ID, + ) == [] + assert settlement_calls == [True] + + def test_report_artifacts_leave_no_temp_residue(tmp_path: Path) -> None: registry, runtime = _fixture(tmp_path) diff --git a/tests/extensions/test_lark_periodic_report_request.py b/tests/extensions/test_lark_periodic_report_request.py index 6068b3f621..7b8c2a77a9 100644 --- a/tests/extensions/test_lark_periodic_report_request.py +++ b/tests/extensions/test_lark_periodic_report_request.py @@ -112,6 +112,97 @@ def test_lark_adapter_binds_only_agent_selected_source_without_text_classificati assert ack_calls == [[selected]] +def test_lark_adapter_classifies_identity_drift_as_terminal_without_ack( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + inbox = tmp_path / "inbox" + inbox.mkdir() + source_ref = "om_terminal_identity" + context = { + "project": tmp_path, + "selected_config_ref": ".loopx/inbox.json", + "config": {"inbox_path": inbox}, + "binding_revision": "sha256:" + "a" * 64, + } + monkeypatch.setattr( + periodic_report_request, + "_resolved_request_context", + lambda **_kwargs: context, + ) + ack_calls: list[list[str]] = [] + monkeypatch.setattr( + periodic_report_request, + "acknowledge_lark_event_inbox", + lambda **kwargs: ack_calls.append(list(kwargs["message_ids"])), + ) + source_receipt = { + "schema_version": "periodic_report_source_binding_receipt_v0", + "provider": "lark", + "goal_id": "goal-alpha", + "agent_id": "agent-alpha", + "source_ref": source_ref, + "source_digest": "sha256:" + "b" * 64, + "observed_at": "2026-09-06T05:00:00Z", + "requester_kind": "user", + "addressing_source": "provider_mention", + "binding_revision": context["binding_revision"], + "raw_content_returned": False, + "external_writes_performed": False, + } + + unavailable = periodic_report_request.settle_lark_periodic_report_source( + registry_path=tmp_path / "registry.json", + runtime_root=tmp_path / "runtime", + goal_id="goal-alpha", + agent_id="agent-alpha", + source_receipt=source_receipt, + execute=True, + ) + assert unavailable["status"] == "terminal_failure" + assert unavailable["failure_code"] == "source_unavailable" + + drifted = periodic_report_request.settle_lark_periodic_report_source( + registry_path=tmp_path / "registry.json", + runtime_root=tmp_path / "runtime", + goal_id="goal-alpha", + agent_id="agent-alpha", + source_receipt={ + **source_receipt, + "binding_revision": "sha256:" + "c" * 64, + }, + execute=True, + ) + assert drifted["status"] == "terminal_failure" + assert drifted["failure_code"] == "source_binding_drift" + + (inbox / f"{source_ref}.json").write_text( + json.dumps(_event(source_ref, "synthetic request")), + encoding="utf-8", + ) + current = periodic_report_request.bind_lark_periodic_report_source( + registry_path=tmp_path / "registry.json", + runtime_root=tmp_path / "runtime", + goal_id="goal-alpha", + agent_id="agent-alpha", + source_ref=source_ref, + ) + receipt_drifted = periodic_report_request.settle_lark_periodic_report_source( + registry_path=tmp_path / "registry.json", + runtime_root=tmp_path / "runtime", + goal_id="goal-alpha", + agent_id="agent-alpha", + source_receipt={ + **current, + "source_digest": "sha256:" + "e" * 64, + }, + execute=True, + ) + assert receipt_drifted["status"] == "terminal_failure" + assert receipt_drifted["failure_code"] == "source_receipt_drift" + assert ack_calls == [] + + def test_lark_request_ports_are_discovered_from_manifest(tmp_path: Path) -> None: state_file = tmp_path / "runtime" / "extensions" / "state.json" manifest = Path(periodic_report_request.__file__).with_name("extension.toml") From 60cd20f22ed3aa7d0f3c79eeef1addc90026a73c Mon Sep 17 00:00:00 2001 From: huangruiteng Date: Sun, 6 Sep 2026 21:08:59 +0800 Subject: [PATCH 10/10] fix(periodic-report): namespace requests by adapter Signed-off-by: huangruiteng --- .../reference/protocols/periodic-report-v0.md | 15 +- loopx/capabilities/periodic_report/README.md | 7 + .../periodic_report/request_action.py | 105 +++++++++++- .../test_periodic_report_pending_intent.py | 159 ++++++++++++++++++ 4 files changed, 273 insertions(+), 13 deletions(-) diff --git a/docs/reference/protocols/periodic-report-v0.md b/docs/reference/protocols/periodic-report-v0.md index af182edbb0..e7942416a3 100644 --- a/docs/reference/protocols/periodic-report-v0.md +++ b/docs/reference/protocols/periodic-report-v0.md @@ -293,9 +293,14 @@ loopx periodic-report request \ One complete active source adapter is selected automatically. If several provider adapters are active, a new request must include `--source-adapter-id `. Replay of an already journaled request does -not require the selector. Settlement reads the owner `adapter_id` from that -journal and resolves only the matching discovered settler; discovery order -cannot change ownership, and another provider is never used as a fallback. +not require the selector when exactly one journal entry matches the source +reference; a source reference already owned by multiple providers requires an +explicit selector. The adapter id participates in request identity together +with Goal, Agent, and the provider-local source reference, so equal opaque ids +from different providers remain independent. Settlement reads the owner +`adapter_id` from that journal and resolves only the matching discovered +settler; discovery order cannot change ownership, and another provider is +never used as a fallback. This action is the report-intent decision. Neither LoopX Core nor the provider adapter classifies message strings, searches for report keywords, or scans the @@ -312,8 +317,8 @@ that typed journal and existing post-writeback intents only; it never calls a provider reader. The request becomes an authorized `manual` trigger and then reuses the existing editorial, generation bundle, Workspace projection, publication candidate, and delivery-Todo pipeline. Repeating the same -Goal/Agent/source action returns the existing request and cannot create another -journal entry. +Goal/Agent/adapter/source action returns the existing request and cannot create +another journal entry. Source binding and settlement ports are dynamically discovered from enabled, doctor-ready extension manifests through a `capability_action` diff --git a/loopx/capabilities/periodic_report/README.md b/loopx/capabilities/periodic_report/README.md index 3941f89231..628c0b11ad 100644 --- a/loopx/capabilities/periodic_report/README.md +++ b/loopx/capabilities/periodic_report/README.md @@ -76,6 +76,13 @@ adapter identity, and later settlement resolves only that owner regardless of extension discovery order. A temporarily unavailable owner leaves the request pending; LoopX never falls through to another provider. +The adapter id is part of the request idempotency namespace together with the +Goal, Agent, and provider-local source reference. Two providers may therefore +use the same opaque source reference without collapsing distinct requests. +Replay without a selector remains valid when exactly one matching journal entry +exists; if the same source reference is already owned by multiple providers, +the Agent must select the intended adapter explicitly. + There is no keyword or regular-expression classifier. The provider adapter binds only the exact source selected by the Agent and checks authorship, addressing, Goal/Agent connection, target, and inbox identity. A manifest- diff --git a/loopx/capabilities/periodic_report/request_action.py b/loopx/capabilities/periodic_report/request_action.py index 44f2a60112..6e722e2d62 100644 --- a/loopx/capabilities/periodic_report/request_action.py +++ b/loopx/capabilities/periodic_report/request_action.py @@ -107,9 +107,11 @@ def _source_ref(value: object) -> str: return ref -def _request_id(*, goal_id: str, agent_id: str, source_ref: str) -> str: +def _request_id( + *, goal_id: str, agent_id: str, source_adapter_id: str, source_ref: str +) -> str: digest = hashlib.sha256( - f"{goal_id}\0{agent_id}\0{source_ref}".encode("utf-8") + f"{goal_id}\0{agent_id}\0{source_adapter_id}\0{source_ref}".encode("utf-8") ).hexdigest() return f"prq_{digest}" @@ -278,6 +280,50 @@ def _load_request_entry(path: Path) -> dict[str, Any] | None: return value +def _matching_request_entries( + *, runtime_root: Path, goal_id: str, agent_id: str, source_ref: str +) -> list[dict[str, Any]]: + directory = _request_dir(runtime_root, goal_id) + if not directory.is_dir(): + return [] + matches: list[dict[str, Any]] = [] + for path in sorted(directory.iterdir()): + if not path.is_file() or not _REQUEST_FILE_RE.fullmatch(path.name): + continue + entry = _load_request_entry(path) + source_receipt = entry.get("source_receipt") if entry is not None else None + if ( + entry is not None + and entry.get("goal_id") == goal_id + and entry.get("agent_id") == agent_id + and isinstance(source_receipt, Mapping) + and source_receipt.get("source_ref") == source_ref + ): + matches.append(entry) + return matches + + +def _validate_request_replay( + entry: Mapping[str, Any], + *, + goal_id: str, + agent_id: str, + source_adapter_id: str, + source_ref: str, + request_id: str, +) -> None: + source_receipt = entry.get("source_receipt") + if ( + entry.get("request_id") != request_id + or entry.get("goal_id") != goal_id + or entry.get("agent_id") != agent_id + or entry.get("adapter_id") != source_adapter_id + or not isinstance(source_receipt, Mapping) + or source_receipt.get("source_ref") != source_ref + ): + raise ValueError("periodic-report request journal identity drifted") + + def record_periodic_report_request( *, registry_path: Path, @@ -292,18 +338,46 @@ def record_periodic_report_request( if not _IDENTITY_RE.fullmatch(goal_id) or not _IDENTITY_RE.fullmatch(agent_id): raise ValueError("periodic-report request Goal/Agent identity is invalid") opaque_ref = _source_ref(source_ref) + selected_adapter_id = str(source_adapter_id or "").strip() + existing: dict[str, Any] | None = None + if not selected_adapter_id: + replay_candidates = _matching_request_entries( + runtime_root=runtime_root, + goal_id=goal_id, + agent_id=agent_id, + source_ref=opaque_ref, + ) + if len(replay_candidates) > 1: + raise ValueError( + "periodic-report request source adapter is ambiguous; " + "select one with --source-adapter-id" + ) + if replay_candidates: + existing = replay_candidates[0] + selected_adapter_id = str(existing.get("adapter_id") or "").strip() + elif request_ports is not None: + selected_adapter_id = request_ports.select_source_adapter(None).adapter_id + if not selected_adapter_id: + raise ValueError("periodic-report request source adapter is unavailable") request_id = _request_id( goal_id=goal_id, agent_id=agent_id, + source_adapter_id=selected_adapter_id, source_ref=opaque_ref, ) path = _request_path(runtime_root, goal_id, request_id) - existing = _load_request_entry(path) if path.is_file() else None + existing = existing or (_load_request_entry(path) if path.is_file() else None) if path.is_file() and existing is None: raise ValueError("periodic-report request journal entry is invalid") if existing is not None: - if existing.get("goal_id") != goal_id or existing.get("agent_id") != agent_id: - raise ValueError("periodic-report request journal identity drifted") + _validate_request_replay( + existing, + goal_id=goal_id, + agent_id=agent_id, + source_adapter_id=selected_adapter_id, + source_ref=opaque_ref, + request_id=request_id, + ) return { "ok": True, "schema_version": REQUEST_RECEIPT_SCHEMA, @@ -318,7 +392,7 @@ def record_periodic_report_request( } if request_ports is None: raise ValueError("periodic-report request source adapter is unavailable") - source_adapter = request_ports.select_source_adapter(source_adapter_id) + source_adapter = request_ports.select_source_adapter(selected_adapter_id) profile_ref, trigger_policy = _request_profile( registry_path=registry_path, runtime_root=runtime_root, @@ -350,6 +424,7 @@ def record_periodic_report_request( "trigger_policy": trigger_policy, } write_performed = False + journal_status = "pending" if execute: with exclusive_file_lock(path, operation="periodic_report_request"): concurrent = _load_request_entry(path) if path.is_file() else None @@ -358,14 +433,28 @@ def record_periodic_report_request( if concurrent is None: atomic_write_json(path, entry) write_performed = True + else: + _validate_request_replay( + concurrent, + goal_id=goal_id, + agent_id=agent_id, + source_adapter_id=selected_adapter_id, + source_ref=opaque_ref, + request_id=request_id, + ) + journal_status = str(concurrent["status"]) return { "ok": True, "schema_version": REQUEST_RECEIPT_SCHEMA, - "status": "accepted" if execute else "preview", + "status": ( + "preview" + if not execute + else ("accepted" if write_performed else "already_requested") + ), "request_id": request_id, "goal_id": goal_id, "agent_id": agent_id, - "journal_status": "pending", + "journal_status": journal_status, "write_performed": write_performed, "raw_content_returned": False, "external_writes_performed": False, diff --git a/tests/capabilities/test_periodic_report_pending_intent.py b/tests/capabilities/test_periodic_report_pending_intent.py index 4c73fab89d..da88c1928e 100644 --- a/tests/capabilities/test_periodic_report_pending_intent.py +++ b/tests/capabilities/test_periodic_report_pending_intent.py @@ -3,6 +3,7 @@ import json import os import hashlib +from concurrent.futures import ThreadPoolExecutor from pathlib import Path import pytest @@ -666,6 +667,164 @@ def settle_other(**kwargs: object) -> dict[str, object]: ) +def test_typed_request_namespaces_equal_source_refs_by_adapter_under_concurrency( + tmp_path: Path, +) -> None: + registry, runtime = _fixture(tmp_path) + source_ref = "provider-local-message-id" + bind_calls: list[str] = [] + settlement_calls: list[str] = [] + + def build_adapter(adapter_id: str) -> PeriodicReportRequestAdapter: + source_identity = { + "provider": adapter_id, + "goal_id": GOAL_ID, + "agent_id": AGENT_ID, + "source_ref": source_ref, + "observed_at": "2026-08-30T09:00:00Z", + "requester_kind": "user", + "addressing_source": "provider_mention", + "binding_revision": "sha256:" + adapter_id[-1] * 64, + } + source_digest = "sha256:" + hashlib.sha256( + json.dumps( + source_identity, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ).encode("utf-8") + ).hexdigest() + + def bind_source(**_kwargs: object) -> dict[str, object]: + bind_calls.append(adapter_id) + return { + "schema_version": SOURCE_BINDING_RECEIPT_SCHEMA, + **source_identity, + "source_digest": source_digest, + "raw_content_returned": False, + "external_writes_performed": False, + } + + def settle_source(**kwargs: object) -> dict[str, object]: + source_receipt = kwargs["source_receipt"] + assert isinstance(source_receipt, dict) + assert source_receipt["provider"] == adapter_id + settlement_calls.append(adapter_id) + return { + "ok": True, + "schema_version": SOURCE_SETTLEMENT_RECEIPT_SCHEMA, + "status": "settled", + "write_performed": False, + "raw_content_returned": False, + "external_writes_performed": False, + } + + return PeriodicReportRequestAdapter( + adapter_id=adapter_id, + bind_source=bind_source, + settle_source=settle_source, + ) + + adapter_a = build_adapter("provider-adapter-a") + adapter_b = build_adapter("provider-adapter-b") + ports_forward = PeriodicReportRequestPorts( + adapters={adapter_a.adapter_id: adapter_a, adapter_b.adapter_id: adapter_b} + ) + ports_reverse = PeriodicReportRequestPorts( + adapters={adapter_b.adapter_id: adapter_b, adapter_a.adapter_id: adapter_a} + ) + + def record( + adapter: PeriodicReportRequestAdapter, + ports: PeriodicReportRequestPorts, + ) -> dict[str, object]: + return record_periodic_report_request( + registry_path=registry, + runtime_root=runtime, + goal_id=GOAL_ID, + agent_id=AGENT_ID, + source_ref=source_ref, + request_ports=ports, + source_adapter_id=adapter.adapter_id, + execute=True, + ) + + with ThreadPoolExecutor(max_workers=2) as executor: + future_a = executor.submit(record, adapter_a, ports_forward) + future_b = executor.submit(record, adapter_b, ports_reverse) + accepted_a = future_a.result() + accepted_b = future_b.result() + + assert accepted_a["status"] == accepted_b["status"] == "accepted" + assert accepted_a["request_id"] != accepted_b["request_id"] + assert sorted(bind_calls) == [adapter_a.adapter_id, adapter_b.adapter_id] + assert record(adapter_a, ports_reverse)["status"] == "already_requested" + assert ( + record_periodic_report_request( + registry_path=registry, + runtime_root=runtime, + goal_id=GOAL_ID, + agent_id=AGENT_ID, + source_ref=source_ref, + request_ports=None, + source_adapter_id=adapter_b.adapter_id, + execute=True, + )["status"] + == "already_requested" + ) + with pytest.raises(ValueError, match="source adapter is ambiguous"): + record_periodic_report_request( + registry_path=registry, + runtime_root=runtime, + goal_id=GOAL_ID, + agent_id=AGENT_ID, + source_ref=source_ref, + request_ports=ports_forward, + source_adapter_id=None, + execute=True, + ) + + intents = periodic_report_request_intents( + runtime_root=runtime, + goal_id=GOAL_ID, + agent_id=AGENT_ID, + ) + assert {intent["source_receipt_id"] for intent in intents} == { + accepted_a["request_id"], + accepted_b["request_id"], + } + for intent in reversed(intents): + settlement = settle_periodic_report_request( + registry_path=registry, + runtime_root=runtime, + goal_id=GOAL_ID, + agent_id=AGENT_ID, + intent=intent, + request_ports=ports_reverse, + execute=True, + ) + assert settlement["status"] == "settled" + assert sorted(settlement_calls) == [adapter_a.adapter_id, adapter_b.adapter_id] + + journal_path = ( + runtime + / "goals" + / GOAL_ID + / "periodic_report_requests" + / f"{accepted_a['request_id']}.json" + ) + journal = json.loads(journal_path.read_text(encoding="utf-8")) + journal["adapter_id"] = adapter_b.adapter_id + journal_path.write_text(json.dumps(journal), encoding="utf-8") + with pytest.raises(ValueError, match="journal identity drifted"): + record(adapter_a, ports_forward) + journal["adapter_id"] = adapter_a.adapter_id + journal["source_receipt"]["source_ref"] = "different-source" + journal_path.write_text(json.dumps(journal), encoding="utf-8") + with pytest.raises(ValueError, match="journal identity drifted"): + record(adapter_a, ports_forward) + + def test_terminal_source_settlement_is_durable_and_not_retried( tmp_path: Path, ) -> None: