Files
2026-09-14 10:32:21 +00:00

239 lines
7.4 KiB
TypeScript

import { parseTurnTimestamp } from "@/lib/state/slices/session/turn-actions";
export type AgentGoalStatus = "active" | "paused" | "blocked" | "limited" | "complete";
export type AgentGoal = {
objective: string;
status: AgentGoalStatus;
createdAt: number;
updatedAt: number;
tokenBudget?: number | null;
tokensUsed?: number;
timeUsedSeconds?: number;
controlMethod?: string;
};
export type AgentGoalReconciliation = {
revision: number;
cleared: boolean;
watermark: { createdAt: number; updatedAt: number } | null;
/** ACP session-info timestamp of the accepted live snapshot. */
sourceUpdatedAt?: string;
};
const GOAL_OBJECTIVE_MAX_LENGTH = 16 * 1024;
const GOAL_STATUSES = new Set<AgentGoalStatus>([
"active",
"paused",
"blocked",
"limited",
"complete",
]);
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function isNonNegativeFiniteNumber(value: unknown): value is number {
return typeof value === "number" && Number.isFinite(value) && value >= 0;
}
function hasOwn(value: Record<string, unknown>, key: string): boolean {
return Object.prototype.hasOwnProperty.call(value, key);
}
function parseOptionalGoalFields(value: Record<string, unknown>, goal: AgentGoal): boolean {
if (hasOwn(value, "tokenBudget")) {
if (value.tokenBudget !== null && !isNonNegativeFiniteNumber(value.tokenBudget)) return false;
goal.tokenBudget = value.tokenBudget as number | null;
}
for (const key of ["tokensUsed", "timeUsedSeconds"] as const) {
if (!hasOwn(value, key)) continue;
if (!isNonNegativeFiniteNumber(value[key])) return false;
goal[key] = value[key];
}
if (hasOwn(value, "controlMethod")) {
if (typeof value.controlMethod !== "string") return false;
goal.controlMethod = value.controlMethod;
}
return true;
}
/** Parses the narrow ACP goal extension into the fields the UI is allowed to read. */
export function parseAgentGoal(value: unknown): AgentGoal | null {
if (!isRecord(value)) return null;
const { objective, status, createdAt, updatedAt } = value;
if (
typeof objective !== "string" ||
objective.length === 0 ||
objective.length > GOAL_OBJECTIVE_MAX_LENGTH ||
typeof status !== "string" ||
!GOAL_STATUSES.has(status as AgentGoalStatus) ||
!isNonNegativeFiniteNumber(createdAt) ||
!isNonNegativeFiniteNumber(updatedAt)
) {
return null;
}
const goal: AgentGoal = {
objective,
status: status as AgentGoalStatus,
createdAt,
updatedAt,
};
return parseOptionalGoalFields(value, goal) ? goal : null;
}
/** Compares provider goal snapshots by goal identity and update time. */
export function isAgentGoalAtLeastAsFresh(incoming: AgentGoal, existing: AgentGoal): boolean {
if (incoming.createdAt !== existing.createdAt) return incoming.createdAt > existing.createdAt;
return incoming.updatedAt >= existing.updatedAt;
}
function isAgentGoalStrictlyNewer(
incoming: AgentGoal,
watermark: { createdAt: number; updatedAt: number },
): boolean {
return (
incoming.createdAt > watermark.createdAt ||
(incoming.createdAt === watermark.createdAt && incoming.updatedAt > watermark.updatedAt)
);
}
/** Returns whether an incoming session snapshot is newer than the accepted live goal snapshot. */
export function isAgentGoalSnapshotNewer(
snapshotUpdatedAt: string | undefined,
reconciliation: AgentGoalReconciliation | undefined,
): boolean {
if (!snapshotUpdatedAt || !reconciliation?.sourceUpdatedAt) return false;
const snapshotTime = parseTurnTimestamp(snapshotUpdatedAt);
const sourceTime = parseTurnTimestamp(reconciliation.sourceUpdatedAt);
return snapshotTime !== null && sourceTime !== null && snapshotTime > sourceTime;
}
/** Returns the accepted goal from ACP metadata, or null for absent/invalid values. */
export function getAgentGoal(metadata: unknown): AgentGoal | null {
if (!isRecord(metadata)) return null;
return parseAgentGoal(metadata.goal);
}
/**
* Merges only the goal field into a sparse provider metadata replacement.
* Other ACP metadata keeps the existing shallow replacement behavior.
*/
export function mergeAgentGoalMetadata(
existing: Record<string, unknown> | undefined,
incoming: Record<string, unknown> | undefined,
options: {
attachmentChanged?: boolean;
source?: "live" | "hydration";
reconciliation?: AgentGoalReconciliation;
snapshotUpdatedAt?: string;
} = {},
): Record<string, unknown> {
const current = existing ?? {};
const next = { ...(incoming ?? {}) };
const existingHasGoal = hasOwn(current, "goal");
const incomingHasGoal = hasOwn(incoming ?? {}, "goal");
if (options.attachmentChanged) {
return mergeAttachedGoal(next, incoming, incomingHasGoal);
}
if (!incomingHasGoal) {
return preserveExistingGoal(next, current, existingHasGoal);
}
if (incoming?.goal === null) {
return mergeClearedGoal(next, current.goal, options);
}
return mergeLiveGoal(next, current, incoming ?? {}, options);
}
function mergeAttachedGoal(
next: Record<string, unknown>,
incoming: Record<string, unknown> | undefined,
incomingHasGoal: boolean,
): Record<string, unknown> {
next.goal = incomingHasGoal ? parseAgentGoal(incoming?.goal) : null;
return next;
}
function preserveExistingGoal(
next: Record<string, unknown>,
current: Record<string, unknown>,
existingHasGoal: boolean,
): Record<string, unknown> {
if (existingHasGoal) next.goal = current.goal;
return next;
}
function mergeClearedGoal(
next: Record<string, unknown>,
currentGoal: unknown,
options: {
attachmentChanged?: boolean;
source?: "live" | "hydration";
reconciliation?: AgentGoalReconciliation;
snapshotUpdatedAt?: string;
},
): Record<string, unknown> {
next.goal = shouldRetainHydrationGoal(currentGoal, options) ? currentGoal : null;
return next;
}
function mergeLiveGoal(
next: Record<string, unknown>,
current: Record<string, unknown>,
incoming: Record<string, unknown>,
options: {
attachmentChanged?: boolean;
source?: "live" | "hydration";
reconciliation?: AgentGoalReconciliation;
snapshotUpdatedAt?: string;
},
): Record<string, unknown> {
const parsed = parseAgentGoal(incoming.goal);
if (!parsed) {
next.goal = null;
return next;
}
const existingGoal = parseAgentGoal(current.goal);
if (shouldRetainClearedGoal(parsed, options)) {
next.goal = current.goal;
return next;
}
next.goal =
existingGoal && !isAgentGoalAtLeastAsFresh(parsed, existingGoal) ? existingGoal : parsed;
return next;
}
function shouldRetainHydrationGoal(
currentGoal: unknown,
options: {
source?: "live" | "hydration";
reconciliation?: AgentGoalReconciliation;
snapshotUpdatedAt?: string;
},
): boolean {
return (
options.source === "hydration" &&
options.reconciliation !== undefined &&
!options.reconciliation.cleared &&
parseAgentGoal(currentGoal) !== null &&
!isAgentGoalSnapshotNewer(options.snapshotUpdatedAt, options.reconciliation)
);
}
function shouldRetainClearedGoal(
parsed: AgentGoal,
options: { source?: "live" | "hydration"; reconciliation?: AgentGoalReconciliation },
): boolean {
if (!options.reconciliation?.cleared) return false;
const watermark = options.reconciliation.watermark;
return (
(!watermark && options.source !== "live") ||
(watermark !== null && !isAgentGoalStrictlyNewer(parsed, watermark))
);
}