diff --git a/loopx/capabilities/agent_turn_recall/README.md b/loopx/capabilities/agent_turn_recall/README.md index dc194cb47d..af81795930 100644 --- a/loopx/capabilities/agent_turn_recall/README.md +++ b/loopx/capabilities/agent_turn_recall/README.md @@ -23,6 +23,10 @@ host-provided `turn_instance_id` produces a `turn_recall_id`. This gives two useful identities: +Turn ids are opaque host identities, not timestamps. Execution uses the current +UTC observation time for memory freshness while preserving the original turn +identity for validation and deduplication. + - a changed Todo, target, phase, or intent changes the situation fingerprint; - a new turn changes the recall id and recalls again, even when the situation is otherwise unchanged. diff --git a/loopx/capabilities/agent_turn_recall/cli.py b/loopx/capabilities/agent_turn_recall/cli.py index fbbdf15346..e841ba4988 100644 --- a/loopx/capabilities/agent_turn_recall/cli.py +++ b/loopx/capabilities/agent_turn_recall/cli.py @@ -6,6 +6,7 @@ import re import sys import tempfile +from datetime import datetime, timezone from collections.abc import Callable, Mapping from pathlib import Path from typing import Any @@ -328,7 +329,7 @@ def handle_agent_turn_recall_command( payload = run_agent_turn_recall( config, situation, - observed_at=args.turn_instance_id, + observed_at=datetime.now(timezone.utc).isoformat(), read_authority_checkpoints=_read_authority_checkpoints( config, args.goal_id ), diff --git a/loopx/capabilities/agent_turn_recall/core.py b/loopx/capabilities/agent_turn_recall/core.py index c0c0ab1494..4f7954981d 100644 --- a/loopx/capabilities/agent_turn_recall/core.py +++ b/loopx/capabilities/agent_turn_recall/core.py @@ -277,6 +277,7 @@ def apply_guidance( "ok": result.get("ok") is True, "schema_version": AGENT_TURN_RECALL_SCHEMA_VERSION, "status": result.get("status"), + "reason_code": result.get("reason_code"), "surface_id": AGENT_TURN_RECALL_SURFACE_ID, "turn_recall_id": situation.get("turn_recall_id"), "situation_fingerprint": situation.get("situation_fingerprint"), diff --git a/loopx/capabilities/reward_memory/OUTBOUND.md b/loopx/capabilities/reward_memory/OUTBOUND.md new file mode 100644 index 0000000000..aeabb8c816 --- /dev/null +++ b/loopx/capabilities/reward_memory/OUTBOUND.md @@ -0,0 +1,69 @@ +# Outbound guidance recall + +[中文版](OUTBOUND.zh-CN.md) + +This optional Reward Memory surface recalls reviewed operating preferences +before an agent sends a message. It is not a transport, a text classifier, or +permission to send. The first shipped caller is goal/agent-bound +`loopx lark-inbox send` and `reply`; other tools and Goal Topic auto-replies are +not intercepted by this integration. + +## Enable and validate + +Use the existing agent-scoped Reward Memory experiment configuration. Add +`outbound_message.before_send` to the corpus and standing-policy surface scopes +and to `surfaces`, using adapter `scoped_feedback`. Set the exact +`peer_ref: agent:` and `automation.automatic_recall: true`. Use a +function-boundary profile with one query and a small result limit. Ingest only +explicitly reviewed, public-safe operating guidance as `soft_preference`; +do not upload draft messages or private incident transcripts. + +```sh +loopx configure-goal --goal-id \ + --reward-memory-config .loopx/config/reward-memory.json \ + --reward-memory-agent --execute +loopx reward-memory experiment-status --goal-id --agent-id +loopx lark-inbox send --goal-id --agent-id \ + --route-key --text '' \ + --message-purpose help --provider-preflight --format json +``` + +Provider preflight verifies identity, membership, mentions and the provider's +dry-run rendering before recall. A plain preview without `--provider-preflight` +does not call either provider. The result includes `outbound_guidance` when the +surface is active. It performs no send. With relevant guidance, even an initial +`--execute` returns `agent_review_required` and zero writes. + +The executing agent must read the guidance, check current facts, alternatives, +recipient and duplicates, and decide whether a message is still appropriate. +If it is, repeat the same send/reply command with `--execute` and +`--reviewed-guidance-digest `. This is an agent review step, +**not a user approval gate**. The digest binds the reviewed guidance, purpose, +scope, sender, destination, placement and message. Changes invalidate it. +It proves acknowledgement, not the quality or truth of the agent's reasoning. +Transport idempotency and readback remain owned by the existing sender. + +Purposes are `help`, `progress`, `urgent`, and default `unspecified`; there is +no substring inference from message text. Urgent notices recall guidance but +do not wait for review. Empty/unavailable memory preserves the existing send +path and never asks the user to repair the provider. Permission or sender +validation failures still block normally. Hard-policy memories are not +interpreted by this advisory adapter. + +The generic implementation lives in `reward_memory.outbound`; the Lark adapter +receives a callback over an opaque intent digest. It owns no memory store or +project-specific escalation rule. Raw text, chat ids and sender profiles never +enter the recall query. Guidance is returned in the caller's private response, +not sent to the recipient or persisted into the public registry. + +## Disable and coverage + +Set `automation.automatic_recall: false` to disable recall for the experiment, +or remove this surface from the experiment to disable only outbound recall. +Unconfigured agents and existing direct provider calls preserve their previous +behavior. Activation grants no provider credentials or external-write authority. + +Tests cover real recall machinery, readback, agent scope, intent invalidation, +unavailable providers, urgent notices and both actual sender paths with a +synthetic transport. A live read-only provider check is separate from those +tests; no test should send a group message as a smoke side effect. diff --git a/loopx/capabilities/reward_memory/OUTBOUND.zh-CN.md b/loopx/capabilities/reward_memory/OUTBOUND.zh-CN.md new file mode 100644 index 0000000000..2f12e5a5f9 --- /dev/null +++ b/loopx/capabilities/reward_memory/OUTBOUND.zh-CN.md @@ -0,0 +1,58 @@ +# 外发消息前的指导召回 + +[English](OUTBOUND.md) + +这个可选的 Reward Memory surface 会在 Agent 发送消息前召回已经审阅过的 +操作偏好。它不是消息传输、文本分类器或发送权限。首个正式调用方是绑定 +Goal/Agent 的 `loopx lark-inbox send` 和 `reply`;本次集成不拦截其他工具, +也不拦截 Goal Topic 自动回复。 + +## 启用与验证 + +使用现有 Agent 级 Reward Memory 实验配置:把 +`outbound_message.before_send` 加入 corpus、standing policy 与 `surfaces`, +adapter 使用 `scoped_feedback`,`peer_ref` 必须精确为 +`agent:`,并启用 `automation.automatic_recall`。建议采用一次查询、 +小结果上限的 function-boundary profile。只写入明确审阅过且可公开的 +`soft_preference`;不要上传消息草稿或私有事故记录。 + +```sh +loopx configure-goal --goal-id \ + --reward-memory-config .loopx/config/reward-memory.json \ + --reward-memory-agent --execute +loopx reward-memory experiment-status --goal-id --agent-id +loopx lark-inbox send --goal-id --agent-id \ + --route-key --text '' \ + --message-purpose help --provider-preflight --format json +``` + +Provider preflight 先校验身份、群成员、mention 和 provider dry-run,再执行 +召回;不带 `--provider-preflight` 的普通预览不会调用任何 provider。启用后, +结果中会包含 `outbound_guidance`,但不会发消息。有相关指导时,即使第一次 +带了 `--execute`,也会返回 `agent_review_required` 且写入次数为零。 + +执行 Agent 需要阅读指导,核对当前事实、替代方案、收件方和重复消息;仍然 +应该发送时,用同一条 send/reply 命令加上 `--execute` 和返回的 +`--reviewed-guidance-digest`。这是 Agent 的审视步骤,**不是用户审批**。 +摘要绑定了指导、用途、scope、发送方、目标、placement 和消息;任一变化都会 +让旧摘要失效。它只能证明 Agent 确认过指导,不能证明推理质量。原发送器继续 +负责幂等和 readback。 + +用途包括 `help`、`progress`、`urgent` 和默认的 `unspecified`,不根据消息 +文本猜测用途。紧急通知会召回指导但不会等待复审;记忆为空或 provider 不可用 +时保留既有发送路径,不会生成用户卡点。原有权限或发送方校验仍会正常阻断。 +本适配器不把 hard-policy 记忆解释成软性指导。 + +通用实现位于 `reward_memory.outbound`,Lark 适配器只收到不透明的意图摘要。 +原始文本、群 ID 和发送 profile 不进入召回查询;指导只出现在调用方私有结果, +不会被发送给收件方,也不会写入公开 registry。 + +## 关闭与覆盖范围 + +将 `automation.automatic_recall` 设为 `false` 可关闭该实验的召回;仅移除 +这个 surface 可单独关闭外发召回。未配置的 Agent 与现有直接 provider 调用 +保持原行为。启用不会授予 provider 凭证或外部写权限。 + +测试覆盖真实召回核心、readback、Agent scope、意图变化、provider 不可用、 +紧急通知及两个真实发送入口的合成传输。实时验证只能做只读 provider 检查; +测试不得以 smoke 的名义向群里发送消息。 diff --git a/loopx/capabilities/reward_memory/README.md b/loopx/capabilities/reward_memory/README.md index f442ce4cef..cd9403a4cb 100644 --- a/loopx/capabilities/reward_memory/README.md +++ b/loopx/capabilities/reward_memory/README.md @@ -619,3 +619,9 @@ candidate linked to the active record. Retire produces a retired decision. Neither command writes provider state. The declared corpus owner still performs the write and exact readback, so operator control cannot silently become a publish, production, or cross-project authority expansion. + +## Outbound communication + +The opt-in [outbound guidance integration](OUTBOUND.md) ([中文](OUTBOUND.zh-CN.md)) recalls reviewed +preferences at the actual goal/agent-bound Lark inbox send and reply boundary. +It returns guidance for agent review without granting send authority. diff --git a/loopx/capabilities/reward_memory/README.zh-CN.md b/loopx/capabilities/reward_memory/README.zh-CN.md index f26a807a20..cf5e38fb47 100644 --- a/loopx/capabilities/reward_memory/README.zh-CN.md +++ b/loopx/capabilities/reward_memory/README.zh-CN.md @@ -521,3 +521,9 @@ Edit 生成一个引用旧 active record 的 replacement candidate,retire 生成 retired decision。两个命令都不写 provider state;真正的 write 与 精确 readback 仍由声明的 corpus owner 执行,因此 operator control 不会悄悄扩大成 publish、production 或跨项目 authority。 + +## 外发消息 + +可选的[外发指导召回](OUTBOUND.zh-CN.md)会在真正绑定 Goal/Agent 的 Lark +inbox send/reply 边界召回已经审阅过的偏好。它把指导交给 Agent 审视,但不会 +授予发送权限。 diff --git a/loopx/capabilities/reward_memory/outbound.py b/loopx/capabilities/reward_memory/outbound.py new file mode 100644 index 0000000000..7f7df2f326 --- /dev/null +++ b/loopx/capabilities/reward_memory/outbound.py @@ -0,0 +1,153 @@ +"""Scoped advisory recall for a caller-owned outbound intent. + +The caller owns authorization, destination validation and delivery. Recalled +text is returned to the agent, never interpreted as a send/deny policy. +""" + +from __future__ import annotations + +import hashlib +import json +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from .experiment import ( + resolve_reward_memory_experiment, + resolve_reward_memory_surface_config, +) +from .runtime_hooks import run_reward_memory_automatic_recall_hook + +SURFACE = "outbound_message.before_send" + + +def outbound_guidance_hook( + *, + registry_path: Path, + goal_id: str | None, + agent_id: str | None, + purpose: str = "unspecified", + reviewed_digest: str | None = None, +): + """Build an opt-in hook; absence preserves the existing sender exactly.""" + if purpose not in {"unspecified", "help", "progress", "urgent"}: + raise ValueError("invalid outbound message purpose") + if not goal_id or not agent_id: + return None + _, config = resolve_reward_memory_experiment( + registry_path=registry_path, goal_id=goal_id, agent_id=agent_id + ) + if config is None or not config["automation"]["automatic_recall"]: + return None + if SURFACE not in config["surfaces"]: + return None + route = resolve_reward_memory_surface_config(config, SURFACE) + identity = None + checkpoints = {} + for item in route["recall_corpora"]: + corpus = item["corpus"] + scope = corpus["scope"] + current = { + key: scope.get(key) + for key in ( + "workspace_ref", + "project_ref", + "user_ref", + "peer_ref", + "session_ref", + ) + } + if current["peer_ref"] != f"agent:{agent_id}": + raise ValueError( + "outbound recall requires the exact configured agent scope" + ) + if identity is not None and identity != current: + raise ValueError("outbound recall corpora must share an identity scope") + identity = current + checkpoints[corpus["corpus_id"]] = { + **{k: v for k, v in current.items() if v is not None}, + "verified": True, + "corpus_id": corpus["corpus_id"], + "surface_id": SURFACE, + "read_authority": corpus["read_authority"], + "source_ref": f"registry:{goal_id}:reward-memory", + } + + def recall(intent_digest: str) -> dict[str, Any]: + def apply(base, items): + guidance = [ + {"candidate_ref": i.candidate_ref, "content_summary": i.content_summary} + for i in items + if i.target_class == "soft_preference" + ] + return { + "outcome": "applied" if guidance else "ignored", + "output": {"guidance": guidance}, + "memory_refs": [ + i.memory_ref for i in items if i.target_class == "soft_preference" + ], + "reasoning_summary": "Guidance returned to the agent before delivery; no send authority granted.", + "current_artifact_verified": True, + } + + result = run_reward_memory_automatic_recall_hook( + config, + surface_id=SURFACE, + base_output={"guidance": []}, + **identity, + revision_ref=intent_digest, + artifact_ref=intent_digest, + queries=[ + { + "query": f"Reviewed guidance before an outbound {purpose} message: alternatives, evidence, recipient and escalation", + "query_summary": "outbound communication guidance", + } + ], + observed_at=datetime.now(timezone.utc).isoformat(), + freshness_context={ + "source_truth_current": True, + "source_revision": intent_digest, + "age_seconds": 0, + }, + conflict_state="clear", + read_authority_checkpoints=checkpoints, + application_id="outbound-guidance", + apply_memory=apply, + ) + guidance = result.get("output", {}).get("guidance", []) + digest = ( + "sha256:" + + hashlib.sha256( + json.dumps( + { + "intent": intent_digest, + "identity": identity, + "purpose": purpose, + "guidance": guidance, + }, + sort_keys=True, + ensure_ascii=False, + separators=(",", ":"), + ).encode() + ).hexdigest() + ) + # This acknowledgement is by the executing agent, never a user gate. + # Urgent notices and unavailable providers preserve the existing path. + review_required = ( + bool(guidance) and purpose != "urgent" and reviewed_digest != digest + ) + return { + "schema_version": "outbound_guidance_review_v0", + "status": result["status"], + "guidance": guidance, + "review_digest": digest, + "agent_review_required": review_required, + "continue_delivery": not review_required, + "urgent_notice": purpose == "urgent", + "provider_failure_is_user_gate": False, + "grants_new_action_authority": False, + "application": result.get("application"), + "telemetry": result.get("telemetry"), + } + + return recall diff --git a/loopx/cli_commands/lark_inbox.py b/loopx/cli_commands/lark_inbox.py index 17b5253acb..ede4324a39 100644 --- a/loopx/cli_commands/lark_inbox.py +++ b/loopx/cli_commands/lark_inbox.py @@ -8,6 +8,7 @@ from pathlib import Path from ..capabilities.issue_fix.provider_hooks import IssueFixReviewerProviderHooks +from ..capabilities.reward_memory.outbound import outbound_guidance_hook from ..control_plane.capability_hooks import ( TURN_START_HOOK_RESULT_SCHEMA_VERSION, TurnStartHookRegistration, @@ -194,6 +195,17 @@ def register_lark_inbox_commands( help="Run identity, membership, mention, and provider dry-run checks.", ) send.add_argument("--execute", action="store_true") + for outbound in (send, reply): + outbound.add_argument( + "--message-purpose", + choices=("unspecified", "help", "progress", "urgent"), + default="unspecified", + help="Context for opt-in guidance recall, not send authority.", + ) + outbound.add_argument( + "--reviewed-guidance-digest", + help="Agent acknowledgement of the exact current pre-send guidance digest; not user approval.", + ) processing = sub.add_parser( "processing", help=( @@ -529,6 +541,20 @@ def _render(payload: dict[str, object]) -> str: for item in payload.get("items") or []: if isinstance(item, dict): lines.append(f"- {item.get('message_id')}: {item.get('content')}") + guidance = payload.get("outbound_guidance") + if isinstance(guidance, dict): + lines.append( + f"- agent_review_required: {guidance.get('agent_review_required')}" + ) + for item in guidance.get("guidance") or []: + lines.append(f"- guidance: {item.get('content_summary')}") + if guidance.get("agent_review_required"): + lines.append( + "Agent: assess this guidance against current evidence and safe alternatives; " + "only if sending is still appropriate, rerun with --reviewed-guidance-digest " + + str(guidance.get("review_digest")) + + ". This is not a request for user approval." + ) return "\n".join(lines).rstrip() + "\n" @@ -612,6 +638,13 @@ def handle_lark_inbox_command( text=args.text, execute=args.execute, provider_preflight=args.provider_preflight, + before_send=outbound_guidance_hook( + registry_path=registry_path, + goal_id=args.goal_id, + agent_id=args.agent_id, + purpose=getattr(args, "message_purpose", "unspecified"), + reviewed_digest=getattr(args, "reviewed_guidance_digest", None), + ), ) elif args.lark_inbox_command == "send": routed_config = resolve_routed_lark_inbox_route( @@ -625,6 +658,13 @@ def handle_lark_inbox_command( text=args.text, execute=args.execute, provider_preflight=args.provider_preflight, + before_send=outbound_guidance_hook( + registry_path=registry_path, + goal_id=args.goal_id, + agent_id=args.agent_id, + purpose=getattr(args, "message_purpose", "unspecified"), + reviewed_digest=getattr(args, "reviewed_guidance_digest", None), + ), ) elif args.lark_inbox_command == "processing": routed_config = resolve_routed_lark_inbox_config( diff --git a/loopx/extensions/lark/inbox_reply.py b/loopx/extensions/lark/inbox_reply.py index 55e5b79949..94a54944ad 100644 --- a/loopx/extensions/lark/inbox_reply.py +++ b/loopx/extensions/lark/inbox_reply.py @@ -147,6 +147,7 @@ def _deliver_lark_inbox_outbound( execute: bool = False, provider_preflight: bool = False, runner: CommandRunner = _default_runner, + before_send: Callable[[str], Mapping[str, Any]] | None = None, ) -> dict[str, Any]: """Deliver through one inbox-configured bot with exact provider readback.""" @@ -371,6 +372,32 @@ def _deliver_lark_inbox_outbound( format_preflight_passed=True, provider_preview_performed=True, ) + guidance = None + if before_send is not None: + # Bind review to destination/profile as well as content and placement. + # None of these private values are supplied to the memory provider. + intent_digest = ( + "sha256:" + + hashlib.sha256( + json.dumps([profile, chat_id, receipt]).encode("utf-8") + ).hexdigest() + ) + guidance = before_send(intent_digest) + if guidance.get("continue_delivery") is not True or not execute: + return _result( + status="agent_review_required" + if guidance.get("agent_review_required") + else "preview_ready", + ok=True, + execute=execute, + receipt=receipt, + identity_verified=True, + membership_verified=True, + placement=placement, + format_preflight_passed=True, + provider_preview_performed=True, + provider_preview_verified=True, + ) | {"outbound_guidance": dict(guidance)} if not execute: return _result( status="preview_ready", @@ -462,7 +489,7 @@ def _deliver_lark_inbox_outbound( reaction_cleanup is not None and reaction_cleanup.get("ok") is True ) completed = bool(verified and reaction_cleanup_verified) - return _result( + result = _result( status=( "sent_verified" if completed @@ -491,6 +518,9 @@ def _deliver_lark_inbox_outbound( provider_preview_performed=True, provider_preview_verified=True, ) + if guidance is not None: + result["outbound_guidance"] = dict(guidance) + return result def reply_lark_event_inbox( @@ -502,6 +532,7 @@ def reply_lark_event_inbox( execute: bool = False, provider_preflight: bool = False, runner: CommandRunner = _default_runner, + before_send: Callable[[str], Mapping[str, Any]] | None = None, ) -> dict[str, Any]: """Reply with the explicit inbox-configured bot and placement policy.""" @@ -513,6 +544,7 @@ def reply_lark_event_inbox( execute=execute, provider_preflight=provider_preflight, runner=runner, + before_send=before_send, ) @@ -524,6 +556,7 @@ def send_lark_inbox_message( execute: bool = False, provider_preflight: bool = False, runner: CommandRunner = _default_runner, + before_send: Callable[[str], Mapping[str, Any]] | None = None, ) -> dict[str, Any]: """Send one verified chat-root message through the configured inbox bot.""" @@ -535,6 +568,7 @@ def send_lark_inbox_message( execute=execute, provider_preflight=provider_preflight, runner=runner, + before_send=before_send, ) result["schema_version"] = "lark_outbound_message_v0" blocker = result.get("blocker") diff --git a/tests/capabilities/test_outbound_guidance.py b/tests/capabilities/test_outbound_guidance.py new file mode 100644 index 0000000000..539ff32916 --- /dev/null +++ b/tests/capabilities/test_outbound_guidance.py @@ -0,0 +1,265 @@ +from __future__ import annotations + +import argparse +import json +from datetime import datetime + +import pytest + +from loopx.capabilities.reward_memory import outbound +from loopx.capabilities.reward_memory.experiment import ( + load_reward_memory_experiment_config, +) +from tests.capabilities import test_agent_turn_recall as turn +from tests.extensions.test_lark_inbox_reactions import _fixture, ReplyRunner +from loopx.extensions.lark.inbox_reply import ( + send_lark_inbox_message, + reply_lark_event_inbox, +) + + +def configure(tmp_path, monkeypatch, *, unavailable=False): + raw = turn.raw_config() + raw["corpora"][0]["corpus"]["scope"]["surface_ids"] = [outbound.SURFACE] + raw["surfaces"][0]["surface_id"] = outbound.SURFACE + path = tmp_path / "memory.json" + path.write_text(json.dumps(raw)) + config = load_reward_memory_experiment_config( + project=tmp_path, config_path="memory.json" + ) + corpus = config["corpora"]["agent_turn_preferences"]["corpus"] + record = json.dumps( + { + "schema_version": "reward_memory_active_record_v0", + "corpus_id": corpus["corpus_id"], + "candidate_ref": "candidate:guidance", + "target_class": "soft_preference", + "content_summary": "Try safe alternatives before requesting help.", + "scope": corpus["scope"], + "lifecycle": {"state": "active"}, + } + ) + provider = turn.RecallProvider(record, unavailable=unavailable) + monkeypatch.setattr( + outbound, "resolve_reward_memory_experiment", lambda **kw: ({}, config) + ) + from loopx.capabilities.reward_memory import application + + monkeypatch.setattr(application, "build_context_provider", lambda value: provider) + return config, provider + + +@pytest.mark.parametrize("purpose", ["help", "progress", "unspecified"]) +def test_real_recall_review_is_intent_bound(tmp_path, monkeypatch, purpose): + _, provider = configure(tmp_path, monkeypatch) + kwargs = dict( + registry_path=tmp_path / "registry.json", + goal_id="goal", + agent_id="pilot", + purpose=purpose, + ) + first = outbound.outbound_guidance_hook(**kwargs)("sha256:first") + assert first["status"] == "applied" + assert first["agent_review_required"] and not first["continue_delivery"] + assert first["application"]["receipt"]["result_readback_verified"] + reviewed = outbound.outbound_guidance_hook( + **kwargs, reviewed_digest=first["review_digest"] + ) + assert reviewed("sha256:first")["continue_delivery"] + assert not reviewed("sha256:changed")["continue_delivery"] + assert provider.calls == 3 + assert all("sha256:first" not in query for query in provider.queries) + + +def test_disabled_urgent_failure_and_wrong_peer(tmp_path, monkeypatch): + config, _ = configure(tmp_path, monkeypatch) + kwargs = dict( + registry_path=tmp_path / "registry.json", goal_id="goal", agent_id="pilot" + ) + assert outbound.outbound_guidance_hook(**kwargs, purpose="urgent")("intent")[ + "continue_delivery" + ] + with pytest.raises(ValueError, match="agent scope"): + outbound.outbound_guidance_hook(**(kwargs | {"agent_id": "other"})) + config["automation"]["automatic_recall"] = False + assert outbound.outbound_guidance_hook(**kwargs) is None + configure(tmp_path, monkeypatch, unavailable=True) + failed = outbound.outbound_guidance_hook(**kwargs)("intent") + assert failed["status"] == "provider_unavailable" + assert failed["continue_delivery"] and not failed["provider_failure_is_user_gate"] + + +def test_unconfigured_surface_preserves_existing_sender(tmp_path, monkeypatch): + config, _ = configure(tmp_path, monkeypatch) + del config["surfaces"][outbound.SURFACE] + assert ( + outbound.outbound_guidance_hook( + registry_path=tmp_path / "registry.json", + goal_id="goal", + agent_id="pilot", + ) + is None + ) + + +@pytest.mark.parametrize("reply", [False, True]) +def test_sender_stops_before_write_then_preserves_readback( + tmp_path, monkeypatch, reply +): + configure(tmp_path, monkeypatch) + config, _, project = _fixture(tmp_path, lifecycle=False) + kwargs = dict( + registry_path=tmp_path / "registry.json", + goal_id="goal", + agent_id="pilot", + purpose="help", + ) + sender = reply_lark_event_inbox if reply else send_lark_inbox_message + send_args = dict(project=project, config_path=config, text="done", execute=True) + if reply: + send_args["message_id"] = "om_reaction_fixture" + runner = ReplyRunner(readback_text="done") + first = sender( + **send_args, + runner=runner, + before_send=outbound.outbound_guidance_hook(**kwargs), + ) + assert first["status"] == "agent_review_required" + assert not first["external_write_performed"] + assert not any( + ("+messages-send" in c or "+messages-reply" in c) and "--dry-run" not in c + for c in runner.calls + ) + hook = outbound.outbound_guidance_hook( + **kwargs, reviewed_digest=first["outbound_guidance"]["review_digest"] + ) + sent = sender(**send_args, runner=runner, before_send=hook) + assert sent["reply_verified"] and sent["external_write_performed"] + assert not sent["outbound_guidance"]["grants_new_action_authority"] + + +def test_cli_opaque_turn_uses_real_timestamp(tmp_path, monkeypatch): + from loopx.capabilities.agent_turn_recall import cli + + config = turn.normalized_config(tmp_path) + quota = turn.quota_decision() | { + "mode": "should-run", + "goal_id": "goal", + "agent_identity": {"agent_id": "pilot"}, + "heartbeat_receipt": {"turn_instance_id": "opaque-turn", "status": "committed"}, + } + monkeypatch.setattr(cli, "_goal_repo", lambda *a: tmp_path) + monkeypatch.setattr( + cli, "resolve_reward_memory_experiment", lambda **kw: ({}, config) + ) + monkeypatch.setattr(cli, "_quota_decision", lambda path: quota) + observed = [] + + def run(config, situation, **kw): + observed.append(datetime.fromisoformat(kw["observed_at"])) + return {"ok": True, "status": "empty"} + + monkeypatch.setattr(cli, "run_agent_turn_recall", run) + args = argparse.Namespace( + command="agent-turn-recall", + goal_id="goal", + agent_id="pilot", + turn_instance_id="opaque-turn", + quota_decision_json="unused", + session_ref=None, + force_refresh=True, + execute=True, + ) + assert ( + cli.handle_agent_turn_recall_command( + args, + registry_path=tmp_path / "registry.json", + output_format=lambda *a: "json", + print_payload=lambda *a: None, + ) + == 0 + ) + assert len(observed) == 1 and observed[0].tzinfo is not None + + +@pytest.mark.parametrize("command", ["send", "reply"]) +def test_cli_legacy_namespace_preserves_default_off_sender(tmp_path, monkeypatch, command): + from loopx.cli_commands import lark_inbox as cli + + config, _, project = _fixture(tmp_path, lifecycle=False) + monkeypatch.setattr(cli, "_inbox_context", lambda *a: (project, config)) + monkeypatch.setattr(cli, "_resolve_lark_activation", lambda *a, **kw: {}) + monkeypatch.setattr(cli, "resolve_routed_lark_inbox_config", lambda **kw: config) + monkeypatch.setattr(cli, "resolve_routed_lark_inbox_route", lambda **kw: config) + calls = [] + + def send(**kwargs): + calls.append(kwargs) + return {"ok": True, "status": "preview_ready", "external_write_performed": False} + + monkeypatch.setattr( + cli, "reply_lark_event_inbox" if command == "reply" else "send_lark_inbox_message", send + ) + # Direct callers predating outbound recall do not have the new parser fields. + args = argparse.Namespace( + command="lark-inbox", lark_inbox_command=command, + goal_id=None, agent_id=None, message_id="om_reaction_fixture", + route_key="example", text="done", execute=False, provider_preflight=True, + ) + results = [] + assert cli.handle_lark_inbox_command( + args, registry_path=tmp_path / "registry.json", runtime_root_arg=None, + output_format=lambda *a: "json", + print_payload=lambda payload, *a: results.append(payload), + ) == 0 + assert len(calls) == 1 + assert calls[0] == { + "project": project, "config_path": config, "text": "done", + "execute": False, "provider_preflight": True, "before_send": None, + **({"message_id": "om_reaction_fixture"} if command == "reply" else {}), + } + assert results[0]["status"] == "preview_ready" + assert results[0]["external_write_performed"] is False + + +@pytest.mark.parametrize("command", ["send", "reply"]) +def test_cli_installs_hook_at_real_sender(tmp_path, monkeypatch, command): + from loopx.cli_commands import lark_inbox as cli + + configure(tmp_path, monkeypatch) + config, _, project = _fixture(tmp_path, lifecycle=False) + monkeypatch.setattr(cli, "_inbox_context", lambda *a: (project, config)) + monkeypatch.setattr(cli, "_resolve_lark_activation", lambda *a, **kw: {}) + monkeypatch.setattr(cli, "resolve_routed_lark_inbox_config", lambda **kw: config) + monkeypatch.setattr(cli, "resolve_routed_lark_inbox_route", lambda **kw: config) + runner = ReplyRunner(readback_text="done") + sender = reply_lark_event_inbox if command == "reply" else send_lark_inbox_message + monkeypatch.setattr( + cli, + "reply_lark_event_inbox" if command == "reply" else "send_lark_inbox_message", + lambda **kw: sender(**kw, runner=runner), + ) + args = argparse.Namespace( + command="lark-inbox", + lark_inbox_command=command, + goal_id="goal", + agent_id="pilot", + message_id="om_reaction_fixture", + route_key="example", + text="done", + execute=True, + provider_preflight=False, + message_purpose="help", + reviewed_guidance_digest=None, + ) + results = [] + cli.handle_lark_inbox_command( + args, + registry_path=tmp_path / "registry.json", + runtime_root_arg=None, + output_format=lambda *a: "json", + print_payload=lambda payload, *a: results.append(payload), + ) + assert results[0]["status"] == "agent_review_required" + assert results[0]["external_write_performed"] is False + assert "not a request for user approval" in cli._render(results[0]) diff --git a/tests/extensions/test_lark_event_collector_routing.py b/tests/extensions/test_lark_event_collector_routing.py index 0767bf8d89..1b341d731b 100644 --- a/tests/extensions/test_lark_event_collector_routing.py +++ b/tests/extensions/test_lark_event_collector_routing.py @@ -730,6 +730,7 @@ def fake_send(**kwargs: object) -> dict[str, object]: assert str(calls[0]["config_path"]).endswith("requirements-beta.json") assert calls[0]["provider_preflight"] is True assert calls[0]["execute"] is False + assert calls[0]["before_send"] is None assert rendered[0]["status"] == "preview_ready" assert rendered[0]["extension_activation"] == {"enabled": True}