Skip to content

Commit 42316f3

Browse files
authored
feat(altk_evolve): entity sharing — public/private visibility (#201)
* fix(tests): resolve 2 failing platform integration tests - Fixed test_uninstall_prunes_evolve_only_hook_groups by adding empty group pruning logic to _remove_user_prompt_hook in install.sh - Fixed test_skips_symlinked_entities by correcting test expectations - symlinks exist in git clones but are filtered by retrieve script - Added RETRIEVE_SCRIPT constant to test_sync.py for cleaner test code * fix(formatting): apply ruff formatting to mcp_server and entity sharing tests * fix(mcp): verify ownership before publish/unpublish entity * fix(mcp): enforce owner_id on public create, consistent limits, ownership mismatch tests * chore: remove unused imports in evolve_client.py
1 parent 7af1eb1 commit 42316f3

16 files changed

Lines changed: 1050 additions & 127 deletions

File tree

README.md

Lines changed: 47 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -106,10 +106,12 @@ npx @modelcontextprotocol/inspector@latest http://127.0.0.1:8201/sse --cli --met
106106
```
107107

108108
**Available tools:**
109-
- `get_entities(task: str, entity_type: str)`: Get relevant entities for a specific task, filtered by type (e.g., 'guideline', 'policy').
110-
- `get_guidelines(task: str)`: Get relevant guidelines for a specific task (backward compatibility alias).
111-
- `save_trajectory(trajectory_data: str, task_id: str | None)`: Save a conversation trajectory and generate new guidelines.
112-
- `create_entity(content: str, entity_type: str, metadata: str | None, enable_conflict_resolution: bool)`: Create a single entity in the namespace.
109+
- `get_entities(task: str, entity_type: str = "guideline", include_public: bool = False)`: Get relevant entities for a specific task. Set `include_public=True` to merge in public entities from all other namespaces; those results are annotated with `[public: {owner_id}]`.
110+
- `get_guidelines(task: str)`: Get relevant guidelines for a specific task (backward compatibility alias for `get_entities`).
111+
- `save_trajectory(trajectory_data: str, task_id: str | None, owner_id: str | None)`: Save a conversation trajectory and generate new guidelines.
112+
- `create_entity(content: str, entity_type: str, metadata: str | None, enable_conflict_resolution: bool, owner_id: str | None, visibility: str = "private")`: Create a single entity. Pass `visibility="public"` and `owner_id` to make it immediately discoverable by other namespaces.
113+
- `publish_entity(entity_id: str, user_id: str | None)`: Make an entity publicly visible to all namespaces. Records the caller as owner and stamps `published_at`.
114+
- `unpublish_entity(entity_id: str, user_id: str | None = None)`: Revert an entity to private visibility. Ownership is enforced server-side: if the entity has an `owner_id`, `user_id` must match it.
113115
- `delete_entity(entity_id: str)`: Delete a specific entity by its ID.
114116

115117
### Filter Migration Note
@@ -123,6 +125,7 @@ Existing integrations that stored custom fields in entity metadata should update
123125
- **Proactive**: Learns how to recognize problems and their solutions, and generates guidelines that get automatically applied to new tasks.
124126
- **Conflict Resolution**: Update existing guidelines when new information contradicts them.
125127
- **On Command**: An array of tools to manage guidelines whether in the agent or through a CLI
128+
- **Sharing**: Publish individual entities so other agents can discover and retrieve them across namespaces.
126129

127130
## Architecture
128131
Evolve is built on a modular architecture which forms a feedback loop, taking conversation traces (trajectories) from an agent, extracting key insights into a database, feeding it back into the agent.
@@ -133,6 +136,46 @@ _Lite Mode omits the Interaction layer. All activity is performed in-agent_
133136
<img src="docs/assets/architecture-wide-light.svg" alt="Architecture" width="480">
134137
</picture>
135138

139+
## Entity Sharing
140+
141+
Evolve supports sharing entities across namespaces using a simple public/private visibility model.
142+
143+
**Visibility** is stored in each entity's metadata and is private by default. Existing entities without a `visibility` field are unaffected.
144+
145+
| Metadata field | Description |
146+
|---|---|
147+
| `owner_id` | User ID who created or last published the entity |
148+
| `visibility` | `"private"` (default) or `"public"` |
149+
| `published_at` | ISO-8601 timestamp of the most recent publish |
150+
151+
### MCP Tools
152+
153+
**Publishing an entity:**
154+
```python
155+
publish_entity(entity_id="42", user_id="alice")
156+
```
157+
Sets `visibility=public` and records the owner and publish timestamp.
158+
159+
**Unpublishing:**
160+
```python
161+
unpublish_entity(entity_id="42", user_id="alice")
162+
```
163+
Reverts the entity to private. The entity stays in its namespace — only its visibility changes.
164+
165+
**Retrieving public entities from all namespaces:**
166+
```python
167+
get_entities(task="write safer code", include_public=True)
168+
```
169+
Merges results from the caller's namespace with public entities from all other namespaces. Public results are annotated with `[public: {owner_id}]`.
170+
171+
**Creating an entity with visibility:**
172+
```python
173+
create_entity(content="...", entity_type="guideline", visibility="public", owner_id="alice")
174+
```
175+
176+
### What's deferred (Phase 1C)
177+
REST API endpoints (`GET /api/entities/public`, publish/unpublish routes) and UI controls are not yet implemented.
178+
136179
## Guideline Provenance
137180
Evolve automatically tracks the origin of every guideline it generates or stores. Every guideline entity contains `metadata` identifying its source:
138181
- `creation_mode`: Identifies how the guideline was created (`auto-phoenix` via trace observability, `auto-mcp` via trajectory saving tools, or `manual`).

altk_evolve/backend/base.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,33 @@ def _post_update(self, namespace_id: str) -> None:
8080
"""Hook called after all entity mutations are complete. No-op by default."""
8181
pass
8282

83+
def patch_entity(self, namespace_id: str, entity_id: str, entity_type: str, content_str: str, timestamp: int, metadata: dict) -> None:
84+
"""Update an existing entity in-place (fetch-merge-write helper).
85+
86+
Backends that require pre-loaded state before calling _update_entity
87+
(e.g. filesystem) must override this method.
88+
"""
89+
self._update_entity(namespace_id, entity_id, entity_type, content_str, timestamp, metadata)
90+
91+
def update_entity_metadata(self, namespace_id: str, entity_id: str, metadata_patch: dict) -> RecordedEntity:
92+
"""Merge metadata_patch into an entity's metadata without touching content.
93+
94+
The default implementation fetches via search_entities, merges, then calls patch_entity.
95+
DB-backed backends should override with a native atomic update.
96+
"""
97+
from altk_evolve.utils.utils import serialize_content
98+
99+
results = self.search_entities(namespace_id, filters={"id": entity_id}, limit=1)
100+
if not results:
101+
from altk_evolve.schema.exceptions import EvolveException
102+
103+
raise EvolveException(f"Entity '{entity_id}' not found in namespace '{namespace_id}'")
104+
entity = results[0]
105+
merged = {**(entity.metadata or {}), **metadata_patch}
106+
timestamp = int(entity.created_at.timestamp())
107+
self.patch_entity(namespace_id, entity_id, entity.type, serialize_content(entity.content), timestamp, merged)
108+
return RecordedEntity(**{**entity.model_dump(), "metadata": merged})
109+
83110
def update_entities(
84111
self,
85112
namespace_id: str,

altk_evolve/backend/filesystem.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,19 @@ def _post_update(self, namespace_id: str) -> None:
168168
self._save_namespace_data(namespace_id, self._active_data)
169169
self._active_data = None
170170

171+
def patch_entity(self, namespace_id: str, entity_id: str, entity_type: str, content_str: str, timestamp: int, metadata: dict) -> None:
172+
"""Override to load namespace data, call _update_entity, then persist."""
173+
if not entity_id:
174+
raise ValueError(f"entity_id must be a non-empty string, got {entity_id!r}")
175+
with self._lock:
176+
self._active_data = self._load_namespace_data(namespace_id)
177+
try:
178+
self._update_entity(namespace_id, entity_id, entity_type, content_str, timestamp, metadata)
179+
self._post_update(namespace_id)
180+
except Exception:
181+
self._active_data = None
182+
raise
183+
171184
def update_entities(
172185
self,
173186
namespace_id: str,

altk_evolve/backend/milvus.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,29 @@ def _update_entity(self, namespace_id: str, entity_id: str, entity_type: str, co
290290
def _delete_entity(self, namespace_id: str, entity_id: str) -> None:
291291
self.delete_entity_by_id(namespace_id=namespace_id, entity_id=entity_id)
292292

293+
def update_entity_metadata(self, namespace_id: str, entity_id: str, metadata_patch: dict) -> RecordedEntity:
294+
try:
295+
entity_id_int = int(entity_id)
296+
except ValueError:
297+
raise EvolveException(f"Invalid entity ID '{entity_id}': must be numeric.")
298+
self._validate_namespace(namespace_id)
299+
results = self.milvus.query(
300+
collection_name=namespace_id,
301+
filter=f"id == {entity_id_int}",
302+
output_fields=["id", "type", "content", "created_at", "metadata"],
303+
limit=1,
304+
)
305+
if not results:
306+
raise EvolveException(f"Entity '{entity_id}' not found in namespace '{namespace_id}'")
307+
entity = parse_milvus_entity(results[0])
308+
merged = {**(entity.metadata or {}), **metadata_patch}
309+
timestamp = int(entity.created_at.timestamp())
310+
from altk_evolve.utils.utils import serialize_content
311+
312+
self._update_entity(namespace_id, entity_id, entity.type, serialize_content(entity.content), timestamp, merged)
313+
self._post_update(namespace_id)
314+
return RecordedEntity(**{**entity.model_dump(), "metadata": merged})
315+
293316
def _post_update(self, namespace_id: str) -> None:
294317
self.milvus.flush(namespace_id)
295318
self.milvus.load_collection(namespace_id)

altk_evolve/backend/postgres.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -277,6 +277,25 @@ def _update_entity(self, namespace_id: str, entity_id: str, entity_type: str, co
277277
def _delete_entity(self, namespace_id: str, entity_id: str) -> None:
278278
self.delete_entity_by_id(namespace_id=namespace_id, entity_id=entity_id)
279279

280+
def update_entity_metadata(self, namespace_id: str, entity_id: str, metadata_patch: dict) -> RecordedEntity:
281+
try:
282+
entity_id_int = int(entity_id)
283+
except ValueError:
284+
raise EvolveException(f"Invalid entity ID '{entity_id}': must be numeric.")
285+
self._validate_namespace(namespace_id)
286+
table = self._table_name(namespace_id)
287+
with self.conn.cursor(row_factory=_entity_row_factory) as cur:
288+
cur.execute(
289+
sql.SQL(
290+
"UPDATE {table} SET metadata = metadata || %s::jsonb WHERE id = %s RETURNING id, type, content, created_at, metadata"
291+
).format(table=sql.Identifier(table)),
292+
(json.dumps(metadata_patch), entity_id_int),
293+
)
294+
results = cur.fetchall()
295+
if not results:
296+
raise EvolveException(f"Entity '{entity_id}' not found in namespace '{namespace_id}'")
297+
return results[0]
298+
280299
# ── search / delete ──────────────────────────────────────────────
281300

282301
def search_entities(

altk_evolve/frontend/client/evolve_client.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,48 @@ def delete_entity_by_id(self, namespace_id: str, entity_id: str) -> None:
8686
"""Delete a specific entity by its ID."""
8787
self.backend.delete_entity_by_id(namespace_id, entity_id)
8888

89+
def get_entity_by_id(self, namespace_id: str, entity_id: str) -> RecordedEntity | None:
90+
"""Fetch a single entity by its ID. Returns None if not found."""
91+
results = self.search_entities(namespace_id, filters={"id": entity_id}, limit=1)
92+
return results[0] if results else None
93+
94+
def patch_entity_metadata(self, namespace_id: str, entity_id: str, metadata_updates: dict) -> RecordedEntity:
95+
"""Merge metadata_updates into an entity without touching content or ID."""
96+
return self.backend.update_entity_metadata(namespace_id, entity_id, metadata_updates)
97+
98+
def get_public_entities(
99+
self,
100+
query: str | None = None,
101+
entity_type: str | None = None,
102+
limit: int = 100,
103+
exclude_namespace_ids: list[str] | None = None,
104+
) -> list[RecordedEntity]:
105+
"""Search for public entities across all namespaces.
106+
107+
Args:
108+
query: Optional semantic search query.
109+
entity_type: Optional type filter (e.g. 'guideline').
110+
limit: Maximum total results to return.
111+
exclude_namespace_ids: Namespace IDs to skip (e.g. the caller's own
112+
namespace whose entities are already returned via a private search).
113+
"""
114+
if limit <= 0:
115+
return []
116+
117+
excluded = set(exclude_namespace_ids or [])
118+
all_results: list[RecordedEntity] = []
119+
for ns in self.search_namespaces(limit=1000):
120+
if ns.id in excluded:
121+
continue
122+
remaining = limit - len(all_results)
123+
if remaining <= 0:
124+
break
125+
filters: dict = {"metadata.visibility": "public"}
126+
if entity_type:
127+
filters["type"] = entity_type
128+
all_results.extend(self.search_entities(ns.id, query=query, filters=filters, limit=remaining))
129+
return all_results
130+
89131
def cluster_guidelines(self, namespace_id: str, threshold: float | None = None, limit: int = 10000) -> list[list[RecordedEntity]]:
90132
"""Cluster guideline entities by task description similarity.
91133

0 commit comments

Comments
 (0)