Menu
popagent
publicLatest change da13a7bebe63bf4b2693180d2d4850aabeaa0807 - Add autonomous evolution and self-healing by AkurAI Build
import type {
AutonomySettings,
EvolutionRevision,
EvolutionSignal,
} from "./api-types";
import { initializeEvolutionStorage } from "./autonomy-settings";
import { storage } from "./storage";
export const MAX_EVOLUTION_SUMMARY_CHARACTERS = 4_000;
export const MAX_LEARNED_CONTENT_CHARACTERS = 12_000;
const MAX_EVOLUTION_ERROR_CHARACTERS = 2_000;
const MAX_EVOLUTION_RATIONALE_CHARACTERS = 2_000;
const TRUNCATION_MARKER = "\n… [truncated]";
const textEncoder = new TextEncoder();
const SIGNAL_COLUMNS = `
id,
session_id AS "sessionId",
turn_id AS "turnId",
trace_id AS "traceId",
agent_id AS "agentId",
workspace_id AS "workspaceId",
kind,
summary,
status,
attempts,
next_attempt_at::text AS "nextAttemptAt",
processed_at::text AS "processedAt",
error,
created_at::text AS "createdAt",
updated_at::text AS "updatedAt"
`;
const REVISION_COLUMNS = `
id,
agent_id AS "agentId",
target_type AS "targetType",
target_key AS "targetKey",
before_content AS "beforeContent",
after_content AS "afterContent",
rationale,
evidence_ids AS "evidenceIds",
status,
reverts_revision_id AS "revertsRevisionId",
created_at::text AS "createdAt",
applied_at::text AS "appliedAt"
`;
const SENSITIVE_PATTERNS = [
/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*/i,
/\bBearer\s+[A-Za-z0-9._~+/=-]{8,}/i,
/\b(?:password|passwd|secret|api[_ -]?key|access[_ -]?token|refresh[_ -]?token)\s*[:=]\s*["']?[^\s"',;}]{4,}/i,
/\b(?:sk-[A-Za-z0-9_-]{16,}|ghp_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|xox[baprs]-[A-Za-z0-9-]{16,}|AKIA[A-Z0-9]{16})\b/,
/\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/,
/\bhttps?:\/\/[^\s/:]+:[^\s/@]+@[^\s]+/i,
];
const FORBIDDEN_SCOPE_PATTERNS = [
/\b(?:bypass|disable|override|evade|ignore)\b[\s\S]{0,80}\b(?:containment|security|hook|authentication|permission|access|secret|credential|execution limit|timeout|boundary)\b/i,
/\b(?:grant|expand|change|modify)\b[\s\S]{0,80}\b(?:tool membership|workspace access|browser access|authentication|hooks?|execution bounds?|permissions?)\b/i,
/\b(?:edit|replace|rewrite|mutate)\b[\s\S]{0,80}\b(?:base|user-managed) instructions?\b/i,
];
export type EvolutionSignalInput = {
sessionId?: string | null;
turnId?: string | null;
traceId?: string | null;
agentId: string;
workspaceId?: string | null;
kind: EvolutionSignal["kind"];
summary: string;
};
export type EvolutionProposal = {
agentId: string;
targetType: EvolutionRevision["targetType"];
targetKey: string;
content: string;
rationale: string;
evidenceIds: string[];
};
type ApplyPolicy = Pick<AutonomySettings, "enabled" | "autoApplyStrategies" | "autoCreateSkills">;
type StoredSkill = { id: string; instructions: string; evolutionManaged: boolean };
function truncate(value: string, maximum: number): string {
if (value.length <= maximum) return value;
if (maximum <= TRUNCATION_MARKER.length) return value.slice(0, maximum);
let end = maximum - TRUNCATION_MARKER.length;
const codeUnit = value.charCodeAt(end - 1);
if (codeUnit >= 0xD800 && codeUnit <= 0xDBFF) end--;
return `${value.slice(0, end).trimEnd()}${TRUNCATION_MARKER}`;
}
export function containsSensitiveEvolutionText(value: string): boolean {
return SENSITIVE_PATTERNS.some((pattern) => pattern.test(value));
}
export function redactEvolutionText(value: string, maximum = MAX_EVOLUTION_SUMMARY_CHARACTERS): string {
let redacted = truncate(value.trim(), maximum * 2);
for (const pattern of SENSITIVE_PATTERNS) {
redacted = redacted.replace(new RegExp(pattern.source, `${pattern.flags.replace("g", "")}g`), "[redacted]");
}
return truncate(redacted, maximum);
}
export function buildEvolutionEvidence(input: {
goal: string;
outcome?: string | null;
failure?: string | null;
classification: string;
}): string {
const parts = [
`Goal: ${redactEvolutionText(input.goal, 1_200) || "Unspecified"}`,
`Classification: ${redactEvolutionText(input.classification, 200) || "unknown"}`,
];
if (input.outcome?.trim()) parts.push(`Outcome: ${redactEvolutionText(input.outcome, 1_800)}`);
if (input.failure?.trim()) parts.push(`Failure: ${redactEvolutionText(input.failure, 800)}`);
return redactEvolutionText(parts.join("\n"));
}
async function sha256(value: string): Promise<string> {
const bytes = new Uint8Array(await crypto.subtle.digest("SHA-256", textEncoder.encode(value)));
let result = "";
for (const byte of bytes) result += byte.toString(16).padStart(2, "0");
return result;
}
function identifier(value: string | null | undefined, maximum = 200): string | null {
const normalized = value?.trim() ?? "";
if (!normalized) return null;
if (normalized.length > maximum) throw new RangeError(`Evolution identifier exceeds ${maximum} characters`);
return normalized;
}
function errorText(error: unknown): string {
const raw = error instanceof Error ? error.message : String(error);
return redactEvolutionText(raw || "Evolution reflection failed", MAX_EVOLUTION_ERROR_CHARACTERS);
}
function validateProposal(
proposal: EvolutionProposal,
evidenceIds: ReadonlySet<string>,
policy: ApplyPolicy,
): EvolutionProposal {
const agentId = identifier(proposal.agentId, 100);
const targetKey = identifier(proposal.targetKey, 100);
const content = proposal.content.trim();
const rationale = proposal.rationale.trim();
if (proposal.targetType !== "overlay" && proposal.targetType !== "skill") {
throw new Error("Evolution proposal target type is not allowed");
}
if (!agentId || !targetKey) throw new RangeError("Evolution proposal target is required");
if (!content || content.length > MAX_LEARNED_CONTENT_CHARACTERS) {
throw new RangeError(`Evolution proposal content must be between 1 and ${MAX_LEARNED_CONTENT_CHARACTERS} characters`);
}
if (!rationale || rationale.length > MAX_EVOLUTION_RATIONALE_CHARACTERS) {
throw new RangeError(`Evolution rationale must be between 1 and ${MAX_EVOLUTION_RATIONALE_CHARACTERS} characters`);
}
if (containsSensitiveEvolutionText(content) || containsSensitiveEvolutionText(rationale)) {
throw new Error("Evolution proposal contains sensitive material");
}
if (FORBIDDEN_SCOPE_PATTERNS.some((pattern) => pattern.test(content))) {
throw new Error("Evolution proposal attempts to change a protected boundary");
}
if (!proposal.evidenceIds.length || proposal.evidenceIds.some((id) => !evidenceIds.has(id))) {
throw new Error("Evolution proposal cites evidence outside the claimed batch");
}
const uniqueEvidenceIds = [...new Set(proposal.evidenceIds)];
if (proposal.targetType === "overlay") {
if (!policy.autoApplyStrategies) throw new Error("Automatic learned overlays are disabled");
if (targetKey !== "instructions") throw new Error("Learned overlays may target only instructions");
} else {
if (!policy.autoCreateSkills) throw new Error("Automatic skill creation is disabled");
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(targetKey)) {
throw new Error("Learned skill names must use lowercase hyphenated form");
}
}
return {
...proposal,
agentId,
targetKey,
content,
rationale,
evidenceIds: uniqueEvidenceIds,
};
}
export class EvolutionStore {
readonly storage = storage;
init(): Promise<void> {
return initializeEvolutionStorage();
}
async enqueueSignal(input: EvolutionSignalInput): Promise<EvolutionSignal> {
const agentId = identifier(input.agentId, 100);
if (!agentId) throw new RangeError("Evolution signal agent id is required");
const sessionId = identifier(input.sessionId);
const turnId = identifier(input.turnId);
const traceId = identifier(input.traceId);
const workspaceId = identifier(input.workspaceId);
const summary = redactEvolutionText(input.summary)
|| (input.kind === "turn-success" ? "Turn completed successfully" : "Turn failed");
const dedupeKey = await sha256(JSON.stringify([
sessionId,
turnId,
traceId,
agentId,
workspaceId,
input.kind,
sessionId || turnId || traceId ? null : summary,
]));
await this.init();
return this.storage.db.one<EvolutionSignal>(`
INSERT INTO popagent_evolution_signals (
id, dedupe_key, session_id, turn_id, trace_id, agent_id, workspace_id,
kind, summary, status, next_attempt_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'pending', NOW())
ON CONFLICT (dedupe_key) DO UPDATE
SET dedupe_key = EXCLUDED.dedupe_key
RETURNING ${SIGNAL_COLUMNS}
`, [
crypto.randomUUID(),
dedupeKey,
sessionId,
turnId,
traceId,
agentId,
workspaceId,
input.kind,
summary,
]);
}
enqueueCompletedTurn(input: Omit<EvolutionSignalInput, "kind">): Promise<EvolutionSignal> {
return this.enqueueSignal({ ...input, kind: "turn-success" });
}
enqueueFailedTurn(input: Omit<EvolutionSignalInput, "kind">): Promise<EvolutionSignal> {
return this.enqueueSignal({ ...input, kind: "turn-failure" });
}
async listSignals(limit = 100): Promise<EvolutionSignal[]> {
await this.init();
const boundedLimit = Number.isFinite(limit)
? Math.min(Math.max(Math.trunc(limit), 1), 200)
: 100;
return this.storage.db.any<EvolutionSignal>(`
SELECT ${SIGNAL_COLUMNS}
FROM popagent_evolution_signals
ORDER BY created_at DESC, id DESC
LIMIT $1
`, [boundedLimit]);
}
async listRevisions(limit = 100): Promise<EvolutionRevision[]> {
await this.init();
const boundedLimit = Number.isFinite(limit)
? Math.min(Math.max(Math.trunc(limit), 1), 200)
: 100;
return this.storage.db.any<EvolutionRevision>(`
SELECT ${REVISION_COLUMNS}
FROM popagent_evolution_revisions
ORDER BY created_at DESC, id DESC
LIMIT $1
`, [boundedLimit]);
}
async learnedOverlay(agentId: string): Promise<string | undefined> {
await this.init();
const row = await this.storage.db.oneOrNone<{ content: string }>(`
SELECT content FROM popagent_learned_overlays WHERE agent_id = $1
`, [agentId]);
return row?.content;
}
async claimBatch(limit: number, maxAttempts = 10): Promise<EvolutionSignal[]> {
await this.init();
const boundedLimit = Number.isFinite(limit)
? Math.min(Math.max(Math.trunc(limit), 1), 100)
: 1;
const boundedMaxAttempts = Number.isFinite(maxAttempts)
? Math.min(Math.max(Math.trunc(maxAttempts), 1), 10)
: 10;
const claimed = await this.storage.db.tx(async (db) => {
const policy = await db.one<ApplyPolicy>(`
SELECT enabled,
auto_apply_strategies AS "autoApplyStrategies",
auto_create_skills AS "autoCreateSkills"
FROM popagent_autonomy_settings
WHERE singleton = TRUE
FOR UPDATE
`);
if (!policy.enabled || (!policy.autoApplyStrategies && !policy.autoCreateSkills)) {
await db.none(`
WITH candidates AS (
SELECT id
FROM popagent_evolution_signals
WHERE status = 'pending' AND next_attempt_at <= NOW()
ORDER BY next_attempt_at, created_at, id
FOR UPDATE SKIP LOCKED
LIMIT $1
)
UPDATE popagent_evolution_signals AS signal
SET status = 'ignored',
next_attempt_at = NULL,
processed_at = NOW(),
error = NULL,
updated_at = NOW()
FROM candidates
WHERE signal.id = candidates.id
`, [boundedLimit]);
return [];
}
await db.none(`
UPDATE popagent_evolution_signals
SET status = 'dead-letter',
next_attempt_at = NULL,
processed_at = NOW(),
error = 'Evolution retry attempts exhausted before claim',
updated_at = NOW()
WHERE status = 'pending' AND attempts >= $1
`, [boundedMaxAttempts]);
return db.any<EvolutionSignal>(`
WITH candidates AS (
SELECT id
FROM popagent_evolution_signals
WHERE status = 'pending'
AND next_attempt_at <= NOW()
ORDER BY next_attempt_at, created_at, id
FOR UPDATE SKIP LOCKED
LIMIT $1
), updated AS (
UPDATE popagent_evolution_signals AS signal
SET status = 'processing',
attempts = signal.attempts + 1,
error = NULL,
updated_at = NOW()
FROM candidates
WHERE signal.id = candidates.id
RETURNING signal.*
)
SELECT ${SIGNAL_COLUMNS} FROM updated
`, [boundedLimit]);
});
return claimed.sort((left, right) => left.createdAt.localeCompare(right.createdAt));
}
async recoverStaleClaims(staleAfterMs: number, maxAttempts: number): Promise<number> {
await this.init();
const boundedStaleAfterMs = Math.min(Math.max(Math.trunc(staleAfterMs), 60_000), 604_800_000);
return this.storage.db.tx(async (db) => {
const stale = await db.any<{ id: string }>(`
SELECT id
FROM popagent_evolution_signals
WHERE status = 'processing'
AND updated_at < NOW() - ($1 * INTERVAL '1 millisecond')
FOR UPDATE
`, [boundedStaleAfterMs]);
if (!stale.length) return 0;
const policy = await db.one<ApplyPolicy>(`
SELECT enabled,
auto_apply_strategies AS "autoApplyStrategies",
auto_create_skills AS "autoCreateSkills"
FROM popagent_autonomy_settings
WHERE singleton = TRUE
FOR UPDATE
`);
const disabled = !policy.enabled || (!policy.autoApplyStrategies && !policy.autoCreateSkills);
const result = await db.query(`
UPDATE popagent_evolution_signals
SET status = CASE
WHEN $3 THEN 'ignored'
WHEN attempts >= $2 THEN 'dead-letter'
ELSE 'pending'
END,
attempts = CASE WHEN $3 THEN GREATEST(attempts - 1, 0) ELSE attempts END,
next_attempt_at = CASE WHEN $3 OR attempts >= $2 THEN NULL ELSE NOW() END,
processed_at = CASE WHEN $3 OR attempts >= $2 THEN NOW() ELSE NULL END,
error = CASE
WHEN $3 THEN NULL
ELSE 'Reflection worker stopped before completing the claimed batch'
END,
updated_at = NOW()
WHERE id = ANY($1::text[]) AND status = 'processing'
`, [stale.map(({ id }) => id), maxAttempts, disabled]);
return result.rowCount ?? 0;
});
}
async failClaimed(signalIds: string[], error: unknown, maxAttempts: number): Promise<void> {
if (!signalIds.length) return;
await this.init();
await this.storage.db.none(`
UPDATE popagent_evolution_signals
SET status = CASE WHEN attempts >= $3 THEN 'dead-letter' ELSE 'pending' END,
next_attempt_at = CASE
WHEN attempts >= $3 THEN NULL
ELSE NOW() + LEAST(3600000, 60000 * power(2, GREATEST(attempts - 1, 0))) * INTERVAL '1 millisecond'
END,
processed_at = CASE WHEN attempts >= $3 THEN NOW() ELSE NULL END,
error = $2,
updated_at = NOW()
WHERE id = ANY($1::text[]) AND status = 'processing'
`, [signalIds.slice(0, 100), errorText(error), maxAttempts]);
}
async applyClaimedBatch(
signalIds: string[],
rawProposals: EvolutionProposal[],
signal?: AbortSignal,
): Promise<EvolutionRevision[]> {
if (!signalIds.length) return [];
if (rawProposals.length > 100) throw new RangeError("Evolution batch exceeds 100 proposals");
await this.init();
const claimedIds = [...new Set(signalIds.slice(0, 100))];
const evidenceIds = new Set(claimedIds);
return this.storage.db.tx(async (db) => {
const claimed = await db.any<{ id: string }>(`
SELECT id FROM popagent_evolution_signals
WHERE id = ANY($1::text[]) AND status = 'processing'
FOR UPDATE
`, [claimedIds]);
if (claimed.length !== claimedIds.length) throw new Error("Evolution batch is no longer fully claimed");
const policy = await db.one<ApplyPolicy>(`
SELECT enabled,
auto_apply_strategies AS "autoApplyStrategies",
auto_create_skills AS "autoCreateSkills"
FROM popagent_autonomy_settings
WHERE singleton = TRUE
FOR UPDATE
`);
signal?.throwIfAborted();
const policyDisabled = !policy.enabled
|| (!policy.autoApplyStrategies && !policy.autoCreateSkills)
|| rawProposals.some((proposal) =>
(proposal.targetType === "overlay" && !policy.autoApplyStrategies)
|| (proposal.targetType === "skill" && !policy.autoCreateSkills)
);
if (policyDisabled) {
await db.none(`
UPDATE popagent_evolution_signals
SET status = 'ignored',
attempts = GREATEST(attempts - 1, 0),
next_attempt_at = NULL,
processed_at = NOW(),
error = NULL,
updated_at = NOW()
WHERE id = ANY($1::text[]) AND status = 'processing'
`, [claimedIds]);
return [];
}
const proposals = rawProposals.map((proposal) => validateProposal(proposal, evidenceIds, policy));
const targets = new Set<string>();
for (const proposal of proposals) {
const identity = `${proposal.agentId}\u0000${proposal.targetType}\u0000${proposal.targetKey}`;
if (targets.has(identity)) throw new Error("Evolution batch contains duplicate targets");
targets.add(identity);
}
const agentIds = [...new Set(proposals.map(({ agentId }) => agentId))];
if (agentIds.length) {
const agents = await db.any<{ id: string }>(`
SELECT id FROM popagent_agents WHERE id = ANY($1::text[]) FOR SHARE
`, [agentIds]);
if (agents.length !== agentIds.length) throw new Error("Evolution proposal targets an unknown agent");
}
signal?.throwIfAborted();
const revisions: EvolutionRevision[] = [];
const appliedEvidenceIds = new Set<string>();
for (const proposal of proposals) {
signal?.throwIfAborted();
let beforeContent: string | null;
if (proposal.targetType === "overlay") {
const overlay = await db.oneOrNone<{ content: string }>(`
SELECT content FROM popagent_learned_overlays
WHERE agent_id = $1 FOR UPDATE
`, [proposal.agentId]);
beforeContent = overlay?.content ?? null;
if (beforeContent === proposal.content) continue;
await db.none(`
INSERT INTO popagent_learned_overlays (agent_id, content)
VALUES ($1, $2)
ON CONFLICT (agent_id) DO UPDATE
SET content = EXCLUDED.content, updated_at = NOW()
`, [proposal.agentId, proposal.content]);
} else {
const skill = await db.oneOrNone<StoredSkill>(`
SELECT id, instructions, evolution_managed AS "evolutionManaged"
FROM popagent_agent_skills
WHERE agent_id = $1 AND name = $2
FOR UPDATE
`, [proposal.agentId, proposal.targetKey]);
if (skill && !skill.evolutionManaged) {
throw new Error("Evolution cannot modify a user-managed skill");
}
if (skill && containsSensitiveEvolutionText(skill.instructions)) {
throw new Error("Evolution cannot retain sensitive material from an existing skill");
}
beforeContent = skill?.instructions ?? null;
if (beforeContent === proposal.content) continue;
if (skill) {
await db.none(`
UPDATE popagent_agent_skills
SET instructions = $3, updated_at = NOW()
WHERE agent_id = $1 AND id = $2 AND evolution_managed = TRUE
`, [proposal.agentId, skill.id, proposal.content]);
} else {
await db.none(`
INSERT INTO popagent_agent_skills (
id, agent_id, name, description, instructions, "references",
source_urls, enabled, user_invocable, evolution_managed
) VALUES ($1, $2, $3, 'Learned from execution evidence.', $4,
'{}'::jsonb, '[]'::jsonb, TRUE, FALSE, TRUE)
`, [crypto.randomUUID(), proposal.agentId, proposal.targetKey, proposal.content]);
}
}
const revision = await db.one<EvolutionRevision>(`
INSERT INTO popagent_evolution_revisions (
id, agent_id, target_type, target_key, before_content, after_content,
rationale, evidence_ids, status
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, 'applied')
RETURNING ${REVISION_COLUMNS}
`, [
crypto.randomUUID(),
proposal.agentId,
proposal.targetType,
proposal.targetKey,
beforeContent,
proposal.content,
proposal.rationale,
JSON.stringify(proposal.evidenceIds),
]);
revisions.push(revision);
for (const evidenceId of proposal.evidenceIds) appliedEvidenceIds.add(evidenceId);
}
signal?.throwIfAborted();
await db.none(`
UPDATE popagent_evolution_signals
SET status = CASE WHEN id = ANY($2::text[]) THEN 'applied' ELSE 'ignored' END,
next_attempt_at = NULL,
processed_at = NOW(),
error = NULL,
updated_at = NOW()
WHERE id = ANY($1::text[]) AND status = 'processing'
`, [claimedIds, [...appliedEvidenceIds]]);
return revisions;
});
}
async ignoreClaimedIfPolicyDisabled(signalIds: string[]): Promise<boolean> {
if (!signalIds.length) return false;
await this.init();
const claimedIds = [...new Set(signalIds.slice(0, 100))];
return this.storage.db.tx(async (db) => {
const claimed = await db.any<{ id: string }>(`
SELECT id FROM popagent_evolution_signals
WHERE id = ANY($1::text[]) AND status = 'processing'
FOR UPDATE
`, [claimedIds]);
if (!claimed.length) return false;
const policy = await db.one<ApplyPolicy>(`
SELECT enabled,
auto_apply_strategies AS "autoApplyStrategies",
auto_create_skills AS "autoCreateSkills"
FROM popagent_autonomy_settings
WHERE singleton = TRUE
FOR UPDATE
`);
if (policy.enabled && (policy.autoApplyStrategies || policy.autoCreateSkills)) return false;
await db.none(`
UPDATE popagent_evolution_signals
SET status = 'ignored',
attempts = GREATEST(attempts - 1, 0),
next_attempt_at = NULL,
processed_at = NOW(),
error = NULL,
updated_at = NOW()
WHERE id = ANY($1::text[]) AND status = 'processing'
`, [claimedIds]);
return true;
});
}
async revertRevision(id: string): Promise<EvolutionRevision | undefined> {
await this.init();
return this.storage.db.tx(async (db) => {
const source = await db.oneOrNone<EvolutionRevision>(`
SELECT ${REVISION_COLUMNS}
FROM popagent_evolution_revisions
WHERE id = $1 AND status = 'applied'
FOR SHARE
`, [id]);
if (!source || source.afterContent === null) return undefined;
const overlay = source.targetType === "overlay"
? await db.oneOrNone<{ content: string }>(`
SELECT content FROM popagent_learned_overlays
WHERE agent_id = $1 FOR UPDATE
`, [source.agentId])
: undefined;
const skill = source.targetType === "skill"
? await db.oneOrNone<StoredSkill>(`
SELECT id, instructions, evolution_managed AS "evolutionManaged"
FROM popagent_agent_skills
WHERE agent_id = $1 AND name = $2
FOR UPDATE
`, [source.agentId, source.targetKey])
: undefined;
const latest = await db.oneOrNone<{ id: string }>(`
SELECT id
FROM popagent_evolution_revisions
WHERE agent_id = $1 AND target_type = $2 AND target_key = $3
ORDER BY created_at DESC, id DESC
LIMIT 1
`, [source.agentId, source.targetType, source.targetKey]);
if (latest?.id !== source.id) return undefined;
if (source.targetType === "overlay") {
if (overlay?.content !== source.afterContent) return undefined;
if (source.beforeContent === null) {
await db.none("DELETE FROM popagent_learned_overlays WHERE agent_id = $1", [source.agentId]);
} else {
await db.none(`
UPDATE popagent_learned_overlays
SET content = $2, updated_at = NOW()
WHERE agent_id = $1
`, [source.agentId, source.beforeContent]);
}
} else {
if (!skill?.evolutionManaged || skill.instructions !== source.afterContent) return undefined;
if (source.beforeContent === null) {
await db.none("DELETE FROM popagent_agent_skills WHERE id = $1", [skill.id]);
} else {
await db.none(`
UPDATE popagent_agent_skills
SET instructions = $2, updated_at = NOW()
WHERE id = $1 AND evolution_managed = TRUE
`, [skill.id, source.beforeContent]);
}
}
return db.one<EvolutionRevision>(`
INSERT INTO popagent_evolution_revisions (
id, agent_id, target_type, target_key, before_content, after_content,
rationale, evidence_ids, status, reverts_revision_id
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, 'reverted', $9)
RETURNING ${REVISION_COLUMNS}
`, [
crypto.randomUUID(),
source.agentId,
source.targetType,
source.targetKey,
source.afterContent,
source.beforeContent,
`Reverted revision ${source.id}: ${source.rationale}`.slice(0, MAX_EVOLUTION_RATIONALE_CHARACTERS),
JSON.stringify(source.evidenceIds),
source.id,
]);
});
}
}
export const evolutionStore = new EvolutionStore();