Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,14 +45,16 @@
"test:dreams-compat": "node --import tsx/esm --test tests/dreams-compat.test.ts",
"test:dreams-anthropic": "node --import tsx/esm --test tests/dreams-anthropic.test.ts",
"test:dreams-bridge": "node --import tsx/esm --test tests/dreams-bridge.test.ts",
"test": "node --import tsx/esm --test --test-concurrency=1 tests/integration.test.ts tests/llm-paths.test.ts tests/http-api.test.ts tests/cache.test.ts tests/embedding-migration.test.ts tests/outcome-revision.test.ts tests/reflection-revision.test.ts tests/selective-forgetting.test.ts tests/multi-device.test.ts tests/dream-runs.test.ts tests/dreams-compat.test.ts tests/dreams-anthropic.test.ts tests/dreams-bridge.test.ts tests/sentiment-tagging.test.ts tests/adaptive-sleep.test.ts tests/epistemic-confidence.test.ts tests/explainable-memory.test.ts tests/causal-graph.test.ts tests/abstractions.test.ts",
"test": "node --import tsx/esm --test --test-concurrency=1 tests/integration.test.ts tests/llm-paths.test.ts tests/http-api.test.ts tests/cache.test.ts tests/embedding-migration.test.ts tests/outcome-revision.test.ts tests/reflection-revision.test.ts tests/selective-forgetting.test.ts tests/multi-device.test.ts tests/dream-runs.test.ts tests/dreams-compat.test.ts tests/dreams-anthropic.test.ts tests/dreams-bridge.test.ts tests/sentiment-tagging.test.ts tests/adaptive-sleep.test.ts tests/epistemic-confidence.test.ts tests/explainable-memory.test.ts tests/causal-graph.test.ts tests/abstractions.test.ts tests/bootstrap.test.ts tests/contested-conflicts.test.ts",
"test:multi-device": "node --import tsx/esm --test tests/multi-device.test.ts",
"test:sentiment-tagging": "node --import tsx/esm --test tests/sentiment-tagging.test.ts",
"test:adaptive-sleep": "node --import tsx/esm --test tests/adaptive-sleep.test.ts",
"test:epistemic-confidence": "node --import tsx/esm --test tests/epistemic-confidence.test.ts",
"test:explainable-memory": "node --import tsx/esm --test tests/explainable-memory.test.ts",
"test:causal-graph": "node --import tsx/esm --test tests/causal-graph.test.ts",
"test:abstractions": "node --import tsx/esm --test tests/abstractions.test.ts",
"test:bootstrap": "node --import tsx/esm --test tests/bootstrap.test.ts",
"test:contested-conflicts": "node --import tsx/esm --test tests/contested-conflicts.test.ts",
"benchmark:longmemeval": "node --import tsx/esm benchmarks/longmemeval/run.ts",
"benchmark:download": "node --import tsx/esm benchmarks/longmemeval/download.ts",
"benchmark:ingest": "node --import tsx/esm benchmarks/longmemeval/ingest.ts",
Expand Down
29 changes: 29 additions & 0 deletions python/memforge/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -377,6 +377,35 @@ async def get_abstractions(
raw = await self._get(f"/memory/{agent_id}/abstractions", params)
return raw if isinstance(raw, list) else []

# ── Cross-Agent Transfer Learning (v3.12) ─────────────────────────────

async def bootstrap_agent(
self,
agent_id: str,
source_agent_id: str,
namespace: str | None = None,
max_memories: int | None = None,
max_procedures: int | None = None,
max_principles: int | None = None,
) -> dict[str, Any]:
"""Bootstrap ``agent_id`` (the target) from an experienced source
agent (v3.12): copies established memories, active procedures, and
active principles at half confidence. Idempotent — knowledge the
target already carries is skipped and counts report only rows
actually written.
"""
body: dict[str, Any] = {"source_agent_id": source_agent_id}
if namespace is not None:
body["namespace"] = namespace
if max_memories is not None:
body["max_memories"] = max_memories
if max_procedures is not None:
body["max_procedures"] = max_procedures
if max_principles is not None:
body["max_principles"] = max_principles
raw = await self._post(f"/memory/{agent_id}/bootstrap", body)
return raw if isinstance(raw, dict) else {}

async def resume(self, agent_id: str, limit: int = 5, namespace: str | None = None) -> ResumeContext:
"""Get session resumption context bundle."""
params: dict[str, Any] = {"limit": limit}
Expand Down
7 changes: 7 additions & 0 deletions python/memforge/resilient.py
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,13 @@ async def get_abstractions(self, agent_id: str, level: str | None = None, namesp
self._handle(e)
return []

async def bootstrap_agent(self, agent_id: str, source_agent_id: str, **kwargs: Any) -> dict[str, Any] | None:
try:
return await self._client.bootstrap_agent(agent_id, source_agent_id, **kwargs)
except Exception as e:
self._handle(e)
return None

async def resume(self, agent_id: str, limit: int = 5, namespace: str | None = None) -> ResumeContext | None:
try:
return await self._client.resume(agent_id, limit, namespace)
Expand Down
2 changes: 1 addition & 1 deletion schema/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -372,7 +372,7 @@ CREATE TABLE IF NOT EXISTS audit_chain (
chain_hash TEXT NOT NULL, -- HMAC-SHA256(previous_hash + content_hash + operation + valid_from)

-- Context
triggered_by TEXT NOT NULL DEFAULT 'api', -- 'api', 'sleep_cycle', 'consolidation', 'reflection', 'dedup', 'feedback'
triggered_by TEXT NOT NULL DEFAULT 'api', -- 'api', 'sleep_cycle', 'consolidation', 'reflection', 'dedup', 'feedback', 'bootstrap'
model_used TEXT, -- LLM model if this was an AI-driven change

created_at TIMESTAMPTZ NOT NULL DEFAULT now()
Expand Down
47 changes: 46 additions & 1 deletion src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import {
httpRequestDurationSeconds,
} from './metrics.js';
import { bearerAuth, requireScope, getClientId } from './auth.js';
import { NamespaceSchema, AddMemorySchema, ConsolidateSchema, SleepSchema, ColdTierSearchSchema, ColdTierRestoreSchema, ConfigReloadSchema, CreateDreamRunSchema, ListDreamRunsQuerySchema, AnthropicDreamCreateSchema, AnthropicPushSchema, AnthropicPullSchema, EpistemicFilterSchema, PredictSchema, AbstractionLevelSchema } from './schemas.js';
import { NamespaceSchema, AddMemorySchema, ConsolidateSchema, SleepSchema, ColdTierSearchSchema, ColdTierRestoreSchema, ConfigReloadSchema, CreateDreamRunSchema, ListDreamRunsQuerySchema, AnthropicDreamCreateSchema, AnthropicPushSchema, AnthropicPullSchema, EpistemicFilterSchema, PredictSchema, AbstractionLevelSchema, BootstrapSchema } from './schemas.js';
import { reloadConfig } from './config.js';
import {
cacheGet,
Expand Down Expand Up @@ -751,6 +751,51 @@ export function createApp(deps: AppDependencies): express.Express {
}
});

// ─── Phase 5: Cross-Agent Transfer Learning ──────────────────────────────

/**
* POST /memory/:agentId/bootstrap
* Body: { source_agent_id, namespace?, max_memories?, max_procedures?, max_principles? }
*
* The path agent is the TARGET; the body names the source. Reads the source
* agent's memory — both agents share this deployment's token scope, so this
* is an intra-trust-domain operation by design.
*/
app.post('/memory/:agentId/bootstrap', requireScope('memforge:write'), async (req: Request, res: Response) => {
const parsed = BootstrapSchema.safeParse(req.body ?? {});
if (!parsed.success) {
const issue = parsed.error.issues[0];
fail(res, 400, issue ? `${issue.path.join('.')}: ${issue.message}` : '"source_agent_id" (string) is required');
return;
}

let targetAgentId: string;
try {
targetAgentId = getAgentId(req);
} catch (err) {
fail(res, 400, (err as Error).message);
return;
}

try {
const result = await manager.bootstrapAgent({
sourceAgentId: parsed.data.source_agent_id,
targetAgentId,
namespace: parsed.data.namespace,
maxMemories: parsed.data.max_memories,
maxProcedures: parsed.data.max_procedures,
maxPrinciples: parsed.data.max_principles,
});
ok(res, result);
} catch (err) {
if (err instanceof TypeError) {
fail(res, 400, err.message);
} else {
fail(res, 500, (err as Error).message);
}
}
});

/**
* GET /memory/:agentId/timeline?[from=<iso>][&to=<iso>][&limit=<n>]
*/
Expand Down
2 changes: 1 addition & 1 deletion src/audit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ type QueryExecutor = Pick<Pool, 'query'>;
// ─── Types ──────────────────────────────────────────────────────────────────────

export type AuditOperation = 'create' | 'update' | 'delete' | 'revise' | 'merge' | 'evict' | 'score' | 'feedback';
export type AuditTrigger = 'api' | 'sleep_cycle' | 'consolidation' | 'reflection' | 'dedup' | 'feedback';
export type AuditTrigger = 'api' | 'sleep_cycle' | 'consolidation' | 'reflection' | 'dedup' | 'feedback' | 'bootstrap';

export interface AuditEntry {
id: bigint;
Expand Down
33 changes: 33 additions & 0 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ import type {
PredictionResult,
Abstraction,
AbstractionLevel,
BootstrapResult,
} from './types.js';

// ─── Config ──────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -439,6 +440,28 @@ export class MemForgeClient {
return this.get<Abstraction[]>(`/memory/${enc(agentId)}/abstractions${qs ? '?' + qs : ''}`);
}

// ─── Phase 5: Cross-Agent Transfer Learning ──────────────────────────────

/** Bootstrap the given (target) agent from an experienced source agent
* (v3.12): copies established memories, active procedures, and active
* principles at 0.5× confidence. Idempotent — already-carried knowledge is
* skipped and counts report only rows actually written. */
async bootstrapAgent(agentId: string, options: {
sourceAgentId: string;
namespace?: string;
maxMemories?: number;
maxProcedures?: number;
maxPrinciples?: number;
}): Promise<BootstrapResult> {
return this.post<BootstrapResult>(`/memory/${enc(agentId)}/bootstrap`, {
source_agent_id: options.sourceAgentId,
namespace: options.namespace,
max_memories: options.maxMemories,
max_procedures: options.maxProcedures,
max_principles: options.maxPrinciples,
});
}

/** Generate a session resumption context for an agent. */
async resume(agentId: string, limit?: number, namespace?: string): Promise<ResumeContext> {
const params = new URLSearchParams();
Expand Down Expand Up @@ -777,6 +800,16 @@ export class ResilientMemForgeClient {
return this.safe('getAbstractions', () => this.client.getAbstractions(agentId, level, namespace), []);
}

async bootstrapAgent(agentId: string, options: {
sourceAgentId: string;
namespace?: string;
maxMemories?: number;
maxProcedures?: number;
maxPrinciples?: number;
}): Promise<BootstrapResult | null> {
return this.safe('bootstrapAgent', () => this.client.bootstrapAgent(agentId, options), null);
}

async resume(agentId: string, limit?: number, namespace?: string): Promise<ResumeContext | null> {
return this.safe('resume', () => this.client.resume(agentId, limit, namespace), null);
}
Expand Down
42 changes: 40 additions & 2 deletions src/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -640,14 +640,30 @@ const TOOLS: MCPToolDefinition[] = [
required: ['agent_id'],
},
},
{
name: 'memforge_bootstrap',
description: 'Bootstrap a new agent from an experienced one: copies established memories, active procedures, and active principles at half confidence. Memories and procedures are marked _transferred_from (principles carry no marker — no metadata column). Idempotent — knowledge the target already carries is skipped. Both agents must live in this deployment.',
inputSchema: {
type: 'object',
properties: {
source_agent_id: { type: 'string', description: 'Agent to copy knowledge from' },
target_agent_id: { type: 'string', description: 'Agent to bootstrap' },
namespace: { type: 'string', description: 'Memory namespace (default: "default")' },
max_memories: { type: 'integer', description: 'Max memories to transfer (default 100, max 1000)', minimum: 0, maximum: 1000 },
max_procedures: { type: 'integer', description: 'Max procedures to transfer (default 20, max 100)', minimum: 0, maximum: 100 },
max_principles: { type: 'integer', description: 'Max principles to transfer (default 10, max 100)', minimum: 0, maximum: 100 },
},
required: ['source_agent_id', 'target_agent_id'],
},
},
];

// ─── Input Validation ────────────────────────────────────────────────────────

const AGENT_ID_RE = /^[\w.@:=-]+$/;

// Tools that use pool_id as primary key instead of agent_id
const POOL_ONLY_TOOLS = new Set(['memforge_shared_procedures', 'memforge_expertise']);
// Tools that use pool_id or their own agent pair instead of a generic agent_id
const POOL_ONLY_TOOLS = new Set(['memforge_shared_procedures', 'memforge_expertise', 'memforge_bootstrap']);

function validateToolArgs(name: string, args: Record<string, unknown>): void {
// agent_id: required for agent-scoped tools
Expand Down Expand Up @@ -718,6 +734,19 @@ function validateToolArgs(name: string, args: Record<string, unknown>): void {
}
}

// memforge_bootstrap: validates its own agent pair (POOL_ONLY skips the generic agent_id rule)
if (name === 'memforge_bootstrap') {
for (const key of ['source_agent_id', 'target_agent_id'] as const) {
const value = args[key];
if (typeof value !== 'string' || value.length < 1 || value.length > 256 || !AGENT_ID_RE.test(value)) {
throw new Error(`${key} must be a string of 1-256 characters matching /^[\\w.@:=-]+$/`);
}
}
if (args['source_agent_id'] === args['target_agent_id']) {
throw new Error('source_agent_id and target_agent_id must be different');
}
}

// memforge_sleep tokenBudget: max 200000
if (name === 'memforge_sleep' && 'token_budget' in args && args['token_budget'] !== undefined) {
const tokenBudget = args['token_budget'];
Expand Down Expand Up @@ -954,6 +983,15 @@ async function executeTool(client: MemForgeClient, name: string, args: Record<st
case 'memforge_mental_models':
return client.getAbstractions(agentId, 'mental_model', args['namespace'] as string | undefined);

case 'memforge_bootstrap':
return client.bootstrapAgent(args['target_agent_id'] as string, {
sourceAgentId: args['source_agent_id'] as string,
namespace: args['namespace'] as string | undefined,
maxMemories: args['max_memories'] as number | undefined,
maxProcedures: args['max_procedures'] as number | undefined,
maxPrinciples: args['max_principles'] as number | undefined,
});

default:
throw new Error(`Unknown tool: ${name}`);
}
Expand Down
Loading