feat(honcho): add list mode to honcho_conclude so delete can resolve a real conclusion id

honcho_conclude's delete action was unreachable in practice: no tool ever
surfaced a real conclusion id for the model to pass as delete_id.
honcho_search only searches the separate Message resource space, and the
SDK's ConclusionScope.list()/.query() (which do return real Conclusion.id
values) were never wired into any tool.

Adds an optional list mode to honcho_conclude (query to search, omit to
browse recent conclusions), backed by a new
HonchoSessionManager.list_conclusions(). No new tool, no changes to the
create/delete signatures or their conclusions_of() routing.
This commit is contained in:
vizi0uz 2026-07-06 15:25:34 -03:00 • committed by Teknium
parent 01d1a663e1
commit f4669f34cf
3 changed files with 190 additions and 30 deletions

View file

@ -171,12 +171,15 @@ CONTEXT_SCHEMA = {
CONCLUDE_SCHEMA = {
"name": "honcho_conclude",
"description": (
"Write or delete a CONCLUSION — a persistent, derived fact about a peer that "
"Write, delete, or list CONCLUSIONS — persistent, derived facts about a peer that "
"feeds their long-term profile (card + representation). Use this to record "
"something durable you've learned about the peer (a stable preference, a "
"correction, a standing constraint) so future sessions carry it forward. "
"You MUST pass exactly one of `conclusion` (to create) or `delete_id` (to "
"delete); passing neither, or both, is an error. Deletion exists only for "
"You MUST pass exactly one of `conclusion` (to create), `delete_id` (to "
"delete), or `list` (to list/search); any other combination is an error. "
"A deletion ID is an opaque server-generated string: first call with `list=true` "
"and optionally `query`, then pass the returned ID as `delete_id`. "
"Deletion exists only for "
"PII removal — for merely wrong facts, write a corrected conclusion instead; "
"Honcho self-heals contradictions over time. This is a WRITE tool: to read "
"the profile use honcho_profile / honcho_context, and to search what was "
@ -187,11 +190,19 @@ CONCLUDE_SCHEMA = {
"properties": {
"conclusion": {
"type": "string",
"description": "A factual statement to persist. Provide this when creating a conclusion. Do not send it together with delete_id.",
"description": "A factual statement to persist. Provide this when creating a conclusion. Do not send it together with delete_id or list.",
},
"delete_id": {
"type": "string",
"description": "Conclusion ID to delete for PII removal. Provide this when deleting a conclusion. Do not send it together with conclusion.",
"description": "Conclusion ID to delete for PII removal. Provide this when deleting a conclusion. Do not send it together with conclusion or list. Get this id from a prior `list` call — never guess it.",
},
"list": {
"type": "boolean",
"description": "Set to true to list or search stored conclusions (with their ids) instead of creating or deleting one. Do not send together with conclusion or delete_id.",
},
"query": {
"type": "string",
"description": "Optional semantic search query, used only when `list` is true. Omit to list the most recent conclusions instead of searching.",
},
"peer": {
"type": "string",
@ -1564,13 +1575,18 @@ class HonchoMemoryProvider(MemoryProvider):
elif tool_name == "honcho_conclude":
delete_id = (args.get("delete_id") or "").strip()
conclusion = args.get("conclusion", "").strip()
list_mode = bool(args.get("list"))
peer = args.get("peer", "user")
has_delete_id = bool(delete_id)
has_conclusion = bool(conclusion)
if has_delete_id == has_conclusion:
return tool_error("Exactly one of conclusion or delete_id must be provided.")
if sum([has_delete_id, has_conclusion, list_mode]) != 1:
return tool_error("Exactly one of conclusion, delete_id, or list must be provided.")
if list_mode:
query = (args.get("query") or "").strip() or None
conclusions = self._manager.list_conclusions(self._session_key, query=query, peer=peer)
return json.dumps({"conclusions": conclusions})
if has_delete_id:
ok = self._manager.delete_conclusion(self._session_key, delete_id, peer=peer)
if ok:

View file

@ -1199,6 +1199,23 @@ class HonchoSessionManager:
return "\n\n".join(lines)
def _conclusions_scope(self, session: Any, target_peer_id: str) -> Any:
"""Resolve the ConclusionScope for observing target_peer_id.
Shared by create/delete/list_conclusions so the observer/observed
routing (self-conclusions vs. AI-observes-others vs. peer-owned)
stays consistent across all three.
"""
if target_peer_id == session.assistant_peer_id:
observer = self._get_or_create_peer(session.assistant_peer_id)
return observer.conclusions_of(session.assistant_peer_id)
elif self._ai_observe_others:
observer = self._get_or_create_peer(session.assistant_peer_id)
return observer.conclusions_of(target_peer_id)
else:
target_peer = self._get_or_create_peer(target_peer_id)
return target_peer.conclusions_of(target_peer_id)
def create_conclusion(self, session_key: str, content: str, peer: str = "user") -> bool:
"""Write a conclusion about a target peer back to Honcho.
@ -1228,16 +1245,7 @@ class HonchoSessionManager:
logger.warning("Could not resolve conclusion peer '%s' for session '%s'", peer, session_key)
return False
if target_peer_id == session.assistant_peer_id:
assistant_peer = self._get_or_create_peer(session.assistant_peer_id)
conclusions_scope = assistant_peer.conclusions_of(session.assistant_peer_id)
elif self._ai_observe_others:
assistant_peer = self._get_or_create_peer(session.assistant_peer_id)
conclusions_scope = assistant_peer.conclusions_of(target_peer_id)
else:
target_peer = self._get_or_create_peer(target_peer_id)
conclusions_scope = target_peer.conclusions_of(target_peer_id)
conclusions_scope = self._conclusions_scope(session, target_peer_id)
conclusions_scope.create([{
"content": content.strip(),
"session_id": session.honcho_session_id,
@ -1264,15 +1272,7 @@ class HonchoSessionManager:
return False
try:
target_peer_id = self._resolve_peer_id(session, peer)
if target_peer_id == session.assistant_peer_id:
observer = self._get_or_create_peer(session.assistant_peer_id)
scope = observer.conclusions_of(session.assistant_peer_id)
elif self._ai_observe_others:
observer = self._get_or_create_peer(session.assistant_peer_id)
scope = observer.conclusions_of(target_peer_id)
else:
target_peer = self._get_or_create_peer(target_peer_id)
scope = target_peer.conclusions_of(target_peer_id)
scope = self._conclusions_scope(session, target_peer_id)
scope.delete(conclusion_id)
logger.info("Deleted conclusion %s for %s", conclusion_id, session_key)
return True
@ -1280,6 +1280,45 @@ class HonchoSessionManager:
logger.error("Failed to delete conclusion %s: %s", conclusion_id, e)
return False
def list_conclusions(
self,
session_key: str,
query: str | None = None,
peer: str = "user",
limit: int = 20,
) -> list[dict]:
"""List or semantically search stored conclusions, including their ids.
This is the lookup path `honcho_conclude`'s delete action needs:
Conclusion.id is a server-generated nanoid that no other Honcho tool
surfaces (honcho_search only searches Messages, a separate resource).
Args:
session_key: Session key for peer resolution.
query: Optional semantic search query. Omit to list recent conclusions.
peer: Peer alias or explicit peer ID.
limit: Max conclusions to return.
Returns:
List of {"id": ..., "content": ...} dicts, or [] on failure/no session.
"""
session = self._cache.get(session_key)
if not session:
return []
try:
target_peer_id = self._resolve_peer_id(session, peer)
if target_peer_id is None:
return []
scope = self._conclusions_scope(session, target_peer_id)
if query:
conclusions = scope.query(query, top_k=limit)
else:
conclusions = scope.list(size=limit).items
return [{"id": c.id, "content": c.content} for c in conclusions]
except Exception as e:
logger.debug("Honcho list_conclusions failed: %s", e)
return []
def set_peer_card(self, session_key: str, card: list[str], peer: str = "user") -> list[str] | None:
"""Update a peer's card.