+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
+ * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations under the License.
+ */
+package org.conductoross.conductor.ai.examples;
+
+import java.util.function.Consumer;
+
+import org.conductoross.conductor.ai.Agent;
+import org.conductoross.conductor.ai.AgentRuntime;
+import org.conductoross.conductor.ai.model.AgentResult;
+import org.conductoross.conductor.ai.model.FeedbackEvent;
+import org.conductoross.conductor.ai.model.OCGMemoryStore;
+import org.conductoross.conductor.ai.model.SemanticMemory;
+
+/**
+ * Example 118 — OCG-backed long-term memory with human good/bad feedback links.
+ *
+ *
Enable memory on an agent and the server-side compiler does two things
+ * automatically once the agent is deployed:
+ *
+ *
+ *
BEFORE a run: relevant past memories (scoped to this agent/user) are
+ * retrieved from OCG and injected into the prompt — no tool call needed.
+ *
AFTER a run: the conversation is summarized (Claude-style: durable facts,
+ * not the raw transcript) by a small internal summarizer agent and saved back
+ * to OCG as a memory.
+ *
+ *
+ *
Feedback is HUMAN-only. Agents never vote. Instead, the runtime hands a
+ * {@link FeedbackEvent} — including signed capability URLs (good/bad) — to the
+ * agent's {@code feedbackSink}. A human (e.g. a support engineer) clicks a link to
+ * mark the memory good or bad; the link skips auth (its signature is the
+ * authorization), so the clicker needs no OCG account. Here the sink just prints the
+ * URLs as they'd appear in a Zendesk ticket comment.
+ *
+ *
The SDK emits a {@code longTermMemory} block on the agent config so the server
+ * activates the feature; the OCG {@code credential} is a server-resolvable secret NAME
+ * ({@code OCG_PUBLIC_KEY}), never the raw client token.
+ *
+ *
Requires the OCG instance to be started with a feedback-link secret
+ * ({@code OCG_FEEDBACK_LINK_SECRET}) for the capability URLs to be minted.
+ *
+ *
+ */
+public class Example118OcgMemory {
+
+ public static void main(String[] args) {
+ String ocgUrl = System.getenv().getOrDefault("OCG_INSTANCE_URL", "");
+ // Unlike the server-side OCG retrieval tools (which resolve a credential
+ // server-side), the memory store calls OCG directly, so it holds the bearer token.
+ String ocgToken = System.getenv("OCG_TOKEN");
+ if (ocgUrl.isEmpty()) {
+ System.err.println(
+ "Set OCG_INSTANCE_URL to your OCG instance, e.g. https://test.contextgraph.io");
+ return;
+ }
+
+ OCGMemoryStore store = OCGMemoryStore.builder()
+ .url(ocgUrl)
+ .agent("agent:support")
+ .user("user:alice")
+ .token(ocgToken)
+ .build();
+
+ // Deliver the good/bad links to a human. In production this would POST a
+ // comment to the Zendesk ticket; here we just print what would be sent.
+ Consumer zendeskSink = event -> {
+ System.out.println("\n--- would post to Zendesk ticket ---");
+ System.out.println("Saved memory: " + event.getMemoryKey());
+ System.out.println("Summary: " + event.getSummary());
+ if (event.getGoodUrl() != null) {
+ System.out.println(" Was this helpful? " + event.getGoodUrl());
+ System.out.println(" Not helpful: " + event.getBadUrl());
+ }
+ System.out.println("------------------------------------\n");
+ };
+
+ Agent agent = Agent.builder()
+ .name("support")
+ .model(Settings.LLM_MODEL)
+ .instructions(
+ "You are a customer support agent. Use any relevant context from "
+ + "memory to personalize your answer. A memory labeled [bad] was "
+ + "flagged by a human — treat it with suspicion.")
+ .semanticMemory(new SemanticMemory(store, 5, null))
+ .feedbackSink(zendeskSink)
+ .build();
+
+ try (AgentRuntime runtime = new AgentRuntime()) {
+ System.out.println("--- Turn 1 ---");
+ AgentResult turn1 =
+ runtime.run(agent, "Hi, I'm Alice. I'm on the Enterprise plan and prefer email.");
+ turn1.printResult();
+
+ System.out.println("\n--- Turn 2 (should recall Alice's plan from memory) ---");
+ AgentResult turn2 = runtime.run(agent, "What plan am I on again?");
+ turn2.printResult();
+ }
+ }
+}
diff --git a/conductor-ai/src/main/java/org/conductoross/conductor/ai/Agent.java b/conductor-ai/src/main/java/org/conductoross/conductor/ai/Agent.java
index af2ceaf00..2c8dd00c1 100644
--- a/conductor-ai/src/main/java/org/conductoross/conductor/ai/Agent.java
+++ b/conductor-ai/src/main/java/org/conductoross/conductor/ai/Agent.java
@@ -16,6 +16,7 @@
import java.util.Arrays;
import java.util.List;
import java.util.Map;
+import java.util.function.Consumer;
import java.util.function.Function;
import java.util.function.Supplier;
import java.util.regex.Pattern;
@@ -25,9 +26,11 @@
import org.conductoross.conductor.ai.handoff.Handoff;
import org.conductoross.conductor.ai.internal.AgentRegistry;
import org.conductoross.conductor.ai.model.ConversationMemory;
+import org.conductoross.conductor.ai.model.FeedbackEvent;
import org.conductoross.conductor.ai.model.GuardrailDef;
import org.conductoross.conductor.ai.model.PrefillToolCall;
import org.conductoross.conductor.ai.model.PromptTemplate;
+import org.conductoross.conductor.ai.model.SemanticMemory;
import org.conductoross.conductor.ai.model.ToolDef;
import org.conductoross.conductor.ai.termination.TerminationCondition;
@@ -128,6 +131,21 @@ public class Agent {
private final List maskedFields;
/** Token threshold for proactive context condensation. */
private final Integer contextWindowBudget;
+ /**
+ * OCG-backed long-term memory (see {@link org.conductoross.conductor.ai.model.OCGMemoryStore}).
+ * When the backing store is OCG-backed, the config serializer emits a
+ * {@code longTermMemory} block so the server-side compiler inlines retrieval
+ * (pre-loop) + distill/save/feedback (post-loop) steps on the deployed path.
+ */
+ private final SemanticMemory semanticMemory;
+ /** Optional model override for the internal conversation summarizer (falls back to {@link #model}). */
+ private final String memorySummaryModel;
+ /**
+ * Optional sink that receives the good/bad capability links (a {@link FeedbackEvent})
+ * for a saved conversation memory, for out-of-band human delivery. When set, the
+ * serializer emits a {@code feedbackSink} worker ref.
+ */
+ private final Consumer feedbackSink;
private Agent(Builder builder) {
this.name = builder.name;
@@ -195,6 +213,9 @@ private Agent(Builder builder) {
this.reasoningEffort = builder.reasoningEffort;
this.maskedFields = builder.maskedFields != null ? new ArrayList<>(builder.maskedFields) : null;
this.contextWindowBudget = builder.contextWindowBudget;
+ this.semanticMemory = builder.semanticMemory;
+ this.memorySummaryModel = builder.memorySummaryModel;
+ this.feedbackSink = builder.feedbackSink;
}
/**
@@ -440,6 +461,21 @@ public Integer getContextWindowBudget() {
return contextWindowBudget;
}
+ /** OCG-backed long-term memory handle, or {@code null} if unset. */
+ public SemanticMemory getSemanticMemory() {
+ return semanticMemory;
+ }
+
+ /** Model override for the conversation summarizer, or {@code null} to reuse {@link #getModel()}. */
+ public String getMemorySummaryModel() {
+ return memorySummaryModel;
+ }
+
+ /** Out-of-band feedback link sink, or {@code null} if unset. */
+ public Consumer getFeedbackSink() {
+ return feedbackSink;
+ }
+
public static Builder builder() {
return new Builder();
}
@@ -542,6 +578,9 @@ public static class Builder {
private String reasoningEffort;
private List maskedFields;
private Integer contextWindowBudget;
+ private SemanticMemory semanticMemory;
+ private String memorySummaryModel;
+ private Consumer feedbackSink;
/** Set the agent name (required). Must match {@code ^[a-zA-Z_][a-zA-Z0-9_-]*$}. */
public Builder name(String name) {
@@ -1052,6 +1091,36 @@ public Builder contextWindowBudget(int contextWindowBudget) {
return this;
}
+ /**
+ * Attach OCG-backed long-term memory. When the backing store is an
+ * {@link org.conductoross.conductor.ai.model.OCGMemoryStore}, the config serializer
+ * emits a {@code longTermMemory} block so the server-side compiler activates
+ * pre-run retrieval and post-run distill/save on the deployed path.
+ */
+ public Builder semanticMemory(SemanticMemory semanticMemory) {
+ this.semanticMemory = semanticMemory;
+ return this;
+ }
+
+ /**
+ * Override the model used by the internal conversation summarizer. Defaults to
+ * the agent's own {@link #model} when unset.
+ */
+ public Builder memorySummaryModel(String memorySummaryModel) {
+ this.memorySummaryModel = memorySummaryModel;
+ return this;
+ }
+
+ /**
+ * Set the sink that receives the good/bad capability links (a
+ * {@link FeedbackEvent}) for a saved conversation memory, for out-of-band human
+ * delivery. When set, the serializer emits a {@code feedbackSink} worker ref.
+ */
+ public Builder feedbackSink(Consumer feedbackSink) {
+ this.feedbackSink = feedbackSink;
+ return this;
+ }
+
/**
* Build the Agent.
*
diff --git a/conductor-ai/src/main/java/org/conductoross/conductor/ai/internal/AgentConfigSerializer.java b/conductor-ai/src/main/java/org/conductoross/conductor/ai/internal/AgentConfigSerializer.java
index d596bc79c..fd0e7551d 100644
--- a/conductor-ai/src/main/java/org/conductoross/conductor/ai/internal/AgentConfigSerializer.java
+++ b/conductor-ai/src/main/java/org/conductoross/conductor/ai/internal/AgentConfigSerializer.java
@@ -29,7 +29,9 @@
import org.conductoross.conductor.ai.handoff.OnTextMention;
import org.conductoross.conductor.ai.handoff.OnToolResult;
import org.conductoross.conductor.ai.model.GuardrailDef;
+import org.conductoross.conductor.ai.model.OCGMemoryStore;
import org.conductoross.conductor.ai.model.PromptTemplate;
+import org.conductoross.conductor.ai.model.SemanticMemory;
import org.conductoross.conductor.ai.model.ToolDef;
import org.conductoross.conductor.ai.plans.Context;
import org.slf4j.Logger;
@@ -262,6 +264,21 @@ private Map serializeAgent(Agent agent) {
agentMap.put("memory", memMap);
}
+ // Long-term (OCG-backed) memory. When present, the server-side compiler
+ // inlines retrieval (pre-loop) + distill/save/feedback (post-loop) steps
+ // so memory works on the deployed/webhook path — not just client run().
+ Map ltm = serializeLongTermMemory(agent);
+ if (ltm != null) {
+ agentMap.put("longTermMemory", ltm);
+ // feedbackSink delivers the human good/bad capability links out-of-band.
+ // Emit a worker ref so the compiled path can call the SDK's sink worker.
+ if (agent.getFeedbackSink() != null) {
+ Map feedbackSink = new LinkedHashMap<>();
+ feedbackSink.put("taskName", agent.getName() + "_feedback_sink");
+ agentMap.put("feedbackSink", feedbackSink);
+ }
+ }
+
// Termination condition
if (agent.getTermination() != null) {
agentMap.put("termination", agent.getTermination().toMap());
@@ -596,6 +613,51 @@ private Map serializeAgent(Agent agent) {
return agentMap;
}
+ /**
+ * Serialize an agent's OCG-backed semantic memory to a {@code LongTermMemoryConfig} map.
+ *
+ *
Returns {@code null} (no-op) unless the agent has a {@link SemanticMemory} whose
+ * store is an {@link OCGMemoryStore} (only OCG-backed stores compile server-side —
+ * they need a base url to call). Reads the OCG instance url, scope owner, user and
+ * scope off the store; the credential is a SERVER-resolvable secret NAME (e.g.
+ * {@code OCG_PUBLIC_KEY}) — never the raw client token. The summary model falls back
+ * to the agent's own model when not explicitly set. Null-valued keys are omitted.
+ */
+ private Map serializeLongTermMemory(Agent agent) {
+ SemanticMemory sm = agent.getSemanticMemory();
+ if (sm == null) {
+ return null;
+ }
+ // Only OCG-backed stores compile server-side (need a base url to call).
+ if (!(sm.getStore() instanceof OCGMemoryStore)) {
+ return null;
+ }
+ OCGMemoryStore store = (OCGMemoryStore) sm.getStore();
+
+ String scope = store.getScope() != null && !store.getScope().isEmpty() ? store.getScope() : "agent";
+ String credential =
+ store.getCredential() != null && !store.getCredential().isEmpty() ? store.getCredential() : "OCG_PUBLIC_KEY";
+ String summaryModel = agent.getMemorySummaryModel() != null && !agent.getMemorySummaryModel().isEmpty()
+ ? agent.getMemorySummaryModel()
+ : (agent.getModel() != null && !agent.getModel().isEmpty() ? agent.getModel() : null);
+
+ Map result = new LinkedHashMap<>();
+ putIfNotNull(result, "ocgUrl", store.getBaseUrl());
+ putIfNotNull(result, "credential", credential);
+ putIfNotNull(result, "agent", store.getAgent());
+ putIfNotNull(result, "user", store.getUser());
+ putIfNotNull(result, "scope", scope);
+ result.put("maxResults", sm.getMaxResults());
+ putIfNotNull(result, "summaryModel", summaryModel);
+ return result;
+ }
+
+ private static void putIfNotNull(Map map, String key, Object value) {
+ if (value != null) {
+ map.put(key, value);
+ }
+ }
+
private Map serializeTool(ToolDef tool, boolean agentStateful) {
Map toolMap = new LinkedHashMap<>();
toolMap.put("name", tool.getName());
diff --git a/conductor-ai/src/main/java/org/conductoross/conductor/ai/model/FeedbackEvent.java b/conductor-ai/src/main/java/org/conductoross/conductor/ai/model/FeedbackEvent.java
new file mode 100644
index 000000000..d47935eaa
--- /dev/null
+++ b/conductor-ai/src/main/java/org/conductoross/conductor/ai/model/FeedbackEvent.java
@@ -0,0 +1,167 @@
+/*
+ * Copyright 2025 Conductor Authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
+ * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations under the License.
+ */
+package org.conductoross.conductor.ai.model;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Handed to an Agent's {@code feedbackSink} after a conversation memory is saved.
+ *
+ *
Carries the distilled summary plus the signed capability URLs a human can click
+ * to mark the memory good/bad. The integrator routes these out-of-band (e.g. posts
+ * them into a Zendesk ticket). These URLs are never shown to the agent's LLM —
+ * agents can only create and read memories; good/bad feedback is human-only.
+ *
+ *
Mirrors the Python ({@code FeedbackEvent}) dataclass in {@code ocg_memory.py}.
+ */
+public class FeedbackEvent {
+
+ private final String memoryKey;
+ private final String summary;
+ private final List facts;
+ private final List tags;
+ private final String goodUrl;
+ private final String badUrl;
+ private final String expiresAt;
+ private final String agent;
+ private final String user;
+ private final String sessionId;
+
+ private FeedbackEvent(Builder b) {
+ this.memoryKey = b.memoryKey;
+ this.summary = b.summary;
+ this.facts = b.facts != null ? new ArrayList<>(b.facts) : new ArrayList<>();
+ this.tags = b.tags != null ? new ArrayList<>(b.tags) : new ArrayList<>();
+ this.goodUrl = b.goodUrl;
+ this.badUrl = b.badUrl;
+ this.expiresAt = b.expiresAt;
+ this.agent = b.agent;
+ this.user = b.user;
+ this.sessionId = b.sessionId;
+ }
+
+ public String getMemoryKey() {
+ return memoryKey;
+ }
+
+ public String getSummary() {
+ return summary;
+ }
+
+ public List getFacts() {
+ return facts;
+ }
+
+ public List getTags() {
+ return tags;
+ }
+
+ /** Signed capability URL to mark the memory helpful, or {@code null} if unavailable. */
+ public String getGoodUrl() {
+ return goodUrl;
+ }
+
+ /** Signed capability URL to mark the memory unhelpful, or {@code null} if unavailable. */
+ public String getBadUrl() {
+ return badUrl;
+ }
+
+ public String getExpiresAt() {
+ return expiresAt;
+ }
+
+ public String getAgent() {
+ return agent;
+ }
+
+ public String getUser() {
+ return user;
+ }
+
+ public String getSessionId() {
+ return sessionId;
+ }
+
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ /** Fluent builder for {@link FeedbackEvent}. */
+ public static class Builder {
+ private String memoryKey;
+ private String summary;
+ private List facts;
+ private List tags;
+ private String goodUrl;
+ private String badUrl;
+ private String expiresAt;
+ private String agent;
+ private String user;
+ private String sessionId;
+
+ public Builder memoryKey(String memoryKey) {
+ this.memoryKey = memoryKey;
+ return this;
+ }
+
+ public Builder summary(String summary) {
+ this.summary = summary;
+ return this;
+ }
+
+ public Builder facts(List facts) {
+ this.facts = facts;
+ return this;
+ }
+
+ public Builder tags(List tags) {
+ this.tags = tags;
+ return this;
+ }
+
+ public Builder goodUrl(String goodUrl) {
+ this.goodUrl = goodUrl;
+ return this;
+ }
+
+ public Builder badUrl(String badUrl) {
+ this.badUrl = badUrl;
+ return this;
+ }
+
+ public Builder expiresAt(String expiresAt) {
+ this.expiresAt = expiresAt;
+ return this;
+ }
+
+ public Builder agent(String agent) {
+ this.agent = agent;
+ return this;
+ }
+
+ public Builder user(String user) {
+ this.user = user;
+ return this;
+ }
+
+ public Builder sessionId(String sessionId) {
+ this.sessionId = sessionId;
+ return this;
+ }
+
+ public FeedbackEvent build() {
+ return new FeedbackEvent(this);
+ }
+ }
+}
diff --git a/conductor-ai/src/main/java/org/conductoross/conductor/ai/model/MemorySummary.java b/conductor-ai/src/main/java/org/conductoross/conductor/ai/model/MemorySummary.java
new file mode 100644
index 000000000..5e524254d
--- /dev/null
+++ b/conductor-ai/src/main/java/org/conductoross/conductor/ai/model/MemorySummary.java
@@ -0,0 +1,70 @@
+/*
+ * Copyright 2025 Conductor Authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
+ * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations under the License.
+ */
+package org.conductoross.conductor.ai.model;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Structured output for the conversation summarizer agent.
+ *
+ *
Used as the {@code outputType} of the internal memory summarizer (see
+ * {@link OCGMemoryStore#buildMemorySummarizer(String)}). The summarizer distills a
+ * conversation into durable facts — a one-paragraph {@link #summary}, a list of
+ * reusable {@link #facts}, and a few topical {@link #tags} — which the post-run
+ * save hook stores back to OCG as a {@code conversation:} memory.
+ *
+ *
Mirrors the Python ({@code MemorySummary}) reference type in
+ * {@code ocg_memory.py}.
+ */
+public class MemorySummary {
+
+ /** One short paragraph: what happened / what was learned. */
+ private String summary = "";
+ /** Durable, reusable facts about the user or task (no chit-chat). */
+ private List facts = new ArrayList<>();
+ /** Short topical tags. */
+ private List tags = new ArrayList<>();
+
+ public MemorySummary() {}
+
+ public MemorySummary(String summary, List facts, List tags) {
+ this.summary = summary != null ? summary : "";
+ this.facts = facts != null ? new ArrayList<>(facts) : new ArrayList<>();
+ this.tags = tags != null ? new ArrayList<>(tags) : new ArrayList<>();
+ }
+
+ public String getSummary() {
+ return summary;
+ }
+
+ public void setSummary(String summary) {
+ this.summary = summary;
+ }
+
+ public List getFacts() {
+ return facts;
+ }
+
+ public void setFacts(List facts) {
+ this.facts = facts;
+ }
+
+ public List getTags() {
+ return tags;
+ }
+
+ public void setTags(List tags) {
+ this.tags = tags;
+ }
+}
diff --git a/conductor-ai/src/main/java/org/conductoross/conductor/ai/model/OCGMemoryStore.java b/conductor-ai/src/main/java/org/conductoross/conductor/ai/model/OCGMemoryStore.java
new file mode 100644
index 000000000..ee83b0458
--- /dev/null
+++ b/conductor-ai/src/main/java/org/conductoross/conductor/ai/model/OCGMemoryStore.java
@@ -0,0 +1,562 @@
+/*
+ * Copyright 2025 Conductor Authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
+ * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations under the License.
+ */
+package org.conductoross.conductor.ai.model;
+
+import java.net.URI;
+import java.net.URLEncoder;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+import org.conductoross.conductor.ai.Agent;
+import org.conductoross.conductor.ai.exceptions.AgentAPIException;
+import org.conductoross.conductor.ai.internal.JsonMapper;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.fasterxml.jackson.databind.JsonNode;
+
+/**
+ * Back agentspan {@link SemanticMemory} with an OCG (Open Context Graph) instance.
+ *
+ *
A synchronous HTTP adapter implementing the {@link MemoryStore} interface over
+ * the OCG BFF, so an agent's memories persist in OCG and ride OCG's feedback-aware
+ * ranking:
+ *
+ *
+ *
{@code add} → {@code POST /api/v1/memories}
+ *
{@code search} → {@code POST /api/v1/memories/search} (feedback-blended ranking)
{@link #feedbackLinks} → {@code POST /api/v1/memories/{key}/feedback-links} (mints signed URLs)
+ *
+ *
+ *
Design notes (mirrors the Python {@code OCGMemoryStore} in {@code ocg_memory.py}):
+ *
+ *
+ *
The OCG bearer {@code token} is held client-side here (e.g. from
+ * {@code OCG_TOKEN}), unlike the server-side retrieval tools which resolve a
+ * credential server-side.
+ *
Agents only ever create and read memories. Good/bad feedback is
+ * human-only: it is delivered out-of-band through the agent's {@code feedbackSink}
+ * (e.g. into a Zendesk ticket) and the capability URLs are never surfaced to the
+ * agent's LLM.
+ *
{@link #getCredential()} is a server-resolvable secret NAME (default
+ * {@code "OCG_PUBLIC_KEY"}) used by the COMPILED/deployed path — distinct from
+ * {@code token}, the raw client token. The
+ * {@link org.conductoross.conductor.ai.internal.AgentConfigSerializer} emits it as
+ * {@code longTermMemory.credential} so the server can resolve the bearer token via a
+ * {@code #{NAME}} HTTP-header placeholder.
+ *
+ */
+public class OCGMemoryStore implements MemoryStore {
+
+ private static final Logger logger = LoggerFactory.getLogger(OCGMemoryStore.class);
+
+ private final String baseUrl;
+ private final String agent;
+ private final String user;
+ private final String credential;
+ private final String scope;
+ private final Map headers;
+ private final Transport transport;
+
+ private OCGMemoryStore(Builder b) {
+ if (b.url == null || b.url.trim().isEmpty()) {
+ throw new IllegalArgumentException("OCGMemoryStore requires a non-blank OCG instance url");
+ }
+ if (b.agent == null || b.agent.trim().isEmpty()) {
+ throw new IllegalArgumentException("OCGMemoryStore requires a non-blank agent owner");
+ }
+ this.baseUrl = stripTrailingSlashes(b.url.trim());
+ this.agent = b.agent;
+ this.user = b.user;
+ this.credential = b.credential != null ? b.credential : "OCG_PUBLIC_KEY";
+ this.scope = b.scope != null ? b.scope : "user";
+ this.headers = new LinkedHashMap<>();
+ if (b.token != null && !b.token.isEmpty()) {
+ this.headers.put("Authorization", "Bearer " + b.token);
+ }
+ this.transport = b.transport != null ? b.transport : new JdkTransport(Duration.ofSeconds(b.timeoutSeconds));
+ }
+
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ // ── Accessors (read by the config serializer for the compiled path) ─────
+
+ /** OCG instance base url, trailing slashes stripped. */
+ public String getBaseUrl() {
+ return baseUrl;
+ }
+
+ /** Agent owner key, e.g. {@code "agent:support"}. */
+ public String getAgent() {
+ return agent;
+ }
+
+ /** Optional user owner, e.g. {@code "user:alice"}, or {@code null}. */
+ public String getUser() {
+ return user;
+ }
+
+ /** Server-resolvable credential NAME for the OCG bearer token (never the raw token). */
+ public String getCredential() {
+ return credential;
+ }
+
+ /** Memory scope for writes (default {@code "user"}). */
+ public String getScope() {
+ return scope;
+ }
+
+ // ── MemoryStore interface ───────────────────────────────────────────────
+
+ @Override
+ public String add(MemoryEntry entry) {
+ String key = entry.getId();
+ if (key == null || key.isEmpty()) {
+ Object mk = entry.getMetadata().get("key");
+ key = mk != null ? String.valueOf(mk) : "";
+ }
+ if (key.isEmpty()) {
+ key = hashKey(entry.getContent());
+ }
+
+ String content = entry.getContent() != null ? entry.getContent() : "";
+ Map body = new LinkedHashMap<>();
+ body.put("key", key);
+ body.put("agent", agent);
+ body.put("value", content);
+ body.put("description", content.substring(0, Math.min(200, content.length())));
+ body.put("scope", scope);
+ body.put("source", "agent_inferred");
+ body.put("tags", asStringList(entry.getMetadata().get("tags")));
+ if (user != null) {
+ body.put("user", user);
+ }
+
+ request("POST", "/api/v1/memories", body, null);
+ entry.setId(key);
+ return key;
+ }
+
+ @Override
+ public List search(String query, int topK) {
+ Map body = new LinkedHashMap<>();
+ body.put("query", query);
+ body.put("agent", agent);
+ body.put("limit", topK);
+ body.put("include_shared", true);
+ if (user != null) {
+ body.put("user", user);
+ }
+
+ JsonNode resp = request("POST", "/api/v1/memories/search", body, null);
+ List out = new ArrayList<>();
+ for (JsonNode m : memories(resp)) {
+ Map metadata = new LinkedHashMap<>();
+ metadata.put("relevance_score", m.has("relevance_score") ? m.get("relevance_score").asDouble() : null);
+ metadata.put("good_count", intOf(m, "good_count"));
+ metadata.put("bad_count", intOf(m, "bad_count"));
+ MemoryEntry entry = new MemoryEntry(withSignal(text(m, "value_preview"), m), metadata);
+ entry.setId(text(m, "key"));
+ out.add(entry);
+ }
+ return out;
+ }
+
+ @Override
+ public boolean delete(String memoryId) {
+ Map params = new LinkedHashMap<>();
+ params.put("agent", agent);
+ if (user != null) {
+ params.put("user", user);
+ }
+ try {
+ request("DELETE", "/api/v1/memories/" + memoryId, null, params);
+ } catch (AgentAPIException e) {
+ return false;
+ }
+ return true;
+ }
+
+ @Override
+ public void clear() {
+ // No bulk-clear endpoint — fan out over the listed keys. Guard usage:
+ // this deletes every memory for the configured agent/user.
+ List entries = listAll();
+ logger.warn("OCGMemoryStore.clear() deleting {} memories for {}", entries.size(), agent);
+ for (MemoryEntry e : entries) {
+ delete(e.getId());
+ }
+ }
+
+ @Override
+ public List listAll() {
+ Map params = new LinkedHashMap<>();
+ params.put("agent", agent);
+ params.put("limit", "200");
+ if (user != null) {
+ params.put("user", user);
+ }
+ JsonNode resp = request("GET", "/api/v1/memories", null, params);
+ List out = new ArrayList<>();
+ for (JsonNode m : memories(resp)) {
+ MemoryEntry entry = new MemoryEntry(text(m, "value_preview"));
+ entry.setId(text(m, "key"));
+ out.add(entry);
+ }
+ return out;
+ }
+
+ // ── Capability feedback links (human-only, out-of-band) ─────────────────
+
+ /**
+ * Mint signed good/bad capability URLs for a memory.
+ *
+ *
Returns {@code {"good_url", "bad_url", "expires_at"}}. The URLs require no OCG
+ * login — a human (e.g. a support engineer) clicks them to vote. Requires the OCG
+ * instance to have a feedback-link secret configured (else OCG returns 501).
+ *
+ * @param key the memory key to mint links for
+ * @return a map of the minted link fields
+ */
+ public Map feedbackLinks(String key) {
+ Map params = new LinkedHashMap<>();
+ params.put("agent", agent);
+ if (user != null) {
+ params.put("user", user);
+ }
+ JsonNode resp = request("POST", "/api/v1/memories/" + key + "/feedback-links", null, params);
+ Map out = new LinkedHashMap<>();
+ if (resp != null && resp.isObject()) {
+ resp.fields().forEachRemaining(e -> out.put(e.getKey(), toPlain(e.getValue())));
+ }
+ return out;
+ }
+
+ // ── HTTP plumbing ───────────────────────────────────────────────────────
+
+ private JsonNode request(String method, String path, Object jsonBody, Map queryParams) {
+ String url = baseUrl + path + queryString(queryParams);
+ String body = jsonBody != null ? JsonMapper.toJson(jsonBody) : null;
+ Transport.Response resp = transport.send(method, url, headers, body);
+ if (resp.status() >= 400) {
+ throw new AgentAPIException(resp.status(), resp.body() != null ? resp.body() : "");
+ }
+ String respBody = resp.body();
+ if (respBody == null || respBody.isEmpty()) {
+ return null;
+ }
+ try {
+ return JsonMapper.get().readTree(respBody);
+ } catch (Exception e) {
+ return null;
+ }
+ }
+
+ /**
+ * Pluggable HTTP transport. The default ({@link JdkTransport}) uses the JDK
+ * {@link HttpClient}; tests inject a stub to capture requests and serve canned
+ * responses (parity with the Python tests' {@code httpx.MockTransport}).
+ */
+ @FunctionalInterface
+ public interface Transport {
+ /**
+ * Perform an HTTP exchange.
+ *
+ * @param method the HTTP method (GET/POST/DELETE)
+ * @param url the fully-qualified request URL (base + path + query string)
+ * @param headers request headers (e.g. Authorization)
+ * @param body the JSON request body, or {@code null} for no body
+ * @return the response status and body
+ */
+ Response send(String method, String url, Map headers, String body);
+
+ /** A minimal HTTP response: status code and raw body text. */
+ final class Response {
+ private final int status;
+ private final String body;
+
+ public Response(int status, String body) {
+ this.status = status;
+ this.body = body;
+ }
+
+ public int status() {
+ return status;
+ }
+
+ public String body() {
+ return body;
+ }
+ }
+ }
+
+ private static final class JdkTransport implements Transport {
+ private final HttpClient client;
+ private final Duration timeout;
+
+ JdkTransport(Duration timeout) {
+ this.timeout = timeout;
+ this.client = HttpClient.newBuilder().connectTimeout(timeout).build();
+ }
+
+ @Override
+ public Response send(String method, String url, Map headers, String body) {
+ try {
+ HttpRequest.Builder b =
+ HttpRequest.newBuilder().uri(URI.create(url)).timeout(timeout);
+ if (headers != null) {
+ headers.forEach(b::header);
+ }
+ if (body != null) {
+ b.header("Content-Type", "application/json");
+ b.method(method, HttpRequest.BodyPublishers.ofString(body));
+ } else {
+ b.method(method, HttpRequest.BodyPublishers.noBody());
+ }
+ HttpResponse r = client.send(b.build(), HttpResponse.BodyHandlers.ofString());
+ return new Response(r.statusCode(), r.body());
+ } catch (Exception e) {
+ // network/timeout — surface as a status-0 API error (matches Python).
+ throw new AgentAPIException(0, e.getMessage() != null ? e.getMessage() : e.toString());
+ }
+ }
+ }
+
+ // ── Conversation summarization (Claude-style distillation) ──────────────
+
+ /** Instructions for the internal conversation summarizer sub-agent. */
+ public static final String MEMORY_SUMMARIZER_INSTRUCTIONS =
+ "You distill a conversation into a durable memory. Read the transcript and "
+ + "extract only reusable, durable facts about the user, their preferences, and "
+ + "the task — the kind of thing worth remembering for next time. Ignore greetings, "
+ + "filler, and one-off details. Write a one-paragraph summary, a short list of "
+ + "facts, and a few topical tags. Be concise and concrete.";
+
+ /**
+ * Build the internal agent that summarizes a conversation into a memory.
+ *
+ *
It uses {@link MemorySummary} structured output and is intentionally created
+ * without {@code semanticMemory} so the post-run save hook skips it (no
+ * recursion). Mirrors the Python {@code build_memory_summarizer}.
+ *
+ * @param model the model id to run the summarizer with (reuses the agent's model)
+ * @return the summarizer {@link Agent}
+ */
+ public static Agent buildMemorySummarizer(String model) {
+ return buildMemorySummarizer(model, "__memory_summarizer");
+ }
+
+ /** Variant of {@link #buildMemorySummarizer(String)} with an explicit agent name. */
+ public static Agent buildMemorySummarizer(String model, String name) {
+ return Agent.builder()
+ .name(name)
+ .model(model)
+ .instructions(MEMORY_SUMMARIZER_INSTRUCTIONS)
+ .outputType(MemorySummary.class)
+ .maxTurns(1)
+ .build();
+ }
+
+ // ── helpers ──────────────────────────────────────────────────────────────
+
+ /**
+ * Fold the human good/bad signal into a search result's content so the injected
+ * prompt context shows the agent when a memory was marked bad and why.
+ */
+ static String withSignal(String content, JsonNode m) {
+ int good = intOf(m, "good_count");
+ int bad = intOf(m, "bad_count");
+ if (good == 0 && bad == 0) {
+ return content;
+ }
+ StringBuilder sb = new StringBuilder(content);
+ sb.append(" [good ").append(good).append(" / bad ").append(bad).append("]");
+ JsonNode notes = m.get("feedback_notes");
+ if (notes != null && notes.isArray()) {
+ for (JsonNode note : notes) {
+ String verdict = text(note, "verdict");
+ String reason = text(note, "reason");
+ if ("bad".equals(verdict) && reason != null && !reason.isEmpty()) {
+ sb.append(" (bad: \"").append(reason).append("\")");
+ }
+ }
+ }
+ return sb.toString();
+ }
+
+ private static Iterable memories(JsonNode resp) {
+ if (resp != null) {
+ JsonNode mems = resp.get("memories");
+ if (mems != null && mems.isArray()) {
+ return mems;
+ }
+ }
+ return new ArrayList<>();
+ }
+
+ private static int intOf(JsonNode node, String field) {
+ JsonNode v = node != null ? node.get(field) : null;
+ return v != null && v.isNumber() ? v.asInt(0) : (v != null && v.isTextual() ? parseInt(v.asText()) : 0);
+ }
+
+ private static int parseInt(String s) {
+ try {
+ return Integer.parseInt(s.trim());
+ } catch (NumberFormatException e) {
+ return 0;
+ }
+ }
+
+ private static String text(JsonNode node, String field) {
+ JsonNode v = node != null ? node.get(field) : null;
+ return v != null && !v.isNull() ? v.asText("") : "";
+ }
+
+ private static Object toPlain(JsonNode v) {
+ if (v == null || v.isNull()) return null;
+ if (v.isTextual()) return v.asText();
+ if (v.isBoolean()) return v.asBoolean();
+ if (v.isNumber()) return v.numberValue();
+ return v.toString();
+ }
+
+ @SuppressWarnings("unchecked")
+ private static List asStringList(Object value) {
+ List out = new ArrayList<>();
+ if (value instanceof Collection) {
+ for (Object o : (Collection