diff --git a/conductor-ai-examples/src/main/java/org/conductoross/conductor/ai/examples/Example118OcgMemory.java b/conductor-ai-examples/src/main/java/org/conductoross/conductor/ai/examples/Example118OcgMemory.java new file mode 100644 index 000000000..00506f1e3 --- /dev/null +++ b/conductor-ai-examples/src/main/java/org/conductoross/conductor/ai/examples/Example118OcgMemory.java @@ -0,0 +1,113 @@ +/* + * 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 + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * 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: + * + *

+ * + *

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. + * + *

+ * OCG_INSTANCE_URL=https://test.contextgraph.io \
+ * OCG_TOKEN=<bearer-token> \
+ * ./gradlew :conductor-ai-examples:run -PmainClass=org.conductoross.conductor.ai.examples.Example118OcgMemory
+ * 
+ */ +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 + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * 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 + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * 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 + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * 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)
  • + *
  • {@code delete} → {@code DELETE /api/v1/memories/{key}}
  • + *
  • {@code listAll} → {@code GET /api/v1/memories}
  • + *
  • {@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) value) { + if (o != null) out.add(String.valueOf(o)); + } + } + return out; + } + + private static String queryString(Map params) { + if (params == null || params.isEmpty()) { + return ""; + } + StringBuilder sb = new StringBuilder("?"); + boolean first = true; + for (Map.Entry e : params.entrySet()) { + if (!first) sb.append('&'); + sb.append(URLEncoder.encode(e.getKey(), StandardCharsets.UTF_8)) + .append('=') + .append(URLEncoder.encode(e.getValue(), StandardCharsets.UTF_8)); + first = false; + } + return sb.toString(); + } + + private static String stripTrailingSlashes(String url) { + int end = url.length(); + while (end > 0 && url.charAt(end - 1) == '/') { + end--; + } + return url.substring(0, end); + } + + private static String hashKey(String content) { + try { + MessageDigest md = MessageDigest.getInstance("SHA-256"); + byte[] hash = md.digest((content != null ? content : "").getBytes(StandardCharsets.UTF_8)); + StringBuilder sb = new StringBuilder("mem-"); + for (int i = 0; i < 8; i++) { + sb.append(String.format("%02x", hash[i])); + } + return sb.toString(); + } catch (NoSuchAlgorithmException e) { + return "mem-" + Integer.toHexString((content != null ? content : "").hashCode()); + } + } + + /** Fluent builder for {@link OCGMemoryStore}. */ + public static class Builder { + private String url; + private String agent; + private String user; + private String token; + private String credential = "OCG_PUBLIC_KEY"; + private String scope = "user"; + private long timeoutSeconds = 10L; + private Transport transport; + + /** Base URL of the OCG instance (required). */ + public Builder url(String url) { + this.url = url; + return this; + } + + /** Agent owner key, e.g. {@code "agent:support"} (required). */ + public Builder agent(String agent) { + this.agent = agent; + return this; + } + + /** Optional user owner, e.g. {@code "user:alice"}. */ + public Builder user(String user) { + this.user = user; + return this; + } + + /** OCG bearer token, held client-side (e.g. from {@code OCG_TOKEN}). */ + public Builder token(String token) { + this.token = token; + return this; + } + + /** + * Server-resolvable credential NAME (default {@code "OCG_PUBLIC_KEY"}) for the + * OCG bearer token. Used by the COMPILED/deployed path. + */ + public Builder credential(String credential) { + this.credential = credential; + return this; + } + + /** Memory scope for writes (default {@code "user"}). */ + public Builder scope(String scope) { + this.scope = scope; + return this; + } + + /** Per-request timeout in seconds (default 10). */ + public Builder timeoutSeconds(long timeoutSeconds) { + this.timeoutSeconds = timeoutSeconds; + return this; + } + + /** Inject a custom HTTP {@link Transport} (mainly for tests). */ + public Builder transport(Transport transport) { + this.transport = transport; + return this; + } + + public OCGMemoryStore build() { + return new OCGMemoryStore(this); + } + } +} diff --git a/conductor-ai/src/main/java/org/conductoross/conductor/ai/model/SemanticMemory.java b/conductor-ai/src/main/java/org/conductoross/conductor/ai/model/SemanticMemory.java index f8cacaa01..3ad2a9664 100644 --- a/conductor-ai/src/main/java/org/conductoross/conductor/ai/model/SemanticMemory.java +++ b/conductor-ai/src/main/java/org/conductoross/conductor/ai/model/SemanticMemory.java @@ -108,6 +108,11 @@ public List listAll() { return store.listAll(); } + /** The backing store (an {@link InMemoryStore} by default, or e.g. an OCGMemoryStore). */ + public MemoryStore getStore() { + return store; + } + public int getMaxResults() { return maxResults; } diff --git a/conductor-ai/src/test/java/org/conductoross/conductor/ai/SerializerTest.java b/conductor-ai/src/test/java/org/conductoross/conductor/ai/SerializerTest.java index 3f9cd50cc..7500ae602 100644 --- a/conductor-ai/src/test/java/org/conductoross/conductor/ai/SerializerTest.java +++ b/conductor-ai/src/test/java/org/conductoross/conductor/ai/SerializerTest.java @@ -973,4 +973,92 @@ void parity_fields_absent_when_unset() { assertFalse(out.containsKey("contextWindowBudget"), "contextWindowBudget omitted when unset"); assertFalse(out.containsKey("memory"), "memory omitted when unset"); } + + // ── Long-term (OCG-backed) memory ───────────────────────── + + @Test + @SuppressWarnings("unchecked") + void serialize_long_term_memory() { + // OCG-backed semanticMemory serializes to longTermMemory + feedbackSink. + org.conductoross.conductor.ai.model.OCGMemoryStore store = + org.conductoross.conductor.ai.model.OCGMemoryStore.builder() + .url("https://ocg.example.com/") + .agent("agent:ce-ticket-resolution") + .user("user:alice") + .scope("agent") + .build(); + org.conductoross.conductor.ai.model.SemanticMemory sm = + new org.conductoross.conductor.ai.model.SemanticMemory(store, 7, null); + + Agent agent = Agent.builder() + .name("ce_agent") + .model("openai/gpt-4o") + .instructions("Resolve tickets.") + .semanticMemory(sm) + .memorySummaryModel("openai/gpt-4o-mini") + .feedbackSink(event -> {}) + .build(); + + Map out = ser.serialize(agent); + + Map ltm = (Map) out.get("longTermMemory"); + assertNotNull(ltm, "longTermMemory must be serialized for an OCG-backed store"); + assertEquals("https://ocg.example.com", ltm.get("ocgUrl")); // trailing slash stripped + assertEquals("OCG_PUBLIC_KEY", ltm.get("credential")); // server-resolvable name, not token + assertEquals("agent:ce-ticket-resolution", ltm.get("agent")); + assertEquals("user:alice", ltm.get("user")); + assertEquals("agent", ltm.get("scope")); + assertEquals(7, ltm.get("maxResults")); + assertEquals("openai/gpt-4o-mini", ltm.get("summaryModel")); + + assertEquals(Map.of("taskName", "ce_agent_feedback_sink"), out.get("feedbackSink")); + } + + @Test + void serialize_long_term_memory_absent() { + // No semanticMemory -> no longTermMemory / feedbackSink keys (no-op). + Agent agent = + Agent.builder().name("plain").model("openai/gpt-4o").instructions("Hi.").build(); + Map out = ser.serialize(agent); + assertFalse(out.containsKey("longTermMemory")); + assertFalse(out.containsKey("feedbackSink")); + } + + @Test + void serialize_long_term_memory_ignores_non_ocg_store() { + // A default (in-memory) SemanticMemory is a client-side helper and must NOT + // compile to longTermMemory — only OCG-backed stores do. + Agent agent = Agent.builder() + .name("plain") + .model("openai/gpt-4o") + .semanticMemory(new org.conductoross.conductor.ai.model.SemanticMemory()) + .build(); + Map out = ser.serialize(agent); + assertFalse(out.containsKey("longTermMemory"), "in-memory store must not emit longTermMemory"); + assertFalse(out.containsKey("feedbackSink")); + } + + @Test + @SuppressWarnings("unchecked") + void serialize_long_term_memory_summary_model_fallback() { + // summaryModel falls back to the agent's own model when unset; no feedback_sink + // -> no feedbackSink emitted. + org.conductoross.conductor.ai.model.OCGMemoryStore store = + org.conductoross.conductor.ai.model.OCGMemoryStore.builder() + .url("https://ocg.example.com") + .agent("agent:x") + .build(); + org.conductoross.conductor.ai.model.SemanticMemory sm = + new org.conductoross.conductor.ai.model.SemanticMemory(store, 5, null); + Agent agent = Agent.builder() + .name("a") + .model("anthropic/claude") + .semanticMemory(sm) + .build(); + + Map out = ser.serialize(agent); + Map ltm = (Map) out.get("longTermMemory"); + assertEquals("anthropic/claude", ltm.get("summaryModel")); + assertFalse(out.containsKey("feedbackSink")); + } } diff --git a/conductor-ai/src/test/java/org/conductoross/conductor/ai/model/OcgMemoryStoreTest.java b/conductor-ai/src/test/java/org/conductoross/conductor/ai/model/OcgMemoryStoreTest.java new file mode 100644 index 000000000..a35cea99e --- /dev/null +++ b/conductor-ai/src/test/java/org/conductoross/conductor/ai/model/OcgMemoryStoreTest.java @@ -0,0 +1,172 @@ +/* + * 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 + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * 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.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; + +import org.conductoross.conductor.ai.Agent; +import org.conductoross.conductor.ai.exceptions.AgentAPIException; +import org.conductoross.conductor.ai.internal.JsonMapper; +import org.junit.jupiter.api.Test; + +import com.fasterxml.jackson.databind.JsonNode; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Unit tests for the OCG-backed memory HTTP adapter ({@link OCGMemoryStore}) and the + * conversation summary helper. Pure in-process: a stub {@link OCGMemoryStore.Transport} + * captures requests and serves canned responses (parity with the Python tests' + * {@code httpx.MockTransport}). + * + *

