feat: OpenBrain ContextEngine plugin v0.0.1 (#3)
Co-authored-by: Jason Woltje <jason@diversecanvas.com> Co-committed-by: Jason Woltje <jason@diversecanvas.com>
This commit was merged in pull request #3.
This commit is contained in:
752
src/engine.ts
Normal file
752
src/engine.ts
Normal file
@@ -0,0 +1,752 @@
|
||||
import { OPENBRAIN_CONTEXT_ENGINE_ID, OPENBRAIN_PLUGIN_VERSION } from "./constants.js";
|
||||
import { OpenBrainConfigError } from "./errors.js";
|
||||
import type {
|
||||
AgentMessage,
|
||||
AssembleResult,
|
||||
BootstrapResult,
|
||||
CompactResult,
|
||||
ContextEngine,
|
||||
ContextEngineInfo,
|
||||
IngestBatchResult,
|
||||
IngestResult,
|
||||
PluginLogger,
|
||||
SubagentEndReason,
|
||||
SubagentSpawnPreparation,
|
||||
} from "./openclaw-types.js";
|
||||
import {
|
||||
OpenBrainClient,
|
||||
type OpenBrainClientLike,
|
||||
type OpenBrainSearchInput,
|
||||
type OpenBrainThought,
|
||||
type OpenBrainThoughtMetadata,
|
||||
} from "./openbrain-client.js";
|
||||
|
||||
export type OpenBrainContextEngineConfig = {
|
||||
baseUrl?: string;
|
||||
apiKey?: string;
|
||||
recentMessages?: number;
|
||||
semanticSearchLimit?: number;
|
||||
source?: string;
|
||||
subagentRecentMessages?: number;
|
||||
};
|
||||
|
||||
type ResolvedOpenBrainContextEngineConfig = {
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
recentMessages: number;
|
||||
semanticSearchLimit: number;
|
||||
source: string;
|
||||
subagentRecentMessages: number;
|
||||
};
|
||||
|
||||
export type OpenBrainContextEngineDeps = {
|
||||
createClient?: (config: ResolvedOpenBrainContextEngineConfig) => OpenBrainClientLike;
|
||||
now?: () => number;
|
||||
logger?: PluginLogger;
|
||||
};
|
||||
|
||||
type SubagentState = {
|
||||
parentSessionKey: string;
|
||||
seedThoughtId?: string;
|
||||
};
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
function parsePositiveInteger(value: unknown, fallback: number): number {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const rounded = Math.floor(value);
|
||||
return rounded > 0 ? rounded : fallback;
|
||||
}
|
||||
|
||||
function normalizeRole(role: unknown): string {
|
||||
if (typeof role !== "string" || role.length === 0) {
|
||||
return "assistant";
|
||||
}
|
||||
|
||||
if (role === "user" || role === "assistant" || role === "tool" || role === "system") {
|
||||
return role;
|
||||
}
|
||||
|
||||
return "assistant";
|
||||
}
|
||||
|
||||
function serializeContent(value: unknown): string {
|
||||
if (typeof value === "string") {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
.map((part) => serializeContent(part))
|
||||
.filter((part) => part.length > 0)
|
||||
.join("\n")
|
||||
.trim();
|
||||
}
|
||||
|
||||
if (isRecord(value) && typeof value.text === "string") {
|
||||
return value.text;
|
||||
}
|
||||
|
||||
if (value === undefined || value === null) {
|
||||
return "";
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
function estimateTextTokens(text: string): number {
|
||||
const normalized = text.trim();
|
||||
if (normalized.length === 0) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return Math.max(1, Math.ceil(normalized.length / 4) + 4);
|
||||
}
|
||||
|
||||
function thoughtTimestamp(thought: OpenBrainThought, fallbackTimestamp: number): number {
|
||||
const createdAt =
|
||||
thought.createdAt ??
|
||||
(typeof thought.created_at === "string" ? thought.created_at : undefined);
|
||||
|
||||
if (createdAt === undefined) {
|
||||
return fallbackTimestamp;
|
||||
}
|
||||
|
||||
const parsed = Date.parse(createdAt);
|
||||
return Number.isFinite(parsed) ? parsed : fallbackTimestamp;
|
||||
}
|
||||
|
||||
function thoughtFingerprint(thought: OpenBrainThought): string {
|
||||
const role = typeof thought.metadata?.role === "string" ? thought.metadata.role : "assistant";
|
||||
return `${role}\n${thought.content}`;
|
||||
}
|
||||
|
||||
function truncateLine(value: string, maxLength: number): string {
|
||||
if (value.length <= maxLength) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return `${value.slice(0, maxLength - 3)}...`;
|
||||
}
|
||||
|
||||
export class OpenBrainContextEngine implements ContextEngine {
|
||||
readonly info: ContextEngineInfo = {
|
||||
id: OPENBRAIN_CONTEXT_ENGINE_ID,
|
||||
name: "OpenBrain Context Engine",
|
||||
version: OPENBRAIN_PLUGIN_VERSION,
|
||||
ownsCompaction: true,
|
||||
};
|
||||
|
||||
private readonly rawConfig: unknown;
|
||||
private readonly createClientFn:
|
||||
| ((config: ResolvedOpenBrainContextEngineConfig) => OpenBrainClientLike)
|
||||
| undefined;
|
||||
private readonly now: () => number;
|
||||
private readonly logger: PluginLogger | undefined;
|
||||
|
||||
private config: ResolvedOpenBrainContextEngineConfig | undefined;
|
||||
private client: OpenBrainClientLike | undefined;
|
||||
private readonly sessionTurns = new Map<string, number>();
|
||||
private readonly subagentState = new Map<string, SubagentState>();
|
||||
private disposed = false;
|
||||
|
||||
constructor(rawConfig: unknown, deps?: OpenBrainContextEngineDeps) {
|
||||
this.rawConfig = rawConfig;
|
||||
this.createClientFn = deps?.createClient;
|
||||
this.now = deps?.now ?? (() => Date.now());
|
||||
this.logger = deps?.logger;
|
||||
}
|
||||
|
||||
async bootstrap(params: { sessionId: string; sessionFile: string }): Promise<BootstrapResult> {
|
||||
this.assertNotDisposed();
|
||||
|
||||
const config = this.getConfig();
|
||||
const client = this.getClient();
|
||||
const source = this.sourceForSession(params.sessionId);
|
||||
|
||||
const recentThoughts = await client.listRecent({
|
||||
limit: config.recentMessages,
|
||||
source,
|
||||
});
|
||||
|
||||
const sessionThoughts = this.filterSessionThoughts(recentThoughts, params.sessionId);
|
||||
|
||||
let maxTurn = -1;
|
||||
for (const thought of sessionThoughts) {
|
||||
const turn = thought.metadata?.turn;
|
||||
if (typeof turn === "number" && Number.isFinite(turn) && turn > maxTurn) {
|
||||
maxTurn = turn;
|
||||
}
|
||||
}
|
||||
|
||||
this.sessionTurns.set(params.sessionId, maxTurn + 1);
|
||||
|
||||
return {
|
||||
bootstrapped: true,
|
||||
importedMessages: sessionThoughts.length,
|
||||
};
|
||||
}
|
||||
|
||||
async ingest(params: {
|
||||
sessionId: string;
|
||||
message: AgentMessage;
|
||||
isHeartbeat?: boolean;
|
||||
}): Promise<IngestResult> {
|
||||
this.assertNotDisposed();
|
||||
|
||||
const client = this.getClient();
|
||||
const content = serializeContent(params.message.content).trim();
|
||||
if (content.length === 0) {
|
||||
return { ingested: false };
|
||||
}
|
||||
|
||||
const metadata: OpenBrainThoughtMetadata = {
|
||||
sessionId: params.sessionId,
|
||||
turn: this.nextTurn(params.sessionId),
|
||||
role: normalizeRole(params.message.role),
|
||||
type: "message",
|
||||
};
|
||||
|
||||
if (params.isHeartbeat === true) {
|
||||
metadata.isHeartbeat = true;
|
||||
}
|
||||
|
||||
await client.createThought({
|
||||
content,
|
||||
source: this.sourceForSession(params.sessionId),
|
||||
metadata,
|
||||
});
|
||||
|
||||
return { ingested: true };
|
||||
}
|
||||
|
||||
async ingestBatch(params: {
|
||||
sessionId: string;
|
||||
messages: AgentMessage[];
|
||||
isHeartbeat?: boolean;
|
||||
}): Promise<IngestBatchResult> {
|
||||
this.assertNotDisposed();
|
||||
|
||||
let ingestedCount = 0;
|
||||
for (const message of params.messages) {
|
||||
const ingestParams: {
|
||||
sessionId: string;
|
||||
message: AgentMessage;
|
||||
isHeartbeat?: boolean;
|
||||
} = {
|
||||
sessionId: params.sessionId,
|
||||
message,
|
||||
};
|
||||
if (params.isHeartbeat !== undefined) {
|
||||
ingestParams.isHeartbeat = params.isHeartbeat;
|
||||
}
|
||||
const result = await this.ingest(ingestParams);
|
||||
|
||||
if (result.ingested) {
|
||||
ingestedCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return { ingestedCount };
|
||||
}
|
||||
|
||||
async assemble(params: {
|
||||
sessionId: string;
|
||||
messages: AgentMessage[];
|
||||
tokenBudget?: number;
|
||||
}): Promise<AssembleResult> {
|
||||
this.assertNotDisposed();
|
||||
|
||||
const config = this.getConfig();
|
||||
const client = this.getClient();
|
||||
const source = this.sourceForSession(params.sessionId);
|
||||
|
||||
const recentThoughts = this.filterSessionThoughts(
|
||||
await client.listRecent({
|
||||
limit: config.recentMessages,
|
||||
source,
|
||||
}),
|
||||
params.sessionId,
|
||||
);
|
||||
|
||||
const semanticThoughts = await this.searchSemanticThoughts({
|
||||
client,
|
||||
source,
|
||||
config,
|
||||
sessionId: params.sessionId,
|
||||
messages: params.messages,
|
||||
});
|
||||
|
||||
const mergedThoughts = this.mergeThoughts(recentThoughts, semanticThoughts);
|
||||
const mergedMessages =
|
||||
mergedThoughts.length > 0
|
||||
? mergedThoughts.map((thought, index) => this.toAgentMessage(thought, index))
|
||||
: params.messages;
|
||||
|
||||
const tokenBudget = params.tokenBudget;
|
||||
const budgetedMessages =
|
||||
typeof tokenBudget === "number" && tokenBudget > 0
|
||||
? this.trimToBudget(mergedMessages, tokenBudget)
|
||||
: mergedMessages;
|
||||
|
||||
return {
|
||||
messages: budgetedMessages,
|
||||
estimatedTokens: this.estimateTokensForMessages(budgetedMessages),
|
||||
};
|
||||
}
|
||||
|
||||
async compact(params: {
|
||||
sessionId: string;
|
||||
sessionFile: string;
|
||||
tokenBudget?: number;
|
||||
force?: boolean;
|
||||
currentTokenCount?: number;
|
||||
compactionTarget?: "budget" | "threshold";
|
||||
customInstructions?: string;
|
||||
legacyParams?: Record<string, unknown>;
|
||||
}): Promise<CompactResult> {
|
||||
this.assertNotDisposed();
|
||||
|
||||
const config = this.getConfig();
|
||||
const client = this.getClient();
|
||||
const source = this.sourceForSession(params.sessionId);
|
||||
|
||||
const recentThoughts = this.filterSessionThoughts(
|
||||
await client.listRecent({
|
||||
limit: Math.max(config.recentMessages, config.subagentRecentMessages),
|
||||
source,
|
||||
}),
|
||||
params.sessionId,
|
||||
);
|
||||
|
||||
if (recentThoughts.length === 0) {
|
||||
return {
|
||||
ok: true,
|
||||
compacted: false,
|
||||
reason: "no-session-context",
|
||||
result: {
|
||||
tokensBefore: 0,
|
||||
tokensAfter: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const summary = this.buildSummary(
|
||||
params.customInstructions !== undefined
|
||||
? {
|
||||
sessionId: params.sessionId,
|
||||
thoughts: recentThoughts,
|
||||
customInstructions: params.customInstructions,
|
||||
}
|
||||
: {
|
||||
sessionId: params.sessionId,
|
||||
thoughts: recentThoughts,
|
||||
},
|
||||
);
|
||||
|
||||
const summaryTokens = estimateTextTokens(summary);
|
||||
const tokensBefore = this.estimateTokensForThoughts(recentThoughts);
|
||||
|
||||
await client.createThought({
|
||||
content: summary,
|
||||
source,
|
||||
metadata: {
|
||||
sessionId: params.sessionId,
|
||||
turn: this.nextTurn(params.sessionId),
|
||||
role: "assistant",
|
||||
type: "summary",
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
compacted: true,
|
||||
reason: "summary-archived",
|
||||
result: {
|
||||
summary,
|
||||
tokensBefore,
|
||||
tokensAfter: summaryTokens,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async prepareSubagentSpawn(params: {
|
||||
parentSessionKey: string;
|
||||
childSessionKey: string;
|
||||
ttlMs?: number;
|
||||
}): Promise<SubagentSpawnPreparation | undefined> {
|
||||
this.assertNotDisposed();
|
||||
|
||||
const config = this.getConfig();
|
||||
const client = this.getClient();
|
||||
|
||||
const parentThoughts = this.filterSessionThoughts(
|
||||
await client.listRecent({
|
||||
limit: config.subagentRecentMessages,
|
||||
source: this.sourceForSession(params.parentSessionKey),
|
||||
}),
|
||||
params.parentSessionKey,
|
||||
);
|
||||
|
||||
const seedContent = this.buildSubagentSeedContent({
|
||||
parentSessionKey: params.parentSessionKey,
|
||||
childSessionKey: params.childSessionKey,
|
||||
thoughts: parentThoughts,
|
||||
});
|
||||
|
||||
const createdThought = await client.createThought({
|
||||
content: seedContent,
|
||||
source: this.sourceForSession(params.childSessionKey),
|
||||
metadata: {
|
||||
sessionId: params.childSessionKey,
|
||||
role: "assistant",
|
||||
type: "summary",
|
||||
parentSessionId: params.parentSessionKey,
|
||||
ttlMs: params.ttlMs,
|
||||
},
|
||||
});
|
||||
|
||||
this.subagentState.set(params.childSessionKey, {
|
||||
parentSessionKey: params.parentSessionKey,
|
||||
seedThoughtId: createdThought.id,
|
||||
});
|
||||
|
||||
return {
|
||||
rollback: async () => {
|
||||
const state = this.subagentState.get(params.childSessionKey);
|
||||
this.subagentState.delete(params.childSessionKey);
|
||||
|
||||
if (state?.seedThoughtId !== undefined && state.seedThoughtId.length > 0) {
|
||||
await client.deleteThought(state.seedThoughtId);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async onSubagentEnded(params: {
|
||||
childSessionKey: string;
|
||||
reason: SubagentEndReason;
|
||||
}): Promise<void> {
|
||||
this.assertNotDisposed();
|
||||
|
||||
const state = this.subagentState.get(params.childSessionKey);
|
||||
if (state === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const client = this.getClient();
|
||||
const config = this.getConfig();
|
||||
|
||||
const childThoughts = this.filterSessionThoughts(
|
||||
await client.listRecent({
|
||||
limit: config.subagentRecentMessages,
|
||||
source: this.sourceForSession(params.childSessionKey),
|
||||
}),
|
||||
params.childSessionKey,
|
||||
);
|
||||
|
||||
const summary = this.buildSubagentResultSummary({
|
||||
childSessionKey: params.childSessionKey,
|
||||
reason: params.reason,
|
||||
thoughts: childThoughts,
|
||||
});
|
||||
|
||||
await client.createThought({
|
||||
content: summary,
|
||||
source: this.sourceForSession(state.parentSessionKey),
|
||||
metadata: {
|
||||
sessionId: state.parentSessionKey,
|
||||
turn: this.nextTurn(state.parentSessionKey),
|
||||
role: "tool",
|
||||
type: "subagent-result",
|
||||
childSessionId: params.childSessionKey,
|
||||
reason: params.reason,
|
||||
},
|
||||
});
|
||||
|
||||
this.subagentState.delete(params.childSessionKey);
|
||||
}
|
||||
|
||||
async dispose(): Promise<void> {
|
||||
this.sessionTurns.clear();
|
||||
this.subagentState.clear();
|
||||
this.disposed = true;
|
||||
}
|
||||
|
||||
private searchSemanticThoughts(params: {
|
||||
client: OpenBrainClientLike;
|
||||
source: string;
|
||||
config: ResolvedOpenBrainContextEngineConfig;
|
||||
sessionId: string;
|
||||
messages: AgentMessage[];
|
||||
}): Promise<OpenBrainThought[]> {
|
||||
const query = this.pickSemanticQuery(params.messages);
|
||||
if (query === undefined || query.length === 0 || params.config.semanticSearchLimit <= 0) {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
|
||||
const request: OpenBrainSearchInput = {
|
||||
query,
|
||||
limit: params.config.semanticSearchLimit,
|
||||
source: params.source,
|
||||
};
|
||||
|
||||
return params.client
|
||||
.search(request)
|
||||
.then((results) => this.filterSessionThoughts(results, params.sessionId))
|
||||
.catch((error) => {
|
||||
this.logger?.warn?.("OpenBrain semantic search failed", error);
|
||||
return [];
|
||||
});
|
||||
}
|
||||
|
||||
private pickSemanticQuery(messages: AgentMessage[]): string | undefined {
|
||||
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
||||
const message = messages[i];
|
||||
if (message === undefined) {
|
||||
continue;
|
||||
}
|
||||
if (normalizeRole(message.role) !== "user") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const content = serializeContent(message.content).trim();
|
||||
if (content.length > 0) {
|
||||
return content;
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
||||
const message = messages[i];
|
||||
if (message === undefined) {
|
||||
continue;
|
||||
}
|
||||
const content = serializeContent(message.content).trim();
|
||||
if (content.length > 0) {
|
||||
return content;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private mergeThoughts(recentThoughts: OpenBrainThought[], semanticThoughts: OpenBrainThought[]): OpenBrainThought[] {
|
||||
const merged: OpenBrainThought[] = [];
|
||||
const seenIds = new Set<string>();
|
||||
const seenFingerprints = new Set<string>();
|
||||
|
||||
for (const thought of [...recentThoughts, ...semanticThoughts]) {
|
||||
const id = thought.id.trim();
|
||||
const fingerprint = thoughtFingerprint(thought);
|
||||
|
||||
if (id.length > 0 && seenIds.has(id)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (seenFingerprints.has(fingerprint)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (id.length > 0) {
|
||||
seenIds.add(id);
|
||||
}
|
||||
seenFingerprints.add(fingerprint);
|
||||
merged.push(thought);
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
private filterSessionThoughts(thoughts: OpenBrainThought[], sessionId: string): OpenBrainThought[] {
|
||||
return thoughts.filter((thought) => {
|
||||
const thoughtSessionId = thought.metadata?.sessionId;
|
||||
if (typeof thoughtSessionId === "string" && thoughtSessionId.length > 0) {
|
||||
return thoughtSessionId === sessionId;
|
||||
}
|
||||
|
||||
return thought.source === this.sourceForSession(sessionId);
|
||||
});
|
||||
}
|
||||
|
||||
private toAgentMessage(thought: OpenBrainThought, index: number): AgentMessage {
|
||||
return {
|
||||
role: normalizeRole(thought.metadata?.role),
|
||||
content: thought.content,
|
||||
timestamp: thoughtTimestamp(thought, this.now() + index),
|
||||
};
|
||||
}
|
||||
|
||||
private trimToBudget(messages: AgentMessage[], tokenBudget: number): AgentMessage[] {
|
||||
if (messages.length === 0 || tokenBudget <= 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
let total = 0;
|
||||
const budgeted: AgentMessage[] = [];
|
||||
|
||||
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
||||
const message = messages[i];
|
||||
if (message === undefined) {
|
||||
continue;
|
||||
}
|
||||
const tokens = estimateTextTokens(serializeContent(message.content));
|
||||
if (total + tokens > tokenBudget) {
|
||||
continue;
|
||||
}
|
||||
|
||||
total += tokens;
|
||||
budgeted.unshift(message);
|
||||
}
|
||||
|
||||
if (budgeted.length === 0) {
|
||||
const lastMessage = messages[messages.length - 1];
|
||||
return lastMessage === undefined ? [] : [lastMessage];
|
||||
}
|
||||
|
||||
return budgeted;
|
||||
}
|
||||
|
||||
private estimateTokensForMessages(messages: AgentMessage[]): number {
|
||||
return messages.reduce((total, message) => {
|
||||
return total + estimateTextTokens(serializeContent(message.content));
|
||||
}, 0);
|
||||
}
|
||||
|
||||
private estimateTokensForThoughts(thoughts: OpenBrainThought[]): number {
|
||||
return thoughts.reduce((total, thought) => total + estimateTextTokens(thought.content), 0);
|
||||
}
|
||||
|
||||
private buildSummary(params: {
|
||||
sessionId: string;
|
||||
thoughts: OpenBrainThought[];
|
||||
customInstructions?: string;
|
||||
}): string {
|
||||
const ordered = [...params.thoughts].sort((a, b) => {
|
||||
return thoughtTimestamp(a, 0) - thoughtTimestamp(b, 0);
|
||||
});
|
||||
|
||||
const maxLines = Math.min(ordered.length, 10);
|
||||
const lines = ordered.slice(Math.max(ordered.length - maxLines, 0)).map((thought) => {
|
||||
const role = normalizeRole(thought.metadata?.role);
|
||||
const content = truncateLine(thought.content.replace(/\s+/g, " ").trim(), 180);
|
||||
return `- ${role}: ${content}`;
|
||||
});
|
||||
|
||||
const header = `Context summary for session ${params.sessionId}`;
|
||||
const instruction =
|
||||
params.customInstructions !== undefined && params.customInstructions.trim().length > 0
|
||||
? `Custom instructions: ${params.customInstructions.trim()}\n`
|
||||
: "";
|
||||
|
||||
return `${header}\n${instruction}${lines.join("\n")}`;
|
||||
}
|
||||
|
||||
private buildSubagentSeedContent(params: {
|
||||
parentSessionKey: string;
|
||||
childSessionKey: string;
|
||||
thoughts: OpenBrainThought[];
|
||||
}): string {
|
||||
const lines = params.thoughts.slice(-5).map((thought) => {
|
||||
const role = normalizeRole(thought.metadata?.role);
|
||||
return `- ${role}: ${truncateLine(thought.content.replace(/\s+/g, " ").trim(), 160)}`;
|
||||
});
|
||||
|
||||
const contextBlock = lines.length > 0 ? lines.join("\n") : "- (no prior context found)";
|
||||
|
||||
return [
|
||||
`Subagent context seed`,
|
||||
`Parent session: ${params.parentSessionKey}`,
|
||||
`Child session: ${params.childSessionKey}`,
|
||||
contextBlock,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
private buildSubagentResultSummary(params: {
|
||||
childSessionKey: string;
|
||||
reason: SubagentEndReason;
|
||||
thoughts: OpenBrainThought[];
|
||||
}): string {
|
||||
const lines = params.thoughts.slice(-5).map((thought) => {
|
||||
const role = normalizeRole(thought.metadata?.role);
|
||||
return `- ${role}: ${truncateLine(thought.content.replace(/\s+/g, " ").trim(), 160)}`;
|
||||
});
|
||||
|
||||
const contextBlock = lines.length > 0 ? lines.join("\n") : "- (no child messages found)";
|
||||
|
||||
return [
|
||||
`Subagent ended (${params.reason})`,
|
||||
`Child session: ${params.childSessionKey}`,
|
||||
contextBlock,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
private sourceForSession(sessionId: string): string {
|
||||
return `${this.getConfig().source}:${sessionId}`;
|
||||
}
|
||||
|
||||
private nextTurn(sessionId: string): number {
|
||||
const next = this.sessionTurns.get(sessionId) ?? 0;
|
||||
this.sessionTurns.set(sessionId, next + 1);
|
||||
return next;
|
||||
}
|
||||
|
||||
private getClient(): OpenBrainClientLike {
|
||||
if (this.client !== undefined) {
|
||||
return this.client;
|
||||
}
|
||||
|
||||
const config = this.getConfig();
|
||||
this.client =
|
||||
this.createClientFn?.(config) ??
|
||||
new OpenBrainClient({
|
||||
baseUrl: config.baseUrl,
|
||||
apiKey: config.apiKey,
|
||||
});
|
||||
|
||||
return this.client;
|
||||
}
|
||||
|
||||
private getConfig(): ResolvedOpenBrainContextEngineConfig {
|
||||
if (this.config !== undefined) {
|
||||
return this.config;
|
||||
}
|
||||
|
||||
const raw = isRecord(this.rawConfig) ? this.rawConfig : {};
|
||||
|
||||
const baseUrl = typeof raw.baseUrl === "string" ? raw.baseUrl.trim() : "";
|
||||
if (baseUrl.length === 0) {
|
||||
throw new OpenBrainConfigError("Missing required OpenBrain config: baseUrl");
|
||||
}
|
||||
|
||||
const apiKey = typeof raw.apiKey === "string" ? raw.apiKey.trim() : "";
|
||||
if (apiKey.length === 0) {
|
||||
throw new OpenBrainConfigError("Missing required OpenBrain config: apiKey");
|
||||
}
|
||||
|
||||
this.config = {
|
||||
baseUrl,
|
||||
apiKey,
|
||||
recentMessages: parsePositiveInteger(raw.recentMessages, 20),
|
||||
semanticSearchLimit: parsePositiveInteger(raw.semanticSearchLimit, 10),
|
||||
source: typeof raw.source === "string" && raw.source.trim().length > 0 ? raw.source.trim() : "openclaw",
|
||||
subagentRecentMessages: parsePositiveInteger(raw.subagentRecentMessages, 8),
|
||||
};
|
||||
|
||||
return this.config;
|
||||
}
|
||||
|
||||
private assertNotDisposed(): void {
|
||||
if (this.disposed) {
|
||||
throw new Error("OpenBrainContextEngine has already been disposed");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user