Skip to content
Open
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
1 change: 1 addition & 0 deletions docs/design/traj-exp-experience-learning-redesign.md
Original file line number Diff line number Diff line change
Expand Up @@ -650,6 +650,7 @@ merge 输入/输出日志通过 `tracer.info(..., console=False)` 记录,避
"trace_id": "...",
"extracted_at": "...",
"operations": {...},
"skipped_operations": [...],
"summary": {...}
}
```
Expand Down
15 changes: 13 additions & 2 deletions docs/en/api/05-sessions.md
Original file line number Diff line number Diff line change
Expand Up @@ -1612,10 +1612,19 @@ When long-term memory extraction runs successfully, the commit writes a `memory_
}
]
},
"skipped_operations": [
{
"memory_type": "events",
"page_id": 101,
"reason_code": "invalid_ranges",
"reason": "No valid event range could be resolved"
}
],
"summary": {
"total_adds": 1,
"total_updates": 1,
"total_deletes": 1
"total_deletes": 1,
"total_skipped": 1
}
}
```
Expand All @@ -1627,11 +1636,13 @@ When long-term memory extraction runs successfully, the commit writes a `memory_
| `operations.adds` | array | New memories created (`uri`, `memory_type`, `after`) |
| `operations.updates` | array | Modified memories (`uri`, `memory_type`, `before`, `after`) |
| `operations.deletes` | array | Deleted memories (`uri`, `memory_type`, `deleted_content`) |
| `skipped_operations` | array | Intentionally skipped operations and their stable reason codes; these do not represent file changes |
| `summary.total_adds` | int | Number of new memories |
| `summary.total_updates` | int | Number of modified memories |
| `summary.total_deletes` | int | Number of deleted memories |
| `summary.total_skipped` | int | Number of intentionally skipped operations |

An empty `memory_diff.json` (all counts zero) is written when long-term memory extraction runs but produces no memory operations.
An empty `memory_diff.json` (all counts zero) is written when long-term memory extraction runs but produces no applied or intentionally skipped operations.

<a id="built-in-memory-types"></a>

Expand Down
14 changes: 12 additions & 2 deletions docs/en/concepts/08-session.md
Original file line number Diff line number Diff line change
Expand Up @@ -205,10 +205,19 @@ Each `session.commit()` writes a `memory_diff.json` to the archive directory, re
}
]
},
"skipped_operations": [
{
"memory_type": "events",
"page_id": 101,
"reason_code": "invalid_ranges",
"reason": "No valid event range could be resolved"
}
],
"summary": {
"total_adds": 1,
"total_updates": 1,
"total_deletes": 1
"total_deletes": 1,
"total_skipped": 1
}
}
```
Expand All @@ -220,9 +229,10 @@ Each `session.commit()` writes a `memory_diff.json` to the archive directory, re
| `operations.adds` | New memories created (no `before`) |
| `operations.updates` | Modified memories (with `before` and `after`) |
| `operations.deletes` | Deleted memories (with `deleted_content`) |
| `skipped_operations` | Intentionally skipped operations and their stable reason codes; these are not file changes |
| `summary` | Counts per operation type |

An empty `memory_diff.json` (all counts zero) is written even when no memory operations occurred.
An empty `memory_diff.json` (all counts zero) is written when no applied or intentionally skipped operations occurred.

## Storage Structure

Expand Down
15 changes: 13 additions & 2 deletions docs/zh/api/05-sessions.md
Original file line number Diff line number Diff line change
Expand Up @@ -1584,10 +1584,19 @@ viking://user/{user_id}/sessions/{session_id}/
}
]
},
"skipped_operations": [
{
"memory_type": "events",
"page_id": 101,
"reason_code": "invalid_ranges",
"reason": "无法解析出有效的事件范围"
}
],
"summary": {
"total_adds": 1,
"total_updates": 1,
"total_deletes": 1
"total_deletes": 1,
"total_skipped": 1
}
}
```
Expand All @@ -1599,11 +1608,13 @@ viking://user/{user_id}/sessions/{session_id}/
| `operations.adds` | array | 新增记忆(`uri`、`memory_type`、`after`) |
| `operations.updates` | array | 修改记忆(`uri`、`memory_type`、`before`、`after`) |
| `operations.deletes` | array | 删除记忆(`uri`、`memory_type`、`deleted_content`) |
| `skipped_operations` | array | 策略性跳过的操作及稳定原因码;不代表文件变更 |
| `summary.total_adds` | int | 新增记忆数 |
| `summary.total_updates` | int | 修改记忆数 |
| `summary.total_deletes` | int | 删除记忆数 |
| `summary.total_skipped` | int | 策略性跳过的操作数 |

