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
6 changes: 6 additions & 0 deletions LightAgent/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -354,6 +354,12 @@ def promote_memory_candidate(
candidate = self._find_memory_candidate(candidate_id)
if candidate is None:
raise ValueError(f"Memory candidate `{candidate_id}` was not found.")
if candidate.status == "promoted":
self._record_trace("memory_promotion_idempotent", {
**self._memory_candidate_trace(candidate),
"manual": True,
})
return True

if decision is None:
promotion_decision = MemoryPromotionDecision.approve(candidate.data)
Expand Down
14 changes: 10 additions & 4 deletions LightAgent/protocol.py
Original file line number Diff line number Diff line change
Expand Up @@ -241,12 +241,12 @@ class MemoryPolicy:
min_confidence: float | None = None
enforce_expires_at: bool = False
memory_write_admission: Callable[[str, dict[str, Any]], Any] | None = None
memory_promotion_admission: Callable[[MemoryCandidate, dict[str, Any]], Any] | None = None
require_promotion_for_internal_memory: bool = True
max_writes_per_run: int | None = None
reject_duplicate_writes: bool = False
min_write_length: int | None = None
reject_write_patterns: Iterable[str] | None = None
memory_promotion_admission: Callable[[MemoryCandidate, dict[str, Any]], Any] | None = None
require_promotion_for_internal_memory: bool = True

def __post_init__(self):
for field_name in ("allowed_sources", "allowed_scopes", "allowed_agent_names", "allowed_trust_levels"):
Expand Down Expand Up @@ -353,7 +353,7 @@ def _blocks_prompt_injection(self, item: dict[str, Any], metadata: dict[str, Any
if injectable is not None and not self._truthy(injectable):
return True

status = self._get_value(item, metadata, ("promotion_status", "memory_status", "status"))
status = self._get_value(item, metadata, ("promotion_status",))
normalized_status = str(status).lower() if status is not None else None
if normalized_status in {
"candidate",
Expand Down Expand Up @@ -451,7 +451,13 @@ def allows_promotion(
"""Return whether a non-injectable candidate can be promoted."""
if self.memory_promotion_admission is None:
return MemoryPromotionDecision.keep("Memory promotion requires explicit approval.")
raw_decision = self.memory_promotion_admission(candidate, context or {})
try:
raw_decision = self.memory_promotion_admission(candidate, context or {})
except Exception as exc:
return MemoryPromotionDecision.keep(
f"Memory promotion admission failed: {type(exc).__name__}",
metadata={"admission_error_type": type(exc).__name__},
)
return self._coerce_promotion_decision(raw_decision, candidate.data)

@staticmethod
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -573,6 +573,7 @@ Hooks can target `before_run`, `after_run`, `on_error`, `before_model_request`,

Use it with `MemoryPolicy` so each agent retrieves only memory that matches the expected namespace, source, scope, trust, confidence, or agent name.
Self-learning and delegation evidence become non-injectable memory candidates first; promote them with `MemoryPolicy(memory_promotion_admission=...)` or `agent.promote_memory_candidate(candidate_id)` before they can enter future prompts.
When upgrading from v0.9.4, audit legacy internal memory before backfilling `promotion_status="promoted"` and `injectable=True`; use `require_promotion_for_internal_memory=False` only as a temporary compatibility option. See [Memory Admission](docs/memory_admission.md#upgrading-from-v094).

## Mainstream Agent Model Support

Expand Down
1 change: 1 addition & 0 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,7 @@ LightAgent 接受任何提供 `store(data, user_id)` 和 `retrieve(query, user_i

### 7. Agent 自我学习
自我学习应与记忆后端和 `MemoryPolicy` 配合使用,避免低质量、隐私、过期或无关内容进入长期记忆。反思、委托摘要等内部证据默认先生成不可注入的记忆候选,需要通过 `memory_promotion_admission` 或 `agent.promote_memory_candidate(candidate_id)` 显式提升后,才会进入未来 prompt。
从 v0.9.4 升级时,应先审查旧的内部记忆,再为通过审查的记录回填 `promotion_status="promoted"` 和 `injectable=True`;`require_promotion_for_internal_memory=False` 仅用于临时兼容。具体迁移说明请查看 [Memory Admission](docs/memory_admission.md#upgrading-from-v094)。

### 8. Trace 与 Langfuse
LightAgent 可通过内置 trace 或 Langfuse 配置观察运行过程。
Expand Down
22 changes: 22 additions & 0 deletions docs/memory_admission.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,28 @@ Promotion decisions can:
Use `agent.list_memory_candidates()` after a run to inspect candidates, or
`agent.promote_memory_candidate(candidate_id)` to explicitly promote one later.

### Upgrading From v0.9.4

v0.9.5 changes the default handling of internal memory. With
`require_promotion_for_internal_memory=True` (the default), new reflection,
self-learning, delegation, and other internal evidence is not persisted until it
is explicitly promoted. Existing internal records without `promotion_status`
and `injectable` metadata are also excluded from prompt injection.

For a temporary compatibility window, applications can restore the previous
retrieval and write behavior while they audit existing records:

```python
policy = MemoryPolicy(require_promotion_for_internal_memory=False)
```

The safer migration is to review legacy internal records and backfill only
approved entries with `promotion_status="promoted"` and `injectable=True`.
Records explicitly marked `injectable=False` or with a candidate, rejected, or
blocked `promotion_status` remain non-injectable even when the compatibility
option is enabled. The exact backfill operation belongs in the memory adapter
because storage APIs differ between vector, graph, and custom backends.

### Expiration-Aware Retrieval

Memory records can include `expires_at` metadata. When
Expand Down
88 changes: 88 additions & 0 deletions tests/test_memory_policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,31 @@ def make_agent(memory, memory_policy=None, memory_namespace=None):
return agent, completions


def test_memory_policy_preserves_legacy_positional_arguments():
policy = MemoryPolicy(
"tenant-a",
False,
("user",),
("user",),
("writer",),
("verified",),
0.8,
True,
None,
7,
True,
4,
(r"ignore previous instructions",),
)

assert policy.max_writes_per_run == 7
assert policy.reject_duplicate_writes is True
assert policy.min_write_length == 4
assert policy.reject_write_patterns == (r"ignore previous instructions",)
assert policy.memory_promotion_admission is None
assert policy.require_promotion_for_internal_memory is True


def test_memory_policy_namespaces_user_id_and_filters_cross_user_results():
memory = RecordingMemory([
{"memory": "safe memory", "metadata": {"user_id": "tenant-a:alice"}},
Expand Down Expand Up @@ -388,6 +413,36 @@ def test_memory_policy_filters_unpromoted_internal_results_before_prompt_injecti
assert policy.allows_result(promoted_reflection, "agent", "agent") is True


def test_business_status_does_not_block_user_memory():
policy = MemoryPolicy(require_promotion_for_internal_memory=False)
order_memory = {
"memory": "Customer order is pending",
"metadata": {
"user_id": "alice",
"source": "user",
"scope": "user",
"status": "pending",
},
}

assert policy.allows_result(order_memory, "alice", "alice") is True


def test_legacy_internal_memory_can_use_compatibility_opt_out():
legacy_reflection = {
"memory": "Legacy reflection",
"metadata": {
"user_id": "writer",
"source": "reflection",
"scope": "agent",
},
}

assert MemoryPolicy().allows_result(legacy_reflection, "writer", "writer") is False
compatibility_policy = MemoryPolicy(require_promotion_for_internal_memory=False)
assert compatibility_policy.allows_result(legacy_reflection, "writer", "writer") is True


def test_before_memory_promote_policy_hook_can_fail_closed():
def broken_policy(ctx):
raise RuntimeError("review service unavailable")
Expand All @@ -413,6 +468,25 @@ def broken_policy(ctx):
assert any(event["type"] == "memory_promotion_blocked" for event in result.trace)


def test_memory_promotion_admission_exception_fails_closed():
def broken_admission(candidate, context):
raise RuntimeError("review service unavailable")

memory = MetadataRecordingMemory([])
policy = MemoryPolicy(memory_promotion_admission=broken_admission)
agent, _ = make_agent(memory, memory_policy=policy)
agent.self_learning = True

result = agent.run("hello", user_id="alice", result_format="object", trace=True)

assert result.content == "done"
assert len(memory.store_calls) == 1
assert agent.list_memory_candidates()[0]["status"] == "kept"
blocked_event = next(event for event in result.trace if event["type"] == "memory_promotion_blocked")
assert blocked_event["data"]["reason"] == "Memory promotion admission failed: RuntimeError"
assert "review service unavailable" not in blocked_event["data"]["reason"]


def test_memory_candidate_can_be_promoted_explicitly_after_run():
memory = MetadataRecordingMemory([])
agent, _ = make_agent(memory)
Expand All @@ -430,6 +504,20 @@ def test_memory_candidate_can_be_promoted_explicitly_after_run():
assert memory.store_calls[1]["metadata"]["injectable"] is True


def test_memory_candidate_promotion_is_idempotent():
memory = MetadataRecordingMemory([])
agent, _ = make_agent(memory)
agent.self_learning = True

agent.run("hello", user_id="alice")
candidate_id = agent.list_memory_candidates()[0]["candidate_id"]

assert agent.promote_memory_candidate(candidate_id) is True
assert agent.promote_memory_candidate(candidate_id) is True
assert len(memory.store_calls) == 2
assert agent.list_memory_candidates()[0]["status"] == "promoted"


def test_memory_policy_limits_writes_per_run():
memory = RecordingMemory([])
policy = MemoryPolicy(max_writes_per_run=1)
Expand Down
Loading