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..e7942416a3 100644 --- a/docs/reference/protocols/periodic-report-v0.md +++ b/docs/reference/protocols/periodic-report-v0.md @@ -277,6 +277,67 @@ 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 +``` + +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 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 +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/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` +`[[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. 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 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..628c0b11ad 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,45 @@ 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 +``` + +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. + +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- +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 +441,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/capabilities/periodic_report/cli.py b/loopx/capabilities/periodic_report/cli.py index 7903d6d2de..028f796c27 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,29 @@ 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( + "--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", + help="Override local extension activation state for this action.", + ) consume_pending = commands.add_parser( "consume-pending", help=( @@ -109,6 +136,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 +455,55 @@ 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, + request_ports=ports, + source_adapter_id=args.source_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_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 d79a131611..07b1251654 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 ( + PeriodicReportRequestPorts, + 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,7 @@ def consume_pending_periodic_report_intent( goal_id: str, agent_id: str, execute: bool, + provider_request_ports: PeriodicReportRequestPorts | None = None, ) -> dict[str, Any]: intents = pending_periodic_report_intents( registry_path=registry_path, @@ -986,12 +1024,82 @@ 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, + request_ports=provider_request_ports, + 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 +1116,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 +1314,18 @@ 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, + request_ports=provider_request_ports, + 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..6e722e2d62 --- /dev/null +++ b/loopx/capabilities/periodic_report/request_action.py @@ -0,0 +1,651 @@ +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" +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" +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 PeriodicReportRequestAdapter: + adapter_id: str + bind_source: SourceBinder + settle_source: SourceSettler + + +@dataclass(frozen=True, slots=True) +class PeriodicReportRequestPorts: + 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: + 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_adapter_id: str, source_ref: str +) -> str: + digest = hashlib.sha256( + f"{goal_id}\0{agent_id}\0{source_adapter_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, + ) + adapter_ports: dict[str, dict[str, SourceBinder | SourceSettler]] = {} + ambiguous_adapter_ids: set[str] = set() + for binding in discovery.ports: + 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], + ) + 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( + 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", "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 + + +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, + runtime_root: Path, + goal_id: str, + agent_id: str, + source_ref: str, + 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): + 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 = 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: + _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, + "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 request_ports is None: + raise ValueError("periodic-report request source adapter is unavailable") + source_adapter = request_ports.select_source_adapter(selected_adapter_id) + 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( + source_adapter.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": source_adapter.adapter_id, + "source_receipt": source_receipt, + "profile_ref": profile_ref, + "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 + 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 + 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": ( + "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": journal_status, + "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], + request_ports: PeriodicReportRequestPorts | 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, + } + 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, + "status": "adapter_unavailable", + "write_performed": False, + "external_writes_performed": False, + } + result = source_adapter.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("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") + 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" + if normalized.get("status") == "settled" + else "settlement_failed" + ) + 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__ = [ + "PeriodicReportRequestAdapter", + "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", + "SOURCE_SETTLEMENT_TERMINAL_STATUS", + "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..5b321d5841 --- /dev/null +++ b/loopx/extensions/hook_adapters.py @@ -0,0 +1,168 @@ +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 _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/README.md b/loopx/extensions/lark/README.md index a5dc76b8a7..796efcc23d 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,18 @@ 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. +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 [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 diff --git a/loopx/extensions/lark/event_inbox.py b/loopx/extensions/lark/event_inbox.py index 85a171d986..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" @@ -37,6 +42,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, @@ -108,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" @@ -164,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 @@ -195,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, @@ -229,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 ( @@ -259,6 +276,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,18 +299,83 @@ 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 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, ) ) 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_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_name + 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 @@ -369,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())) @@ -434,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/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_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 60b46a15c1..963f13f7c2 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 " @@ -781,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", @@ -852,6 +855,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..c25de0a242 --- /dev/null +++ b/loopx/extensions/lark/periodic_report_request.py @@ -0,0 +1,438 @@ +"""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, + 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 +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 _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, + 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("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 "") + ) + ) + ): + 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"), + "bot_app_id": identity.get("bot_app_id"), + "bot_open_id": identity.get("bot_open_id"), + } + ) + 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.""" + + 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 + 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) + ): + 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): + 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): + return _terminal_settlement_failure("source_receipt_drift") + 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, } 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..da88c1928e 100644 --- a/tests/capabilities/test_periodic_report_pending_intent.py +++ b/tests/capabilities/test_periodic_report_pending_intent.py @@ -2,6 +2,8 @@ import json import os +import hashlib +from concurrent.futures import ThreadPoolExecutor from pathlib import Path import pytest @@ -13,6 +15,16 @@ pending_periodic_report_intents, periodic_report_pending_intent_interaction_hook, ) +from loopx.capabilities.periodic_report.request_action import ( + PeriodicReportRequestAdapter, + 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, +) from loopx.capabilities.periodic_report.incremental import ( build_periodic_report_publication_candidate, commit_periodic_report_publication_cursor, @@ -458,6 +470,460 @@ 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, + } + + 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, + request_ports=ports_forward, + source_adapter_id=owner_adapter.adapter_id, + 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, + request_ports=ports_reverse, + source_adapter_id=None, + 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 = settle_periodic_report_request( + registry_path=registry, + runtime_root=runtime, + goal_id=GOAL_ID, + agent_id=AGENT_ID, + intent=intents[0], + request_ports=PeriodicReportRequestPorts( + adapters={other_adapter.adapter_id: other_adapter} + ), + execute=True, + ) + assert mismatch["status"] == "adapter_unavailable" + 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, + runtime_root=runtime, + goal_id=GOAL_ID, + agent_id=AGENT_ID, + execute=True, + ) + assert editorial_required["status"] == "editorial_required" + _write_editorial_response(editorial_required) + + first = consume_pending_periodic_report_intent( + registry_path=registry, + runtime_root=runtime, + goal_id=GOAL_ID, + agent_id=AGENT_ID, + execute=True, + provider_request_ports=ports_reverse, + ) + retry = consume_pending_periodic_report_intent( + registry_path=registry, + runtime_root=runtime, + goal_id=GOAL_ID, + agent_id=AGENT_ID, + execute=True, + provider_request_ports=ports_forward, + ) + + 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] + assert other_settlement_calls == [] + 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_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: + 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_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_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_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( 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..7b8c2a77a9 --- /dev/null +++ b/tests/extensions/test_lark_periodic_report_request.py @@ -0,0 +1,409 @@ +from __future__ import annotations + +import json +from pathlib import Path + +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, +) +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 + + +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_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") + 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_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() -> ( + 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: + 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", + "bot_app_id": "cli_public_fixture", + "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") 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,