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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions loopx/capabilities/agent_turn_recall/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 2 additions & 1 deletion loopx/capabilities/agent_turn_recall/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
),
Expand Down
1 change: 1 addition & 0 deletions loopx/capabilities/agent_turn_recall/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
69 changes: 69 additions & 0 deletions loopx/capabilities/reward_memory/OUTBOUND.md
Original file line number Diff line number Diff line change
@@ -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:<agent-id>` 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 <goal-id> \
--reward-memory-config .loopx/config/reward-memory.json \
--reward-memory-agent <agent-id> --execute
loopx reward-memory experiment-status --goal-id <goal-id> --agent-id <agent-id>
loopx lark-inbox send --goal-id <goal-id> --agent-id <agent-id> \
--route-key <configured-route> --text '<message>' \
--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 <returned-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.
58 changes: 58 additions & 0 deletions loopx/capabilities/reward_memory/OUTBOUND.zh-CN.md
Original file line number Diff line number Diff line change
@@ -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:<agent-id>`,并启用 `automation.automatic_recall`。建议采用一次查询、
小结果上限的 function-boundary profile。只写入明确审阅过且可公开的
`soft_preference`;不要上传消息草稿或私有事故记录。

```sh
loopx configure-goal --goal-id <goal-id> \
--reward-memory-config .loopx/config/reward-memory.json \
--reward-memory-agent <agent-id> --execute
loopx reward-memory experiment-status --goal-id <goal-id> --agent-id <agent-id>
loopx lark-inbox send --goal-id <goal-id> --agent-id <agent-id> \
--route-key <configured-route> --text '<message>' \
--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 的名义向群里发送消息。
6 changes: 6 additions & 0 deletions loopx/capabilities/reward_memory/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
6 changes: 6 additions & 0 deletions loopx/capabilities/reward_memory/README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 审视,但不会
授予发送权限。
153 changes: 153 additions & 0 deletions loopx/capabilities/reward_memory/outbound.py
Original file line number Diff line number Diff line change
@@ -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(

Check failure on line 24 in loopx/capabilities/reward_memory/outbound.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 16 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=huangruiteng_loopx&issues=AaByqwuo0XoH5Eiac-0j&open=AaByqwuo0XoH5Eiac-0j&pullRequest=3968
*,
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
Loading