如果长记忆抽取已运行但没有产生记忆操作,也会写入空结构的 `memory_diff.json`(所有计数为零)。
如果长记忆抽取已运行但没有产生实际变更或策略性跳过,也会写入空结构的 `memory_diff.json`(所有计数为零)。

<a id="内置记忆类型"></a>

Expand Down
14 changes: 12 additions & 2 deletions docs/zh/concepts/08-session.md
Original file line number Diff line number Diff line change
Expand Up @@ -205,10 +205,19 @@ LLM 去重决策 → candidate(skip/create/none) + item(merge/delete)
}
]
},
"skipped_operations": [
{
"memory_type": "events",
"page_id": 101,
"reason_code": "invalid_ranges",
"reason": "无法解析出有效的事件范围"
}
],
"summary": {
"total_adds": 1,
"total_updates": 1,
"total_deletes": 1
"total_deletes": 1,
"total_skipped": 1
}
}
```
Expand All @@ -220,9 +229,10 @@ LLM 去重决策 → candidate(skip/create/none) + item(merge/delete)
| `operations.adds` | 新增的记忆(无 `before`) |
| `operations.updates` | 修改的记忆(含 `before` 和 `after`) |
| `operations.deletes` | 删除的记忆(含 `deleted_content`) |
| `skipped_operations` | 策略性跳过的操作及稳定原因码;不代表文件变更 |
| `summary` | 各操作类型的计数 |

即使没有记忆操作,也会写入空结构的 `memory_diff.json`(所有计数为零)。
如果没有实际变更或策略性跳过,也会写入空结构的 `memory_diff.json`(所有计数为零)。

## 存储结构

Expand Down
57 changes: 50 additions & 7 deletions openviking/session/compressor_v3.py
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,9 @@ async def _build_memory_diff(
adds=adds,
updates=updates,
deletes=deletes,
skipped_operations=_serialize_skipped_operations(
getattr(result, "skipped_operations", [])
),
)

@tracer(ignore_result=True)
Expand All @@ -363,6 +366,7 @@ async def extract_long_term_memories(
allow_self_memory: bool = True,
allowed_peer_ids: Optional[set[str]] = None,
event_search_tags: Optional[List[str]] = None,
peer_memory_enabled: bool = True,
):
if not agent_evolution_enabled:
effective_types = (
Expand Down Expand Up @@ -400,6 +404,7 @@ async def extract_long_term_memories(
archive_uri=archive_uri,
allowed_memory_types=allowed_memory_types,
allow_self_memory=allow_self_memory,
peer_memory_enabled=peer_memory_enabled,
allowed_peer_ids=allowed_peer_ids,
event_search_tags=event_search_tags,
)
Expand Down Expand Up @@ -453,6 +458,7 @@ async def extract_long_term_memories(
contexts=result.contexts,
train_result=train_result,
archive_uri=archive_uri or "",
skipped_operations=getattr(result, "skipped_operations", []),
)
except Exception:
if strict_extract_errors:
Expand Down Expand Up @@ -601,6 +607,7 @@ async def _extract_user_memories(
archive_uri: Optional[str] = None,
allowed_memory_types: Optional[set[str]] = None,
allow_self_memory: bool = True,
peer_memory_enabled: bool = True,
allowed_peer_ids: Optional[set[str]] = None,
event_search_tags: Optional[List[str]] = None,
) -> "_V3ExtractionResult":
Expand Down Expand Up @@ -642,6 +649,7 @@ async def _extract_user_memories(
allowed_memory_types=allowed_memory_types,
allow_self=allow_self_memory,
allowed_peer_ids=allowed_peer_ids,
peer_memory_enabled=peer_memory_enabled,
)
isolation_handler.prepare_messages()
context_provider._isolation_handler = isolation_handler
Expand Down Expand Up @@ -682,6 +690,7 @@ async def _extract_user_memories(
"allowed_memory_types": allowed_memory_types,
"allow_self": allow_self_memory,
"allowed_peer_ids": allowed_peer_ids,
"peer_memory_enabled": peer_memory_enabled,
},
metadata={
"source_extraction_id": extraction_id,
Expand Down Expand Up @@ -719,6 +728,9 @@ async def _extract_user_memories(
cases=canonical_cases,
memory_diff=memory_diff,
case_uri_by_name=_case_uri_by_name(canonical_cases, patch_operations, result),
skipped_operations=_serialize_skipped_operations(
getattr(result, "skipped_operations", [])
),
)

def _session_skill_extraction_enabled(self) -> bool:
Expand Down Expand Up @@ -1223,6 +1235,7 @@ class _V3ExtractionResult:
cases: list[Case] = field(default_factory=list)
memory_diff: dict[str, Any] | None = None
case_uri_by_name: dict[str, str] = field(default_factory=dict)
skipped_operations: list[dict[str, Any]] = field(default_factory=list)


@dataclass(slots=True)
Expand Down Expand Up @@ -2041,20 +2054,36 @@ def _same_memory_file(before: Optional[MemoryFile], after: Optional[MemoryFile])
)


def _serialize_skipped_operations(items: Any) -> list[dict[str, Any]]:
serialized: list[dict[str, Any]] = []
for item in list(items or []):
if isinstance(item, dict):
payload = dict(item)
else:
model_dump = getattr(item, "model_dump", None)
if not callable(model_dump):
continue
payload = model_dump(mode="json", exclude_none=True)
if isinstance(payload, dict):
payload.pop("source", None)
serialized.append(payload)
return serialized


def _v3_extraction_response(
*,
contexts: list[Context],
train_result: Any,
archive_uri: str,
skipped_operations: Optional[list[dict[str, Any]]] = None,
) -> list[Context] | dict[str, Any]:
"""Build the extraction response.

Historically ``extract_long_term_memories`` returned ``list[Context]`` and
a number of direct callers still index/compare the return value as a list.
Commit orchestration now also understands the execution-memory style
``{"contexts": ..., "session_skills": ...}`` shape so it can count
session skills. Preserve the old list shape unless there are actual
session skills to report.
Commit orchestration also understands a structured response for session
skills and intentionally skipped operations. Preserve the old list shape
unless either field has content.
"""
skill_dicts: list[dict[str, Any]] = []
seen: set[str] = set()
Expand All @@ -2064,9 +2093,13 @@ def _v3_extraction_response(
if uri_str and uri_str not in seen:
seen.add(uri_str)
skill_dicts.append({"uri": uri_str, "archive_uri": archive_uri})
if not skill_dicts:
public_skips = list(skipped_operations or [])

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Bug] 仅发生 skipped_operations 时,这里会把 extract_long_term_memories() 的历史返回类型从 list[Context] 改为 dict,即使没有 session skills。SessionService.extract() 仍声明返回 List[Any],并把结果直接暴露给 POST /api/v1/sessions/{session_id}/extract,因此同一公共接口会根据是否发生 skip 在 array/object 之间切换,形成条件性 API contract break。请保持公共提取接口的 list 返回形态,把 skip 信息通过 commit 专用内部结果或独立 metadata 通道传递。

if not skill_dicts and not public_skips:
return contexts
return {"contexts": contexts, "session_skills": skill_dicts}
response: dict[str, Any] = {"contexts": contexts, "session_skills": skill_dicts}
if public_skips:
response["skipped_operations"] = public_skips
return response


def _make_memory_diff(
Expand All @@ -2075,7 +2108,9 @@ def _make_memory_diff(
adds: list[dict[str, Any]],
updates: list[dict[str, Any]],
deletes: list[dict[str, Any]],
skipped_operations: Optional[list[dict[str, Any]]] = None,
) -> dict[str, Any]:
skipped = list(skipped_operations or [])
return {
"archive_uri": archive_uri,
"trace_id": tracer.get_trace_id() or None,
Expand All @@ -2085,10 +2120,12 @@ def _make_memory_diff(
"updates": list(updates),
"deletes": list(deletes),
},
"skipped_operations": skipped,
"summary": {
"total_adds": len(adds),
"total_updates": len(updates),
"total_deletes": len(deletes),
"total_skipped": len(skipped),
},
}

Expand All @@ -2101,12 +2138,16 @@ def _merge_memory_diffs(
adds: list[dict[str, Any]] = []
updates: list[dict[str, Any]] = []
deletes: list[dict[str, Any]] = []
skipped_operations: list[dict[str, Any]] = []
trace_id = tracer.get_trace_id() or None
for diff in diffs:
if not isinstance(diff, dict):
continue
if trace_id is None and diff.get("trace_id"):
trace_id = str(diff.get("trace_id"))
skipped_operations.extend(
item for item in diff.get("skipped_operations", []) if isinstance(item, dict)
)
operations = diff.get("operations")
if not isinstance(operations, dict):
continue
Expand All @@ -2118,6 +2159,7 @@ def _merge_memory_diffs(
adds=adds,
updates=updates,
deletes=deletes,
skipped_operations=skipped_operations,
)
merged["trace_id"] = trace_id
return merged
Expand All @@ -2130,7 +2172,8 @@ def _memory_diff_has_changes(diff: Any) -> bool:
if not isinstance(summary, dict):
return False
return any(
int(summary.get(key) or 0) > 0 for key in ("total_adds", "total_updates", "total_deletes")
int(summary.get(key) or 0) > 0
for key in ("total_adds", "total_updates", "total_deletes", "total_skipped")
)


Expand Down
37 changes: 37 additions & 0 deletions openviking/session/memory/dataclass.py
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,40 @@ class MemoryOperationSource(BaseModel):
extracted_at: Optional[str] = None


class MemoryOperationSkipCode(str, Enum):
"""Stable reason codes for intentionally skipped memory operations."""

MEMORY_TYPE_FILTERED = "memory_type_filtered"
SELF_MEMORY_DISABLED = "self_memory_disabled"
PEER_MEMORY_DISABLED = "peer_memory_disabled"
INVALID_PEER_ID = "invalid_peer_id"
PEER_NOT_ALLOWED = "peer_not_allowed"
INVALID_RANGES = "invalid_ranges"
AMBIGUOUS_TARGET = "ambiguous_target"
NO_WRITABLE_TARGET = "no_writable_target"
DEPENDENT_DELETE_SUPPRESSED = "dependent_delete_suppressed"


class MemoryOperationSkip(BaseModel):
"""Internal policy/validation decision explaining why no URI was produced."""

reason_code: MemoryOperationSkipCode
reason: str


class SkippedMemoryOperation(BaseModel):
"""Structured, task-visible record for one intentionally skipped operation."""

memory_type: str
page_id: Optional[int] = None
uri: Optional[str] = None
reason_code: MemoryOperationSkipCode
reason: str
# Source is used only to scope shared streaming-batch results back to the
# submitting commit. It must never be serialized into the public task result.
source: Optional[MemoryOperationSource] = Field(default=None, exclude=True)


# ============================================================================
# Memory Field and Schema Definitions
# ============================================================================
Expand Down Expand Up @@ -298,6 +332,9 @@ class ResolvedOperation(BaseModel):
uris: List[str]
page_id: Optional[int] = None # Temporary page_id for link resolution (not persisted)
source: Optional[MemoryOperationSource] = None
# Runtime-only resolution decision. It is deliberately excluded from model
# serialization so it cannot enter later LLM merge prompts or memory files.
resolution_skip: Optional[MemoryOperationSkip] = Field(default=None, exclude=True)
# Custom scalar tags (already normalized as "key=value") to attach to this
# operation's memories in the vector index. None means "no tags"; used by
# event-memory auto-tagging. Not persisted in the memory file content.
Expand Down
Loading