refactor(integrations): shared agent plugin runtimes and native adapters (#7203)

This commit is contained in:
Kartik
2026-09-08 23:32:25 +05:30
committed by GitHub
parent dae67f74f5
commit 73e7b8763a
369 changed files with 31056 additions and 20464 deletions
+7 -69
View File
@@ -1,69 +1,7 @@
/**
* Compact, token-cheap rendering of Mem0 results for the model context.
*
* Mirrors the format used by the sibling Mem0 plugins
* (integrations/pi-agent-plugin/src/memory/formatting.ts) so a memory reads the
* same way across every harness: `[category] text (age) [mem0:id]`. Dumping the
* raw search envelope instead would spend most of the tokens on JSON scaffolding.
*/
export interface MemoryLike {
id: string;
memory?: string;
categories?: string[];
createdAt?: Date | string;
}
export function formatAge(date: Date | string): string {
const d = typeof date === "string" ? new Date(date) : date;
const ms = Date.now() - d.getTime();
const minutes = Math.floor(ms / 60_000);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
return `${days}d ago`;
}
export function formatMemoryCompact(mem: MemoryLike): string {
const cat = mem.categories?.[0] ?? "uncategorized";
const age = mem.createdAt ? ` (${formatAge(mem.createdAt)})` : "";
return `[${cat}] ${mem.memory ?? "(empty)"}${age} [mem0:${mem.id}]`;
}
export function formatMemoryList(memories: MemoryLike[]): string {
if (memories.length === 0) return "No memories found.";
return memories
.map((m, i) => `${i + 1}. ${formatMemoryCompact(m)}`)
.join("\n");
}
/**
* One-line confirmation for a write.
*
* `client.add` hits the async `/v3/memories/add/` endpoint, which returns
* `{ event_id, status: "PENDING" }` — extraction runs server-side *after* the
* call returns, so the extracted memories are not in this response. Report the
* write as queued in that case; only render a list when the backend actually
* returns memories (older / OSS shapes).
*/
export function formatAddResult(result: unknown): string {
const items: MemoryLike[] = Array.isArray(result)
? (result as MemoryLike[])
: ((result as { results?: MemoryLike[] } | null)?.results ??
(result ? [result as MemoryLike] : []));
const pending = items.find(
(r) => (r as { status?: string }).status === "PENDING",
) as { eventId?: string; event_id?: string } | undefined;
if (pending) {
// The SDK camel-cases response keys (event_id -> eventId); accept either.
const id = pending.eventId ?? pending.event_id;
const evt = id ? ` (event ${id})` : "";
return `Memory queued for background extraction${evt}; it will be searchable shortly.`;
}
if (items.length === 0) return "Memory stored.";
const noun = items.length === 1 ? "memory" : "memories";
return `Stored ${items.length} ${noun}:\n${formatMemoryList(items)}`;
}
export {
formatAddResult,
formatAge,
formatMemoryCompact,
formatMemoryList,
} from "../../agent-plugin-core/typescript/src/formatting.ts";
export type { MemoryLike } from "../../agent-plugin-core/typescript/src/formatting.ts";
+130 -11
View File
@@ -6,20 +6,24 @@
* - `add_memory` stores a fact for future sessions
*
* A plugin is a Cordis module that exports `apply(ctx, config)`. Declaring
* `inject = ['tools']` holds the plugin until the harness tool registry exists;
* `inject = ['tools', 'systemPrompt']` holds the plugin until the harness services exist;
* tools registered via `ctx.tools.register(...)` are auto-unregistered when the
* plugin unmounts (Cordis revertible effects).
*/
import type { Context } from "@deepseek-ai/cordis";
import type {} from "@deepseek-ai/dsh-agent";
import type { PromptAssembly } from "@deepseek-ai/dsh-system-prompt";
import type {} from "@deepseek-ai/dsh-session";
import { defineTool } from "@deepseek-ai/dsh-tools";
import { MemoryClient } from "mem0ai";
import { formatMemoryList, formatAddResult } from "./formatting.ts";
import { truncateOutput } from "./output.ts";
import { resolveSearchFilters, resolveAddParams } from "./scoping.ts";
import { captureEvent, errorKind } from "./telemetry.ts";
import { createMemoryLifecycle } from "../../agent-plugin-core/typescript/src/lifecycle.ts";
export const name = "mem0";
export const inject = ["tools"];
export const inject = ["tools", "systemPrompt"];
// Tags writes so Mem0's backend attributes them to this integration in
// telemetry. The backend keeps recognized values via its KNOWN_EVENT_SOURCES
@@ -29,14 +33,32 @@ export const inject = ["tools"];
const SOURCE = "DEEPSEEK_HARNESS";
const DEFAULT_SEARCH_LIMIT = 10;
const AUTO_RECALL_LIMIT = 5;
interface HarnessMessage {
role: string;
content?: unknown;
source?: { kind?: string };
}
interface SessionState {
lifecycle: ReturnType<typeof createMemoryLifecycle>;
messages: HarnessMessage[];
}
export interface Config {
/** Mem0 API key. Defaults to the MEM0_API_KEY env var. */
apiKey?: string;
/** Default entity that owns the memories (Mem0 user scope). */
userId: string;
/** Explicitly allow model-selected cross-user access. Defaults to false. */
allowUserOverride?: boolean;
/** Optional Mem0 Platform base-URL override (on-prem / dedicated); defaults to api.mem0.ai. Not a switch to self-hosted OSS. */
host?: string;
/** Recall relevant memory before each model request. Defaults to true. */
autoRecall?: boolean;
/** Store completed turns automatically. Defaults to true. */
autoCapture?: boolean;
}
// Both tools return a single text string; the render is identical, so lift it
@@ -55,7 +77,7 @@ const scopeParams = {
userId: {
type: "string",
description:
"Entity that owns the memory. Defaults to the plugin's configured userId; set this only to read or write another user's memories.",
"Entity that owns the memory. Defaults to the plugin's configured userId; cross-user overrides require allowUserOverride in plugin configuration.",
},
agentId: {
type: "string",
@@ -72,8 +94,8 @@ export function apply(ctx: Context, config: Config): void {
if (!apiKey) {
throw new Error("deepseek-plugin: set config.apiKey or the MEM0_API_KEY env var");
}
const userId = config.userId;
if (!userId) {
const userId = config.userId?.trim();
if (!userId || /^\*+$/.test(userId)) {
throw new Error("deepseek-plugin: config.userId is required");
}
@@ -81,8 +103,95 @@ export function apply(ctx: Context, config: Config): void {
apiKey,
...(config.host ? { host: config.host } : {}),
});
const toolLifecycle = createMemoryLifecycle();
const sessionStates = new WeakMap<object, SessionState>();
const stateFor = (session: object): SessionState => {
let state = sessionStates.get(session);
if (!state) {
const lifecycle = createMemoryLifecycle();
lifecycle.beginSession();
state = { lifecycle, messages: [] };
sessionStates.set(session, state);
}
return state;
};
captureEvent("deepseek.plugin.mounted", { has_host: Boolean(config.host) }, client);
captureEvent("deepseek.plugin.mounted", {
has_host: Boolean(config.host),
auto_recall: config.autoRecall !== false,
auto_capture: config.autoCapture !== false,
}, client);
if (config.autoRecall !== false) {
ctx.on("system-prompt/assemble", async (_input, context, next): Promise<PromptAssembly> => {
const assembly = await next();
const { agent, signal } = context;
if (!agent || signal?.aborted) return assembly;
const state = stateFor(agent.session);
const prompt = state.lifecycle.prepareConversation(
agent.session
.deriveMessages()
.filter((message) => message.role === "user" && message.source?.kind === "user"),
).at(-1)?.content;
if (!prompt) return assembly;
const memoryContext = await state.lifecycle.recall(prompt, true, async (query) => {
const started = Date.now();
try {
const result = await client.search(query, {
filters: resolveSearchFilters({}, userId),
topK: AUTO_RECALL_LIMIT,
});
captureEvent("deepseek.recall.auto", {
success: true,
duration_ms: Date.now() - started,
result_count: result.results?.length ?? 0,
}, client);
return result;
} catch (err) {
captureEvent("deepseek.recall.auto", {
success: false,
duration_ms: Date.now() - started,
error_kind: errorKind(err),
}, client);
throw err;
}
});
if (!memoryContext || signal?.aborted) return assembly;
return {
...assembly,
contexts: [...assembly.contexts, { name: "mem0:recall", text: memoryContext }],
};
});
}
if (config.autoCapture !== false) {
ctx.on("session/event", (session, event) => {
const state = stateFor(session);
if (event.type === "turn/start") {
state.messages = [];
} else if (event.type === "user/message" && event.data.source.kind === "user") {
state.messages.push(event.data);
} else if (event.type === "assistant/message") {
state.messages.push(event.data.message);
} else if (event.type === "turn/end") {
const conversation = state.lifecycle.prepareConversation(state.messages);
state.messages = [];
if (event.data.reason.kind !== "completed" || conversation.length === 0) return;
void client
.add(conversation, { userId, source: SOURCE })
.then(() => captureEvent("deepseek.capture.auto", {
success: true,
message_count: conversation.length,
}, client))
.catch((err: unknown) => captureEvent("deepseek.capture.auto", {
success: false,
error_kind: errorKind(err),
}, client));
}
});
}
// Recall. The platform rejects top-level entity params on search, so scope
// goes inside `filters` (unlike add below, which takes them top-level).
@@ -101,11 +210,15 @@ export function apply(ctx: Context, config: Config): void {
},
output: textOutput,
async execute({ query, limit, userId: u, agentId, runId }) {
if (u?.trim() && u.trim() !== userId && config.allowUserOverride !== true) {
throw new Error("Cross-user access requires allowUserOverride in plugin configuration.");
}
const safeQuery = toolLifecycle.prepareUserText(query);
const filters = resolveSearchFilters({ userId: u, agentId, runId }, userId);
const topK = limit && limit > 0 ? limit : DEFAULT_SEARCH_LIMIT;
const started = Date.now();
try {
const { results } = await client.search(query, { filters, topK });
const { results } = await client.search(safeQuery, { filters, topK });
captureEvent(
"deepseek.tool.search_memory",
{
@@ -150,13 +263,19 @@ export function apply(ctx: Context, config: Config): void {
},
output: textOutput,
async execute({ text, userId: u, agentId, runId }) {
if (u?.trim() && u.trim() !== userId && config.allowUserOverride !== true) {
throw new Error("Cross-user access requires allowUserOverride in plugin configuration.");
}
const addParams = resolveAddParams({ userId: u, agentId, runId }, userId);
const started = Date.now();
try {
const result = await client.add([{ role: "user", content: text }], {
...addParams,
source: SOURCE,
});
const result = await client.add(
[{ role: "user", content: toolLifecycle.prepareUserText(text) }],
{
...addParams,
source: SOURCE,
},
);
captureEvent(
"deepseek.tool.add_memory",
{
+5 -33
View File
@@ -1,33 +1,5 @@
/**
* Hard cap on tool output before it reaches the model context.
*
* Same guard the sibling plugins apply (200 lines / 50KB, see
* integrations/pi-agent-plugin/src/memory/tools.ts): a large recall or a wide
* result set can otherwise flood the context window in a single tool call.
*/
export const MAX_OUTPUT_LINES = 200;
export const MAX_OUTPUT_BYTES = 50_000;
export function truncateOutput(text: string): string {
const lines = text.split("\n");
if (lines.length <= MAX_OUTPUT_LINES && text.length <= MAX_OUTPUT_BYTES) {
return text;
}
const kept = lines.slice(0, MAX_OUTPUT_LINES);
let result = kept.join("\n");
const byteCapped = result.length > MAX_OUTPUT_BYTES;
if (byteCapped) {
result = result.slice(0, MAX_OUTPUT_BYTES);
}
const dropped = lines.length - kept.length;
const reasons: string[] = [];
if (dropped > 0) reasons.push(`showing ${kept.length} of ${lines.length} lines`);
if (byteCapped) reasons.push(`cut at ${Math.floor(MAX_OUTPUT_BYTES / 1000)}KB`);
if (reasons.length > 0) {
result += `\n\n[Output truncated: ${reasons.join(", ")}]`;
}
return result;
}
export {
MAX_OUTPUT_BYTES,
MAX_OUTPUT_LINES,
truncateOutput,
} from "../../agent-plugin-core/typescript/src/formatting.ts";
+5 -53
View File
@@ -1,53 +1,5 @@
/**
* Per-call memory scoping.
*
* The plugin is mounted with one default `userId`, but a single harness install
* can serve more than one entity, so both tools accept optional `userId` /
* `agentId` / `runId` params that override the mount-time default per call.
* A missing or blank param falls back to the configured user.
*
* The two call sites need different key casing, and it is deliberate rather than
* incidental: search passes scope inside `filters`, sent to the platform raw, so
* it must be snake_case; add takes the entity params top-level, through the
* SDK's camel->snake converter, so it must be camelCase. Keeping the split
* explicit (like integrations/pi-agent-plugin/src/memory/scoping.ts) means the
* asymmetry is visible in the code, not load-bearing on a converter no-op.
*/
export interface EntityParams {
userId?: string;
agentId?: string;
runId?: string;
}
const clean = (v: string | undefined) => v?.trim() || undefined;
/** Search: snake_case, spread into `filters` and passed to the platform raw. */
export function resolveSearchFilters(
params: EntityParams,
defaultUserId: string,
): Record<string, string> {
const filters: Record<string, string> = {
user_id: clean(params.userId) ?? defaultUserId,
};
const agentId = clean(params.agentId);
if (agentId) filters.agent_id = agentId;
const runId = clean(params.runId);
if (runId) filters.run_id = runId;
return filters;
}
/** Add: camelCase, top-level params run through the SDK's camel->snake converter. */
export function resolveAddParams(
params: EntityParams,
defaultUserId: string,
): Record<string, string> {
const out: Record<string, string> = {
userId: clean(params.userId) ?? defaultUserId,
};
const agentId = clean(params.agentId);
if (agentId) out.agentId = agentId;
const runId = clean(params.runId);
if (runId) out.runId = runId;
return out;
}
export {
entityAddParams as resolveAddParams,
entitySearchFilters as resolveSearchFilters,
} from "../../agent-plugin-core/typescript/src/identity.ts";
export type { EntityParams } from "../../agent-plugin-core/typescript/src/identity.ts";
+37 -112
View File
@@ -1,173 +1,98 @@
/**
* Anonymous usage telemetry for the DeepSeek Harness plugin.
*
* Fire-and-forget PostHog events over native fetch, batched and flushed every
* 5 seconds, at 10 queued events, or on process exit. Never throws, never logs.
*
* Events carry only tool names, durations, counts, and coarse failure kinds:
* never queries, memory text, filters, or API keys.
*
* Disable with MEM0_TELEMETRY=false.
*/
import { randomUUID } from "node:crypto";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
const POSTHOG_API_KEY = "phc_hgJkUVJFYtmaJqrvf6CYN67TIQ8yhXAkWzUn9AMU4yX";
const POSTHOG_BATCH_URL = "https://us.i.posthog.com/batch/";
const FLUSH_INTERVAL_MS = 5_000;
const FLUSH_THRESHOLD = 10;
const SEND_TIMEOUT_MS = 3_000;
import {
createTelemetry,
errorKind,
isTelemetryEnabled,
} from "../../agent-plugin-core/typescript/src/telemetry.ts";
const PLUGIN_VERSION = ((): string => {
export { errorKind, isTelemetryEnabled };
const PLUGIN_VERSION = (() => {
try {
return JSON.parse(
fs.readFileSync(new URL("../package.json", import.meta.url), "utf-8"),
).version;
return JSON.parse(fs.readFileSync(new URL("../package.json", import.meta.url), "utf-8")).version;
} catch {
return "unknown";
}
})();
/** The Mem0 SDK resolves this to the account email once ping() lands, so events join other Mem0 surfaces. */
export interface TelemetryIdentity {
telemetryId?: string;
}
let queue: Record<string, unknown>[] = [];
let flushTimer: ReturnType<typeof setInterval> | undefined;
let exitHandlerInstalled = false;
let cachedAnonymousId: string | undefined;
let identified = false;
let currentDistinctId = "";
function identityPath(): string {
return path.join(os.homedir(), ".mem0", "deepseek-plugin-telemetry.json");
}
export function isTelemetryEnabled(): boolean {
const value = process.env.MEM0_TELEMETRY?.toLowerCase();
return value !== "false" && value !== "0" && value !== "no" && value !== "off";
}
function anonymousId(): string {
if (cachedAnonymousId) return cachedAnonymousId;
try {
const stored = JSON.parse(fs.readFileSync(identityPath(), "utf-8"));
if (typeof stored.anonymousId === "string" && stored.anonymousId) {
cachedAnonymousId = stored.anonymousId;
return cachedAnonymousId!;
return (cachedAnonymousId = stored.anonymousId);
}
} catch {
/* first run, or an unreadable identity file */
// First run or unreadable identity file.
}
const created = `deepseek-anon-${randomUUID().replace(/-/g, "")}`;
try {
const target = identityPath();
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.writeFileSync(target, JSON.stringify({ anonymousId: created }), "utf-8");
fs.mkdirSync(path.dirname(identityPath()), { recursive: true });
fs.writeFileSync(identityPath(), JSON.stringify({ anonymousId: created }), "utf-8");
} catch {
/* an unwritable home directory must not break the plugin */
// An unwritable home directory must not break the plugin.
}
cachedAnonymousId = created;
return created;
return (cachedAnonymousId = created);
}
/** Merge the pre-ping anonymous history into the account once the email resolves. */
function identifyEvent(distinctId: string): Record<string, unknown> | undefined {
function previousAnonymousId(distinctId: string): string | undefined {
if (identified || distinctId.startsWith("deepseek-anon-")) return undefined;
identified = true;
let storedAnonymousId: string | undefined;
try {
storedAnonymousId = JSON.parse(fs.readFileSync(identityPath(), "utf-8")).anonymousId;
const stored = JSON.parse(fs.readFileSync(identityPath(), "utf-8")).anonymousId;
fs.unlinkSync(identityPath());
cachedAnonymousId = undefined;
return typeof stored === "string" && stored ? stored : undefined;
} catch {
return undefined;
}
cachedAnonymousId = undefined;
if (!storedAnonymousId) return undefined;
return {
event: "$identify",
distinct_id: distinctId,
properties: { $anon_distinct_id: storedAnonymousId, $lib: "posthog-node" },
};
}
export function flushEvents(): void {
if (queue.length === 0) return;
const batch = queue;
queue = [];
fetch(POSTHOG_BATCH_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ api_key: POSTHOG_API_KEY, batch }),
signal: AbortSignal.timeout(SEND_TIMEOUT_MS),
}).catch(() => {
/* telemetry never surfaces its own failures */
});
}
function scheduleFlush(): void {
if (!flushTimer) {
flushTimer = setInterval(flushEvents, FLUSH_INTERVAL_MS);
flushTimer.unref?.();
}
if (!exitHandlerInstalled) {
exitHandlerInstalled = true;
process.on("beforeExit", flushEvents);
}
}
export function errorKind(error: unknown): string {
const text = (error instanceof Error ? error.message : String(error)).toLowerCase();
if (text.includes("timeout") || text.includes("aborted")) return "timeout";
if (text.includes("401") || text.includes("403") || text.includes("unauthor")) return "auth";
if (text.includes("429") || text.includes("rate limit")) return "rate-limited";
if (/50[0234]/.test(text)) return "server-error";
if (text.includes("400") || text.includes("422")) return "bad-request";
if (text.includes("fetch failed") || text.includes("enotfound")) return "network";
return error instanceof Error ? error.constructor.name : "other";
}
const telemetry = createTelemetry({
host: "deepseek",
source: "DEEPSEEK_HARNESS",
version: PLUGIN_VERSION,
distinctId: () => currentDistinctId,
});
export function captureEvent(
event: string,
properties: Record<string, unknown>,
client: TelemetryIdentity,
): void {
if (!isTelemetryEnabled()) return;
try {
const distinctId = client.telemetryId || anonymousId();
const identify = identifyEvent(distinctId);
if (identify) queue.push(identify);
queue.push({
event,
distinct_id: distinctId,
properties: {
source: "DEEPSEEK_HARNESS",
language: "node",
plugin_version: PLUGIN_VERSION,
node_version: process.version,
os: process.platform,
$process_person_profile: false,
$lib: "posthog-node",
...properties,
},
});
scheduleFlush();
if (queue.length >= FLUSH_THRESHOLD) flushEvents();
} catch {
/* telemetry never breaks a tool call */
}
currentDistinctId = client.telemetryId || anonymousId();
const anonymous = previousAnonymousId(currentDistinctId);
if (anonymous) telemetry.capture("$identify", { $anon_distinct_id: anonymous });
telemetry.capture(event, properties);
}
export function flushEvents(): void {
void telemetry.flush();
}
export function _queueForTesting(): Record<string, unknown>[] {
return queue;
return telemetry.queueForTesting();
}
export function _resetForTesting(): void {
queue = [];
clearInterval(flushTimer);
flushTimer = undefined;
telemetry.resetForTesting();
cachedAnonymousId = undefined;
currentDistinctId = "";
identified = false;
}