Skip to content

Commit 33ed0aa

Browse files
NicholasDColeclaude
andcommitted
feat: OCG-backed long-term agent memory with human good/bad feedback links
Port of the SDK side of agentspan-ai/agentspan#298. - OCGMemoryStore: synchronous MemoryStore HTTP adapter over the OCG BFF (search / save / delete / list, plus minting signed good/bad capability feedback URLs). Bearer-token auth held client-side. - Agent params: semantic_memory, memory_summary_model, feedback_sink. - AgentRuntime pre-run hook injects relevant agent/user-scoped memories into a COPY of the agent's instructions; post-run hook distills the conversation via an internal summarizer sub-agent (structured MemorySummary output, reuses the agent's model unless memory_summary_model is set) and saves it as conversation:<session>. Both hooks are best-effort and recursion-guarded — memory never fails the primary run. - Feedback is human-only: the runtime hands a FeedbackEvent (distilled summary + signed good/bad URLs) to feedback_sink for out-of-band delivery; the URLs are never shown to the agent's LLM. The compiled path registers a spawn-safe {agent}_feedback_sink worker (FeedbackSinkEntry) mirroring run()'s delivery. - Serializer emits longTermMemory {ocgUrl, credential, agent, user, scope, maxResults, summaryModel} and feedbackSink {taskName} so the server-side compiler can inline memory steps on the deployed/webhook path. credential is a server-resolvable secret NAME, never the raw client token. - Example examples/agents/120_ocg_memory.py (renumbered from upstream 118, which is taken here) and unit tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent eee9fa6 commit 33ed0aa

9 files changed

Lines changed: 1014 additions & 2 deletions

File tree

examples/agents/120_ocg_memory.py

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
# Copyright (c) 2025 Agentspan
2+
# Licensed under the MIT License.
3+
4+
"""120 — OCG-backed long-term memory with human good/bad feedback links.
5+
6+
Enable memory on an agent and the runtime does two things automatically:
7+
8+
- BEFORE a run: relevant past memories (scoped to this agent/user) are
9+
retrieved from OCG and injected into the prompt — no tool call needed.
10+
- AFTER a run: the conversation is summarized (Claude-style: durable facts,
11+
not the raw transcript) by a small internal summarizer agent and saved back
12+
to OCG as a memory.
13+
14+
Feedback is HUMAN-only. Agents never vote. Instead, the runtime hands a
15+
``FeedbackEvent`` — including signed *capability URLs* (good/bad) — to the
16+
agent's ``feedback_sink``. A human (e.g. a support engineer) clicks a link to
17+
mark the memory good or bad; the link skips auth (its signature is the
18+
authorization), so the clicker needs no OCG account. Here the sink just prints
19+
the URLs as they'd appear in a Zendesk ticket comment.
20+
21+
Requires the OCG instance to be started with a feedback-link secret
22+
(``OCG_FEEDBACK_LINK_SECRET``) for the capability URLs to be minted.
23+
24+
Run (from the repo root)::
25+
26+
OCG_INSTANCE_URL=https://test.contextgraph.io \
27+
OCG_TOKEN=<bearer-token> \
28+
uv run python examples/agents/120_ocg_memory.py
29+
30+
# against an embedded server, also set AGENTSPAN_SERVER_URL.
31+
"""
32+
33+
import os
34+
35+
from conductor.ai.agents import Agent, AgentRuntime, OCGMemoryStore, SemanticMemory
36+
from conductor.ai.agents.ocg_memory import FeedbackEvent
37+
38+
MODEL = os.environ.get("AGENTSPAN_LLM_MODEL", "openai/gpt-4o-mini")
39+
40+
OCG_INSTANCE_URL = os.environ.get("OCG_INSTANCE_URL") or ""
41+
# Unlike the ocg.py retrieval tools (which resolve a credential server-side),
42+
# the memory store calls OCG directly from Python, so it holds the bearer token.
43+
OCG_TOKEN = os.environ.get("OCG_TOKEN")
44+
if not OCG_INSTANCE_URL:
45+
raise SystemExit("Set OCG_INSTANCE_URL to your OCG instance, e.g. https://test.contextgraph.io")
46+
47+
48+
def zendesk_sink(event: FeedbackEvent) -> None:
49+
"""Deliver the good/bad links to a human. In production this would POST a
50+
comment to the Zendesk ticket; here we just print what would be sent."""
51+
print("\n--- would post to Zendesk ticket ---")
52+
print(f"Saved memory: {event.memory_key}")
53+
print(f"Summary: {event.summary}")
54+
if event.good_url:
55+
print(f" 👍 Was this helpful? {event.good_url}")
56+
print(f" 👎 Not helpful: {event.bad_url}")
57+
print("------------------------------------\n")
58+
59+
60+
def main() -> None:
61+
store = OCGMemoryStore(
62+
url=OCG_INSTANCE_URL,
63+
agent="agent:support",
64+
user="user:alice",
65+
token=OCG_TOKEN,
66+
)
67+
68+
agent = Agent(
69+
name="support",
70+
model=MODEL,
71+
instructions=(
72+
"You are a customer support agent. Use any relevant context from "
73+
"memory to personalize your answer. A memory labeled [bad] was "
74+
"flagged by a human — treat it with suspicion."
75+
),
76+
semantic_memory=SemanticMemory(store=store, max_results=5),
77+
feedback_sink=zendesk_sink,
78+
)
79+
80+
with AgentRuntime() as runtime:
81+
print("--- Turn 1 ---")
82+
runtime.run(
83+
agent, "Hi, I'm Alice. I'm on the Enterprise plan and prefer email."
84+
).print_result()
85+
86+
print("\n--- Turn 2 (should recall Alice's plan from memory) ---")
87+
runtime.run(agent, "What plan am I on again?").print_result()
88+
89+
90+
if __name__ == "__main__":
91+
main()