Mirrors {@code tests/unit/test_ocg_memory_store.py}. The runtime save/retrieval + * hook tests from the Python suite are intentionally omitted — this SDK has no + * client-side agent run loop (the server-side compiler drives retrieval/distill/save + * on the deployed path); see the PR body. + */ +class OcgMemoryStoreTest { + + /** A captured HTTP exchange. */ + private static final class Captured { + String method; + String url; + JsonNode body; + } + + private OCGMemoryStore storeWith(OCGMemoryStore.Transport transport) { + return OCGMemoryStore.builder() + .url("https://ocg.test") + .agent("agent:a") + .user("user:bob") + .transport(transport) + .build(); + } + + @Test + void add_posts_value_field_and_no_confidence() { + AtomicReference captured = new AtomicReference<>(); + OCGMemoryStore store = storeWith((method, url, headers, body) -> { + Captured c = new Captured(); + c.method = method; + c.url = url; + c.body = body != null ? JsonMapper.get().valueToTree(JsonMapper.fromJson(body, Map.class)) : null; + captured.set(c); + return new OCGMemoryStore.Transport.Response(200, "{\"key\":\"k1\"}"); + }); + + String key = store.add(new MemoryEntry("alice prefers email", Map.of("key", "pref"))); + + assertEquals("pref", key); + Captured c = captured.get(); + assertTrue(c.url.endsWith("/api/v1/memories"), "url was: " + c.url); + // field is "value", NOT "string_value"; confidence was removed from the API. + assertEquals("alice prefers email", c.body.get("value").asText()); + assertFalse(c.body.has("string_value"), "body must not carry string_value"); + assertFalse(c.body.has("confidence"), "body must not carry confidence"); + assertEquals("agent:a", c.body.get("agent").asText()); + assertEquals("user:bob", c.body.get("user").asText()); + } + + @Test + void search_folds_good_bad_signal_into_content() { + OCGMemoryStore store = storeWith((method, url, headers, body) -> { + assertTrue(url.endsWith("/api/v1/memories/search"), "url was: " + url); + String json = "{\"memories\":[{" + + "\"key\":\"m1\"," + + "\"value_preview\":\"use us-east-1\"," + + "\"good_count\":2,\"bad_count\":1,\"relevance_score\":0.9," + + "\"feedback_notes\":[{\"verdict\":\"bad\",\"reason\":\"stale region\"}]" + + "}]}"; + return new OCGMemoryStore.Transport.Response(200, json); + }); + + List entries = store.search("which region", 5); + assertEquals(1, entries.size()); + assertTrue(entries.get(0).getContent().contains("[good 2 / bad 1]"), entries.get(0).getContent()); + assertTrue(entries.get(0).getContent().contains("bad: \"stale region\""), entries.get(0).getContent()); + } + + @Test + void feedback_links_hits_mint_route() { + OCGMemoryStore store = storeWith((method, url, headers, body) -> { + assertTrue( + url.split("\\?")[0].endsWith("/api/v1/memories/k1/feedback-links"), "url was: " + url); + String json = "{\"good_url\":\"https://ocg.test/api/v1/feedback/GOOD\"," + + "\"bad_url\":\"https://ocg.test/api/v1/feedback/BAD\"," + + "\"expires_at\":\"2026-09-01T00:00:00Z\"}"; + return new OCGMemoryStore.Transport.Response(200, json); + }); + + Map links = store.feedbackLinks("k1"); + assertTrue(String.valueOf(links.get("good_url")).endsWith("/feedback/GOOD")); + assertTrue(String.valueOf(links.get("bad_url")).endsWith("/feedback/BAD")); + } + + @Test + void non_2xx_raises() { + OCGMemoryStore store = storeWith((method, url, headers, body) -> new OCGMemoryStore.Transport.Response(500, "boom")); + assertThrows( + AgentAPIException.class, () -> store.add(new MemoryEntry("x", Map.of("key", "k")))); + } + + @Test + void delete_swallows_error_and_returns_false() { + OCGMemoryStore store = storeWith((method, url, headers, body) -> new OCGMemoryStore.Transport.Response(404, "not found")); + assertFalse(store.delete("missing"), "delete must return false on a non-2xx response"); + } + + @Test + void validates_required_url_and_agent() { + assertThrows(IllegalArgumentException.class, () -> OCGMemoryStore.builder() + .agent("agent:a") + .build()); + assertThrows(IllegalArgumentException.class, () -> OCGMemoryStore.builder() + .url("https://ocg.test") + .build()); + } + + @Test + void build_memory_summarizer_is_recursion_safe() { + Agent summarizer = OCGMemoryStore.buildMemorySummarizer("openai/gpt-4o"); + assertEquals("__memory_summarizer", summarizer.getName()); + assertEquals("openai/gpt-4o", summarizer.getModel()); + assertEquals(MemorySummary.class, summarizer.getOutputType()); + assertEquals(1, summarizer.getMaxTurns()); + // Created WITHOUT semantic_memory so the post-run save hook skips it (no recursion). + assertNull(summarizer.getSemanticMemory()); + } + + @Test + void agent_stores_memory_attrs() { + SemanticMemory sm = new SemanticMemory(new OCGMemoryStore.Builder() + .url("https://ocg.test") + .agent("agent:a") + .build(), + 5, + null); + Agent agent = Agent.builder() + .name("a") + .model("openai/gpt-4o") + .semanticMemory(sm) + .build(); + assertEquals(sm, agent.getSemanticMemory()); + assertNull(agent.getMemorySummaryModel(), "defaults to null -> reuse agent model"); + assertNull(agent.getFeedbackSink()); + } +}