blob: 3e0b3b6c21845ef98fa19c146de3c4333cf87dcd [file]
/**
* Goal execution state — session-scoped, in-memory.
*
* A goal is a long-running objective the agent works toward autonomously across
* turns. After each turn, an external evaluator (CC-style) judges whether the
* condition is met; if not, the system auto-continues.
*
* PERSISTENCE: this phase owns only in-process state. A restart clears every
* Goal; persisted snapshots and restart recovery require a separate lifecycle
* boundary and are deliberately deferred.
*
* Lifecycle (Codex-inspired):
* active → waiting → active
* → achieved / impossible / cleared / paused
* → stalled (block cap: N consecutive no-progress turns)
* → budget_limited (token budget exhausted)
* → max_iterations (total turn ceiling)
*/
import {
GOAL_CONDITION_TEXT_LIMIT,
GOAL_REASON_TEXT_LIMIT,
type GoalStatus,
type GoalTextLimit,
} from '@maka/core';
export {
GOAL_CONDITION_TEXT_LIMIT,
GOAL_REASON_TEXT_LIMIT,
type GoalStatus,
type GoalTextLimit,
} from '@maka/core';
/** Terminal statuses — a goal in one of these states will not continue. */
export const TERMINAL_GOAL_STATUSES: ReadonlySet<GoalStatus> = new Set<GoalStatus>([
'achieved',
'impossible',
'cleared',
'stalled',
'budget_limited',
'max_iterations',
]);
export interface GoalState {
readonly id: string;
readonly revision: number;
readonly sessionId: string;
readonly condition: string;
readonly status: GoalStatus;
readonly setAt: number;
readonly iterations: number;
readonly maxIterations: number;
/** Consecutive turns with no progress (drives the block cap → stalled). */
readonly consecutiveNoProgress: number;
/** Force-stop after this many consecutive no-progress turns (CC's 8). */
readonly blockCap: number;
/**
* Optional working-turn token budget; goal → budget_limited when exceeded.
* Evaluator calls are usage-metered separately and do not spend this budget.
*/
readonly tokenBudget?: number;
/** Token count observed when the goal was set (baseline for spend). */
readonly tokensAtStart: number;
/** Latest observed token count (used to compute spend). */
readonly tokensNow: number;
/**
* True until the first real token observation. The baseline captured at set
* time can be stale/0 (the model calls GoalSet before any continuation has
* observed the session's token count), so the first settlement re-baselines
* to measure only tokens the goal itself spends.
*/
readonly tokensBaselinePending: boolean;
readonly lastReason?: string;
readonly achievedAt?: number;
readonly pausedAt?: number;
}
/** Immutable identity of the Goal snapshot an asynchronous operation observed. */
export interface GoalCheckpoint {
readonly goalId: string;
readonly revision: number;
}
/**
* Opaque in-process ownership token for externally queued Goal evidence.
* Ordinary turn settlements retain the lease; explicit lifecycle control
* replaces it so queued work cannot cross a pause/resume ABA boundary.
*/
export interface GoalControlLease {
readonly goalId: string;
}
export function goalCheckpoint(goal: Pick<GoalState, 'id' | 'revision'>): GoalCheckpoint {
return Object.freeze({ goalId: goal.id, revision: goal.revision });
}
export type GoalCreateResult =
| { kind: 'created'; goal: GoalState }
| { kind: 'unfinished'; goal: GoalState };
interface GoalTurnSettlementBase {
readonly checkpoint: GoalCheckpoint;
readonly reason: string;
}
export type GoalTurnSettlementInput =
| (GoalTurnSettlementBase & {
readonly verdict: 'achieved';
})
| (GoalTurnSettlementBase & {
readonly verdict: 'impossible';
})
| (GoalTurnSettlementBase &
(
| {
readonly verdict: 'continue';
readonly waiting: true;
readonly madeProgress?: never;
readonly tokensNow?: number;
}
| {
readonly verdict: 'continue';
readonly waiting?: false;
/** Undefined is neutral: neither advances nor resets the no-progress streak. */
readonly madeProgress?: boolean;
readonly tokensNow?: number;
}
));
export interface GoalManagerDeps {
generateId: () => string;
now: () => number;
/**
* Fired after every accepted goal state transition. Lets a host surface an
* autonomous loop to the UI — a token-burning goal must never run without a
* visible indicator and a clear affordance. This is a best-effort observer:
* failures cannot roll back an already committed state transition.
*/
onChange?: (goal: GoalState, previous?: GoalStatus) => void;
}
export const DEFAULT_MAX_ITERATIONS = 50;
export const DEFAULT_BLOCK_CAP = 8;
export function isGoalTextWithinLimit(value: string, limit: GoalTextLimit): boolean {
return value.length <= limit.codeUnits && Buffer.byteLength(value, 'utf8') <= limit.utf8Bytes;
}
export function truncateGoalText(value: string, limit: GoalTextLimit): string {
if (isGoalTextWithinLimit(value, limit)) return value;
let result = '';
let codeUnits = 0;
let utf8Bytes = 0;
for (const character of value) {
const nextCodeUnits = character.length;
const nextUtf8Bytes = Buffer.byteLength(character, 'utf8');
if (
codeUnits + nextCodeUnits > limit.codeUnits ||
utf8Bytes + nextUtf8Bytes > limit.utf8Bytes
) {
break;
}
result += character;
codeUnits += nextCodeUnits;
utf8Bytes += nextUtf8Bytes;
}
return result;
}
interface GoalRecord {
state: GoalState;
controlLease: GoalControlLease;
}
export interface GoalPauseOptions {
readonly checkpoint?: GoalCheckpoint;
readonly reason?: string;
}
type GoalStatePatch = Partial<
Omit<GoalState, 'id' | 'revision' | 'sessionId' | 'condition' | 'setAt'>
>;
export class GoalManager {
private goals = new Map<string, GoalRecord>();
constructor(private readonly deps: GoalManagerDeps) {}
private emit(goal: GoalState, previous?: GoalStatus): void {
try {
this.deps.onChange?.(goal, previous);
} catch {
// State and control leases are already committed. A host notification
// must not make the caller observe failure after that point.
}
}
private commit(
record: GoalRecord,
patch: GoalStatePatch,
options?: { renewControlLease?: boolean },
): GoalState {
const previous = record.state.status;
const boundedPatch =
patch.lastReason === undefined
? patch
: { ...patch, lastReason: truncateGoalText(patch.lastReason, GOAL_REASON_TEXT_LIMIT) };
const committed = Object.freeze({
...record.state,
...boundedPatch,
revision: record.state.revision + 1,
});
record.state = committed;
if (options?.renewControlLease) {
record.controlLease = createControlLease(committed.id);
}
this.emit(committed, previous);
return committed;
}
create(
sessionId: string,
condition: string,
opts?: {
maxIterations?: number;
blockCap?: number;
tokenBudget?: number;
tokensAtStart?: number;
},
): GoalCreateResult {
if (!condition.trim() || !isGoalTextWithinLimit(condition, GOAL_CONDITION_TEXT_LIMIT)) {
throw new RangeError('Goal condition exceeds its shared text limit');
}
const existing = this.goals.get(sessionId)?.state;
if (existing && !TERMINAL_GOAL_STATUSES.has(existing.status)) {
return { kind: 'unfinished', goal: existing };
}
const start = opts?.tokensAtStart ?? 0;
const goal: GoalState = Object.freeze({
id: this.deps.generateId(),
revision: 0,
sessionId,
condition,
status: 'active',
setAt: this.deps.now(),
iterations: 0,
maxIterations: opts?.maxIterations ?? DEFAULT_MAX_ITERATIONS,
consecutiveNoProgress: 0,
blockCap: opts?.blockCap ?? DEFAULT_BLOCK_CAP,
tokenBudget: opts?.tokenBudget,
tokensAtStart: start,
tokensNow: start,
tokensBaselinePending: true,
});
const goalRecord: GoalRecord = {
state: goal,
controlLease: createControlLease(goal.id),
};
this.goals.set(sessionId, goalRecord);
this.emit(goal);
return { kind: 'created', goal };
}
get(sessionId: string): GoalState | undefined {
return this.goals.get(sessionId)?.state;
}
getActive(sessionId: string): GoalState | undefined {
const goal = this.goals.get(sessionId)?.state;
return goal?.status === 'active' ? goal : undefined;
}
getControlLease(sessionId: string): GoalControlLease | undefined {
return this.goals.get(sessionId)?.controlLease;
}
matchesControlLease(sessionId: string, lease: GoalControlLease): boolean {
return this.goals.get(sessionId)?.controlLease === lease;
}
matchesActive(sessionId: string, checkpoint: GoalCheckpoint): boolean {
const goal = this.goals.get(sessionId)?.state;
return (
goal?.status === 'active' &&
goal.id === checkpoint.goalId &&
goal.revision === checkpoint.revision
);
}
matches(sessionId: string, checkpoint: GoalCheckpoint): boolean {
const goal = this.goals.get(sessionId)?.state;
return goal?.id === checkpoint.goalId && goal.revision === checkpoint.revision;
}
tokensSpent(sessionId: string): number {
const goal = this.goals.get(sessionId)?.state;
if (!goal) return 0;
return Math.max(0, goal.tokensNow - goal.tokensAtStart);
}
settleTurn(sessionId: string, input: GoalTurnSettlementInput): GoalState | undefined {
const record = this.goals.get(sessionId);
if (!record) return undefined;
const current = record.state;
if (current.id !== input.checkpoint.goalId) return undefined;
if (current.revision !== input.checkpoint.revision) return undefined;
if (current.status !== 'active') return undefined;
let patch: GoalStatePatch;
if (input.verdict === 'achieved') {
patch = {
status: 'achieved',
lastReason: input.reason,
achievedAt: this.deps.now(),
};
} else if (input.verdict === 'impossible') {
patch = { status: 'impossible', lastReason: input.reason };
} else {
let tokensAtStart = current.tokensAtStart;
let tokensNow = current.tokensNow;
let tokensBaselinePending = current.tokensBaselinePending;
let iterations = current.iterations;
let consecutiveNoProgress = current.consecutiveNoProgress;
let status: GoalStatus = current.status;
let lastReason = input.reason;
if (input.tokensNow !== undefined) {
if (tokensBaselinePending) {
tokensAtStart = input.tokensNow;
tokensNow = input.tokensNow;
tokensBaselinePending = false;
} else {
tokensNow = Math.max(tokensNow, input.tokensNow);
if (
current.tokenBudget !== undefined &&
tokensNow - tokensAtStart >= current.tokenBudget
) {
status = 'budget_limited';
lastReason = `Token budget exhausted (${current.tokenBudget} tokens)`;
}
}
}
if (status === 'active') {
iterations++;
if (iterations >= current.maxIterations) {
status = 'max_iterations';
lastReason = `Reached maximum iterations (${current.maxIterations})`;
}
}
if (status === 'active' && input.madeProgress !== undefined) {
if (input.madeProgress) {
consecutiveNoProgress = 0;
} else {
consecutiveNoProgress++;
if (consecutiveNoProgress >= current.blockCap) {
status = 'stalled';
lastReason = `No progress for ${current.blockCap} consecutive turns`;
}
}
}
if (status === 'active' && input.waiting === true) {
status = 'waiting';
}
patch = {
status,
iterations,
consecutiveNoProgress,
tokensAtStart,
tokensNow,
tokensBaselinePending,
lastReason,
};
}
return this.commit(record, patch);
}
pause(sessionId: string, options?: GoalPauseOptions): GoalState | undefined {
const record = this.goals.get(sessionId);
if (!record || (record.state.status !== 'active' && record.state.status !== 'waiting')) {
return undefined;
}
if (options?.checkpoint && !this.matches(sessionId, options.checkpoint)) return undefined;
return this.commit(
record,
{
status: 'paused',
pausedAt: this.deps.now(),
...(options?.reason !== undefined ? { lastReason: options.reason } : {}),
},
{ renewControlLease: true },
);
}
resume(sessionId: string, checkpoint?: GoalCheckpoint): GoalState | undefined {
const record = this.goals.get(sessionId);
if (!record || record.state.status !== 'paused') return undefined;
if (checkpoint && !this.matches(sessionId, checkpoint)) return undefined;
return this.commit(
record,
{ status: 'active', pausedAt: undefined },
{ renewControlLease: true },
);
}
wakeWaiting(sessionId: string, checkpoint: GoalCheckpoint): GoalState | undefined {
const record = this.goals.get(sessionId);
if (!record || record.state.status !== 'waiting' || !this.matches(sessionId, checkpoint)) {
return undefined;
}
return this.commit(record, { status: 'active' });
}
clear(sessionId: string): GoalState | undefined {
const record = this.goals.get(sessionId);
if (!record || TERMINAL_GOAL_STATUSES.has(record.state.status)) return undefined;
return this.commit(record, { status: 'cleared' }, { renewControlLease: true });
}
remove(sessionId: string): boolean {
const record = this.goals.get(sessionId);
const deleted = this.goals.delete(sessionId);
if (record && deleted) this.emit(record.state, record.state.status);
return deleted;
}
dispose(): void {
this.goals.clear();
}
}
function createControlLease(goalId: string): GoalControlLease {
return Object.freeze({ goalId });
}