src/conductor/ai/agents/__init__.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,12 @@ def resolve_credentials(task: object, names: list) -> dict:
193193
schedules,
194194
)
195195
from conductor.ai.agents.semantic_memory import MemoryEntry, MemoryStore, SemanticMemory
196+
from conductor.ai.agents.ocg_memory import (
197+
FeedbackEvent,
198+
MemorySummary,
199+
OCGMemoryStore,
200+
build_memory_summarizer,
201+
)
196202

197203
# Termination conditions
198204
from conductor.ai.agents.termination import (
@@ -326,6 +332,10 @@ def resolve_credentials(task: object, names: list) -> dict:
326332
"SemanticMemory",
327333
"MemoryStore",
328334
"MemoryEntry",
335+
"OCGMemoryStore",
336+
"MemorySummary",
337+
"FeedbackEvent",
338+
"build_memory_summarizer",
329339
# Code execution
330340
"CodeExecutionConfig",
331341
"CliConfig",

src/conductor/ai/agents/agent.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -562,6 +562,9 @@ def __init__(
562562
output_type: Optional[type] = None,
563563
guardrails: Optional[List[Any]] = None,
564564
memory: Optional[Any] = None,
565+
semantic_memory: Optional[Any] = None,
566+
memory_summary_model: Optional[str] = None,
567+
feedback_sink: Optional[Callable[..., Any]] = None,
565568
dependencies: Optional[Dict[str, Any]] = None,
566569
max_turns: int = 25,
567570
max_tokens: Optional[int] = None,
@@ -724,6 +727,13 @@ def __init__(
724727
self.output_type = output_type
725728
self.guardrails: List[Any] = list(guardrails) if guardrails else []
726729
self.memory = memory
730+
# OCG-backed long-term memory (see agents/ocg_memory.py). When set, the
731+
# runtime auto-injects relevant memories into the prompt before a run and,
732+
# after the run, summarizes the conversation into a memory. feedback_sink,
733+
# if provided, receives the good/bad capability links for that memory.
734+
self.semantic_memory = semantic_memory
735+
self.memory_summary_model = memory_summary_model
736+
self.feedback_sink = feedback_sink
727737
self.dependencies: Dict[str, Any] = dict(dependencies) if dependencies else {}
728738
self.max_turns = max_turns
729739
self.max_tokens = max_tokens

src/conductor/ai/agents/config_serializer.py

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,10 +130,21 @@ def _serialize_agent(self, agent: "Agent") -> dict:
130130
if agent.guardrails:
131131
config["guardrails"] = [self._serialize_guardrail(g) for g in agent.guardrails]
132132

133-
# Memory
133+
# Memory (short-term conversation)
134134
if hasattr(agent, "memory") and agent.memory:
135135
config["memory"] = self._serialize_memory(agent.memory)
136136

137+
# Long-term (OCG-backed) memory. When present, the server-side compiler
138+
# inlines retrieval (pre-loop) + distill/save/feedback (post-loop) steps
139+
# so memory works on the deployed/webhook path — not just client run().
140+
ltm = self._serialize_long_term_memory(agent)
141+
if ltm is not None:
142+
config["longTermMemory"] = ltm
143+
# feedback_sink delivers the human good/bad capability links out-of-band.
144+
# Emit a worker ref so the compiled path can call the Python sink worker.
145+
if getattr(agent, "feedback_sink", None) is not None:
146+
config["feedbackSink"] = {"taskName": f"{agent.name}_feedback_sink"}
147+
137148
# Max tokens
138149
if agent.max_tokens is not None:
139150
config["maxTokens"] = agent.max_tokens
@@ -508,3 +519,34 @@ def _serialize_memory(self, memory: Any) -> dict:
508519
if hasattr(memory, "max_messages") and memory.max_messages:
509520
result["maxMessages"] = memory.max_messages
510521
return result
522+
523+
def _serialize_long_term_memory(self, agent: "Agent") -> "Any":
524+
"""Serialize an agent's OCG-backed semantic memory to a LongTermMemoryConfig dict.
525+
526+
Returns ``None`` (no-op) unless the agent has a ``semantic_memory`` whose
527+
store exposes an OCG base url. Reads the OCG instance url, scope owner,
528+
user and scope off the store; the credential is a SERVER-resolvable secret
529+
NAME (e.g. ``OCG_PUBLIC_KEY``) — never the raw client token. The summary
530+
model falls back to the agent's own model when not explicitly set.
531+
"""
532+
sm = getattr(agent, "semantic_memory", None)
533+
if sm is None:
534+
return None
535+
store = getattr(sm, "store", None)
536+
# Only OCG-backed stores compile server-side (need a base url to call).
537+
base = getattr(store, "_base", None) if store is not None else None
538+
if not base:
539+
return None
540+
541+
result: Dict[str, Any] = {
542+
"ocgUrl": base,
543+
"credential": getattr(store, "_credential", None) or "OCG_PUBLIC_KEY",
544+
"agent": getattr(store, "_agent", None),
545+
"scope": getattr(store, "_scope", None) or "agent",
546+
"maxResults": getattr(sm, "max_results", None),
547+
"summaryModel": getattr(agent, "memory_summary_model", None) or (agent.model or None),
548+
}
549+
user = getattr(store, "_user", None)
550+
if user:
551+
result["user"] = user
552+
return {k: v for k, v in result.items() if v is not None}

0 commit comments

Comments
 (0)