generated from coulomb/repo-seed
feat(memory): add agent safe APIs
This commit is contained in:
@@ -33,6 +33,13 @@ with `markitect-tool`.
|
|||||||
- `MemoryRuntimeService.compact_memory()` creates a deterministic summary node,
|
- `MemoryRuntimeService.compact_memory()` creates a deterministic summary node,
|
||||||
preserves source spans/provenance, optionally retires source nodes, and appends
|
preserves source spans/provenance, optionally retires source nodes, and appends
|
||||||
a compaction event.
|
a compaction event.
|
||||||
|
- `MemoryRuntimeService.plan_memory_update()` creates dry-run update plans with
|
||||||
|
source and policy explanations.
|
||||||
|
- `MemoryRuntimeService.apply_memory_update()` requires explicit approval for
|
||||||
|
durable writes when a plan is review-gated.
|
||||||
|
- `MemoryRuntimeService.export_context_package_inputs()` emits
|
||||||
|
Markitect-compatible context package input envelopes without invoking the
|
||||||
|
Markitect compiler.
|
||||||
|
|
||||||
## Boundary
|
## Boundary
|
||||||
|
|
||||||
@@ -48,6 +55,7 @@ with `markitect-tool`.
|
|||||||
- append-only event storage
|
- append-only event storage
|
||||||
- permission-aware retrieval and context assembly
|
- permission-aware retrieval and context assembly
|
||||||
- retention, refresh, compaction, review gates, and audit behavior
|
- retention, refresh, compaction, review gates, and audit behavior
|
||||||
|
- agent-safe update plans and Markitect-compatible export envelopes
|
||||||
|
|
||||||
`infospace-bench` should consume these records and Markitect fixtures to measure
|
`infospace-bench` should consume these records and Markitect fixtures to measure
|
||||||
retrieval quality, latency, budget pressure, and regression behavior.
|
retrieval quality, latency, budget pressure, and regression behavior.
|
||||||
|
|||||||
@@ -139,12 +139,19 @@ from .services import (
|
|||||||
MemoryGraphImportSummary,
|
MemoryGraphImportSummary,
|
||||||
MemoryLifecycleNodeUpdate,
|
MemoryLifecycleNodeUpdate,
|
||||||
MemoryLifecycleResult,
|
MemoryLifecycleResult,
|
||||||
|
MemoryNodeUpdateInstruction,
|
||||||
|
MemoryPackageExportRequest,
|
||||||
|
MemoryPackageExportResult,
|
||||||
|
MemoryPlannedNodeUpdate,
|
||||||
MemoryQueryRequest,
|
MemoryQueryRequest,
|
||||||
MemoryRefreshRequest,
|
MemoryRefreshRequest,
|
||||||
MemoryRetrievalItem,
|
MemoryRetrievalItem,
|
||||||
MemoryRetrievalResult,
|
MemoryRetrievalResult,
|
||||||
MemoryRetentionRequest,
|
MemoryRetentionRequest,
|
||||||
MemoryRuntimeService,
|
MemoryRuntimeService,
|
||||||
|
MemoryUpdatePlan,
|
||||||
|
MemoryUpdateRequest,
|
||||||
|
MemoryUpdateResult,
|
||||||
RelationshipChangeResult,
|
RelationshipChangeResult,
|
||||||
RelationshipQueryItem,
|
RelationshipQueryItem,
|
||||||
RelationshipQueryRequest,
|
RelationshipQueryRequest,
|
||||||
@@ -271,6 +278,10 @@ __all__ = [
|
|||||||
"MemoryLifecycleNodeUpdate",
|
"MemoryLifecycleNodeUpdate",
|
||||||
"MemoryLifecycleResult",
|
"MemoryLifecycleResult",
|
||||||
"MemoryNodeRecord",
|
"MemoryNodeRecord",
|
||||||
|
"MemoryNodeUpdateInstruction",
|
||||||
|
"MemoryPackageExportRequest",
|
||||||
|
"MemoryPackageExportResult",
|
||||||
|
"MemoryPlannedNodeUpdate",
|
||||||
"MemoryProfileRecord",
|
"MemoryProfileRecord",
|
||||||
"MemoryQueryRequest",
|
"MemoryQueryRequest",
|
||||||
"MemoryRefreshRequest",
|
"MemoryRefreshRequest",
|
||||||
@@ -279,6 +290,9 @@ __all__ = [
|
|||||||
"MemoryRetentionRequest",
|
"MemoryRetentionRequest",
|
||||||
"MemoryRuntimeService",
|
"MemoryRuntimeService",
|
||||||
"MemorySourceSpan",
|
"MemorySourceSpan",
|
||||||
|
"MemoryUpdatePlan",
|
||||||
|
"MemoryUpdateRequest",
|
||||||
|
"MemoryUpdateResult",
|
||||||
"NormalizedDocument",
|
"NormalizedDocument",
|
||||||
"NotFoundError",
|
"NotFoundError",
|
||||||
"OperationFailure",
|
"OperationFailure",
|
||||||
|
|||||||
@@ -12,12 +12,19 @@ from .memory_service import (
|
|||||||
MemoryGraphImportSummary,
|
MemoryGraphImportSummary,
|
||||||
MemoryLifecycleNodeUpdate,
|
MemoryLifecycleNodeUpdate,
|
||||||
MemoryLifecycleResult,
|
MemoryLifecycleResult,
|
||||||
|
MemoryNodeUpdateInstruction,
|
||||||
|
MemoryPackageExportRequest,
|
||||||
|
MemoryPackageExportResult,
|
||||||
|
MemoryPlannedNodeUpdate,
|
||||||
MemoryQueryRequest,
|
MemoryQueryRequest,
|
||||||
MemoryRefreshRequest,
|
MemoryRefreshRequest,
|
||||||
MemoryRetrievalItem,
|
MemoryRetrievalItem,
|
||||||
MemoryRetrievalResult,
|
MemoryRetrievalResult,
|
||||||
MemoryRetentionRequest,
|
MemoryRetentionRequest,
|
||||||
MemoryRuntimeService,
|
MemoryRuntimeService,
|
||||||
|
MemoryUpdatePlan,
|
||||||
|
MemoryUpdateRequest,
|
||||||
|
MemoryUpdateResult,
|
||||||
)
|
)
|
||||||
from .retrieval_service import (
|
from .retrieval_service import (
|
||||||
AssetQueryItem,
|
AssetQueryItem,
|
||||||
@@ -69,12 +76,19 @@ __all__ = [
|
|||||||
"MemoryGraphImportSummary",
|
"MemoryGraphImportSummary",
|
||||||
"MemoryLifecycleNodeUpdate",
|
"MemoryLifecycleNodeUpdate",
|
||||||
"MemoryLifecycleResult",
|
"MemoryLifecycleResult",
|
||||||
|
"MemoryNodeUpdateInstruction",
|
||||||
|
"MemoryPackageExportRequest",
|
||||||
|
"MemoryPackageExportResult",
|
||||||
|
"MemoryPlannedNodeUpdate",
|
||||||
"MemoryQueryRequest",
|
"MemoryQueryRequest",
|
||||||
"MemoryRefreshRequest",
|
"MemoryRefreshRequest",
|
||||||
"MemoryRetrievalItem",
|
"MemoryRetrievalItem",
|
||||||
"MemoryRetrievalResult",
|
"MemoryRetrievalResult",
|
||||||
"MemoryRetentionRequest",
|
"MemoryRetentionRequest",
|
||||||
"MemoryRuntimeService",
|
"MemoryRuntimeService",
|
||||||
|
"MemoryUpdatePlan",
|
||||||
|
"MemoryUpdateRequest",
|
||||||
|
"MemoryUpdateResult",
|
||||||
"RelationshipChangeResult",
|
"RelationshipChangeResult",
|
||||||
"RepresentationContentResult",
|
"RepresentationContentResult",
|
||||||
"RepresentationContentStream",
|
"RepresentationContentStream",
|
||||||
|
|||||||
@@ -276,6 +276,178 @@ class MemoryLifecycleResult:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class MemoryNodeUpdateInstruction:
|
||||||
|
contract_node_id: str
|
||||||
|
operation: str = "upsert_node"
|
||||||
|
kind: str | None = None
|
||||||
|
text: str | None = None
|
||||||
|
lifecycle: str | None = None
|
||||||
|
source_spans: tuple[dict[str, Any], ...] = ()
|
||||||
|
provenance: tuple[dict[str, Any], ...] = ()
|
||||||
|
freshness: dict[str, Any] = field(default_factory=dict)
|
||||||
|
policy: dict[str, Any] = field(default_factory=dict)
|
||||||
|
metadata: dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
object.__setattr__(self, "source_spans", tuple(dict(item) for item in self.source_spans))
|
||||||
|
object.__setattr__(self, "provenance", tuple(dict(item) for item in self.provenance))
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"contract_node_id": self.contract_node_id,
|
||||||
|
"operation": self.operation,
|
||||||
|
"kind": self.kind,
|
||||||
|
"text": self.text,
|
||||||
|
"lifecycle": self.lifecycle,
|
||||||
|
"source_spans": [dict(item) for item in self.source_spans],
|
||||||
|
"provenance": [dict(item) for item in self.provenance],
|
||||||
|
"freshness": dict(self.freshness),
|
||||||
|
"policy": dict(self.policy),
|
||||||
|
"metadata": dict(self.metadata),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class MemoryUpdateRequest:
|
||||||
|
graph_id: str
|
||||||
|
instructions: tuple[MemoryNodeUpdateInstruction, ...] = ()
|
||||||
|
require_review: bool = True
|
||||||
|
reason: str | None = None
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
object.__setattr__(self, "instructions", tuple(self.instructions))
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"graph_id": self.graph_id,
|
||||||
|
"instructions": [instruction.to_dict() for instruction in self.instructions],
|
||||||
|
"require_review": self.require_review,
|
||||||
|
"reason": self.reason,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class MemoryPlannedNodeUpdate:
|
||||||
|
action: str
|
||||||
|
before: MemoryNodeRecord | None
|
||||||
|
after: MemoryNodeRecord
|
||||||
|
source_explanation: tuple[dict[str, Any], ...] = ()
|
||||||
|
policy_explanation: dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
object.__setattr__(self, "source_explanation", tuple(dict(item) for item in self.source_explanation))
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"action": self.action,
|
||||||
|
"before": self.before.to_dict() if self.before else None,
|
||||||
|
"after": self.after.to_dict(),
|
||||||
|
"source_explanation": [dict(item) for item in self.source_explanation],
|
||||||
|
"policy_explanation": dict(self.policy_explanation),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class MemoryUpdatePlan:
|
||||||
|
plan_id: str
|
||||||
|
request: MemoryUpdateRequest
|
||||||
|
correlation_id: str
|
||||||
|
dry_run: bool = True
|
||||||
|
review_required: bool = True
|
||||||
|
planned_updates: tuple[MemoryPlannedNodeUpdate, ...] = ()
|
||||||
|
diagnostics: tuple[Diagnostic, ...] = ()
|
||||||
|
policy_decision: PolicyDecision | None = None
|
||||||
|
success: bool = True
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
object.__setattr__(self, "planned_updates", tuple(self.planned_updates))
|
||||||
|
object.__setattr__(self, "diagnostics", tuple(self.diagnostics))
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"plan_id": self.plan_id,
|
||||||
|
"request": self.request.to_dict(),
|
||||||
|
"correlation_id": self.correlation_id,
|
||||||
|
"dry_run": self.dry_run,
|
||||||
|
"review_required": self.review_required,
|
||||||
|
"success": self.success,
|
||||||
|
"planned_updates": [update.to_dict() for update in self.planned_updates],
|
||||||
|
"diagnostics": [diagnostic.to_dict() for diagnostic in self.diagnostics],
|
||||||
|
"policy_decision": self.policy_decision.to_dict() if self.policy_decision else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class MemoryUpdateResult:
|
||||||
|
plan: MemoryUpdatePlan
|
||||||
|
applied_nodes: tuple[MemoryNodeRecord, ...] = ()
|
||||||
|
appended_events: tuple[MemoryEventRecord, ...] = ()
|
||||||
|
audit_event: AuditEvent | None = None
|
||||||
|
diagnostics: tuple[Diagnostic, ...] = ()
|
||||||
|
review_required: bool = False
|
||||||
|
success: bool = True
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
object.__setattr__(self, "applied_nodes", tuple(self.applied_nodes))
|
||||||
|
object.__setattr__(self, "appended_events", tuple(self.appended_events))
|
||||||
|
object.__setattr__(self, "diagnostics", tuple(self.diagnostics))
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"plan": self.plan.to_dict(),
|
||||||
|
"success": self.success,
|
||||||
|
"review_required": self.review_required,
|
||||||
|
"applied_nodes": [node.to_dict() for node in self.applied_nodes],
|
||||||
|
"appended_events": [event.to_dict() for event in self.appended_events],
|
||||||
|
"audit_event": self.audit_event.to_dict() if self.audit_event else None,
|
||||||
|
"diagnostics": [diagnostic.to_dict() for diagnostic in self.diagnostics],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class MemoryPackageExportRequest:
|
||||||
|
query: MemoryQueryRequest
|
||||||
|
title: str
|
||||||
|
intent: str
|
||||||
|
namespace: dict[str, Any] = field(default_factory=dict)
|
||||||
|
budget: dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"query": self.query.to_dict(),
|
||||||
|
"title": self.title,
|
||||||
|
"intent": self.intent,
|
||||||
|
"namespace": dict(self.namespace),
|
||||||
|
"budget": dict(self.budget),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class MemoryPackageExportResult:
|
||||||
|
request: MemoryPackageExportRequest
|
||||||
|
correlation_id: str
|
||||||
|
package_input: dict[str, Any] = field(default_factory=dict)
|
||||||
|
retrieval: MemoryRetrievalResult | None = None
|
||||||
|
audit_event: AuditEvent | None = None
|
||||||
|
diagnostics: tuple[Diagnostic, ...] = ()
|
||||||
|
success: bool = True
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
object.__setattr__(self, "diagnostics", tuple(self.diagnostics))
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"request": self.request.to_dict(),
|
||||||
|
"correlation_id": self.correlation_id,
|
||||||
|
"success": self.success,
|
||||||
|
"package_input": dict(self.package_input),
|
||||||
|
"retrieval": self.retrieval.to_dict() if self.retrieval else None,
|
||||||
|
"audit_event": self.audit_event.to_dict() if self.audit_event else None,
|
||||||
|
"diagnostics": [diagnostic.to_dict() for diagnostic in self.diagnostics],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class MemoryRuntimeService:
|
class MemoryRuntimeService:
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -690,6 +862,161 @@ class MemoryRuntimeService:
|
|||||||
metadata={"source_nodes": len(nodes), "retired_source_nodes": len(retired_nodes)},
|
metadata={"source_nodes": len(nodes), "retired_source_nodes": len(retired_nodes)},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def plan_memory_update(
|
||||||
|
self,
|
||||||
|
request: MemoryUpdateRequest,
|
||||||
|
context: OperationContext,
|
||||||
|
) -> MemoryUpdatePlan:
|
||||||
|
diagnostics = _validate_update_request(request)
|
||||||
|
plan_id = new_id("memplan")
|
||||||
|
decision = self._authorize(
|
||||||
|
context,
|
||||||
|
"memory.update.plan",
|
||||||
|
f"memory-graph:{request.graph_id}",
|
||||||
|
resource_metadata={"request": request.to_dict()},
|
||||||
|
)
|
||||||
|
if diagnostics or not decision.allowed:
|
||||||
|
if not decision.allowed:
|
||||||
|
diagnostics.append(_permission_denied_diagnostic(decision))
|
||||||
|
return MemoryUpdatePlan(
|
||||||
|
plan_id=plan_id,
|
||||||
|
request=request,
|
||||||
|
correlation_id=context.correlation_id,
|
||||||
|
review_required=request.require_review,
|
||||||
|
diagnostics=tuple(diagnostics),
|
||||||
|
policy_decision=decision,
|
||||||
|
success=False,
|
||||||
|
)
|
||||||
|
nodes = self.repository.list_memory_nodes(graph_id=request.graph_id)
|
||||||
|
graph_context = _graph_context(nodes, request.graph_id)
|
||||||
|
planned: list[MemoryPlannedNodeUpdate] = []
|
||||||
|
for instruction in request.instructions:
|
||||||
|
try:
|
||||||
|
planned.append(_planned_update_for_instruction(instruction, graph_context, context))
|
||||||
|
except ValueError as exc:
|
||||||
|
diagnostics.append(Diagnostic("error", "memory.update.invalid", str(exc)))
|
||||||
|
return MemoryUpdatePlan(
|
||||||
|
plan_id=plan_id,
|
||||||
|
request=request,
|
||||||
|
correlation_id=context.correlation_id,
|
||||||
|
review_required=request.require_review,
|
||||||
|
planned_updates=tuple(planned),
|
||||||
|
diagnostics=tuple(diagnostics),
|
||||||
|
policy_decision=decision,
|
||||||
|
success=not diagnostics,
|
||||||
|
)
|
||||||
|
|
||||||
|
def apply_memory_update(
|
||||||
|
self,
|
||||||
|
plan: MemoryUpdatePlan,
|
||||||
|
context: OperationContext,
|
||||||
|
*,
|
||||||
|
review_decision: str | None = None,
|
||||||
|
) -> MemoryUpdateResult:
|
||||||
|
if not plan.success:
|
||||||
|
return MemoryUpdateResult(
|
||||||
|
plan=plan,
|
||||||
|
diagnostics=plan.diagnostics,
|
||||||
|
success=False,
|
||||||
|
)
|
||||||
|
if plan.review_required and review_decision != "approved":
|
||||||
|
diagnostic = Diagnostic(
|
||||||
|
"warning",
|
||||||
|
"memory.update.review_required",
|
||||||
|
"Memory update requires explicit review approval before durable write.",
|
||||||
|
details={"plan_id": plan.plan_id, "review_decision": review_decision},
|
||||||
|
)
|
||||||
|
audit_event = AuditEvent.from_context(
|
||||||
|
"memory.update.apply",
|
||||||
|
f"memory-graph:{plan.request.graph_id}",
|
||||||
|
AuditOutcome.REVIEW_REQUIRED,
|
||||||
|
context,
|
||||||
|
details={"plan_id": plan.plan_id, "review_decision": review_decision},
|
||||||
|
)
|
||||||
|
return MemoryUpdateResult(
|
||||||
|
plan=plan,
|
||||||
|
audit_event=audit_event,
|
||||||
|
diagnostics=(diagnostic,),
|
||||||
|
review_required=True,
|
||||||
|
success=False,
|
||||||
|
)
|
||||||
|
decision = self._authorize(
|
||||||
|
context,
|
||||||
|
"memory.update.apply",
|
||||||
|
f"memory-graph:{plan.request.graph_id}",
|
||||||
|
resource_metadata={"plan": plan.to_dict(), "review_decision": review_decision},
|
||||||
|
)
|
||||||
|
if not decision.allowed:
|
||||||
|
diagnostic = _permission_denied_diagnostic(decision)
|
||||||
|
audit_event = AuditEvent.from_context(
|
||||||
|
"memory.update.apply",
|
||||||
|
f"memory-graph:{plan.request.graph_id}",
|
||||||
|
AuditOutcome.DENIED,
|
||||||
|
context,
|
||||||
|
policy_decision=decision,
|
||||||
|
details={"plan_id": plan.plan_id},
|
||||||
|
)
|
||||||
|
return MemoryUpdateResult(plan=plan, audit_event=audit_event, diagnostics=(diagnostic,), success=False)
|
||||||
|
for update in plan.planned_updates:
|
||||||
|
self.repository.save_memory_node(update.after)
|
||||||
|
event = self._append_lifecycle_event(
|
||||||
|
"updated",
|
||||||
|
plan.request.graph_id,
|
||||||
|
context,
|
||||||
|
node_updates=[_planned_update_event_payload(update) for update in plan.planned_updates],
|
||||||
|
metadata={"plan_id": plan.plan_id, "review_decision": review_decision},
|
||||||
|
)
|
||||||
|
audit_event = AuditEvent.from_context(
|
||||||
|
"memory.update.apply",
|
||||||
|
f"memory-graph:{plan.request.graph_id}",
|
||||||
|
AuditOutcome.SUCCESS,
|
||||||
|
context,
|
||||||
|
policy_decision=decision,
|
||||||
|
details={"plan_id": plan.plan_id, "applied_nodes": len(plan.planned_updates), "event_id": event.event_id},
|
||||||
|
)
|
||||||
|
return MemoryUpdateResult(
|
||||||
|
plan=plan,
|
||||||
|
applied_nodes=tuple(update.after for update in plan.planned_updates),
|
||||||
|
appended_events=(event,),
|
||||||
|
audit_event=audit_event,
|
||||||
|
)
|
||||||
|
|
||||||
|
def export_context_package_inputs(
|
||||||
|
self,
|
||||||
|
request: MemoryPackageExportRequest,
|
||||||
|
context: OperationContext,
|
||||||
|
) -> MemoryPackageExportResult:
|
||||||
|
retrieval = self.query_memory(request.query, context)
|
||||||
|
if not retrieval.success:
|
||||||
|
return MemoryPackageExportResult(
|
||||||
|
request=request,
|
||||||
|
correlation_id=context.correlation_id,
|
||||||
|
retrieval=retrieval,
|
||||||
|
diagnostics=retrieval.diagnostics,
|
||||||
|
success=False,
|
||||||
|
)
|
||||||
|
package_input = _markitect_package_input(request, retrieval)
|
||||||
|
outcome = AuditOutcome.PARTIAL if retrieval.metadata.get("permission_filtered_count") else AuditOutcome.SUCCESS
|
||||||
|
audit_event = AuditEvent.from_context(
|
||||||
|
"memory.package.export",
|
||||||
|
f"memory-graph:{request.query.graph_id or '*'}",
|
||||||
|
outcome,
|
||||||
|
context,
|
||||||
|
details={
|
||||||
|
"request": request.to_dict(),
|
||||||
|
"items": len(package_input.get("items", ())),
|
||||||
|
"permission_filtered_count": retrieval.metadata.get("permission_filtered_count", 0),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return MemoryPackageExportResult(
|
||||||
|
request=request,
|
||||||
|
correlation_id=context.correlation_id,
|
||||||
|
package_input=package_input,
|
||||||
|
retrieval=retrieval,
|
||||||
|
audit_event=audit_event,
|
||||||
|
diagnostics=retrieval.diagnostics,
|
||||||
|
)
|
||||||
|
|
||||||
def _authorize(
|
def _authorize(
|
||||||
self,
|
self,
|
||||||
context: OperationContext,
|
context: OperationContext,
|
||||||
@@ -1003,6 +1330,260 @@ def _deterministic_compaction_summary(nodes: list[MemoryNodeRecord]) -> str:
|
|||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_update_request(request: MemoryUpdateRequest) -> list[Diagnostic]:
|
||||||
|
diagnostics = _validate_node_selection_request(request.graph_id, ())
|
||||||
|
if not request.instructions:
|
||||||
|
diagnostics.append(Diagnostic("error", "memory.update.empty", "At least one update instruction is required."))
|
||||||
|
for instruction in request.instructions:
|
||||||
|
if not instruction.contract_node_id:
|
||||||
|
diagnostics.append(Diagnostic("error", "memory.update.node_id_missing", "contract_node_id is required."))
|
||||||
|
if instruction.operation not in {"upsert_node", "transition_lifecycle"}:
|
||||||
|
diagnostics.append(
|
||||||
|
Diagnostic(
|
||||||
|
"error",
|
||||||
|
"memory.update.operation_invalid",
|
||||||
|
"operation must be upsert_node or transition_lifecycle.",
|
||||||
|
details={"operation": instruction.operation},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if instruction.operation == "transition_lifecycle" and not instruction.lifecycle:
|
||||||
|
diagnostics.append(
|
||||||
|
Diagnostic(
|
||||||
|
"error",
|
||||||
|
"memory.update.lifecycle_missing",
|
||||||
|
"transition_lifecycle requires a lifecycle value.",
|
||||||
|
details={"contract_node_id": instruction.contract_node_id},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return diagnostics
|
||||||
|
|
||||||
|
|
||||||
|
def _graph_context(nodes: list[MemoryNodeRecord], graph_id: str) -> dict[str, Any]:
|
||||||
|
nodes_by_contract = {node.contract_node_id: node for node in nodes}
|
||||||
|
nodes_by_id = {str(node.node_id): node for node in nodes}
|
||||||
|
first = nodes[0] if nodes else None
|
||||||
|
return {
|
||||||
|
"graph_id": graph_id,
|
||||||
|
"contract_graph_id": first.contract_graph_id if first else graph_id,
|
||||||
|
"namespace": dict(first.namespace) if first else {},
|
||||||
|
"nodes_by_contract": nodes_by_contract,
|
||||||
|
"nodes_by_id": nodes_by_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _planned_update_for_instruction(
|
||||||
|
instruction: MemoryNodeUpdateInstruction,
|
||||||
|
graph_context: dict[str, Any],
|
||||||
|
context: OperationContext,
|
||||||
|
) -> MemoryPlannedNodeUpdate:
|
||||||
|
existing = (
|
||||||
|
graph_context["nodes_by_contract"].get(instruction.contract_node_id)
|
||||||
|
or graph_context["nodes_by_id"].get(instruction.contract_node_id)
|
||||||
|
)
|
||||||
|
if instruction.operation == "transition_lifecycle" and existing is None:
|
||||||
|
raise ValueError(f"Cannot transition unknown memory node `{instruction.contract_node_id}`.")
|
||||||
|
now = utc_now().isoformat()
|
||||||
|
source_spans = _instruction_source_spans(instruction, existing)
|
||||||
|
provenance = _instruction_provenance(instruction, existing, context)
|
||||||
|
if existing is None:
|
||||||
|
after = MemoryNodeRecord(
|
||||||
|
graph_id=graph_context["graph_id"],
|
||||||
|
contract_graph_id=graph_context["contract_graph_id"],
|
||||||
|
contract_node_id=instruction.contract_node_id,
|
||||||
|
kind=instruction.kind or "memory",
|
||||||
|
text=instruction.text or "",
|
||||||
|
namespace=dict(graph_context["namespace"]),
|
||||||
|
source_spans=source_spans,
|
||||||
|
provenance=provenance,
|
||||||
|
freshness=dict(instruction.freshness),
|
||||||
|
policy=dict(instruction.policy),
|
||||||
|
metadata=_agent_update_metadata(instruction.metadata, context, "create_node", now),
|
||||||
|
lifecycle=LifecycleState(instruction.lifecycle) if instruction.lifecycle else LifecycleState.ACTIVE,
|
||||||
|
created_at=now,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
action = "create_node"
|
||||||
|
else:
|
||||||
|
metadata = dict(existing.metadata)
|
||||||
|
metadata.update(instruction.metadata)
|
||||||
|
metadata = _agent_update_metadata(metadata, context, instruction.operation, now)
|
||||||
|
freshness = dict(existing.freshness)
|
||||||
|
freshness.update(instruction.freshness)
|
||||||
|
policy = dict(existing.policy)
|
||||||
|
policy.update(instruction.policy)
|
||||||
|
after = replace(
|
||||||
|
existing,
|
||||||
|
kind=instruction.kind or existing.kind,
|
||||||
|
text=existing.text if instruction.text is None else instruction.text,
|
||||||
|
source_spans=source_spans,
|
||||||
|
provenance=provenance,
|
||||||
|
freshness=freshness,
|
||||||
|
policy=policy,
|
||||||
|
metadata=metadata,
|
||||||
|
lifecycle=LifecycleState(instruction.lifecycle) if instruction.lifecycle else existing.lifecycle,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
action = "transition_lifecycle" if instruction.operation == "transition_lifecycle" else "update_node"
|
||||||
|
return MemoryPlannedNodeUpdate(
|
||||||
|
action=action,
|
||||||
|
before=existing,
|
||||||
|
after=after,
|
||||||
|
source_explanation=_source_explanation(after),
|
||||||
|
policy_explanation={
|
||||||
|
"policy": dict(after.policy),
|
||||||
|
"actor_id": context.actor.id,
|
||||||
|
"correlation_id": context.correlation_id,
|
||||||
|
"durable_write_requires_apply": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _instruction_source_spans(
|
||||||
|
instruction: MemoryNodeUpdateInstruction,
|
||||||
|
existing: MemoryNodeRecord | None,
|
||||||
|
) -> tuple[MemorySourceSpan, ...]:
|
||||||
|
if instruction.source_spans:
|
||||||
|
return tuple(MemorySourceSpan.from_contract(span) for span in instruction.source_spans)
|
||||||
|
if existing:
|
||||||
|
return existing.source_spans
|
||||||
|
return ()
|
||||||
|
|
||||||
|
|
||||||
|
def _instruction_provenance(
|
||||||
|
instruction: MemoryNodeUpdateInstruction,
|
||||||
|
existing: MemoryNodeRecord | None,
|
||||||
|
context: OperationContext,
|
||||||
|
) -> tuple[dict[str, Any], ...]:
|
||||||
|
provenance = [dict(item) for item in existing.provenance] if existing else []
|
||||||
|
provenance.extend(dict(item) for item in instruction.provenance)
|
||||||
|
provenance.append(
|
||||||
|
{
|
||||||
|
"kind": "agent-memory-update-plan",
|
||||||
|
"actor_id": context.actor.id,
|
||||||
|
"correlation_id": context.correlation_id,
|
||||||
|
"operation": instruction.operation,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return tuple(provenance)
|
||||||
|
|
||||||
|
|
||||||
|
def _agent_update_metadata(
|
||||||
|
metadata: dict[str, Any],
|
||||||
|
context: OperationContext,
|
||||||
|
action: str,
|
||||||
|
timestamp: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
updated = dict(metadata)
|
||||||
|
updated["agent_update"] = {
|
||||||
|
"action": action,
|
||||||
|
"actor_id": context.actor.id,
|
||||||
|
"correlation_id": context.correlation_id,
|
||||||
|
"at": timestamp,
|
||||||
|
}
|
||||||
|
return updated
|
||||||
|
|
||||||
|
|
||||||
|
def _source_explanation(node: MemoryNodeRecord) -> tuple[dict[str, Any], ...]:
|
||||||
|
if node.source_spans:
|
||||||
|
return tuple(
|
||||||
|
{
|
||||||
|
"path": span.path,
|
||||||
|
"selector": span.selector,
|
||||||
|
"unit_kind": span.unit_kind,
|
||||||
|
"engine": span.engine,
|
||||||
|
}
|
||||||
|
for span in node.source_spans
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
{
|
||||||
|
"path": f"memory://{node.graph_id}/nodes/{node.node_id}",
|
||||||
|
"selector": f"nodes[id={node.contract_node_id}]",
|
||||||
|
"unit_kind": node.kind,
|
||||||
|
"engine": "kontextual-memory",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _planned_update_event_payload(update: MemoryPlannedNodeUpdate) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"action": update.action,
|
||||||
|
"node_id": update.after.node_id,
|
||||||
|
"contract_node_id": update.after.contract_node_id,
|
||||||
|
"before_lifecycle": update.before.lifecycle.value if update.before else "missing",
|
||||||
|
"after_lifecycle": update.after.lifecycle.value,
|
||||||
|
"source_explanation": [dict(item) for item in update.source_explanation],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _markitect_package_input(
|
||||||
|
request: MemoryPackageExportRequest,
|
||||||
|
retrieval: MemoryRetrievalResult,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
graph_id = request.query.graph_id or "*"
|
||||||
|
data: dict[str, Any] = {
|
||||||
|
"schema_version": "markitect.context-package.input.v1",
|
||||||
|
"title": request.title,
|
||||||
|
"intent": request.intent,
|
||||||
|
"namespace": dict(request.namespace),
|
||||||
|
"budget": dict(request.budget),
|
||||||
|
"retrieval_recipes": [
|
||||||
|
{
|
||||||
|
"kind": "memory-runtime-query",
|
||||||
|
"query": request.query.to_dict(),
|
||||||
|
"engine": "kontextual-engine.memory",
|
||||||
|
"sources": [f"memory://{graph_id}"],
|
||||||
|
"metadata": {
|
||||||
|
"correlation_id": retrieval.correlation_id,
|
||||||
|
"permission_filtered_count": retrieval.metadata.get("permission_filtered_count", 0),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"items": [_markitect_context_item(item) for item in retrieval.items],
|
||||||
|
"metadata": {
|
||||||
|
"runtime": "kontextual-engine",
|
||||||
|
"export_kind": "markitect-context-package-input",
|
||||||
|
"permission_filtered_count": retrieval.metadata.get("permission_filtered_count", 0),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def _markitect_context_item(item: MemoryRetrievalItem) -> dict[str, Any]:
|
||||||
|
node = item.node
|
||||||
|
source = node.source_spans[0].to_dict() if node.source_spans else {
|
||||||
|
"path": f"memory://{node.graph_id}/nodes/{node.node_id}",
|
||||||
|
"unit_kind": node.kind,
|
||||||
|
"selector": f"nodes[id={node.contract_node_id}]",
|
||||||
|
"engine": "kontextual-memory",
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"source": source,
|
||||||
|
"text": node.text,
|
||||||
|
"summary": node.metadata.get("summary") or node.metadata.get("title"),
|
||||||
|
"policy": dict(node.policy),
|
||||||
|
"provenance": [
|
||||||
|
{
|
||||||
|
"kind": "kontextual-memory-export",
|
||||||
|
"graph_id": node.graph_id,
|
||||||
|
"node_id": node.node_id,
|
||||||
|
"contract_node_id": node.contract_node_id,
|
||||||
|
},
|
||||||
|
*[dict(entry) for entry in node.provenance],
|
||||||
|
],
|
||||||
|
"metadata": {
|
||||||
|
"memory_runtime": {
|
||||||
|
"graph_id": node.graph_id,
|
||||||
|
"contract_graph_id": node.contract_graph_id,
|
||||||
|
"node_id": node.node_id,
|
||||||
|
"contract_node_id": node.contract_node_id,
|
||||||
|
"kind": node.kind,
|
||||||
|
"edges": [edge.to_dict() for edge in item.edges],
|
||||||
|
},
|
||||||
|
**dict(node.metadata),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _optional_str(value: Any) -> str | None:
|
def _optional_str(value: Any) -> str | None:
|
||||||
if value is None:
|
if value is None:
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -8,10 +8,13 @@ from kontextual_engine import (
|
|||||||
LifecycleState,
|
LifecycleState,
|
||||||
MemoryCompactionRequest,
|
MemoryCompactionRequest,
|
||||||
MemoryGraphImportResult,
|
MemoryGraphImportResult,
|
||||||
|
MemoryNodeUpdateInstruction,
|
||||||
|
MemoryPackageExportRequest,
|
||||||
MemoryRefreshRequest,
|
MemoryRefreshRequest,
|
||||||
MemoryRetentionRequest,
|
MemoryRetentionRequest,
|
||||||
MemoryQueryRequest,
|
MemoryQueryRequest,
|
||||||
MemoryRuntimeService,
|
MemoryRuntimeService,
|
||||||
|
MemoryUpdateRequest,
|
||||||
OperationContext,
|
OperationContext,
|
||||||
PolicyDecision,
|
PolicyDecision,
|
||||||
ValidationError,
|
ValidationError,
|
||||||
@@ -197,6 +200,89 @@ def test_memory_compaction_creates_summary_preserves_spans_and_retires_sources()
|
|||||||
assert {node.lifecycle for node in source_nodes} == {LifecycleState.RETIRED}
|
assert {node.lifecycle for node in source_nodes} == {LifecycleState.RETIRED}
|
||||||
|
|
||||||
|
|
||||||
|
def test_agent_safe_memory_update_plans_dry_run_and_requires_review_before_write() -> None:
|
||||||
|
repo = InMemoryMemoryGraphRepository()
|
||||||
|
service = MemoryRuntimeService(repo)
|
||||||
|
summary = service.import_markitect_graph(_graph_contract())
|
||||||
|
context = operation_context()
|
||||||
|
|
||||||
|
plan = service.plan_memory_update(
|
||||||
|
MemoryUpdateRequest(
|
||||||
|
graph_id=summary.graph_id,
|
||||||
|
instructions=(
|
||||||
|
MemoryNodeUpdateInstruction(
|
||||||
|
contract_node_id="claim.agent-safe-update",
|
||||||
|
kind="claim",
|
||||||
|
text="Agent-safe update is planned before durable write.",
|
||||||
|
source_spans=(
|
||||||
|
{
|
||||||
|
"path": "docs/memory-agent-safe.md",
|
||||||
|
"unit_kind": "section",
|
||||||
|
"selector": "sections[heading=Agent safe]",
|
||||||
|
"engine": "selector",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
policy={"labels": ["public"]},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
require_review=True,
|
||||||
|
reason="capture implementation decision",
|
||||||
|
),
|
||||||
|
context,
|
||||||
|
)
|
||||||
|
pending = service.apply_memory_update(plan, context)
|
||||||
|
|
||||||
|
assert plan.success is True
|
||||||
|
assert plan.dry_run is True
|
||||||
|
assert plan.review_required is True
|
||||||
|
assert plan.planned_updates[0].action == "create_node"
|
||||||
|
assert plan.planned_updates[0].source_explanation[0]["path"] == "docs/memory-agent-safe.md"
|
||||||
|
assert pending.success is False
|
||||||
|
assert pending.review_required is True
|
||||||
|
assert repo.list_memory_nodes(graph_id=summary.graph_id, kind="claim") == []
|
||||||
|
approved = service.apply_memory_update(plan, context, review_decision="approved")
|
||||||
|
assert approved.success is True
|
||||||
|
assert approved.appended_events[0].kind == "updated"
|
||||||
|
assert repo.list_memory_nodes(graph_id=summary.graph_id, kind="claim")[0].contract_node_id == (
|
||||||
|
"claim.agent-safe-update"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_memory_package_export_emits_markitect_context_package_inputs_without_denied_content() -> None:
|
||||||
|
repo = InMemoryMemoryGraphRepository()
|
||||||
|
service = MemoryRuntimeService(repo, policy_gateway=DenyInternalMemoryPolicy())
|
||||||
|
graph = _graph_contract()
|
||||||
|
graph["nodes"].append(
|
||||||
|
{
|
||||||
|
"id": "claim.internal-secret",
|
||||||
|
"kind": "claim",
|
||||||
|
"text": "secret memory text must not leak",
|
||||||
|
"policy": {"labels": ["internal"]},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
summary = service.import_markitect_graph(graph)
|
||||||
|
|
||||||
|
export = service.export_context_package_inputs(
|
||||||
|
MemoryPackageExportRequest(
|
||||||
|
query=MemoryQueryRequest(graph_id=summary.graph_id, text_contains="memory"),
|
||||||
|
title="Memory Package Input",
|
||||||
|
intent="Export allowed runtime memories for Markitect packaging.",
|
||||||
|
namespace={"project": "kontextual-engine"},
|
||||||
|
budget={"max_items": 3},
|
||||||
|
),
|
||||||
|
operation_context(),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert export.success is True
|
||||||
|
assert export.package_input["schema_version"] == "markitect.context-package.input.v1"
|
||||||
|
assert export.package_input["retrieval_recipes"][0]["kind"] == "memory-runtime-query"
|
||||||
|
assert export.package_input["items"][0]["source"]["path"] == "workplans/MKTT-WP-0016.md"
|
||||||
|
assert export.package_input["metadata"]["permission_filtered_count"] == 1
|
||||||
|
assert "secret memory text must not leak" not in str(export.to_dict())
|
||||||
|
assert export.audit_event is not None
|
||||||
|
assert export.audit_event.operation == "memory.package.export"
|
||||||
|
|
||||||
|
|
||||||
def test_memory_runtime_service_rejects_invalid_edge_contracts() -> None:
|
def test_memory_runtime_service_rejects_invalid_edge_contracts() -> None:
|
||||||
repo = InMemoryMemoryGraphRepository()
|
repo = InMemoryMemoryGraphRepository()
|
||||||
service = MemoryRuntimeService(repo)
|
service = MemoryRuntimeService(repo)
|
||||||
|
|||||||
@@ -93,6 +93,11 @@ to `delete_requested`; `refresh_memory()` clears stale markers; and
|
|||||||
optionally retiring the source nodes. Each mutating operation appends a memory
|
optionally retiring the source nodes. Each mutating operation appends a memory
|
||||||
event and returns audit metadata.
|
event and returns audit metadata.
|
||||||
|
|
||||||
|
The agent-safe API slice is implemented with dry-run update plans, explicit
|
||||||
|
review gates for durable writes, source/policy explanations on planned node
|
||||||
|
updates, and Markitect-compatible context package input export. The export
|
||||||
|
method emits package inputs only; Markitect remains the package compiler.
|
||||||
|
|
||||||
## P17.1 - Import and map Markitect memory contracts
|
## P17.1 - Import and map Markitect memory contracts
|
||||||
|
|
||||||
```task
|
```task
|
||||||
@@ -188,7 +193,7 @@ and tests.
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: KONT-WP-0017-T005
|
id: KONT-WP-0017-T005
|
||||||
status: todo
|
status: done
|
||||||
priority: medium
|
priority: medium
|
||||||
state_hub_task_id: "9e2ad830-c9e6-47a2-8eca-ffe895781fdf"
|
state_hub_task_id: "9e2ad830-c9e6-47a2-8eca-ffe895781fdf"
|
||||||
```
|
```
|
||||||
|
|||||||
Reference in New Issue
Block a user