Skip to content

Commit e747b65

Browse files
NicholasDColeclaude
andcommitted
feat(agents): OCG-backed long-term agent memory + feedback links
Port the agentspan "OCG-backed agent memory" feature (agentspan-ai/agentspan#298) to the TypeScript SDK. - OCGMemoryStore: async HTTP adapter over the OCG BFF (search / save / delete / list / feedback-links mint), using fetch. - Agent params: semanticMemory, memorySummaryModel, feedbackSink. - Serializer: emit longTermMemory {ocgUrl, credential, agent, user, scope, maxResults, summaryModel} + feedbackSink {taskName} — the wire contract the server-side compiler activates on (credential is a server-resolvable secret NAME, never the raw client token). - Runtime: register the {agent}_feedback_sink worker so the compiled path can hand human good/bad capability links to the local sink (best-effort). - FeedbackEvent / MemorySummary / buildMemorySummarizer helpers. - Tests (jest) for the store, the serializer emission, and the feedback worker. - Example examples/agents/118-ocg-memory.ts. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent a410bad commit e747b65

9 files changed

Lines changed: 958 additions & 2 deletions

File tree

examples/agents/118-ocg-memory.ts

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
/**
2+
* 118 - OCG-backed long-term memory with human good/bad feedback links.
3+
*
4+
* Enable memory on an agent and the server-side compiler does two things
5+
* automatically once the config is deployed/started:
6+
*
7+
* - BEFORE a run: relevant past memories (scoped to this agent/user) are
8+
* retrieved from OCG and injected into the prompt — no tool call needed.
9+
* - AFTER a run: the conversation is summarized (Claude-style: durable facts,
10+
* not the raw transcript) by a small internal summarizer agent and saved
11+
* back to OCG as a memory.
12+
*
13+
* Feedback is HUMAN-only. Agents never vote. Instead, the runtime hands a
14+
* `FeedbackEvent` — including signed *capability URLs* (good/bad) — to the
15+
* agent's `feedbackSink`. A human (e.g. a support engineer) clicks a link to
16+
* mark the memory good or bad; the link skips auth (its signature is the
17+
* authorization), so the clicker needs no OCG account. Here the sink just
18+
* prints the URLs as they'd appear in a Zendesk ticket comment.
19+
*
20+
* Requires the OCG instance to be started with a feedback-link secret
21+
* (`OCG_FEEDBACK_LINK_SECRET`) for the capability URLs to be minted.
22+
*
23+
* Run:
24+
*
25+
* OCG_INSTANCE_URL=https://test.contextgraph.io \
26+
* OCG_TOKEN=<bearer-token> \
27+
* npx tsx examples/agents/118-ocg-memory.ts
28+
*/
29+
30+
import {
31+
Agent,
32+
AgentRuntime,
33+
OCGMemoryStore,
34+
type FeedbackEvent,
35+
} from '@io-orkes/conductor-javascript/agents';
36+
37+
const MODEL = process.env.AGENTSPAN_LLM_MODEL ?? 'openai/gpt-4o-mini';
38+
39+
const OCG_INSTANCE_URL = process.env.OCG_INSTANCE_URL ?? '';
40+
// Unlike the credential-resolving retrieval tools (which resolve a credential
41+
// server-side), the memory store calls OCG directly, so it holds the bearer
42+
// token client-side.
43+
const OCG_TOKEN = process.env.OCG_TOKEN;
44+
if (!OCG_INSTANCE_URL) {
45+
throw new Error(
46+
'Set OCG_INSTANCE_URL to your OCG instance, e.g. https://test.contextgraph.io',
47+
);
48+
}
49+
50+
/**
51+
* Deliver the good/bad links to a human. In production this would POST a comment
52+
* to the Zendesk ticket; here we just print what would be sent.
53+
*/
54+
function zendeskSink(event: FeedbackEvent): void {
55+
console.log('\n--- would post to Zendesk ticket ---');
56+
console.log(`Saved memory: ${event.memoryKey}`);
57+
console.log(`Summary: ${event.summary}`);
58+
if (event.goodUrl) {
59+
console.log(` 👍 Was this helpful? ${event.goodUrl}`);
60+
console.log(` 👎 Not helpful: ${event.badUrl}`);
61+
}
62+
console.log('------------------------------------\n');
63+
}
64+
65+
const store = new OCGMemoryStore({
66+
url: OCG_INSTANCE_URL,
67+
agent: 'agent:support',
68+
user: 'user:alice',
69+
token: OCG_TOKEN,
70+
maxResults: 5,
71+
});
72+
73+
export const supportAgent = new Agent({
74+
name: 'support',
75+
model: MODEL,
76+
instructions:
77+
'You are a customer support agent. Use any relevant context from memory to ' +
78+
'personalize your answer. A memory labeled [bad] was flagged by a human — ' +
79+
'treat it with suspicion.',
80+
semanticMemory: store,
81+
feedbackSink: zendeskSink,
82+
});
83+
84+
async function main() {
85+
const runtime = new AgentRuntime();
86+
try {
87+
console.log('--- Turn 1 ---');
88+
const t1 = await runtime.run(
89+
supportAgent,
90+
"Hi, I'm Alice. I'm on the Enterprise plan and prefer email.",
91+
);
92+
t1.printResult();
93+
94+
console.log("\n--- Turn 2 (should recall Alice's plan from memory) ---");
95+
const t2 = await runtime.run(supportAgent, 'What plan am I on again?');
96+
t2.printResult();
97+
} finally {
98+
await runtime.shutdown();
99+
}
100+
}
101+
102+
main().catch(console.error);
Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
import { describe, it, expect, jest, beforeEach } from "@jest/globals";
2+
import { stubGlobal } from "./helpers/stub-global.js";
3+
import { OCGMemoryStore } from "../ocg-memory.js";
4+
import type { FeedbackEvent } from "../ocg-memory.js";
5+
import { Agent } from "../agent.js";
6+
import { AgentRuntime } from "../runtime.js";
7+
import { AgentAPIError } from "../errors.js";
8+
9+
// ── Helpers ──────────────────────────────────────────────
10+
11+
/** A jest-mocked fetch returning a single canned JSON response (200 OK). */
12+
function mockJson(body: unknown) {
13+
const fn = jest.fn(async () => ({
14+
ok: true,
15+
status: 200,
16+
text: async () => JSON.stringify(body),
17+
}));
18+
stubGlobal("fetch", fn);
19+
return fn;
20+
}
21+
22+
function makeStore() {
23+
return new OCGMemoryStore({ url: "https://ocg.test/", agent: "agent:a", user: "user:bob" });
24+
}
25+
26+
describe("OCGMemoryStore", () => {
27+
beforeEach(() => {
28+
jest.restoreAllMocks();
29+
});
30+
31+
it("strips trailing slash from the OCG url and exposes config", () => {
32+
const store = new OCGMemoryStore({ url: "https://ocg.example.com/", agent: "agent:x" });
33+
expect(store.ocgUrl).toBe("https://ocg.example.com");
34+
expect(store.credential).toBe("OCG_PUBLIC_KEY");
35+
expect(store.scope).toBe("user");
36+
});
37+
38+
it("rejects a blank url or agent", () => {
39+
expect(() => new OCGMemoryStore({ url: " ", agent: "agent:x" })).toThrow();
40+
expect(() => new OCGMemoryStore({ url: "https://ocg.test", agent: "" })).toThrow();
41+
});
42+
43+
it("add posts a value field (not string_value / confidence) and agent/user", async () => {
44+
const fetchMock = mockJson({ key: "k1" });
45+
const store = makeStore();
46+
47+
const key = await store.add({ content: "alice prefers email", metadata: { key: "pref" } });
48+
49+
expect(key).toBe("pref");
50+
const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
51+
expect(url).toBe("https://ocg.test/api/v1/memories");
52+
expect(init.method).toBe("POST");
53+
const body = JSON.parse(init.body as string);
54+
expect(body.value).toBe("alice prefers email"); // field is "value", NOT "string_value"
55+
expect(body).not.toHaveProperty("string_value");
56+
expect(body).not.toHaveProperty("confidence"); // confidence was removed from the API
57+
expect(body.agent).toBe("agent:a");
58+
expect(body.user).toBe("user:bob");
59+
expect(body.source).toBe("agent_inferred");
60+
});
61+
62+
it("search folds the good/bad signal into result content", async () => {
63+
const fetchMock = mockJson({
64+
memories: [
65+
{
66+
key: "m1",
67+
value_preview: "use us-east-1",
68+
good_count: 2,
69+
bad_count: 1,
70+
relevance_score: 0.9,
71+
feedback_notes: [{ verdict: "bad", reason: "stale region" }],
72+
},
73+
],
74+
});
75+
const store = makeStore();
76+
77+
const entries = await store.search("which region", 5);
78+
79+
const [url] = fetchMock.mock.calls[0] as unknown as [string];
80+
expect(url).toBe("https://ocg.test/api/v1/memories/search");
81+
expect(entries).toHaveLength(1);
82+
expect(entries[0].content).toContain("[good 2 / bad 1]");
83+
expect(entries[0].content).toContain('bad: "stale region"');
84+
});
85+
86+
it("feedbackLinks hits the mint route and returns the urls", async () => {
87+
const fetchMock = mockJson({
88+
good_url: "https://ocg.test/api/v1/feedback/GOOD",
89+
bad_url: "https://ocg.test/api/v1/feedback/BAD",
90+
expires_at: "2026-09-01T00:00:00Z",
91+
});
92+
const store = makeStore();
93+
94+
const links = await store.feedbackLinks("k1");
95+
96+
const [url] = fetchMock.mock.calls[0] as unknown as [string];
97+
expect(url.split("?")[0]).toBe("https://ocg.test/api/v1/memories/k1/feedback-links");
98+
expect(links.good_url).toContain("/feedback/GOOD");
99+
expect(links.bad_url).toContain("/feedback/BAD");
100+
});
101+
102+
it("sends the bearer token when provided", async () => {
103+
const fetchMock = mockJson({ key: "k" });
104+
const store = new OCGMemoryStore({ url: "https://ocg.test", agent: "agent:a", token: "tok-123" });
105+
106+
await store.add({ content: "x", metadata: { key: "k" } });
107+
108+
const [, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
109+
const headers = init.headers as Record<string, string>;
110+
expect(headers.Authorization).toBe("Bearer tok-123");
111+
});
112+
113+
it("throws AgentAPIError on a non-2xx response", async () => {
114+
stubGlobal(
115+
"fetch",
116+
jest.fn(async () => ({ ok: false, status: 500, text: async () => "boom" })),
117+
);
118+
const store = makeStore();
119+
await expect(store.add({ content: "x", metadata: { key: "k" } })).rejects.toThrow(AgentAPIError);
120+
});
121+
});
122+
123+
// ── Runtime feedbackSink worker ──────────────────────────
124+
125+
interface PendingWorker {
126+
taskName: string;
127+
handler: (input: Record<string, unknown>) => Promise<unknown>;
128+
}
129+
130+
function findWorker(runtime: AgentRuntime, taskName: string): PendingWorker | undefined {
131+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
132+
const workers = (runtime as any).workerManager.pendingWorkers as PendingWorker[];
133+
return workers.find((w) => w.taskName === taskName);
134+
}
135+
136+
describe("AgentRuntime feedbackSink worker", () => {
137+
it("registers a feedback_sink worker that rebuilds a FeedbackEvent and calls the sink", async () => {
138+
const runtime = new AgentRuntime();
139+
const events: FeedbackEvent[] = [];
140+
const agent = new Agent({
141+
name: "support",
142+
model: "openai/gpt-4o",
143+
semanticMemory: new OCGMemoryStore({ url: "https://ocg.test", agent: "agent:a" }),
144+
feedbackSink: (ev) => {
145+
events.push(ev);
146+
},
147+
});
148+
149+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
150+
await (runtime as any)._registerSystemWorkers(agent, null);
151+
152+
const worker = findWorker(runtime, "support_feedback_sink");
153+
expect(worker).toBeDefined();
154+
155+
const result = await worker!.handler({
156+
memory_key: "conversation:sess-9",
157+
summary: "Alice is on Enterprise.",
158+
facts: ["plan=enterprise"],
159+
tags: ["billing"],
160+
good_url: "https://ocg.test/api/v1/feedback/GOOD",
161+
bad_url: "https://ocg.test/api/v1/feedback/BAD",
162+
agent: "agent:a",
163+
user: "user:alice",
164+
});
165+
166+
expect(result).toEqual({ delivered: true });
167+
expect(events).toHaveLength(1);
168+
expect(events[0].memoryKey).toBe("conversation:sess-9");
169+
expect(events[0].summary).toBe("Alice is on Enterprise.");
170+
expect(events[0].facts).toEqual(["plan=enterprise"]);
171+
expect(events[0].goodUrl).toContain("/feedback/GOOD");
172+
expect(events[0].badUrl).toContain("/feedback/BAD");
173+
});
174+
175+
it("swallows sink failures so memory never fails the run", async () => {
176+
const runtime = new AgentRuntime();
177+
const agent = new Agent({
178+
name: "support",
179+
model: "openai/gpt-4o",
180+
semanticMemory: new OCGMemoryStore({ url: "https://ocg.test", agent: "agent:a" }),
181+
feedbackSink: () => {
182+
throw new Error("sink exploded");
183+
},
184+
});
185+
186+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
187+
await (runtime as any)._registerSystemWorkers(agent, null);
188+
const worker = findWorker(runtime, "support_feedback_sink");
189+
expect(worker).toBeDefined();
190+
191+
await expect(worker!.handler({ memory_key: "k", summary: "s" })).resolves.toEqual({
192+
delivered: false,
193+
});
194+
});
195+
196+
it("registers no feedback_sink worker without a sink", async () => {
197+
const runtime = new AgentRuntime();
198+
const agent = new Agent({
199+
name: "plain",
200+
model: "openai/gpt-4o",
201+
semanticMemory: new OCGMemoryStore({ url: "https://ocg.test", agent: "agent:a" }),
202+
});
203+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
204+
await (runtime as any)._registerSystemWorkers(agent, null);
205+
expect(findWorker(runtime, "plain_feedback_sink")).toBeUndefined();
206+
});
207+
208+
it("stores the memory attrs on the Agent", () => {
209+
const agent = new Agent({
210+
name: "a",
211+
model: "openai/gpt-4o",
212+
semanticMemory: new OCGMemoryStore({ url: "https://ocg.test", agent: "agent:a" }),
213+
});
214+
expect(agent.semanticMemory).toBeDefined();
215+
expect(agent.memorySummaryModel).toBeUndefined(); // defaults to reuse the agent model
216+
expect(agent.feedbackSink).toBeUndefined();
217+
});
218+
});

0 commit comments

Comments
 (0)