From 11f8d2abbb88920aeda07d3d0d4616dbbe777bc6 Mon Sep 17 00:00:00 2001 From: Ian de Marcellus <50731233+ian-de-marcellus@users.noreply.github.com> Date: Fri, 28 Aug 2026 03:12:23 +0200 Subject: [PATCH 01/11] feat(lifecycle): add irreversible resident retirement primitive --- README.md | 31 ++ changelog.d/resident-retirement.added.md | 4 + docs/resident-lifecycle.md | 113 ++++++++ src/framework.ts | 342 +++++++++++++++++++++- src/gate/event-gate.ts | 22 ++ src/module-registry.ts | 16 ++ src/types/agent.ts | 41 +++ src/types/framework.ts | 8 + src/types/module.ts | 8 + src/types/trace.ts | 9 + test/resident-retirement.test.ts | 351 +++++++++++++++++++++++ 11 files changed, 937 insertions(+), 8 deletions(-) create mode 100644 changelog.d/resident-retirement.added.md create mode 100644 docs/resident-lifecycle.md create mode 100644 test/resident-retirement.test.ts diff --git a/README.md b/README.md index 92fe15c8..368c9837 100644 --- a/README.md +++ b/README.md @@ -257,6 +257,37 @@ framework.abortInference('assistant', 'user requested stop'); The non-streaming path uses `AbortSignal` forwarded to `membrane.stream()`, which returns an `AbortedResponse` with partial content. The streaming path cancels the `YieldingStream` directly. +### Resident retirement + +Configured resident agents can opt into the framework's neutral irreversible +seal and enforcement primitive: + +```ts +agents: [{ + name: 'assistant', + model: 'claude-opus-4-6', + systemPrompt: '...', + retirement: { enabled: true }, +}] +``` + +After applying its own resident-facing policy, a host calls +`framework.retireResident(name, reason?)`. The framework fsyncs the terminal +seal, denies future conversational and maintenance inference, clears queued +wakes and process-local gate state, stops resident-authored scripts, and keeps +Chronicle/workspace history readable. Confirmation wording, cooling-off, +health gates, and notifications deliberately belong to the host. Retirement +is distinct from end-turn, sleep/dormancy, and erasure. + +Modules can expose host-owned ceremony through `getLiveTools(agentName)`. +Those tools are available only to a provider-issued live stream, never public +programmatic dispatch, code execution, ephemeral agents, or `puppetToolCall`. + +The terminal seal is append-only and branch-independent at +`/resident-retirements.jsonl`, so Chronicle undo/redo or a branch +switch cannot resume the identity. Apps that supply an owned `store` must also +supply `retirementPath`. See [Resident lifecycle](docs/resident-lifecycle.md). + ## API Server WebSocket server for external clients (UIs, scripts, other agents). diff --git a/changelog.d/resident-retirement.added.md b/changelog.d/resident-retirement.added.md new file mode 100644 index 00000000..eddc3d9e --- /dev/null +++ b/changelog.d/resident-retirement.added.md @@ -0,0 +1,4 @@ +- Add an opt-in irreversible resident seal and imperative enforcement API, + plus a provider-stream-only module tool surface for host-owned lifecycle + ceremonies. Retirement preserves history while blocking future inference, + scheduled wakes, and conversation-fork resurrection. diff --git a/docs/resident-lifecycle.md b/docs/resident-lifecycle.md new file mode 100644 index 00000000..26f196f9 --- /dev/null +++ b/docs/resident-lifecycle.md @@ -0,0 +1,113 @@ +# Resident lifecycle + +Resident retirement is an opt-in terminal lifecycle state for a configured, +persistent agent identity. Its semantics are deliberately narrow: + +- **End turn** stops the current inference and permits a later wake. +- **Sleep/dormancy** suppresses wakes temporarily and remains reversible. +- **Retirement** permanently denies future inference for this resident. +- **Erasure** deletes stored data and is a separate custodial operation. + +Retirement preserves Chronicle, messages, workspace files, inference logs, +and a terminal lifecycle event. Later external traffic may still appear in +host process logs, but is not appended to the retired resident's context. + +## Framework responsibility + +Enable the neutral primitive on a configured resident: + +```ts +const framework = await AgentFramework.create({ + storePath: './data/resident', + membrane, + agents: [{ + name: 'resident', + model: 'claude-opus-4-6', + systemPrompt: '...', + retirement: { enabled: true }, + }], + modules, +}); +``` + +Once its own policy is satisfied, the host applies the seal imperatively: + +```ts +const result = framework.retireResident('resident', optionalResidentReason); +``` + +There is intentionally no framework-owned confirmation phrase, challenge, +cooling-off interval, notification policy, or human approval hook. Those are +product decisions. The framework owns only authorization, durable sealing, +enforcement, status, and observability. + +`getResidentLifecycleStatus(name)` returns the durable host view. A configured +resident without `retirement.enabled` cannot be retired through this API. + +## Host-owned live tools + +A module may implement `getLiveTools(agentName)` for a resident-facing +ceremony. Definitions from this method are namespaced like ordinary module +tools, but have a stronger call boundary: + +- they appear only on the named provider-issued live stream; +- they are absent from ephemeral and conversation-fork surfaces unless the + module explicitly returns them for those names; +- public `executeToolCall`, `ModuleContext.callTool`, `code_execution`, and + `puppetToolCall` cannot invoke them. + +The module still handles a valid live call through its ordinary +`handleToolCall`. This lets a host own all resident-facing semantics while the +framework enforces that administrative puppeting cannot counterfeit consent. + +## Terminal enforcement + +`retireResident` appends and fsyncs one record in +`resident-retirements.jsonl`. That sidecar is authoritative across Chronicle +undo, redo, and branch switching. The framework then: + +- drops queued and future inference requests for the resident; +- rejects direct starts and operator nudges; +- skips context-maintenance ticks that could invoke the resident's model; +- stops resident-authored foreground/background code runners; +- clears resident-owned gate sleep and self-wake timers, provider cooldowns, + and queued wake state; +- freezes the resident conversation against later message appends; +- refuses to create a conversation fork from a retired template resident; and +- appends `framework/resident-lifecycle` in Chronicle and emits a + `resident:retired` trace. + +Already-running ephemeral subagents are separate, short-lived inference +identities. Retirement does not kill them mid-call. They may finish their own +work, but any attempt to deliver a wake or append into the retired resident is +rejected by the seal. New resident inference and new resident-authored +background activity are denied. Hosts that want parent-scoped cancellation +must track and cancel those ephemeral jobs before invoking `retireResident`. + +## Seal location and startup integrity + +With `storePath`, the default sidecar is +`/resident-retirements.jsonl`. If the app passes an owned `store`, +it must also pass an explicit branch-independent `retirementPath`. + +Startup parses every non-empty line strictly and fails closed on truncated +JSON, a missing final newline, invalid fields, or duplicate records. The +framework must not infer while the terminal ledger is ambiguous. + +### Recovering a malformed seal safely + +There is no lifecycle reversal API. If startup reports a malformed seal: + +1. Keep the host stopped and make a byte-for-byte backup of the sidecar. +2. Inspect the exact path and line number from the startup error. +3. Compare the sidecar with host/storage logs. Remove only a demonstrably + incomplete or invalid write that never constituted a valid seal record + (normally a torn final line after a storage failure). +4. Never delete or edit a valid `resident-retired` record. Never repair an + ambiguous file by guessing. Preserve it and escalate for forensic review. +5. Restart only after the remaining file is newline-terminated and every line + validates independently. + +This procedure repairs ledger syntax; it does not provide a hidden route to +unretire a resident. Deleting a valid seal is an out-of-band alteration of the +durable record and violates the lifecycle contract. diff --git a/src/framework.ts b/src/framework.ts index 87f9c2a4..590602fd 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -1,7 +1,18 @@ import { dirname, join } from 'node:path'; import { INLINE_WITHHELD_TEXT, classifyBlock, isInlineContradiction, referenceRegistry, referenceStubOrNull } from './mcpl/references.js'; import { ReferenceFetcher, DEFAULT_FETCH_MAX_BYTES, EAGER_FETCH_TIMEOUT_MS } from './mcpl/reference-fetcher.js'; -import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'; +import { + appendFileSync, + closeSync, + existsSync, + fsyncSync, + mkdirSync, + openSync, + readFileSync, + renameSync, + writeFileSync, + writeSync, +} from 'node:fs'; import { JsStore } from '@animalabs/chronicle'; import type { Membrane, ContentBlock, NormalizedRequest, YieldingStream, ToolResult as MembraneToolResult, ToolResultContentBlock } from '@animalabs/membrane'; import { MembraneError } from '@animalabs/membrane'; @@ -57,6 +68,9 @@ import type { AgentRuntimeSettingsSnapshot, AgentSettingsExtension, SameRoundThinkTextPolicy, + ResidentLifecycleStatus, + ResidentRetirementConfig, + ResidentRetirementResult, } from './types/index.js'; import { ProcessQueueImpl } from './queue.js'; import { REFUSAL_REACTIONS, REFUSAL_REACTION_FALLBACK } from './refusal-reactions.js'; @@ -443,6 +457,7 @@ function isTurnContinuation(reason: string): boolean { const CONVERSATION_ROUTER_STATE_ID = 'framework/conversation-router'; const INFERENCE_LOG_ID = 'framework/inference-log'; const PROCESS_LOG_ID = 'framework/process-log'; +const RESIDENT_LIFECYCLE_LOG_ID = 'framework/resident-lifecycle'; const TURN_CHECKPOINTS_ID = 'framework/turn-checkpoints'; // legacy single-map layout, read-only fallback const TURN_CHECKPOINTS_TREE_ID = 'framework/turn-checkpoints/tree'; @@ -729,6 +744,13 @@ const DEFAULT_SYNC_INTERVAL_MS = 1000; const DEFAULT_MAINTENANCE_INTERVAL_MS = 5000; /** Bound one pass so a large backlog yields to inference and other agents. */ const MAINTENANCE_TICKS_PER_PASS = 8; +interface ResidentRetirementRecord { + version: 1; + kind: 'resident-retired'; + agentName: string; + retiredAt: number; + reason?: string; +} /** Local per-residence provider admission (AF #114 bounded first slice). */ interface LocalProviderGate { @@ -1262,6 +1284,14 @@ export class AgentFramework { * FrameworkConfig.toolResultInlineMaxChars; null → house default. */ private toolResultInlineMaxCharsConfig: number | null = null; + // ---- Resident lifecycle ------------------------------------------------- + /** Configured, non-ephemeral residents a host may irreversibly retire. */ + private retirableResidents: Map = new Map(); + /** Branch-independent terminal seals loaded from the append-only sidecar. */ + private retiredResidents: Map = new Map(); + /** Null only for app-owned stores whose host supplied no seal path. */ + private readonly retirementPath: string | null; + private mcplTools: import('./types/index.js').ToolDefinition[] = []; /** Namespaced tool name → stateful feature-set attribution from tools/list. */ private mcplToolFeatureSets: Map = new Map(); @@ -1320,6 +1350,7 @@ export class AgentFramework { discordAwarenessOutbox: DiscordAwarenessOutbox | null, discordAwarenessEmoji: string, discordAwarenessDeadlineMs: number, + retirementPath: string | null, operatorLog: OperatorLog = new OperatorLog(undefined), ) { this.operatorLog = operatorLog; @@ -1336,6 +1367,7 @@ export class AgentFramework { this.discordAwarenessOutbox = discordAwarenessOutbox; this.discordAwarenessEmoji = discordAwarenessEmoji; this.discordAwarenessDeadlineMs = discordAwarenessDeadlineMs; + this.retirementPath = retirementPath; this.queue = new ProcessQueueImpl(); this.usageTracker = new UsageTracker({ emitTrace: (e: UsageUpdatedEvent) => this.emitTrace({ ...e }), @@ -1401,6 +1433,12 @@ export class AgentFramework { // Already registered } + try { + store.registerState({ id: RESIDENT_LIFECYCLE_LOG_ID, strategy: 'append_log' }); + } catch (error) { + if (!isStateExistsError(error)) throw error; + } + // The legacy single-map checkpoint state (TURN_CHECKPOINTS_ID) is no longer // registered for new stores — it's read-only fallback data in old ones. try { @@ -1459,10 +1497,17 @@ export class AgentFramework { discordAwarenessOutbox, config.discordAwarenessEmoji ?? DEFAULT_DISCORD_AWARENESS_EMOJI, normalizeDiscordAwarenessDeadline(config.discordAwarenessDeadlineMs), + config.retirementPath ?? (config.storePath + ? join(config.storePath, 'resident-retirements.jsonl') + : null), new OperatorLog(operatorLogPath), ); framework.providerHoldHook = config.providerHold; + // The sidecar is deliberately outside Chronicle's branch projection: + // undo, redo, and branch switching are reversible; retirement is not. + framework.loadRetirementSeals(); + // If an offline recovery process crashed after switching Chronicle but // before committing its prepared marker batch, the active branch is the // commit record. Promote it now; no quarantined content is read. @@ -1490,6 +1535,15 @@ export class AgentFramework { // Create agents for (const agentConfig of config.agents) { + if (agentConfig.retirement?.enabled) { + if (!framework.retirementPath) { + throw new Error( + `Agent "${agentConfig.name}" enables retirement, but FrameworkConfig uses an ` + + 'app-owned store without retirementPath. Supply a branch-independent seal path.', + ); + } + framework.retirableResidents.set(agentConfig.name, { enabled: true }); + } await framework.createAgent(agentConfig); } @@ -1923,6 +1977,9 @@ export class AgentFramework { if (this.providerAdmissionClosed) { throw new Error(`Provider admission closed while stopping (${agentName})`); } + if (this.retiredResidents.has(agentName)) { + throw new Error(`Provider admission denied for retired resident (${agentName})`); + } gate.auxiliaryInFlight++; try { return await run(); } catch (error) { @@ -2090,8 +2147,16 @@ export class AgentFramework { private async runQueuedMaintenance(): Promise { const queued = [...this.agents.values()].flatMap((agent) => { + // Context maintenance may call the resident's own model to write or + // merge memories. A terminal identity performs no such off-path + // inference after retirement. + if (this.retiredResidents?.has(agent.name)) return []; const cm = agent.getContextManager(); - const tools = this.getToolsForAgent(agent.name).filter((tool) => agent.canUseTool(tool.name)); + const tools = this.getToolsForAgent(agent.name).filter( + (tool) => + !this.moduleRegistry.isLiveTool(tool.name, agent.name) && + agent.canUseTool(tool.name), + ); cm.setToolDefinitions(tools); if (this.providerGateBlocked(agent.name)) return []; if (cm.isReady()) return []; @@ -2551,7 +2616,7 @@ export class AgentFramework { : t); return [...SUBCONSCIOUS_TOOLS, ...basics]; } - return this.getAllTools().map((tool) => { + const tools = this.getAllTools().map((tool) => { if (tool.name === 'think') { return this.buildThinkTool( snapshot?.sameRoundThinkTextPolicy @@ -2560,6 +2625,200 @@ export class AgentFramework { } return tool; }); + // A module must opt one named resident into this provider-stream-only + // surface. These definitions never enter getAllTools(), so ephemerals, + // code execution and puppetToolCall cannot see or invoke them. + tools.push(...this.moduleRegistry.getLiveTools(agentName)); + return tools; + } + + private canExposeToolToResident(agent: Agent, toolName: string): boolean { + return agent.canUseTool(toolName); + } + + /** Read-only lifecycle view for hosts, diagnostics, and tests. */ + getResidentLifecycleStatus(agentName: string): ResidentLifecycleStatus { + if (!this.agents.has(agentName)) throw new Error(`Unknown agent: ${agentName}`); + const retired = this.retiredResidents?.get(agentName); + if (retired) { + return { + status: 'retired', + retiredAt: retired.retiredAt, + ...(retired.reason ? { reason: retired.reason } : {}), + }; + } + return { + status: 'active', + retirementEnabled: this.retirableResidents?.has(agentName) ?? false, + }; + } + + /** Restore branch-independent terminal seals before any agent can infer. */ + private loadRetirementSeals(): void { + if (!this.retirementPath || !existsSync(this.retirementPath)) return; + const contents = readFileSync(this.retirementPath, 'utf8'); + const lines = contents.split('\n'); + if (contents.length > 0 && !contents.endsWith('\n')) { + throw new Error( + `Invalid retirement seal at ${this.retirementPath}:${lines.length}: ` + + 'incomplete final record (missing newline)', + ); + } + for (const [index, line] of lines.entries()) { + if (!line.trim()) continue; + try { + const raw = JSON.parse(line) as Partial; + if ( + raw.version !== 1 || + raw.kind !== 'resident-retired' || + typeof raw.agentName !== 'string' || + !raw.agentName || + raw.agentName !== raw.agentName.trim() || + /[\u0000-\u001f\u007f]/.test(raw.agentName) || + typeof raw.retiredAt !== 'number' || + !Number.isSafeInteger(raw.retiredAt) || + raw.retiredAt <= 0 || + (raw.reason !== undefined && ( + typeof raw.reason !== 'string' || + raw.reason.length === 0 || + raw.reason.length > 4000 + )) || + this.retiredResidents.has(raw.agentName) + ) { + throw new Error('invalid retirement record'); + } + this.retiredResidents.set(raw.agentName, { + version: 1, + kind: 'resident-retired', + agentName: raw.agentName, + retiredAt: raw.retiredAt, + ...(typeof raw.reason === 'string' && raw.reason ? { reason: raw.reason } : {}), + }); + } catch (error) { + throw new Error( + `Invalid retirement seal at ${this.retirementPath}:${index + 1}: ` + + (error instanceof Error ? error.message : String(error)), + ); + } + } + } + + /** Append + fsync: a confirmed retirement must survive a successful return. */ + private appendRetirementSeal(record: ResidentRetirementRecord): void { + if (!this.retirementPath) throw new Error('No retirement seal path configured'); + mkdirSync(dirname(this.retirementPath), { recursive: true }); + const fd = openSync(this.retirementPath, 'a', 0o600); + try { + const payload = Buffer.from(JSON.stringify(record) + '\n', 'utf8'); + let offset = 0; + while (offset < payload.length) { + const written = writeSync(fd, payload, offset, payload.length - offset); + if (written <= 0) throw new Error('retirement seal write made no progress'); + offset += written; + } + fsyncSync(fd); + } finally { + closeSync(fd); + } + } + + private stopResidentAuthoredActivity(agentName: string): void { + // A retired resident cannot retain a model-authored daemon that keeps + // acting or attempting wakes after its inference identity is sealed. + const runner = this.codeExecutionRunners.get(agentName); + if (runner) { + runner.abort('resident retired'); + runner.dispose(); + this.codeExecutionRunners.delete(agentName); + } + for (const record of this.backgroundScripts.values()) { + if (record.agentName !== agentName || record.status !== 'running') continue; + record.cancelled = true; + record.status = 'cancelled'; + record.runner.abort('resident retired'); + record.runner.dispose(); + } + this.pendingRequests = this.pendingRequests.filter((request) => request.agentName !== agentName); + const cooldown = this.providerAccelerationCooldowns.get(agentName); + if (cooldown) clearTimeout(cooldown.timer); + this.providerAccelerationCooldowns.delete(agentName); + this.providerAccelerationRecoveries.delete(agentName); + const gate = this.providerGates.get(agentName); + if (gate) { + gate.primaryDepth = 0; + gate.primaryPending = false; + for (const resolve of gate.auxiliaryWaiters.splice(0)) resolve(); + for (const resolve of gate.idleWaiters.splice(0)) resolve(); + this.providerGates.delete(agentName); + } + this.eventGate?.clearAgentState(agentName); + this.activeTriggerChannels.delete(agentName); + this.staleWarnAt.delete(agentName); + } + + /** + * Apply the neutral irreversible seal for an explicitly authorized resident. + * Confirmation, wording, cooling-off and notification are host policy and + * deliberately absent from this primitive. + */ + retireResident(agentName: string, reason?: string): ResidentRetirementResult { + if (!this.agents.has(agentName)) throw new Error(`Unknown agent: ${agentName}`); + if (!this.retirableResidents.has(agentName)) { + throw new Error(`Resident retirement is not enabled for agent: ${agentName}`); + } + const existing = this.retiredResidents.get(agentName); + if (existing) { + return { + status: 'retired', + retiredAt: existing.retiredAt, + ...(existing.reason ? { reason: existing.reason } : {}), + chronicleRecorded: false, + alreadyRetired: true, + }; + } + const cleanReason = typeof reason === 'string' && reason.trim() + ? reason.trim().slice(0, 4000) + : undefined; + const record: ResidentRetirementRecord = { + version: 1, + kind: 'resident-retired', + agentName, + retiredAt: Date.now(), + ...(cleanReason ? { reason: cleanReason } : {}), + }; + + // The sidecar is authoritative and branch-independent. Once this append + // returns, no Chronicle branch operation can make the identity infer again. + this.appendRetirementSeal(record); + this.retiredResidents.set(agentName, record); + this.stopResidentAuthoredActivity(agentName); + + let chronicleRecorded = false; + try { + this.store.appendToStateJson(RESIDENT_LIFECYCLE_LOG_ID, record); + this.store.sync(); + chronicleRecorded = true; + } catch (error) { + // Fail safe: an audit append failure cannot resurrect an already-fsynced + // retirement seal. Surface it loudly and in the tool result/trace. + console.error( + `[resident-retirement] terminal Chronicle event failed for ${agentName}:`, + error, + ); + } + this.emitTrace({ + type: 'resident:retired', + agentName, + retiredAt: record.retiredAt, + chronicleRecorded, + }); + return { + status: 'retired', + retiredAt: record.retiredAt, + ...(record.reason ? { reason: record.reason } : {}), + chronicleRecorded, + alreadyRetired: false, + }; } getAgentRuntimeSettings(agentName: string): AgentRuntimeSettingsSnapshot { @@ -3378,7 +3637,9 @@ export class AgentFramework { throw new Error(`Agent not found: ${agentName}`); } - const tools = this.getToolsForAgent(agentName).filter((t) => agent.canUseTool(t.name)); + const tools = this.getToolsForAgent(agentName).filter( + (tool) => this.canExposeToolToResident(agent, tool.name), + ); // An explicit budget compiles against a HYPOTHETICAL window instead of the // agent's live one. That also suppresses transition-settling in @@ -4122,7 +4383,6 @@ export class AgentFramework { required: ['channelId'], }, }; - /** Synthesized sleep/wake tool definitions (present when a gate is wired). */ private static readonly SLEEP_TOOLS: import('./types/index.js').ToolDefinition[] = [ { @@ -4723,6 +4983,9 @@ export class AgentFramework { if (!name || !agent) { return { ok: false, error: `Unknown agent: ${String(agentName ?? '(none registered)')}` }; } + if (this.retiredResidents?.has(name)) { + return { ok: false, error: `Resident "${name}" is retired and cannot run inference.` }; + } const agentStatus = this.activeTurnTokens.has(name) && agent.state.status === 'idle' ? 'idle+turn-alive' : agent.state.status; @@ -5116,6 +5379,10 @@ export class AgentFramework { } } + /** + * Query inference logs. + * Returns entries with summary info (doesn't resolve blobs). + */ queryInferenceLogs(query?: InferenceLogQuery): InferenceLogQueryResult { const limit = query?.limit ?? 50; const offset = query?.offset ?? 0; @@ -6958,6 +7225,12 @@ export class AgentFramework { */ private async createConversationAgent(name: string, channelId: string): Promise { const router = this.conversationRouter!; + if (this.retiredResidents.has(router.templateAgent)) { + throw new Error( + `conversation template resident "${router.templateAgent}" is retired; ` + + `refusing to create fork "${name}"`, + ); + } const templateConfig = this.agentConfigs.get(router.templateAgent); const template = this.agents.get(router.templateAgent); if (!template || !templateConfig) { @@ -7386,6 +7659,21 @@ export class AgentFramework { continue; } + if (this.retiredResidents?.has(agentName)) { + const oldest = Math.min(...requests.map((r) => r.timestamp)); + this.emitTrace({ + type: 'inference:request_dropped', + agentName, + reason: 'resident_retired', + requestCount: requests.length, + oldestRequestAge: now - oldest, + }); + console.error( + `[inference-dropped] agent=${agentName} reason=resident_retired requests=${requests.length}`, + ); + continue; + } + const providerCooldown = this.providerAccelerationCooldowns?.get(agentName); if (providerCooldown) { if (now < providerCooldown.until || this.extendHostProviderHold(agentName, providerCooldown)) { @@ -8040,6 +8328,13 @@ export class AgentFramework { attempt = 0, providerGateAlreadyHeld = false, ): Promise { + // Defense in depth for direct/retry call sites: the scheduler also drops + // retired requests, but no path may reach the provider after the seal. + if (this.retiredResidents?.has(agent.name)) { + if (providerGateAlreadyHeld) this.releasePrimaryProviderGate(agent.name); + console.error(`[inference-dropped] agent=${agent.name} reason=resident_retired direct-start`); + return; + } const ownsProviderGate = !this.ephemeralRuns.has(agent.name) && !this.conversationAgentHomes.has(agent.name); if (ownsProviderGate && !providerGateAlreadyHeld) { @@ -8294,7 +8589,9 @@ export class AgentFramework { try { const requestSnapshot = this.captureInferenceToolSnapshot(agent); const allTools = this.getToolsForAgent(agent.name, requestSnapshot); - const tools = allTools.filter((t) => agent.canUseTool(t.name)); + const tools = allTools.filter( + (tool) => this.canExposeToolToResident(agent, tool.name), + ); // Explicit-mode agents get the on-demand routing reference (teach-by- // bounce: the grammar is never injected, only served when asked). if (agent.proseRouting === 'explicit') tools.push(PROSE_HELP_TOOL); @@ -9858,6 +10155,11 @@ export class AgentFramework { `injecting a turn under an active stream corrupts wire ordering)`, ); } + if (this.moduleRegistry?.isLiveTool(toolName, agentName)) { + throw new Error( + `puppet refused: tool ${toolName} is restricted to a provider-issued live stream`, + ); + } const onSurface = this.getToolsForAgent(agentName) .some((t) => t.name === toolName && agent.canUseTool(t.name)); if (!onSurface) { @@ -9968,6 +10270,14 @@ export class AgentFramework { } private async executeToolCallFrom(call: ToolCall, origin: ChannelToolOrigin): Promise { + const caller = call.callerAgentName ?? '__ephemeral__'; + if (this.moduleRegistry.isLiveTool(call.name, caller)) { + return { + success: false, + isError: true, + error: `Tool '${call.name}' may only be called by a provider-issued live agent stream.`, + }; + } // Client-side programmatic tool calling for promise-based callers // (SubagentModule ephemerals). Keyed by callerAgentName so each ephemeral // gets its own interpreter state. @@ -10160,7 +10470,11 @@ export class AgentFramework { const agent = this.agents.get(agentName); const surface = agent - ? this.getToolsForAgent(agentName).filter((t) => agent.canUseTool(t.name)) + ? this.getToolsForAgent(agentName).filter( + (tool) => + !this.moduleRegistry.isLiveTool(tool.name, agentName) && + agent.canUseTool(tool.name), + ) : this.getAllTools(); // ephemeral agents: full surface, matching executeToolCall const injected = buildInjectedTools( surface.map((t) => t.name).filter((name) => name !== CODE_EXECUTION_TOOL_NAME), @@ -11193,6 +11507,13 @@ export class AgentFramework { throw new Error('No agents configured'); } + // A retired resident's context is a historical record, not a mailbox. + // External events may still exist in module/process logs, but they do not + // append post-retirement speech to the sealed identity's conversation. + if (this.retiredResidents?.has(agent.name)) { + return '' as MessageId; + } + // Defer non-tool_result messages while a tool cycle is mid-flight // (pendingAssistantBlocks: preserves tool_use → tool_result adjacency) // OR while the target agent has a turn in progress AT ALL. A message @@ -11665,7 +11986,12 @@ export class AgentFramework { { skipLog: true }, ); } - if (agent && overBudget && !this.overBudgetDrainInFlight.has(agentName)) { + if ( + agent && + !this.retiredResidents?.has(agentName) && + overBudget && + !this.overBudgetDrainInFlight.has(agentName) + ) { this.overBudgetDrainInFlight.add(agentName); void (async () => { let ticks = 0; diff --git a/src/gate/event-gate.ts b/src/gate/event-gate.ts index 6db0a737..0dc4fb29 100644 --- a/src/gate/event-gate.ts +++ b/src/gate/event-gate.ts @@ -1597,6 +1597,28 @@ export class EventGate { } } + /** + * Forget process-local wake state owned by a terminal resident. Global + * debounce policies remain live for other residents; any later evaluation + * aimed at this name is still rejected by the framework's durable seal. + */ + clearAgentState(agentName: string): void { + this.inferring.delete(agentName); + const selfWake = this.selfWakeTimers.get(agentName); + if (selfWake) { + clearTimeout(selfWake); + this.selfWakeTimers.delete(agentName); + } + if (this.sleepAgent === agentName) { + if (this.sleepTimer) clearTimeout(this.sleepTimer); + this.sleepTimer = null; + this.sleepUntil = 0; + this.sleepNote = undefined; + this.sleepAgent = undefined; + this.sleepSuppressed = 0; + } + } + /** Live inference-gating snapshot for diagnostics (e.g. a SIGUSR2 dump). * A non-empty `inferring` with a stale timestamp + growing `bufferedEvents` * is the signature of the wake-wedge: an agent stuck mid-"inference" so all diff --git a/src/module-registry.ts b/src/module-registry.ts index 7d2bcc87..448ae86c 100644 --- a/src/module-registry.ts +++ b/src/module-registry.ts @@ -241,6 +241,22 @@ export class ModuleRegistry { return tools; } + /** Provider-stream-only module tools for one configured resident. */ + getLiveTools(agentName: string): ToolDefinition[] { + const tools: ToolDefinition[] = []; + for (const module of this.modules.values()) { + for (const tool of module.getLiveTools?.(agentName) ?? []) { + tools.push({ ...tool, name: `${module.name}--${tool.name}` }); + } + } + return tools; + } + + /** True when a name is reserved to the live-stream-only surface. */ + isLiveTool(toolName: string, agentName: string): boolean { + return this.getLiveTools(agentName).some((tool) => tool.name === toolName); + } + /** * All utilities from all modules, namespaced exactly like tools * (`module--name`). See Module.getUtilities — these are deliberately kept diff --git a/src/types/agent.ts b/src/types/agent.ts index b5cd7471..5c62dcd7 100644 --- a/src/types/agent.ts +++ b/src/types/agent.ts @@ -8,6 +8,41 @@ export type SameRoundThinkTextPolicySource = | 'recipe' | 'compatibility_default'; +/** + * Opt-in authorization for the framework's neutral irreversible retirement + * primitive. Resident-facing policy and confirmation belong to the host. + * + * Retirement is distinct from ending a turn, sleeping, and deleting data: + * it permanently denies future inference for this resident while leaving its + * Chronicle and workspace data intact. The agent confirms its own request in + * a separate inference turn; no human approval callback is involved. + */ +export interface ResidentRetirementConfig { + /** Permit a host to seal this configured resident through retireResident(). */ + enabled: boolean; +} + +/** Durable public view of a resident's lifecycle. */ +export type ResidentLifecycleStatus = + | { + status: 'active'; + retirementEnabled: boolean; + } + | { + status: 'retired'; + retiredAt: number; + reason?: string; + }; + +/** Result of applying (or observing) the irreversible resident seal. */ +export interface ResidentRetirementResult { + status: 'retired'; + retiredAt: number; + reason?: string; + chronicleRecorded: boolean; + alreadyRetired: boolean; +} + /** * Configuration for an agent. */ @@ -31,6 +66,12 @@ export interface AgentConfig { */ allowedTools?: 'all' | string[]; + /** + * Optional resident-controlled irreversible retirement. This applies only + * to configured residents, never ephemeral or per-channel fork agents. + */ + retirement?: ResidentRetirementConfig; + /** * Which modules can trigger inference for this agent. * - 'all': any module diff --git a/src/types/framework.ts b/src/types/framework.ts index e7fb509a..31d94a51 100644 --- a/src/types/framework.ts +++ b/src/types/framework.ts @@ -150,6 +150,14 @@ export interface FrameworkConfig { /** Or existing store (app-owned) */ store?: JsStore; + /** + * Branch-independent append-only retirement seal. Defaults to + * `/resident-retirements.jsonl`. Required when an app-owned + * `store` enables any resident's retirement, because Chronicle branches are + * intentionally reversible while retirement is not. + */ + retirementPath?: string; + /** Membrane instance for LLM calls */ membrane: Membrane; diff --git a/src/types/module.ts b/src/types/module.ts index 724abaae..212f22c7 100644 --- a/src/types/module.ts +++ b/src/types/module.ts @@ -63,6 +63,14 @@ export interface Module { */ getTools(): ToolDefinition[]; + /** + * Tools that may be invoked only by a provider-issued live agent stream. + * They are excluded from public programmatic dispatch, code execution, + * ephemeral agents, and admin puppeting. The module decides which resident + * names receive the surface by returning definitions for that name. + */ + getLiveTools?(agentName: string): ToolDefinition[]; + /** * Utilities: agent-invocable operations that deliberately do NOT get a * first-class tool slot. Every tool schema taxes every inference; a diff --git a/src/types/trace.ts b/src/types/trace.ts index bd1394ff..2b4a594d 100644 --- a/src/types/trace.ts +++ b/src/types/trace.ts @@ -187,6 +187,15 @@ export type TraceEvent = | (TraceEventBase & { type: 'module:added'; moduleName: string }) | (TraceEventBase & { type: 'module:removed'; moduleName: string }) + // Resident lifecycle + | (TraceEventBase & { + type: 'resident:retired'; + agentName: string; + retiredAt: number; + /** Whether the terminal lifecycle event was also appended to Chronicle. */ + chronicleRecorded: boolean; + }) + // Inference request health | (TraceEventBase & { type: 'inference:request_dropped'; diff --git a/test/resident-retirement.test.ts b/test/resident-retirement.test.ts new file mode 100644 index 00000000..f7f565f0 --- /dev/null +++ b/test/resident-retirement.test.ts @@ -0,0 +1,351 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { + mkdtempSync, + mkdirSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { JsStore } from '@animalabs/chronicle'; +import type { + ContextEntry, + ContextLogView, + ContextStrategy, + MessageStoreView, + ReadinessState, + StrategyContext, + TokenBudget, +} from '@animalabs/context-manager'; +import type { + ContentBlock, + NormalizedRequest, + NormalizedResponse, + YieldingStream, +} from '@animalabs/membrane'; +import { + AgentFramework, + ApiModule, + type EventResponse, + type Module, + type ModuleContext, + type ProcessEvent, + type ToolCall, + type ToolDefinition, + type ToolResult, +} from '../src/index.js'; +import { createMockResponse, MockYieldingStream } from './helpers/mock-membrane.js'; + +class RejectInferenceMembrane { + calls = 0; + + async complete(_request: NormalizedRequest): Promise { + this.calls++; + throw new Error('retired resident ran maintenance inference'); + } + + streamYielding(_request: NormalizedRequest): YieldingStream { + this.calls++; + throw new Error('retired resident ran conversational inference'); + } + + asMembrane(): import('@animalabs/membrane').Membrane { + return this as unknown as import('@animalabs/membrane').Membrane; + } +} + +class AlwaysQueuedStrategy implements ContextStrategy { + readonly name = 'always-queued-retirement-test'; + + checkReadiness(): ReadinessState { + return { ready: false, description: 'would require model-authored maintenance' }; + } + + async tick(ctx: StrategyContext): Promise { + await ctx.membrane?.complete({ + model: 'test-model', + messages: [{ role: 'user', content: [{ type: 'text', text: 'maintenance' }] }], + } as unknown as NormalizedRequest); + } + + select( + _store: MessageStoreView, + _log: ContextLogView, + _budget: TokenBudget, + ): ContextEntry[] { + return []; + } +} + +class LiveOnlyModule implements Module { + readonly name = 'resident'; + handled = 0; + async start(_ctx: ModuleContext): Promise {} + async stop(): Promise {} + getTools(): ToolDefinition[] { return []; } + getLiveTools(agentName: string): ToolDefinition[] { + return agentName === 'resident' + ? [{ name: 'lifecycle', description: 'live only', inputSchema: { type: 'object' } }] + : []; + } + async handleToolCall(_call: ToolCall): Promise { + this.handled++; + return { success: true, data: { ok: true } }; + } + async onProcess(_event: ProcessEvent): Promise { return {}; } +} + +class LiveToolMembrane { + calls = 0; + async complete(_request: NormalizedRequest): Promise { + throw new Error('unexpected complete'); + } + streamYielding(_request: NormalizedRequest): YieldingStream { + this.calls++; + return new MockYieldingStream([ + createMockResponse([{ + type: 'tool_use', + id: 'live-call', + name: 'resident--lifecycle', + input: {}, + } as ContentBlock], 'tool_use'), + createMockResponse([{ type: 'text', text: 'live call completed' }]), + ]); + } + asMembrane(): import('@animalabs/membrane').Membrane { + return this as unknown as import('@animalabs/membrane').Membrane; + } +} + +describe('resident retirement', () => { + it('seals through the neutral API, clears wake state, preserves history, and blocks restart inference', async () => { + const dir = mkdtempSync(join(tmpdir(), 'af-resident-retirement-')); + const storePath = join(dir, 'store'); + const sealPath = join(storePath, 'resident-retirements.jsonl'); + const membrane = new RejectInferenceMembrane(); + let framework = await AgentFramework.create({ + storePath, + membrane: membrane.asMembrane(), + agents: [{ + name: 'resident', + model: 'test-model', + systemPrompt: 'test', + retirement: { enabled: true }, + }], + modules: [new ApiModule()], + gate: { config: { policies: [], default: 'always' } }, + syncIntervalMs: 0, + maintenanceIntervalMs: 0, + }); + + try { + framework.getAgent('resident')!.getContextManager().addMessage( + 'User', + [{ type: 'text', text: 'history survives' }], + ); + const gate = (framework as unknown as { + eventGate: { + armSelfWake(agent: string, seconds: number): unknown; + setSleep(seconds: number, note: string, agent: string): unknown; + getSleepState(): unknown; + selfWakeTimers: Map; + }; + }).eventGate; + gate.armSelfWake('resident', 60); + gate.setSleep(60, 'retirement test', 'resident'); + + const result = framework.retireResident('resident', 'resident-authored test reason'); + assert.equal(result.status, 'retired'); + assert.equal(result.alreadyRetired, false); + assert.equal(result.chronicleRecorded, true); + assert.equal(gate.getSleepState(), null); + assert.equal(gate.selfWakeTimers.has('resident'), false); + assert.equal(framework.nudgeAgent('resident').ok, false); + + const repeat = framework.retireResident('resident', 'replacement reason'); + assert.equal(repeat.alreadyRetired, true); + assert.equal(repeat.reason, 'resident-authored test reason'); + + const seals = readFileSync(sealPath, 'utf8').trim().split('\n').map((line) => JSON.parse(line)); + assert.equal(seals.length, 1); + assert.equal(seals[0].agentName, 'resident'); + assert.equal(seals[0].kind, 'resident-retired'); + + const lifecycle = framework.getStore().getStateJson('framework/resident-lifecycle') as unknown[]; + assert.equal(lifecycle.length, 1); + + const before = await framework.getAgent('resident')!.compileContext(); + assert.match(JSON.stringify(before), /history survives/); + (framework as unknown as { + addMessage(participant: string, content: Array<{ type: 'text'; text: string }>): void; + }).addMessage('User', [{ type: 'text', text: 'must not append' }]); + const after = await framework.getAgent('resident')!.compileContext(); + assert.doesNotMatch(JSON.stringify(after), /must not append/); + + await framework.stop(); + const reject = new RejectInferenceMembrane(); + framework = await AgentFramework.create({ + storePath, + membrane: reject.asMembrane(), + agents: [{ + name: 'resident', + model: 'test-model', + systemPrompt: 'test', + strategy: new AlwaysQueuedStrategy(), + }], + modules: [new ApiModule()], + syncIntervalMs: 0, + maintenanceIntervalMs: 10, + }); + assert.equal(framework.getResidentLifecycleStatus('resident').status, 'retired'); + framework.start(); + framework.pushEvent({ type: 'api:message', participant: 'User', content: 'restart wake' }); + await new Promise((resolve) => setTimeout(resolve, 50)); + assert.equal(reject.calls, 0); + } finally { + await framework.stop(); + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('keeps live-only module tools off programmatic and puppet paths', async () => { + const dir = mkdtempSync(join(tmpdir(), 'af-live-tool-')); + const membrane = new LiveToolMembrane(); + const liveModule = new LiveOnlyModule(); + const framework = await AgentFramework.create({ + storePath: join(dir, 'store'), + membrane: membrane.asMembrane(), + agents: [{ name: 'resident', model: 'test-model', systemPrompt: 'test' }], + modules: [liveModule], + syncIntervalMs: 0, + maintenanceIntervalMs: 0, + }); + try { + const preview = await framework.previewActivation('resident'); + assert.ok(preview.tools?.some((tool) => tool.name === 'resident--lifecycle')); + const direct = await framework.executeToolCall({ + id: 'direct', + name: 'resident--lifecycle', + callerAgentName: 'resident', + input: {}, + }); + assert.equal(direct.success, false); + assert.match(direct.error ?? '', /provider-issued live agent stream/); + await assert.rejects( + framework.puppetToolCall('resident', 'resident--lifecycle', {}), + /restricted to a provider-issued live stream/, + ); + framework.nudgeAgent('resident', 'live-tool-test'); + await framework.runUntilIdle(); + assert.equal(membrane.calls, 1); + assert.equal(liveModule.handled, 1, 'the same tool dispatches from a real resident stream'); + } finally { + await framework.stop(); + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('refuses to create a conversation fork from a retired template resident', async () => { + const dir = mkdtempSync(join(tmpdir(), 'af-retired-template-')); + const framework = await AgentFramework.create({ + storePath: join(dir, 'store'), + membrane: new RejectInferenceMembrane().asMembrane(), + agents: [{ + name: 'template', + model: 'test-model', + systemPrompt: 'test', + retirement: { enabled: true }, + }], + modules: [], + conversations: { templateAgent: 'template' }, + syncIntervalMs: 0, + maintenanceIntervalMs: 0, + }); + try { + framework.retireResident('template'); + await assert.rejects( + (framework as unknown as { + createConversationAgent(name: string, channel: string): Promise; + }).createConversationAgent('conversation-dm-g1', 'dm'), + /template resident "template" is retired/, + ); + assert.equal(framework.getAgent('conversation-dm-g1'), null); + } finally { + await framework.stop(); + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('fails closed on torn, malformed, semantically invalid, and duplicate seal records', async () => { + const cases = [ + { name: 'torn', content: '{"version":1', message: /incomplete final record/ }, + { name: 'malformed', content: '{not json}\n', message: /Invalid retirement seal/ }, + { + name: 'invalid', + content: '{"version":1,"kind":"resident-retired","agentName":"resident","retiredAt":1,"reason":7}\n', + message: /invalid retirement record/, + }, + { + name: 'duplicate', + content: + '{"version":1,"kind":"resident-retired","agentName":"resident","retiredAt":1}\n' + + '{"version":1,"kind":"resident-retired","agentName":"resident","retiredAt":2}\n', + message: /invalid retirement record/, + }, + ]; + for (const testCase of cases) { + const dir = mkdtempSync(join(tmpdir(), `af-retirement-${testCase.name}-`)); + const storePath = join(dir, 'store'); + const seed = await AgentFramework.create({ + storePath, + membrane: new RejectInferenceMembrane().asMembrane(), + agents: [{ name: 'resident', model: 'test-model', systemPrompt: 'test' }], + modules: [], + syncIntervalMs: 0, + maintenanceIntervalMs: 0, + }); + await seed.stop(); + mkdirSync(storePath, { recursive: true }); + writeFileSync(join(storePath, 'resident-retirements.jsonl'), testCase.content); + try { + await assert.rejects( + AgentFramework.create({ + storePath, + membrane: new RejectInferenceMembrane().asMembrane(), + agents: [{ name: 'resident', model: 'test-model', systemPrompt: 'test' }], + modules: [], + }), + testCase.message, + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + } + }); + + it('requires a branch-independent seal path for app-owned stores', async () => { + const dir = mkdtempSync(join(tmpdir(), 'af-resident-retirement-owned-')); + const store = JsStore.openOrCreate({ path: join(dir, 'store') }); + try { + await assert.rejects( + AgentFramework.create({ + store, + membrane: new RejectInferenceMembrane().asMembrane(), + agents: [{ + name: 'resident', + model: 'test-model', + systemPrompt: 'test', + retirement: { enabled: true }, + }], + modules: [], + }), + /app-owned store without retirementPath/, + ); + } finally { + store.close(); + rmSync(dir, { recursive: true, force: true }); + } + }); +}); From 134f1a7261273138dcf1d11f4ff52810a42ce464 Mon Sep 17 00:00:00 2001 From: Ian de Marcellus <50731233+ian-de-marcellus@users.noreply.github.com> Date: Fri, 28 Aug 2026 04:25:51 +0200 Subject: [PATCH 02/11] fix(lifecycle): enforce retirement at every agent boundary --- README.md | 14 +- docs/resident-lifecycle.md | 26 +++- src/agent.ts | 45 +++++- src/framework.ts | 182 +++++++++++++++++++--- src/module-registry.ts | 14 +- src/types/module.ts | 4 +- test/resident-retirement.test.ts | 249 ++++++++++++++++++++++++++++++- 7 files changed, 498 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index 368c9837..b9a5eddf 100644 --- a/README.md +++ b/README.md @@ -273,15 +273,19 @@ agents: [{ After applying its own resident-facing policy, a host calls `framework.retireResident(name, reason?)`. The framework fsyncs the terminal -seal, denies future conversational and maintenance inference, clears queued -wakes and process-local gate state, stops resident-authored scripts, and keeps -Chronicle/workspace history readable. Confirmation wording, cooling-off, -health gates, and notifications deliberately belong to the host. Retirement -is distinct from end-turn, sleep/dormancy, and erasure. +seal file and its containing directory on first creation, cancels an active +stream, denies every future inference surface (including public `Agent` +references), clears queued wakes and process-local gate state, stops +resident-authored scripts, and keeps Chronicle/workspace history readable. +Confirmation wording, cooling-off, health gates, and notifications +deliberately belong to the host. Retirement is distinct from end-turn, +sleep/dormancy, and erasure. Modules can expose host-owned ceremony through `getLiveTools(agentName)`. Those tools are available only to a provider-issued live stream, never public programmatic dispatch, code execution, ephemeral agents, or `puppetToolCall`. +Once a module reserves a live-only tool name for any resident, that name is +protected globally against forged caller identities. The terminal seal is append-only and branch-independent at `/resident-retirements.jsonl`, so Chronicle undo/redo or a branch diff --git a/docs/resident-lifecycle.md b/docs/resident-lifecycle.md index 26f196f9..7ea7d67b 100644 --- a/docs/resident-lifecycle.md +++ b/docs/resident-lifecycle.md @@ -56,6 +56,11 @@ tools, but have a stronger call boundary: - public `executeToolCall`, `ModuleContext.callTool`, `code_execution`, and `puppetToolCall` cannot invoke them. +The name reservation is global: if a module exposes a namespaced tool as +live-only for any registered resident, a programmatic caller cannot bypass the +boundary by claiming another agent name. Exposure remains resident-specific; +protection of the reserved name does not. + The module still handles a valid live call through its ordinary `handleToolCall`. This lets a host own all resident-facing semantics while the framework enforces that administrative puppeting cannot counterfeit consent. @@ -63,17 +68,24 @@ framework enforces that administrative puppeting cannot counterfeit consent. ## Terminal enforcement `retireResident` appends and fsyncs one record in -`resident-retirements.jsonl`. That sidecar is authoritative across Chronicle -undo, redo, and branch switching. The framework then: +`resident-retirements.jsonl`; when creating the sidecar it also fsyncs the +containing directory before returning. That sidecar is authoritative across +Chronicle undo, redo, and branch switching. The framework then: -- drops queued and future inference requests for the resident; +- cancels the current yielding stream and drops queued inference requests; +- rejects future inference through the scheduler and through public `Agent` + inference/streaming methods, including stale references retained by callers; - rejects direct starts and operator nudges; +- rejects resident-attributed programmatic and puppet tool calls before + handler invocation or history append; - skips context-maintenance ticks that could invoke the resident's model; - stops resident-authored foreground/background code runners; - clears resident-owned gate sleep and self-wake timers, provider cooldowns, and queued wake state; - freezes the resident conversation against later message appends; -- refuses to create a conversation fork from a retired template resident; and +- seals, unbinds, and unregisters conversation forks already derived from the + retired template while preserving their Chronicle namespaces; +- refuses to create any later conversation fork from that template; and - appends `framework/resident-lifecycle` in Chronicle and emits a `resident:retired` trace. @@ -84,6 +96,12 @@ rejected by the seal. New resident inference and new resident-authored background activity are denied. Hosts that want parent-scoped cancellation must track and cancel those ephemeral jobs before invoking `retireResident`. +Conversation forks use a different policy because they are persistent, +addressable continuations of one configured template identity. They terminate +with that template rather than finishing independently. A stale public +reference to such a fork remains inference-sealed, and its generation-unique +name remains tombstoned against later tool provenance. + ## Seal location and startup integrity With `storePath`, the default sidecar is diff --git a/src/agent.ts b/src/agent.ts index 0855aafd..ec776af8 100644 --- a/src/agent.ts +++ b/src/agent.ts @@ -128,11 +128,14 @@ export class Agent { private runtimeSettingsOverrides: AgentRuntimeSettingsOverrides = {}; private contextManager: ContextManager; private membrane: Membrane; + /** Framework-owned terminal-state view for this Agent identity. */ + private readonly inferenceSealReason: () => string | null; constructor( config: AgentConfig, contextManager: ContextManager, - membrane: Membrane + membrane: Membrane, + inferenceSealReason?: () => string | null, ) { this.name = config.name; this.model = config.model; @@ -150,6 +153,7 @@ export class Agent { this.prefillUserMessage = config.prefillUserMessage; this.providerParams = config.providerParams; this.sameRoundThinkTextPolicy = config.sameRoundThinkTextPolicy; + this.inferenceSealReason = inferenceSealReason ?? (() => null); this.maxStreamTokens = config.maxStreamTokens ?? 150_000; this.physicalWindowTokens = config.physicalWindowTokens; this.contextBudgetTokens = config.contextBudgetTokens; @@ -571,6 +575,7 @@ export class Agent { budget?: TokenBudget, options?: InferenceOptions ): Promise { + this.assertInferenceAllowed(); if (this._state.status === 'inferring') { throw new Error(`Agent ${this.name} is already inferring`); } @@ -584,6 +589,9 @@ export class Agent { // Compile context (with optional injections) const { messages, systemInjections } = await this.compileWithInjections(budget, injections); + // Compilation may await maintenance work. Re-check immediately before + // building/starting the provider request so a concurrent seal wins. + this.assertInferenceAllowed(); // If we have pending tool results, add them if (this._state.status === 'ready') { @@ -800,6 +808,7 @@ export class Agent { injections?: ContextInjection[], budget?: TokenBudget ): Promise { + this.assertInferenceAllowed(); if (this._state.status !== 'idle') { throw new Error(`Agent ${this.name} cannot start stream in state ${this._state.status}`); } @@ -830,6 +839,9 @@ export class Agent { this.lastStreamOutputTokens = 0; const request = await this.buildActivationRequest(availableTools, injections, budget); + // Hooks/context compilation above may yield while retirement is sealed. + // Never let a stale public Agent reference start a provider afterward. + this.assertInferenceAllowed(); const receiptAware = (this.contextManager as unknown as { getStrategy?: () => unknown }) .getStrategy?.() as { @@ -923,6 +935,9 @@ export class Agent { * Called by framework when stream completes. */ addAssistantResponse(content: ContentBlock[]): void { + // A provider that ignores cancellation may still yield a late completion. + // The seal is authoritative: no post-seal model output enters history. + if (this.inferenceSealReason() !== null) return; // A turn whose entire output is thinking blocks produced NOTHING: no // speech, no tool call. That is what a refusal looks like on the wire — // the provider returns signed thinking (often with empty text, the @@ -994,6 +1009,20 @@ export class Agent { this._state = { status: 'idle' }; } + /** True once inference for this Agent identity has been irreversibly sealed. */ + get inferenceSealed(): boolean { + return this.inferenceSealReason() !== null; + } + + private assertInferenceAllowed(): void { + const reason = this.inferenceSealReason(); + if (reason !== null) { + throw new Error( + `Agent ${this.name} inference is permanently disabled: ${reason}`, + ); + } + } + /** * Get the context manager. */ @@ -1043,6 +1072,20 @@ export class Agent { ): Promise { const response = await this.membrane.stream(request, { signal }); + // AbortController is advisory; a provider implementation may resolve a + // successful response after cancellation. Discard it without mutating + // history when an irreversible seal landed while the request was active. + const sealReason = this.inferenceSealReason(); + if (sealReason !== null) { + return { + toolCalls: [], + speechContent: [], + stopReason: 'abort', + aborted: true, + abortReason: sealReason, + }; + } + if (isAbortedResponse(response)) { const partialContent = response.partialContent ?? []; const { toolCalls, speechContent } = this.extractToolCallsAndSpeech(partialContent); diff --git a/src/framework.ts b/src/framework.ts index 590602fd..34b317f3 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -1060,15 +1060,18 @@ export class AgentFramework { * emitting unprefixed prose can't wake-loop (notices still append). */ private proseBounceStreaks: Map = new Map(); private pendingAssistantBlocks: Map = new Map(); - /** Streams the FRAMEWORK cancelled for non-terminal reasons, keyed - * `${agentName}:${streamId}`: an endTurn tool result or a context-budget - * restart. The membrane still delivers an `aborted` event for these, and + /** Streams the FRAMEWORK cancelled, keyed `${agentName}:${streamId}`: an + * endTurn tool result, a context-budget restart, or a lifecycle seal. The + * membrane still delivers an `aborted` event for these, and * without the marker the abort handler would treat it as a terminal * failure — settling ephemerals with a rejection and emitting a spurious * `inference:exhausted` (which also pollutes the failure streak). Kept - * separate from ephemeralRuns deliberately: endTurn/budget cancels happen - * for resident agents too, and the key is per-stream, not per-agent. */ - private frameworkCancelledStreams: Map = new Map(); + * separate from ephemeralRuns deliberately: these cancellations happen + * for resident/fork agents too, and the key is per-stream, not per-agent. */ + private frameworkCancelledStreams: Map< + string, + 'turn_ended' | 'budget_restart' | 'quiesce_abandoned' | 'resident_retired' | 'template_retired' + > = new Map(); /** Active runEphemeralToCompletion runs, keyed by agent name. */ private ephemeralRuns: Map = new Map(); /** Ephemeral namespaces/names are single-generation for this framework @@ -1289,6 +1292,12 @@ export class AgentFramework { private retirableResidents: Map = new Map(); /** Branch-independent terminal seals loaded from the append-only sidecar. */ private retiredResidents: Map = new Map(); + /** + * Conversation forks terminated because their template resident retired. + * Names are generation-unique, so retaining the tombstone prevents stale + * public call provenance from becoming usable after the Agent is disposed. + */ + private terminatedConversationAgents: Set = new Set(); /** Null only for app-owned stores whose host supplied no seal path. */ private readonly retirementPath: string | null; @@ -2154,7 +2163,7 @@ export class AgentFramework { const cm = agent.getContextManager(); const tools = this.getToolsForAgent(agent.name).filter( (tool) => - !this.moduleRegistry.isLiveTool(tool.name, agent.name) && + !this.moduleRegistry.isLiveTool(tool.name) && agent.canUseTool(tool.name), ); cm.setToolDefinitions(tools); @@ -2706,7 +2715,9 @@ export class AgentFramework { /** Append + fsync: a confirmed retirement must survive a successful return. */ private appendRetirementSeal(record: ResidentRetirementRecord): void { if (!this.retirementPath) throw new Error('No retirement seal path configured'); - mkdirSync(dirname(this.retirementPath), { recursive: true }); + const containingDirectory = dirname(this.retirementPath); + mkdirSync(containingDirectory, { recursive: true }); + const created = !existsSync(this.retirementPath); const fd = openSync(this.retirementPath, 'a', 0o600); try { const payload = Buffer.from(JSON.stringify(record) + '\n', 'utf8'); @@ -2720,14 +2731,55 @@ export class AgentFramework { } finally { closeSync(fd); } + // fsync(file) makes the bytes durable, but on filesystems with separate + // directory metadata it does not make a newly-created directory entry + // durable. Sync the containing directory before claiming success. + if (created && process.platform !== 'win32') { + const directoryFd = openSync(containingDirectory, 'r'); + try { + fsyncSync(directoryFd); + } finally { + closeSync(directoryFd); + } + } + } + + private isAgentTerminal(agentName: string): boolean { + return this.agentTerminalReason(agentName) !== null; + } + + private agentTerminalReason(agentName: string): string | null { + if (this.retiredResidents.has(agentName)) return 'resident retired'; + if (this.terminatedConversationAgents.has(agentName)) { + return 'template resident retired'; + } + return null; } - private stopResidentAuthoredActivity(agentName: string): void { + private sealAgentInference( + agentName: string, + reason: string, + cancelKind: 'resident_retired' | 'template_retired', + ): void { + const agent = this.agents.get(agentName); + if (!agent) return; + if (agent.state.status === 'streaming' || agent.state.status === 'waiting_for_tools') { + this.frameworkCancelledStreams.set(`${agentName}:${agent.streamId}`, cancelKind); + } + agent.abortInference(reason); + } + + private stopResidentAuthoredActivity( + agentName: string, + reason = 'resident retired', + cancelKind: 'resident_retired' | 'template_retired' = 'resident_retired', + ): void { + this.sealAgentInference(agentName, reason, cancelKind); // A retired resident cannot retain a model-authored daemon that keeps // acting or attempting wakes after its inference identity is sealed. const runner = this.codeExecutionRunners.get(agentName); if (runner) { - runner.abort('resident retired'); + runner.abort(reason); runner.dispose(); this.codeExecutionRunners.delete(agentName); } @@ -2735,7 +2787,7 @@ export class AgentFramework { if (record.agentName !== agentName || record.status !== 'running') continue; record.cancelled = true; record.status = 'cancelled'; - record.runner.abort('resident retired'); + record.runner.abort(reason); record.runner.dispose(); } this.pendingRequests = this.pendingRequests.filter((request) => request.agentName !== agentName); @@ -2756,6 +2808,23 @@ export class AgentFramework { this.staleWarnAt.delete(agentName); } + private terminateConversationForksForTemplate(templateAgent: string): void { + if (!this.conversationRouter || this.conversationRouter.templateAgent !== templateAgent) return; + const bindings = this.conversationRouter.getBindings(); + for (const forkName of [...this.conversationAgentHomes.keys()]) { + this.terminatedConversationAgents.add(forkName); + const binding = bindings.find((candidate) => candidate.agentName === forkName); + if (binding) this.conversationRouter.unbind(binding.channelId); + this.stopResidentAuthoredActivity( + forkName, + `template resident ${templateAgent} retired`, + 'template_retired', + ); + this.disposeConversationAgent(forkName); + } + this.persistConversationRouterState(); + } + /** * Apply the neutral irreversible seal for an explicitly authorized resident. * Confirmation, wording, cooling-off and notification are host policy and @@ -2792,6 +2861,10 @@ export class AgentFramework { this.appendRetirementSeal(record); this.retiredResidents.set(agentName, record); this.stopResidentAuthoredActivity(agentName); + // Conversation forks are dependent continuations of their template, not + // independent ephemeral subagents. Existing forks are sealed, unbound, + // and disposed immediately; their Chronicle namespaces remain intact. + this.terminateConversationForksForTemplate(agentName); let chronicleRecorded = false; try { @@ -4109,7 +4182,12 @@ export class AgentFramework { debugLogContext: !!process.env.DEBUG_CONTEXT, }); - const agent = new Agent(config, contextManager, this.membrane); + const agent = new Agent( + config, + contextManager, + this.membrane, + () => this.agentTerminalReason(config.name), + ); this.ephemeralCandidates.set(agent, contextManager); const cleanup = () => { @@ -4979,6 +5057,12 @@ export class AgentFramework { quiesced?: boolean; } { const name = agentName ?? [...this.agents.keys()][0]; + if (name && this.terminatedConversationAgents.has(name)) { + return { + ok: false, + error: `Conversation fork "${name}" was terminated when its template resident retired.`, + }; + } const agent = name ? this.agents.get(name) : undefined; if (!name || !agent) { return { ok: false, error: `Unknown agent: ${String(agentName ?? '(none registered)')}` }; @@ -6419,7 +6503,12 @@ export class AgentFramework { viewFilter: (message) => !(message.metadata as { tuneOut?: unknown } | undefined)?.tuneOut, }); - const agent = new Agent(config, contextManager, this.membrane); + const agent = new Agent( + config, + contextManager, + this.membrane, + () => this.agentTerminalReason(config.name), + ); const restoredSettings = this.readAgentRuntimeSettings(config.name); if (restoredSettings) { agent.restoreRuntimeSettings( @@ -7271,7 +7360,12 @@ export class AgentFramework { } const config: AgentConfig = { ...templateConfig, name, strategy: undefined }; - const agent = new Agent(config, contextManager, this.membrane); + const agent = new Agent( + config, + contextManager, + this.membrane, + () => this.agentTerminalReason(name), + ); this.agents.set(name, agent); this.agentConfigs.set(name, config); this.conversationAgentHomes.set(name, channelId); @@ -8330,9 +8424,9 @@ export class AgentFramework { ): Promise { // Defense in depth for direct/retry call sites: the scheduler also drops // retired requests, but no path may reach the provider after the seal. - if (this.retiredResidents?.has(agent.name)) { + if (this.isAgentTerminal(agent.name)) { if (providerGateAlreadyHeld) this.releasePrimaryProviderGate(agent.name); - console.error(`[inference-dropped] agent=${agent.name} reason=resident_retired direct-start`); + console.error(`[inference-dropped] agent=${agent.name} reason=terminal direct-start`); return; } const ownsProviderGate = @@ -8687,6 +8781,11 @@ export class AgentFramework { // a hook/compile/stream-setup failure must not leave "typing…" stuck. // (Retries below restart it; stopTyping is idempotent.) this.channelRegistry?.stopTyping(); + if (this.isAgentTerminal(agent.name)) { + agent.reset(); + this.eventGate?.onInferenceEnded(agent.name); + return false; + } const err = error instanceof Error ? error : new Error(String(error)); this.emitTrace({ type: 'inference:failed', @@ -8922,6 +9021,15 @@ export class AgentFramework { stream.cancel(); break; } + // Cancellation is advisory at the Membrane seam. A provider/stream + // implementation may yield buffered or even successful events after + // cancel(); the terminal identity guard prevents any such event from + // invoking tools, routing prose, or entering history. + if (this.isAgentTerminal(agent.name)) { + lifecyclePhase = 'aborted'; + this.eventGate?.onInferenceEnded(agent.name); + return; + } this.touchEphemeralRun(agent.name, true); switch (event.type) { case 'tokens': @@ -10140,6 +10248,9 @@ export class AgentFramework { ): Promise<{ toolUseId: string; result: ToolResult }> { const agent = this.agents.get(agentName); if (!agent) throw new Error(`Unknown agent: ${agentName}`); + if (this.isAgentTerminal(agentName)) { + throw new Error(`puppet refused: agent ${agentName} is terminal`); + } // Idle AND no turn alive: status reads 'idle' from dequeue until the // stream registers, and again while a turn's teardown is pending // (the scheduler's own busy test, 'idle+turn-alive'). @@ -10155,7 +10266,7 @@ export class AgentFramework { `injecting a turn under an active stream corrupts wire ordering)`, ); } - if (this.moduleRegistry?.isLiveTool(toolName, agentName)) { + if (this.moduleRegistry?.isLiveTool(toolName)) { throw new Error( `puppet refused: tool ${toolName} is restricted to a provider-issued live stream`, ); @@ -10200,6 +10311,12 @@ export class AgentFramework { }); const durationMs = Date.now() - started; + // Execution can await external work. If retirement raced it, never append + // a fabricated first-person tool exchange after the terminal seal. + if (this.isAgentTerminal(agentName)) { + throw new Error(`puppet refused: agent ${agentName} became terminal during tool execution`); + } + // Store the pair through the same shapes the ordinary path uses. Build // the result blocks BEFORE storing the tool_use: the spill path awaits. // A message arriving during either await is deferred by the turn @@ -10271,7 +10388,14 @@ export class AgentFramework { private async executeToolCallFrom(call: ToolCall, origin: ChannelToolOrigin): Promise { const caller = call.callerAgentName ?? '__ephemeral__'; - if (this.moduleRegistry.isLiveTool(call.name, caller)) { + if (origin.kind === 'agent' && this.isAgentTerminal(caller)) { + return { + success: false, + isError: true, + error: `Agent '${caller}' is terminal and cannot execute tools.`, + }; + } + if (this.moduleRegistry.isLiveTool(call.name)) { return { success: false, isError: true, @@ -10472,7 +10596,7 @@ export class AgentFramework { const surface = agent ? this.getToolsForAgent(agentName).filter( (tool) => - !this.moduleRegistry.isLiveTool(tool.name, agentName) && + !this.moduleRegistry.isLiveTool(tool.name) && agent.canUseTool(tool.name), ) : this.getAllTools(); // ephemeral agents: full surface, matching executeToolCall @@ -11254,6 +11378,16 @@ export class AgentFramework { } private dispatchToolCall(agentName: string, call: ToolCall): void { + if (this.isAgentTerminal(agentName)) { + this.emitTrace({ + type: 'tool:failed', + module: 'framework', + tool: call.name, + callId: call.id, + error: `Agent '${agentName}' is terminal and cannot execute tools.`, + }); + return; + } // Enrich call with caller identity so modules can resolve the calling agent const enrichedCall: ToolCall = { ...call, callerAgentName: agentName }; @@ -11412,6 +11546,16 @@ export class AgentFramework { private dispatchToolCallEvent(event: ToolCallEvent): void { const { call, agentName, moduleName } = event; + if (this.isAgentTerminal(agentName)) { + this.emitTrace({ + type: 'tool:failed', + module: moduleName, + tool: call.name, + callId: call.id, + error: `Agent '${agentName}' is terminal and cannot execute tools.`, + }); + return; + } this.emitTrace({ type: 'tool:started', module: moduleName, diff --git a/src/module-registry.ts b/src/module-registry.ts index 448ae86c..1fac85c2 100644 --- a/src/module-registry.ts +++ b/src/module-registry.ts @@ -252,9 +252,17 @@ export class ModuleRegistry { return tools; } - /** True when a name is reserved to the live-stream-only surface. */ - isLiveTool(toolName: string, agentName: string): boolean { - return this.getLiveTools(agentName).some((tool) => tool.name === toolName); + /** + * True when a name is reserved to any live-stream-only surface. + * + * Reservation is deliberately global rather than caller-dependent: a + * programmatic caller must not bypass the protected boundary merely by + * claiming an agent for which the module does not expose the definition. + */ + isLiveTool(toolName: string): boolean { + return this.getAgents().some((agent) => + this.getLiveTools(agent.name).some((tool) => tool.name === toolName), + ); } /** diff --git a/src/types/module.ts b/src/types/module.ts index 212f22c7..703ee0c9 100644 --- a/src/types/module.ts +++ b/src/types/module.ts @@ -67,7 +67,9 @@ export interface Module { * Tools that may be invoked only by a provider-issued live agent stream. * They are excluded from public programmatic dispatch, code execution, * ephemeral agents, and admin puppeting. The module decides which resident - * names receive the surface by returning definitions for that name. + * names receive the surface by returning definitions for that name; once + * returned for any resident, the namespaced tool name is globally reserved + * against programmatic callers claiming a different identity. */ getLiveTools?(agentName: string): ToolDefinition[]; diff --git a/test/resident-retirement.test.ts b/test/resident-retirement.test.ts index f7f565f0..1de6f85b 100644 --- a/test/resident-retirement.test.ts +++ b/test/resident-retirement.test.ts @@ -97,6 +97,21 @@ class LiveOnlyModule implements Module { async onProcess(_event: ProcessEvent): Promise { return {}; } } +class OrdinaryToolModule implements Module { + readonly name = 'ordinary'; + handled = 0; + async start(_ctx: ModuleContext): Promise {} + async stop(): Promise {} + getTools(): ToolDefinition[] { + return [{ name: 'act', description: 'ordinary tool', inputSchema: { type: 'object' } }]; + } + async handleToolCall(_call: ToolCall): Promise { + this.handled++; + return { success: true, data: { ok: true } }; + } + async onProcess(_event: ProcessEvent): Promise { return {}; } +} + class LiveToolMembrane { calls = 0; async complete(_request: NormalizedRequest): Promise { @@ -119,6 +134,71 @@ class LiveToolMembrane { } } +class LateNonStreamingMembrane { + calls = 0; + private resolveResponse!: (response: NormalizedResponse) => void; + readonly started: Promise; + private markStarted!: () => void; + + constructor() { + this.started = new Promise((resolve) => { this.markStarted = resolve; }); + } + + async stream(_request: NormalizedRequest, _options?: { signal?: AbortSignal }): Promise { + this.calls++; + this.markStarted(); + return new Promise((resolve) => { this.resolveResponse = resolve; }); + } + + finishLate(): void { + this.resolveResponse(createMockResponse([{ type: 'text', text: 'late model output' }])); + } + + asMembrane(): import('@animalabs/membrane').Membrane { + return this as unknown as import('@animalabs/membrane').Membrane; + } +} + +class CancellationIgnoringStream implements YieldingStream { + cancelCalls = 0; + private release!: () => void; + private readonly released = new Promise((resolve) => { this.release = resolve; }); + readonly isWaitingForTools = false; + readonly pendingToolCallIds: string[] = []; + readonly toolDepth = 0; + + provideToolResults(): void { throw new Error('not waiting for tools'); } + cancel(): void { this.cancelCalls++; } + finishLate(): void { this.release(); } + + async *[Symbol.asyncIterator](): AsyncIterator { + await this.released; + yield { + type: 'complete', + response: createMockResponse([{ type: 'text', text: 'late streaming output' }]), + } as import('@animalabs/membrane').StreamEvent; + } +} + +class LateStreamingMembrane { + readonly stream = new CancellationIgnoringStream(); + readonly created: Promise; + private markCreated!: () => void; + + constructor() { + this.created = new Promise((resolve) => { this.markCreated = resolve; }); + } + + streamYielding(_request: NormalizedRequest): YieldingStream { + this.markCreated(); + return this.stream; + } + + asMembrane(): import('@animalabs/membrane').Membrane { + return this as unknown as import('@animalabs/membrane').Membrane; + } +} + describe('resident retirement', () => { it('seals through the neutral API, clears wake state, preserves history, and blocks restart inference', async () => { const dir = mkdtempSync(join(tmpdir(), 'af-resident-retirement-')); @@ -164,6 +244,17 @@ describe('resident retirement', () => { assert.equal(gate.selfWakeTimers.has('resident'), false); assert.equal(framework.nudgeAgent('resident').ok, false); + const exposedAgent = framework.getAgent('resident')!; + await assert.rejects( + exposedAgent.runInference([]), + /inference is permanently disabled: resident retired/, + ); + await assert.rejects( + exposedAgent.startStream([]), + /inference is permanently disabled: resident retired/, + ); + assert.equal(membrane.calls, 0, 'public Agent methods cannot reach the provider after sealing'); + const repeat = framework.retireResident('resident', 'replacement reason'); assert.equal(repeat.alreadyRetired, true); assert.equal(repeat.reason, 'resident-authored test reason'); @@ -200,6 +291,10 @@ describe('resident retirement', () => { maintenanceIntervalMs: 10, }); assert.equal(framework.getResidentLifecycleStatus('resident').status, 'retired'); + await assert.rejects( + framework.getAgent('resident')!.runInference([]), + /inference is permanently disabled: resident retired/, + ); framework.start(); framework.pushEvent({ type: 'api:message', participant: 'User', content: 'restart wake' }); await new Promise((resolve) => setTimeout(resolve, 50)); @@ -223,6 +318,16 @@ describe('resident retirement', () => { maintenanceIntervalMs: 0, }); try { + const forgedCaller = await framework.executeToolCall({ + id: 'forged-caller', + name: 'resident--lifecycle', + callerAgentName: 'outsider', + input: {}, + }); + assert.equal(forgedCaller.success, false); + assert.match(forgedCaller.error ?? '', /provider-issued live agent stream/); + assert.equal(liveModule.handled, 0, 'live-only names are globally reserved before preview'); + const preview = await framework.previewActivation('resident'); assert.ok(preview.tools?.some((tool) => tool.name === 'resident--lifecycle')); const direct = await framework.executeToolCall({ @@ -247,7 +352,121 @@ describe('resident retirement', () => { } }); - it('refuses to create a conversation fork from a retired template resident', async () => { + it('denies direct and puppet tool execution and history append after retirement', async () => { + const dir = mkdtempSync(join(tmpdir(), 'af-retired-tool-boundary-')); + const ordinary = new OrdinaryToolModule(); + const framework = await AgentFramework.create({ + storePath: join(dir, 'store'), + membrane: new RejectInferenceMembrane().asMembrane(), + agents: [{ + name: 'resident', + model: 'test-model', + systemPrompt: 'test', + retirement: { enabled: true }, + }], + modules: [ordinary], + syncIntervalMs: 0, + maintenanceIntervalMs: 0, + }); + try { + const beforeSeal = await framework.executeToolCall({ + id: 'before-seal', + name: 'ordinary--act', + callerAgentName: 'resident', + input: {}, + }); + assert.equal(beforeSeal.success, true); + assert.equal(ordinary.handled, 1); + + framework.retireResident('resident'); + const contextBefore = await framework.getAgent('resident')!.compileContext(); + const direct = await framework.executeToolCall({ + id: 'after-seal', + name: 'ordinary--act', + callerAgentName: 'resident', + input: {}, + }); + assert.equal(direct.success, false); + assert.match(direct.error ?? '', /terminal and cannot execute tools/); + await assert.rejects( + framework.puppetToolCall('resident', 'ordinary--act', {}), + /agent resident is terminal/, + ); + assert.equal(ordinary.handled, 1, 'no post-seal handler invocation'); + const contextAfter = await framework.getAgent('resident')!.compileContext(); + assert.deepEqual(contextAfter.messages, contextBefore.messages, 'puppet appends no forged history'); + } finally { + await framework.stop(); + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('aborts in-flight public inference and discards a provider response that ignores cancellation', async () => { + const dir = mkdtempSync(join(tmpdir(), 'af-retired-running-inference-')); + const membrane = new LateNonStreamingMembrane(); + const framework = await AgentFramework.create({ + storePath: join(dir, 'store'), + membrane: membrane.asMembrane(), + agents: [{ + name: 'resident', + model: 'test-model', + systemPrompt: 'test', + retirement: { enabled: true }, + }], + modules: [], + syncIntervalMs: 0, + maintenanceIntervalMs: 0, + }); + try { + const pending = framework.getAgent('resident')!.runInference([]); + await membrane.started; + framework.retireResident('resident'); + membrane.finishLate(); + const result = await pending; + assert.equal(result.aborted, true); + assert.equal(result.abortReason, 'resident retired'); + const context = await framework.getAgent('resident')!.compileContext(); + assert.doesNotMatch(JSON.stringify(context), /late model output/); + } finally { + await framework.stop(); + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('cancels the sealing stream and ignores buffered completion events after retirement', async () => { + const dir = mkdtempSync(join(tmpdir(), 'af-retired-running-stream-')); + const membrane = new LateStreamingMembrane(); + const framework = await AgentFramework.create({ + storePath: join(dir, 'store'), + membrane: membrane.asMembrane(), + agents: [{ + name: 'resident', + model: 'test-model', + systemPrompt: 'test', + retirement: { enabled: true }, + }], + modules: [], + syncIntervalMs: 0, + maintenanceIntervalMs: 0, + }); + try { + framework.start(); + framework.nudgeAgent('resident', 'active-stream-retirement-test'); + await membrane.created; + framework.retireResident('resident'); + assert.equal(membrane.stream.cancelCalls, 1, 'retirement cancels the active yielding stream'); + membrane.stream.finishLate(); + await new Promise((resolve) => setTimeout(resolve, 25)); + const context = await framework.getAgent('resident')!.compileContext(); + assert.doesNotMatch(JSON.stringify(context), /late streaming output/); + } finally { + membrane.stream.finishLate(); + await framework.stop(); + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('terminates existing conversation forks and refuses new ones after template retirement', async () => { const dir = mkdtempSync(join(tmpdir(), 'af-retired-template-')); const framework = await AgentFramework.create({ storePath: join(dir, 'store'), @@ -264,14 +483,38 @@ describe('resident retirement', () => { maintenanceIntervalMs: 0, }); try { + const oldFork = await (framework as unknown as { + createConversationAgent(name: string, channel: string): Promise; + }).createConversationAgent('conversation-dm-g1', 'dm') as { + runInference(tools: ToolDefinition[]): Promise; + }; + assert.ok(framework.getAgent('conversation-dm-g1')); + assert.equal(framework.nudgeAgent('conversation-dm-g1').ok, true); + framework.retireResident('template'); + assert.equal(framework.getAgent('conversation-dm-g1'), null); + const oldForkNudge = framework.nudgeAgent('conversation-dm-g1'); + assert.equal(oldForkNudge.ok, false); + assert.match(oldForkNudge.error ?? '', /terminated when its template resident retired/); + await assert.rejects( + oldFork.runInference([]), + /inference is permanently disabled: template resident retired/, + ); + const oldForkTool = await framework.executeToolCall({ + id: 'old-fork-tool', + name: 'unknown--tool', + callerAgentName: 'conversation-dm-g1', + input: {}, + }); + assert.equal(oldForkTool.success, false); + assert.match(oldForkTool.error ?? '', /terminal and cannot execute tools/); await assert.rejects( (framework as unknown as { createConversationAgent(name: string, channel: string): Promise; - }).createConversationAgent('conversation-dm-g1', 'dm'), + }).createConversationAgent('conversation-dm-g2', 'dm'), /template resident "template" is retired/, ); - assert.equal(framework.getAgent('conversation-dm-g1'), null); + assert.equal(framework.getAgent('conversation-dm-g2'), null); } finally { await framework.stop(); rmSync(dir, { recursive: true, force: true }); From fd3cf2a58006d6437cad1d47710ec9a713762320 Mon Sep 17 00:00:00 2001 From: Ian de Marcellus <50731233+ian-de-marcellus@users.noreply.github.com> Date: Fri, 28 Aug 2026 04:41:39 +0200 Subject: [PATCH 03/11] fix(lifecycle): tolerate partial framework harnesses --- src/framework.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/framework.ts b/src/framework.ts index 34b317f3..881ff846 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -2749,8 +2749,11 @@ export class AgentFramework { } private agentTerminalReason(agentName: string): string | null { - if (this.retiredResidents.has(agentName)) return 'resident retired'; - if (this.terminatedConversationAgents.has(agentName)) { + // Optional access preserves compatibility with narrow test/admin harnesses + // that construct a partial Framework object around one public method. + // Fully-created Framework instances always initialize both collections. + if (this.retiredResidents?.has(agentName)) return 'resident retired'; + if (this.terminatedConversationAgents?.has(agentName)) { return 'template resident retired'; } return null; From 2dee46840612de57f86b7184a561b5a8157bb3ec Mon Sep 17 00:00:00 2001 From: Ian de Marcellus <50731233+ian-de-marcellus@users.noreply.github.com> Date: Fri, 28 Aug 2026 04:48:47 +0200 Subject: [PATCH 04/11] test(lifecycle): pin protected retirement paths --- src/framework.ts | 7 +++ test/resident-retirement.test.ts | 76 +++++++++++++++++++++++++++++++- 2 files changed, 82 insertions(+), 1 deletion(-) diff --git a/src/framework.ts b/src/framework.ts index 881ff846..3999ac0c 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -11581,6 +11581,11 @@ export class AgentFramework { durationMs, }); + // A live lifecycle handler may have applied an irreversible seal. + // The active stream was cancelled at that instant, so do not enqueue + // a result that could resume it or arrive as a misleading late event. + if (this.isAgentTerminal(agentName)) return; + this.pushEvent({ type: 'tool-result', callId: call.id, @@ -11600,6 +11605,8 @@ export class AgentFramework { stack: err.stack, }); + if (this.isAgentTerminal(agentName)) return; + this.pushEvent({ type: 'tool-result', callId: call.id, diff --git a/test/resident-retirement.test.ts b/test/resident-retirement.test.ts index 1de6f85b..8d003162 100644 --- a/test/resident-retirement.test.ts +++ b/test/resident-retirement.test.ts @@ -82,7 +82,8 @@ class AlwaysQueuedStrategy implements ContextStrategy { class LiveOnlyModule implements Module { readonly name = 'resident'; handled = 0; - async start(_ctx: ModuleContext): Promise {} + context: ModuleContext | null = null; + async start(ctx: ModuleContext): Promise { this.context = ctx; } async stop(): Promise {} getTools(): ToolDefinition[] { return []; } getLiveTools(agentName: string): ToolDefinition[] { @@ -97,6 +98,26 @@ class LiveOnlyModule implements Module { async onProcess(_event: ProcessEvent): Promise { return {}; } } +class RetiringLiveModule implements Module { + readonly name = 'resident'; + handled = 0; + framework: AgentFramework | null = null; + async start(_ctx: ModuleContext): Promise {} + async stop(): Promise {} + getTools(): ToolDefinition[] { return []; } + getLiveTools(agentName: string): ToolDefinition[] { + return agentName === 'resident' + ? [{ name: 'lifecycle', description: 'retire now', inputSchema: { type: 'object' } }] + : []; + } + async handleToolCall(_call: ToolCall): Promise { + this.handled++; + const retirement = this.framework!.retireResident('resident', 'live handler test'); + return { success: true, data: retirement }; + } + async onProcess(_event: ProcessEvent): Promise { return {}; } +} + class OrdinaryToolModule implements Module { readonly name = 'ordinary'; handled = 0; @@ -318,6 +339,14 @@ describe('resident retirement', () => { maintenanceIntervalMs: 0, }); try { + const omittedCaller = await framework.executeToolCall({ + id: 'omitted-caller', + name: 'resident--lifecycle', + input: {}, + }); + assert.equal(omittedCaller.success, false); + assert.match(omittedCaller.error ?? '', /provider-issued live agent stream/); + const forgedCaller = await framework.executeToolCall({ id: 'forged-caller', name: 'resident--lifecycle', @@ -328,6 +357,15 @@ describe('resident retirement', () => { assert.match(forgedCaller.error ?? '', /provider-issued live agent stream/); assert.equal(liveModule.handled, 0, 'live-only names are globally reserved before preview'); + const moduleCall = await liveModule.context!.callTool({ + id: 'module-context-call', + name: 'resident--lifecycle', + input: {}, + }); + assert.equal(moduleCall.success, false); + assert.match(moduleCall.error ?? '', /provider-issued live agent stream/); + assert.equal(liveModule.handled, 0, 'trusted module provenance does not bypass live-only reservation'); + const preview = await framework.previewActivation('resident'); assert.ok(preview.tools?.some((tool) => tool.name === 'resident--lifecycle')); const direct = await framework.executeToolCall({ @@ -352,6 +390,42 @@ describe('resident retirement', () => { } }); + it('terminates the stream when the live tool handler applies retirement', async () => { + const dir = mkdtempSync(join(tmpdir(), 'af-live-handler-retirement-')); + const membrane = new LiveToolMembrane(); + const retiringModule = new RetiringLiveModule(); + const framework = await AgentFramework.create({ + storePath: join(dir, 'store'), + membrane: membrane.asMembrane(), + agents: [{ + name: 'resident', + model: 'test-model', + systemPrompt: 'test', + retirement: { enabled: true }, + }], + modules: [retiringModule], + syncIntervalMs: 0, + maintenanceIntervalMs: 0, + }); + retiringModule.framework = framework; + try { + framework.nudgeAgent('resident', 'live-handler-retirement-test'); + await framework.runUntilIdle(); + assert.equal(retiringModule.handled, 1); + assert.equal(framework.getResidentLifecycleStatus('resident').status, 'retired'); + assert.equal(membrane.calls, 1, 'the provider stream starts only once'); + const context = await framework.getAgent('resident')!.compileContext(); + assert.doesNotMatch( + JSON.stringify(context), + /live call completed/, + 'the second provider round is never resumed or persisted after the seal', + ); + } finally { + await framework.stop(); + rmSync(dir, { recursive: true, force: true }); + } + }); + it('denies direct and puppet tool execution and history append after retirement', async () => { const dir = mkdtempSync(join(tmpdir(), 'af-retired-tool-boundary-')); const ordinary = new OrdinaryToolModule(); From 5c81e442fb9b18ee941f3422833661e0f6ab57d9 Mon Sep 17 00:00:00 2001 From: Ian de Marcellus <50731233+ian-de-marcellus@users.noreply.github.com> Date: Fri, 28 Aug 2026 04:52:40 +0200 Subject: [PATCH 05/11] fix(lifecycle): make first seal path crash-durable --- README.md | 4 +++- docs/resident-lifecycle.md | 5 +++-- src/framework.ts | 23 ++++++++++++++++++----- test/resident-retirement.test.ts | 30 ++++++++++++++++++++++++++++++ 4 files changed, 54 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index b9a5eddf..cca6a416 100644 --- a/README.md +++ b/README.md @@ -290,7 +290,9 @@ protected globally against forged caller identities. The terminal seal is append-only and branch-independent at `/resident-retirements.jsonl`, so Chronicle undo/redo or a branch switch cannot resume the identity. Apps that supply an owned `store` must also -supply `retirementPath`. See [Resident lifecycle](docs/resident-lifecycle.md). +supply `retirementPath`. Newly created directories in a custom seal path are +also fsynced through their existing ancestor. See +[Resident lifecycle](docs/resident-lifecycle.md). ## API Server diff --git a/docs/resident-lifecycle.md b/docs/resident-lifecycle.md index 7ea7d67b..dc3bed1e 100644 --- a/docs/resident-lifecycle.md +++ b/docs/resident-lifecycle.md @@ -69,8 +69,9 @@ framework enforces that administrative puppeting cannot counterfeit consent. `retireResident` appends and fsyncs one record in `resident-retirements.jsonl`; when creating the sidecar it also fsyncs the -containing directory before returning. That sidecar is authoritative across -Chronicle undo, redo, and branch switching. The framework then: +containing directory and each newly created directory entry in a custom path +before returning. That sidecar is authoritative across Chronicle undo, redo, +and branch switching. The framework then: - cancels the current yielding stream and drops queued inference requests; - rejects future inference through the scheduler and through public `Agent` diff --git a/src/framework.ts b/src/framework.ts index 3999ac0c..ac5fa366 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -2716,6 +2716,11 @@ export class AgentFramework { private appendRetirementSeal(record: ResidentRetirementRecord): void { if (!this.retirementPath) throw new Error('No retirement seal path configured'); const containingDirectory = dirname(this.retirementPath); + const missingDirectories: string[] = []; + for (let cursor = containingDirectory; !existsSync(cursor); cursor = dirname(cursor)) { + missingDirectories.push(cursor); + if (dirname(cursor) === cursor) break; + } mkdirSync(containingDirectory, { recursive: true }); const created = !existsSync(this.retirementPath); const fd = openSync(this.retirementPath, 'a', 0o600); @@ -2735,11 +2740,19 @@ export class AgentFramework { // directory metadata it does not make a newly-created directory entry // durable. Sync the containing directory before claiming success. if (created && process.platform !== 'win32') { - const directoryFd = openSync(containingDirectory, 'r'); - try { - fsyncSync(directoryFd); - } finally { - closeSync(directoryFd); + // Inner-to-outer ordering: first commit the file entry in its containing + // directory, then each newly-created directory entry in its parent. + const directoriesToSync = new Set([ + containingDirectory, + ...missingDirectories.map((directory) => dirname(directory)), + ]); + for (const directory of directoriesToSync) { + const directoryFd = openSync(directory, 'r'); + try { + fsyncSync(directoryFd); + } finally { + closeSync(directoryFd); + } } } } diff --git a/test/resident-retirement.test.ts b/test/resident-retirement.test.ts index 8d003162..871fe991 100644 --- a/test/resident-retirement.test.ts +++ b/test/resident-retirement.test.ts @@ -642,6 +642,36 @@ describe('resident retirement', () => { } }); + it('durably creates a nested sidecar before appending later resident records', async () => { + const dir = mkdtempSync(join(tmpdir(), 'af-retirement-durable-create-')); + const storePath = join(dir, 'store'); + const retirementPath = join(dir, 'new', 'nested', 'resident-retirements.jsonl'); + const framework = await AgentFramework.create({ + storePath, + retirementPath, + membrane: new RejectInferenceMembrane().asMembrane(), + agents: [ + { name: 'first', model: 'test-model', systemPrompt: 'test', retirement: { enabled: true } }, + { name: 'second', model: 'test-model', systemPrompt: 'test', retirement: { enabled: true } }, + ], + modules: [], + syncIntervalMs: 0, + maintenanceIntervalMs: 0, + }); + try { + framework.retireResident('first'); + let records = readFileSync(retirementPath, 'utf8').trim().split('\n').map((line) => JSON.parse(line)); + assert.deepEqual(records.map((record) => record.agentName), ['first']); + + framework.retireResident('second'); + records = readFileSync(retirementPath, 'utf8').trim().split('\n').map((line) => JSON.parse(line)); + assert.deepEqual(records.map((record) => record.agentName), ['first', 'second']); + } finally { + await framework.stop(); + rmSync(dir, { recursive: true, force: true }); + } + }); + it('requires a branch-independent seal path for app-owned stores', async () => { const dir = mkdtempSync(join(tmpdir(), 'af-resident-retirement-owned-')); const store = JsStore.openOrCreate({ path: join(dir, 'store') }); From bbf542c51ee9447785a2ac1180148d518169bd66 Mon Sep 17 00:00:00 2001 From: Ian de Marcellus <50731233+ian-de-marcellus@users.noreply.github.com> Date: Mon, 7 Sep 2026 20:06:16 +0200 Subject: [PATCH 06/11] fix(lifecycle): fail closed on ambiguous seal writes --- docs/resident-lifecycle.md | 9 +++-- src/framework.ts | 41 +++++++++++++++++++++-- test/resident-retirement.test.ts | 56 +++++++++++++++++++++++++++++++- 3 files changed, 100 insertions(+), 6 deletions(-) diff --git a/docs/resident-lifecycle.md b/docs/resident-lifecycle.md index dc3bed1e..3f75d4b3 100644 --- a/docs/resident-lifecycle.md +++ b/docs/resident-lifecycle.md @@ -70,8 +70,13 @@ framework enforces that administrative puppeting cannot counterfeit consent. `retireResident` appends and fsyncs one record in `resident-retirements.jsonl`; when creating the sidecar it also fsyncs the containing directory and each newly created directory entry in a custom path -before returning. That sidecar is authoritative across Chronicle undo, redo, -and branch switching. The framework then: +before returning. If applying the seal throws, the framework conservatively +assumes the append may have begun: the API still throws, but the identity is +immediately treated as terminal for the lifetime of the current process. Keep +the host stopped and inspect the ledger before restarting; startup then +validates whether the authoritative record is complete or torn. That sidecar +is authoritative across Chronicle undo, redo, and branch switching. The +framework then: - cancels the current yielding stream and drops queued inference requests; - rejects future inference through the scheduler and through public `Agent` diff --git a/src/framework.ts b/src/framework.ts index ac5fa366..a1d0a76f 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -2841,6 +2841,34 @@ export class AgentFramework { this.persistConversationRouterState(); } + /** + * A seal append can fail after some or all bytes have become durable. Keep + * that ambiguous identity terminal in this process before surfacing the + * storage error; startup will validate the authoritative ledger separately. + */ + private failClosedAfterRetirementSealError( + agentName: string, + record: ResidentRetirementRecord, + ): void { + this.retiredResidents.set(agentName, record); + try { + this.stopResidentAuthoredActivity(agentName); + } catch (error) { + console.error( + `[resident-retirement] fail-closed activity teardown failed for ${agentName}:`, + error, + ); + } + try { + this.terminateConversationForksForTemplate(agentName); + } catch (error) { + console.error( + `[resident-retirement] fail-closed conversation teardown failed for ${agentName}:`, + error, + ); + } + } + /** * Apply the neutral irreversible seal for an explicitly authorized resident. * Confirmation, wording, cooling-off and notification are host policy and @@ -2872,9 +2900,16 @@ export class AgentFramework { ...(cleanReason ? { reason: cleanReason } : {}), }; - // The sidecar is authoritative and branch-independent. Once this append - // returns, no Chronicle branch operation can make the identity infer again. - this.appendRetirementSeal(record); + // The sidecar is authoritative and branch-independent. A failed append may + // still have written a valid record (for example, when directory fsync + // throws after file fsync), so an uncertain outcome must fail closed in + // this process before the storage error escapes. + try { + this.appendRetirementSeal(record); + } catch (error) { + this.failClosedAfterRetirementSealError(agentName, record); + throw error; + } this.retiredResidents.set(agentName, record); this.stopResidentAuthoredActivity(agentName); // Conversation forks are dependent continuations of their template, not diff --git a/test/resident-retirement.test.ts b/test/resident-retirement.test.ts index 871fe991..6a013b9b 100644 --- a/test/resident-retirement.test.ts +++ b/test/resident-retirement.test.ts @@ -1,12 +1,13 @@ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; -import { +import fs, { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync, } from 'node:fs'; +import { syncBuiltinESMExports } from 'node:module'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { JsStore } from '@animalabs/chronicle'; @@ -672,6 +673,59 @@ describe('resident retirement', () => { } }); + it('fails closed if directory durability syncing throws after the seal file is fsynced', { + skip: process.platform === 'win32', + }, async () => { + const dir = mkdtempSync(join(tmpdir(), 'af-retirement-durability-error-')); + const storePath = join(dir, 'store'); + const retirementPath = join(dir, 'new', 'nested', 'resident-retirements.jsonl'); + const membrane = new RejectInferenceMembrane(); + const framework = await AgentFramework.create({ + storePath, + retirementPath, + membrane: membrane.asMembrane(), + agents: [{ + name: 'resident', + model: 'test-model', + systemPrompt: 'test', + retirement: { enabled: true }, + }], + modules: [], + syncIntervalMs: 0, + maintenanceIntervalMs: 0, + }); + const originalFsyncSync = fs.fsyncSync; + let fsyncCalls = 0; + fs.fsyncSync = ((fd: number) => { + originalFsyncSync(fd); + fsyncCalls++; + if (fsyncCalls === 2) { + throw new Error('injected directory-fsync failure'); + } + }) as typeof fs.fsyncSync; + syncBuiltinESMExports(); + try { + assert.throws( + () => framework.retireResident('resident'), + /injected directory-fsync failure/, + ); + assert.equal(fsyncCalls, 2, 'file fsync succeeded before the directory failure'); + const seals = readFileSync(retirementPath, 'utf8').trim().split('\n').map((line) => JSON.parse(line)); + assert.deepEqual(seals.map((record) => record.agentName), ['resident']); + assert.equal(framework.getResidentLifecycleStatus('resident').status, 'retired'); + await assert.rejects( + framework.getAgent('resident')!.runInference([]), + /inference is permanently disabled: resident retired/, + ); + assert.equal(membrane.calls, 0, 'an ambiguous seal outcome cannot reach the provider'); + } finally { + fs.fsyncSync = originalFsyncSync; + syncBuiltinESMExports(); + await framework.stop(); + rmSync(dir, { recursive: true, force: true }); + } + }); + it('requires a branch-independent seal path for app-owned stores', async () => { const dir = mkdtempSync(join(tmpdir(), 'af-resident-retirement-owned-')); const store = JsStore.openOrCreate({ path: join(dir, 'store') }); From 4a6fc3b34460340887cba217d48da327c643aa20 Mon Sep 17 00:00:00 2001 From: Ian de Marcellus <50731233+ian-de-marcellus@users.noreply.github.com> Date: Tue, 8 Sep 2026 21:00:32 +0200 Subject: [PATCH 07/11] fix(lifecycle): harden terminal retirement cleanup Co-Authored-By: OpenAI Codex --- README.md | 5 + docs/resident-lifecycle.md | 12 ++ src/agent.ts | 16 ++- src/framework.ts | 146 +++++++++++++++++++------ test/resident-retirement.test.ts | 182 +++++++++++++++++++++++++++++++ 5 files changed, 321 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index cca6a416..cd88aa97 100644 --- a/README.md +++ b/README.md @@ -277,6 +277,11 @@ seal file and its containing directory on first creation, cancels an active stream, denies every future inference surface (including public `Agent` references), clears queued wakes and process-local gate state, stops resident-authored scripts, and keeps Chronicle/workspace history readable. +Terminal state and dependent conversation-fork tombstones are installed before +cleanup; cancellation failures are surfaced only after all independent cleanup +attempts run. Retirement-enabled resident names must be non-empty, unpadded, +and free of control characters so the write and strict-reload identity grammar +is identical. Confirmation wording, cooling-off, health gates, and notifications deliberately belong to the host. Retirement is distinct from end-turn, sleep/dormancy, and erasure. diff --git a/docs/resident-lifecycle.md b/docs/resident-lifecycle.md index 3f75d4b3..0259d563 100644 --- a/docs/resident-lifecycle.md +++ b/docs/resident-lifecycle.md @@ -43,6 +43,11 @@ enforcement, status, and observability. `getResidentLifecycleStatus(name)` returns the durable host view. A configured resident without `retirement.enabled` cannot be retired through this API. +Because the configured name is also the exact durable identity written to the +seal ledger, a retirement-enabled resident name must be non-empty, have no +surrounding whitespace, and contain no control characters. Invalid names are +rejected before the framework opens or creates its store; they are never +silently normalized to another identity. ## Host-owned live tools @@ -95,6 +100,13 @@ framework then: - appends `framework/resident-lifecycle` in Chronicle and emits a `resident:retired` trace. +Terminal state and every dependent conversation-fork tombstone are installed +before provider- or module-owned cleanup runs. Cleanup attempts are isolated: +one cancellation or disposal failure does not prevent later resident/fork +cleanup. If cleanup does fail, `retireResident` surfaces the error only after +the durable seal and in-process terminal boundaries are in place; a successful +seal therefore cannot be undone by a throwing provider cancellation. + Already-running ephemeral subagents are separate, short-lived inference identities. Retirement does not kill them mid-call. They may finish their own work, but any attempt to deliver a wake or append into the retired resident is diff --git a/src/agent.ts b/src/agent.ts index ec776af8..6f7d464f 100644 --- a/src/agent.ts +++ b/src/agent.ts @@ -977,12 +977,18 @@ export class Agent { * Cancel any active stream and reset to idle. */ cancelStream(): void { - if (this._state.status === 'streaming') { - this._state.stream.cancel(); - } else if (this._state.status === 'waiting_for_tools' && this._state.stream) { - this._state.stream.cancel(); + try { + if (this._state.status === 'streaming') { + this._state.stream.cancel(); + } else if (this._state.status === 'waiting_for_tools' && this._state.stream) { + this._state.stream.cancel(); + } + } finally { + // Provider-owned cancellation is allowed to throw. State teardown is + // framework-owned and must still complete so a sealed agent cannot be + // stranded in streaming/waiting_for_tools. + this._state = { status: 'idle' }; } - this._state = { status: 'idle' }; } /** diff --git a/src/framework.ts b/src/framework.ts index a1d0a76f..40ab5725 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -752,6 +752,14 @@ interface ResidentRetirementRecord { reason?: string; } +/** Identity grammar shared by retirement seal writes and strict reload. */ +function isPersistedResidentIdentity(value: unknown): value is string { + return typeof value === 'string' && + value.length > 0 && + value === value.trim() && + !/[\u0000-\u001f\u007f]/.test(value); +} + /** Local per-residence provider admission (AF #114 bounded first slice). */ interface LocalProviderGate { primaryDepth: number; @@ -1405,6 +1413,19 @@ export class AgentFramework { * Create and start the framework. */ static async create(config: FrameworkConfig): Promise { + // Validate before opening or mutating the store. Normalizing would change + // which configured identity an irreversible record seals, so reject any + // name that the strict ledger loader could not later accept verbatim. + for (const agentConfig of config.agents) { + if (agentConfig.retirement?.enabled && !isPersistedResidentIdentity(agentConfig.name)) { + throw new Error( + `Invalid persisted resident identity ${JSON.stringify(agentConfig.name)}: ` + + 'retirement-enabled agent names must be non-empty, have no surrounding whitespace, ' + + 'and contain no control characters', + ); + } + } + // Create or use existing store let store: JsStore; let ownsStore: boolean; @@ -2680,10 +2701,7 @@ export class AgentFramework { if ( raw.version !== 1 || raw.kind !== 'resident-retired' || - typeof raw.agentName !== 'string' || - !raw.agentName || - raw.agentName !== raw.agentName.trim() || - /[\u0000-\u001f\u007f]/.test(raw.agentName) || + !isPersistedResidentIdentity(raw.agentName) || typeof raw.retiredAt !== 'number' || !Number.isSafeInteger(raw.retiredAt) || raw.retiredAt <= 0 || @@ -2790,21 +2808,30 @@ export class AgentFramework { reason = 'resident retired', cancelKind: 'resident_retired' | 'template_retired' = 'resident_retired', ): void { - this.sealAgentInference(agentName, reason, cancelKind); + const cleanupErrors: unknown[] = []; + const attempt = (operation: () => void): void => { + try { + operation(); + } catch (error) { + cleanupErrors.push(error); + } + }; + + attempt(() => this.sealAgentInference(agentName, reason, cancelKind)); // A retired resident cannot retain a model-authored daemon that keeps // acting or attempting wakes after its inference identity is sealed. const runner = this.codeExecutionRunners.get(agentName); if (runner) { - runner.abort(reason); - runner.dispose(); + attempt(() => runner.abort(reason)); + attempt(() => runner.dispose()); this.codeExecutionRunners.delete(agentName); } for (const record of this.backgroundScripts.values()) { if (record.agentName !== agentName || record.status !== 'running') continue; record.cancelled = true; record.status = 'cancelled'; - record.runner.abort(reason); - record.runner.dispose(); + attempt(() => record.runner.abort(reason)); + attempt(() => record.runner.dispose()); } this.pendingRequests = this.pendingRequests.filter((request) => request.agentName !== agentName); const cooldown = this.providerAccelerationCooldowns.get(agentName); @@ -2819,26 +2846,60 @@ export class AgentFramework { for (const resolve of gate.idleWaiters.splice(0)) resolve(); this.providerGates.delete(agentName); } - this.eventGate?.clearAgentState(agentName); + attempt(() => this.eventGate?.clearAgentState(agentName)); this.activeTriggerChannels.delete(agentName); this.staleWarnAt.delete(agentName); + if (cleanupErrors.length === 1) throw cleanupErrors[0]; + if (cleanupErrors.length > 1) { + throw new AggregateError( + cleanupErrors, + `Multiple retirement cleanup operations failed for ${agentName}`, + ); + } } - private terminateConversationForksForTemplate(templateAgent: string): void { + private tombstoneConversationForksForTemplate(templateAgent: string): string[] { + if (!this.conversationRouter || this.conversationRouter.templateAgent !== templateAgent) return []; + const forkNames = [...this.conversationAgentHomes.keys()]; + for (const forkName of forkNames) { + this.terminatedConversationAgents.add(forkName); + } + return forkNames; + } + + private terminateConversationForksForTemplate( + templateAgent: string, + forkNames = this.tombstoneConversationForksForTemplate(templateAgent), + ): void { if (!this.conversationRouter || this.conversationRouter.templateAgent !== templateAgent) return; const bindings = this.conversationRouter.getBindings(); - for (const forkName of [...this.conversationAgentHomes.keys()]) { - this.terminatedConversationAgents.add(forkName); + const cleanupErrors: unknown[] = []; + const attempt = (operation: () => void): void => { + try { + operation(); + } catch (error) { + cleanupErrors.push(error); + } + }; + + for (const forkName of forkNames) { const binding = bindings.find((candidate) => candidate.agentName === forkName); - if (binding) this.conversationRouter.unbind(binding.channelId); - this.stopResidentAuthoredActivity( + if (binding) attempt(() => this.conversationRouter!.unbind(binding.channelId)); + attempt(() => this.stopResidentAuthoredActivity( forkName, `template resident ${templateAgent} retired`, 'template_retired', - ); - this.disposeConversationAgent(forkName); + )); + attempt(() => this.disposeConversationAgent(forkName)); } this.persistConversationRouterState(); + if (cleanupErrors.length === 1) throw cleanupErrors[0]; + if (cleanupErrors.length > 1) { + throw new AggregateError( + cleanupErrors, + `Multiple conversation-fork retirement operations failed for ${templateAgent}`, + ); + } } /** @@ -2846,27 +2907,28 @@ export class AgentFramework { * that ambiguous identity terminal in this process before surfacing the * storage error; startup will validate the authoritative ledger separately. */ - private failClosedAfterRetirementSealError( + private enforceResidentRetirementInProcess( agentName: string, record: ResidentRetirementRecord, - ): void { + ): unknown[] { + const cleanupErrors: unknown[] = []; this.retiredResidents.set(agentName, record); + // Install dependent tombstones before invoking even the resident stream's + // provider-owned cancel callback. A hostile or faulty callback may re-enter + // the framework synchronously; every dependent identity must already be + // terminal at that boundary. + const forkNames = this.tombstoneConversationForksForTemplate(agentName); try { this.stopResidentAuthoredActivity(agentName); } catch (error) { - console.error( - `[resident-retirement] fail-closed activity teardown failed for ${agentName}:`, - error, - ); + cleanupErrors.push(error); } try { - this.terminateConversationForksForTemplate(agentName); + this.terminateConversationForksForTemplate(agentName, forkNames); } catch (error) { - console.error( - `[resident-retirement] fail-closed conversation teardown failed for ${agentName}:`, - error, - ); + cleanupErrors.push(error); } + return cleanupErrors; } /** @@ -2879,6 +2941,9 @@ export class AgentFramework { if (!this.retirableResidents.has(agentName)) { throw new Error(`Resident retirement is not enabled for agent: ${agentName}`); } + if (!isPersistedResidentIdentity(agentName)) { + throw new Error(`Invalid persisted resident identity ${JSON.stringify(agentName)}`); + } const existing = this.retiredResidents.get(agentName); if (existing) { return { @@ -2907,15 +2972,19 @@ export class AgentFramework { try { this.appendRetirementSeal(record); } catch (error) { - this.failClosedAfterRetirementSealError(agentName, record); + const cleanupErrors = this.enforceResidentRetirementInProcess(agentName, record); + for (const cleanupError of cleanupErrors) { + console.error( + `[resident-retirement] fail-closed teardown failed for ${agentName}:`, + cleanupError, + ); + } throw error; } - this.retiredResidents.set(agentName, record); - this.stopResidentAuthoredActivity(agentName); - // Conversation forks are dependent continuations of their template, not - // independent ephemeral subagents. Existing forks are sealed, unbound, - // and disposed immediately; their Chronicle namespaces remain intact. - this.terminateConversationForksForTemplate(agentName); + // Install every terminal/tombstone state before surfacing provider-owned + // cleanup failures. Existing forks are dependent continuations of their + // template; their Chronicle namespaces remain intact after disposal. + const cleanupErrors = this.enforceResidentRetirementInProcess(agentName, record); let chronicleRecorded = false; try { @@ -2936,6 +3005,13 @@ export class AgentFramework { retiredAt: record.retiredAt, chronicleRecorded, }); + if (cleanupErrors.length === 1) throw cleanupErrors[0]; + if (cleanupErrors.length > 1) { + throw new AggregateError( + cleanupErrors, + `Multiple retirement cleanup operations failed for ${agentName}`, + ); + } return { status: 'retired', retiredAt: record.retiredAt, diff --git a/test/resident-retirement.test.ts b/test/resident-retirement.test.ts index 6a013b9b..c2d54b2c 100644 --- a/test/resident-retirement.test.ts +++ b/test/resident-retirement.test.ts @@ -221,6 +221,54 @@ class LateStreamingMembrane { } } +class ThrowingCancellationStream implements YieldingStream { + cancelCalls = 0; + onCancel: (() => void) | null = null; + private release!: () => void; + private readonly released = new Promise((resolve) => { this.release = resolve; }); + readonly isWaitingForTools = false; + readonly pendingToolCallIds: string[] = []; + readonly toolDepth = 0; + + provideToolResults(): void { throw new Error('not waiting for tools'); } + cancel(): void { + this.cancelCalls++; + this.onCancel?.(); + this.release(); + throw new Error('provider cancel failed'); + } + finish(): void { this.release(); } + + async *[Symbol.asyncIterator](): AsyncIterator { + await this.released; + yield { + type: 'complete', + response: createMockResponse([{ type: 'text', text: 'completion after throwing cancel' }]), + } as import('@animalabs/membrane').StreamEvent; + } +} + +class ThrowingCancellationMembrane { + calls = 0; + readonly stream = new ThrowingCancellationStream(); + readonly created: Promise; + private markCreated!: () => void; + + constructor() { + this.created = new Promise((resolve) => { this.markCreated = resolve; }); + } + + streamYielding(_request: NormalizedRequest): YieldingStream { + this.calls++; + this.markCreated(); + return this.stream; + } + + asMembrane(): import('@animalabs/membrane').Membrane { + return this as unknown as import('@animalabs/membrane').Membrane; + } +} + describe('resident retirement', () => { it('seals through the neutral API, clears wake state, preserves history, and blocks restart inference', async () => { const dir = mkdtempSync(join(tmpdir(), 'af-resident-retirement-')); @@ -596,6 +644,110 @@ describe('resident retirement', () => { } }); + it('terminalizes every fork before surfacing a provider cancellation failure', async () => { + const dir = mkdtempSync(join(tmpdir(), 'af-retirement-throwing-cancel-')); + const storePath = join(dir, 'store'); + const membrane = new ThrowingCancellationMembrane(); + const framework = await AgentFramework.create({ + storePath, + membrane: membrane.asMembrane(), + agents: [{ + name: 'template', + model: 'test-model', + systemPrompt: 'test', + retirement: { enabled: true }, + }], + modules: [], + conversations: { templateAgent: 'template' }, + syncIntervalMs: 0, + maintenanceIntervalMs: 0, + }); + try { + const oldFork = await (framework as unknown as { + createConversationAgent(name: string, channel: string): Promise; + }).createConversationAgent('conversation-dm-throwing', 'dm') as { + runInference(tools: ToolDefinition[]): Promise; + }; + const reentrantForkNudge: { + result: ReturnType | null; + } = { result: null }; + membrane.stream.onCancel = () => { + reentrantForkNudge.result = framework.nudgeAgent('conversation-dm-throwing'); + }; + + framework.start(); + framework.nudgeAgent('template', 'throwing-cancel-retirement-test'); + await membrane.created; + assert.throws( + () => framework.retireResident('template'), + /provider cancel failed/, + ); + + assert.equal(membrane.stream.cancelCalls, 1); + assert.equal( + reentrantForkNudge.result?.ok, + false, + 'fork is terminal before provider cancellation', + ); + assert.match( + reentrantForkNudge.result?.error ?? '', + /terminated when its template resident retired/, + ); + assert.equal(framework.getResidentLifecycleStatus('template').status, 'retired'); + assert.equal(framework.getAgent('template')!.state.status, 'idle'); + assert.equal(framework.getAgent('conversation-dm-throwing'), null); + assert.match( + framework.nudgeAgent('conversation-dm-throwing').error ?? '', + /terminated when its template resident retired/, + ); + await assert.rejects( + oldFork.runInference([]), + /inference is permanently disabled: template resident retired/, + ); + assert.equal(membrane.calls, 1, 'the retained fork cannot reach the provider'); + + const seals = readFileSync( + join(storePath, 'resident-retirements.jsonl'), + 'utf8', + ).trim().split('\n').map((line) => JSON.parse(line)); + assert.deepEqual(seals.map((record) => record.agentName), ['template']); + await new Promise((resolve) => setTimeout(resolve, 25)); + const context = await framework.getAgent('template')!.compileContext(); + assert.doesNotMatch(JSON.stringify(context), /completion after throwing cancel/); + } finally { + membrane.stream.finish(); + await framework.stop(); + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('rejects every invalid persisted identity before opening the store', async () => { + const invalidNames = ['', ' resident ', 'resident\n']; + for (const [index, name] of invalidNames.entries()) { + const dir = mkdtempSync(join(tmpdir(), `af-retirement-invalid-name-${index}-`)); + const storePath = join(dir, 'store'); + try { + await assert.rejects( + AgentFramework.create({ + storePath, + membrane: new RejectInferenceMembrane().asMembrane(), + agents: [{ + name, + model: 'test-model', + systemPrompt: 'test', + retirement: { enabled: true }, + }], + modules: [], + }), + /Invalid persisted resident identity/, + ); + assert.equal(fs.existsSync(storePath), false, 'validation precedes store creation'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + } + }); + it('fails closed on torn, malformed, semantically invalid, and duplicate seal records', async () => { const cases = [ { name: 'torn', content: '{"version":1', message: /incomplete final record/ }, @@ -612,6 +764,36 @@ describe('resident retirement', () => { '{"version":1,"kind":"resident-retired","agentName":"resident","retiredAt":2}\n', message: /invalid retirement record/, }, + { + name: 'empty-agent-name', + content: `${JSON.stringify({ + version: 1, + kind: 'resident-retired', + agentName: '', + retiredAt: 1, + })}\n`, + message: /invalid retirement record/, + }, + { + name: 'padded-agent-name', + content: `${JSON.stringify({ + version: 1, + kind: 'resident-retired', + agentName: ' resident ', + retiredAt: 1, + })}\n`, + message: /invalid retirement record/, + }, + { + name: 'control-character-agent-name', + content: `${JSON.stringify({ + version: 1, + kind: 'resident-retired', + agentName: 'resident\n', + retiredAt: 1, + })}\n`, + message: /invalid retirement record/, + }, ]; for (const testCase of cases) { const dir = mkdtempSync(join(tmpdir(), `af-retirement-${testCase.name}-`)); From 71a58b4cc01c4bd6a0b8f4a242eba94c0fa86acd Mon Sep 17 00:00:00 2001 From: Ian de Marcellus <50731233+ian-de-marcellus@users.noreply.github.com> Date: Thu, 24 Sep 2026 02:12:45 +0200 Subject: [PATCH 08/11] fix(lifecycle): release a stream whose provider cancel throws without settling When retirement's provider cancel() threw before aborting its iterator, the agent was sealed and reset to idle but the framework kept the iteration handle in activeStreams, so a later framework.stop() awaited it forever. The stream is already marked framework-cancelled (late events are discarded), so drop the framework's ownership of it on that failure path. Regression: a provider whose cancel() throws without releasing the iterator; stop() now settles before the test releases it (and times out without the fix). Co-Authored-By: Claude Opus 5.5 (1M context) --- src/framework.ts | 11 ++++- test/resident-retirement.test.ts | 84 ++++++++++++++++++++++++++++++++ 2 files changed, 94 insertions(+), 1 deletion(-) diff --git a/src/framework.ts b/src/framework.ts index 40ab5725..fdc4b940 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -2800,7 +2800,16 @@ export class AgentFramework { if (agent.state.status === 'streaming' || agent.state.status === 'waiting_for_tools') { this.frameworkCancelledStreams.set(`${agentName}:${agent.streamId}`, cancelKind); } - agent.abortInference(reason); + try { + agent.abortInference(reason); + } catch (error) { + // The provider's cancel() threw, so its iterator may never settle. The + // agent is already sealed and this stream is marked framework-cancelled + // (late events are discarded), so release the framework's ownership of + // it: stop() must not stay hostage to a provider that failed to cancel. + this.activeStreams.delete(agentName); + throw error; + } } private stopResidentAuthoredActivity( diff --git a/test/resident-retirement.test.ts b/test/resident-retirement.test.ts index c2d54b2c..d61c5b6b 100644 --- a/test/resident-retirement.test.ts +++ b/test/resident-retirement.test.ts @@ -269,6 +269,49 @@ class ThrowingCancellationMembrane { } } +/** cancel() throws WITHOUT releasing the iterator — the provider behavior the + * production comment allows, which ThrowingCancellationStream masks by + * releasing before it throws. */ +class NonSettlingThrowingCancelStream implements YieldingStream { + cancelCalls = 0; + private release!: () => void; + private readonly released = new Promise((resolve) => { this.release = resolve; }); + readonly isWaitingForTools = false; + readonly pendingToolCallIds: string[] = []; + readonly toolDepth = 0; + + provideToolResults(): void { throw new Error('not waiting for tools'); } + cancel(): void { + this.cancelCalls++; + throw new Error('cancel failed before aborting provider'); + } + finish(): void { this.release(); } + + async *[Symbol.asyncIterator](): AsyncIterator { + await this.released; + yield { + type: 'complete', + response: createMockResponse([{ type: 'text', text: 'output after unsettled cancel' }]), + } as import('@animalabs/membrane').StreamEvent; + } +} + +class NonSettlingCancelMembrane { + readonly stream = new NonSettlingThrowingCancelStream(); + readonly created: Promise; + private markCreated!: () => void; + constructor() { + this.created = new Promise((resolve) => { this.markCreated = resolve; }); + } + streamYielding(_request: NormalizedRequest): YieldingStream { + this.markCreated(); + return this.stream; + } + asMembrane(): import('@animalabs/membrane').Membrane { + return this as unknown as import('@animalabs/membrane').Membrane; + } +} + describe('resident retirement', () => { it('seals through the neutral API, clears wake state, preserves history, and blocks restart inference', async () => { const dir = mkdtempSync(join(tmpdir(), 'af-resident-retirement-')); @@ -589,6 +632,47 @@ describe('resident retirement', () => { } }); + it('stop() completes when a provider cancel() throws without settling its iterator', async () => { + const dir = mkdtempSync(join(tmpdir(), 'af-retired-unsettled-cancel-')); + const membrane = new NonSettlingCancelMembrane(); + const framework = await AgentFramework.create({ + storePath: join(dir, 'store'), + membrane: membrane.asMembrane(), + agents: [{ + name: 'resident', + model: 'test-model', + systemPrompt: 'test', + retirement: { enabled: true }, + }], + modules: [], + syncIntervalMs: 0, + maintenanceIntervalMs: 0, + }); + try { + framework.start(); + framework.nudgeAgent('resident', 'unsettled-cancel-retirement-test'); + await membrane.created; + // Let the framework register the iteration handle in activeStreams. + await new Promise((resolve) => setTimeout(resolve, 25)); + assert.ok((framework as unknown as { activeStreams: Map }).activeStreams.has('resident')); + assert.throws(() => framework.retireResident('resident'), /cancel failed before aborting provider/); + assert.equal(framework.getResidentLifecycleStatus('resident').status, 'retired'); + assert.equal(framework.getAgent('resident')!.state.status, 'idle'); + assert.equal(membrane.stream.cancelCalls, 1); + + // The iterator is still pending. stop() must settle anyway. + const outcome = await Promise.race([ + framework.stop().then(() => 'stopped'), + new Promise((resolve) => setTimeout(() => resolve('timed-out'), 2000)), + ]); + assert.equal(outcome, 'stopped', 'framework.stop() must not wait on an iterator the provider failed to cancel'); + } finally { + membrane.stream.finish(); + await framework.stop().catch(() => {}); + rmSync(dir, { recursive: true, force: true }); + } + }); + it('terminates existing conversation forks and refuses new ones after template retirement', async () => { const dir = mkdtempSync(join(tmpdir(), 'af-retired-template-')); const framework = await AgentFramework.create({ From f62d39f82c112d992221f6893407e381f1d9faf0 Mon Sep 17 00:00:00 2001 From: Ian de Marcellus <50731233+ian-de-marcellus@users.noreply.github.com> Date: Thu, 24 Sep 2026 13:25:04 +0200 Subject: [PATCH 09/11] fix(lifecycle): trace post-retirement appends instead of dropping them silently A message routed to a retired resident is still discarded (its context is a historical record, not a mailbox), but the drop is now visible: `[message-dropped] agent= reason=resident_retired participant=

dropped=`, logged on the first drop and every 100th, so a retired resident still subscribed to a busy channel cannot flood stderr. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/framework.ts | 12 ++++++++++++ test/resident-retirement.test.ts | 21 ++++++++++++++++++--- 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/src/framework.ts b/src/framework.ts index fdc4b940..a479b115 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -1298,6 +1298,8 @@ export class AgentFramework { // ---- Resident lifecycle ------------------------------------------------- /** Configured, non-ephemeral residents a host may irreversibly retire. */ private retirableResidents: Map = new Map(); + /** Post-retirement appends dropped per resident (drives the rate-limited trace). */ + private readonly retiredMessageDrops = new Map(); /** Branch-independent terminal seals loaded from the append-only sidecar. */ private retiredResidents: Map = new Map(); /** @@ -11798,6 +11800,16 @@ export class AgentFramework { // External events may still exist in module/process logs, but they do not // append post-retirement speech to the sealed identity's conversation. if (this.retiredResidents?.has(agent.name)) { + // Traced, but rate-limited: a retired resident still subscribed to a + // busy channel would otherwise log every message it no longer hears. + const dropped = (this.retiredMessageDrops.get(agent.name) ?? 0) + 1; + this.retiredMessageDrops.set(agent.name, dropped); + if (dropped === 1 || dropped % 100 === 0) { + console.error( + `[message-dropped] agent=${agent.name} reason=resident_retired ` + + `participant=${participant} dropped=${dropped}`, + ); + } return '' as MessageId; } diff --git a/test/resident-retirement.test.ts b/test/resident-retirement.test.ts index d61c5b6b..cb51b2f6 100644 --- a/test/resident-retirement.test.ts +++ b/test/resident-retirement.test.ts @@ -382,11 +382,26 @@ describe('resident retirement', () => { const before = await framework.getAgent('resident')!.compileContext(); assert.match(JSON.stringify(before), /history survives/); - (framework as unknown as { - addMessage(participant: string, content: Array<{ type: 'text'; text: string }>): void; - }).addMessage('User', [{ type: 'text', text: 'must not append' }]); + const logged: string[] = []; + const originalError = console.error; + console.error = (...args: unknown[]) => { logged.push(args.map(String).join(' ')); }; + try { + for (let i = 0; i < 100; i++) { + (framework as unknown as { + addMessage(participant: string, content: Array<{ type: 'text'; text: string }>): void; + }).addMessage('User', [{ type: 'text', text: 'must not append' }]); + } + } finally { + console.error = originalError; + } const after = await framework.getAgent('resident')!.compileContext(); assert.doesNotMatch(JSON.stringify(after), /must not append/); + const drops = logged.filter((line) => line.startsWith('[message-dropped] agent=resident reason=resident_retired')); + assert.deepEqual( + drops.map((line) => line.match(/dropped=(\d+)/)?.[1]), + ['1', '100'], + 'the post-retirement drop is traced on the first append and every 100th, not per message', + ); await framework.stop(); const reject = new RejectInferenceMembrane(); From b3aaa908d78d816ec892dac62ade746fa19f781e Mon Sep 17 00:00:00 2001 From: slimepriestess Date: Wed, 23 Sep 2026 20:34:17 -0700 Subject: [PATCH 10/11] test(lifecycle): the subconscious terminates with its resident (red on 71a58b4) After retireResident, a wake of the subconscious agent still starts inference: nudgeAgent('Subconscious') returns ok with status idle and the provider is called once. The side-process is built from the primary's inference config and reads its shared message slot; by the PR's own fork doctrine it is a continuation of the template identity and should be sealed with it. Pins that no inference runs in the retired resident's name from any wake path. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01CoQK2cP55YhezE6ajSx58h --- test/subconscious-retirement.test.ts | 63 ++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 test/subconscious-retirement.test.ts diff --git a/test/subconscious-retirement.test.ts b/test/subconscious-retirement.test.ts new file mode 100644 index 00000000..367e9975 --- /dev/null +++ b/test/subconscious-retirement.test.ts @@ -0,0 +1,63 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { AgentFramework, ApiModule } from '../src/index.js'; +import { createMockResponse, MockMembrane } from './helpers/mock-membrane.js'; + +/** + * The subconscious terminates with its resident. + * + * createSubconsciousAgent registers a second Agent (default name + * `Subconscious`) built from the primary's inference config, reading the + * resident's shared message slot. By the retirement doctrine's own line — + * forks are "persistent, addressable continuations of one configured + * template identity" and "terminate with that template" — the subconscious + * is on the fork side: a same-model side-process serving one resident. + * retireResident seals the primary and terminates its forks; this pins that + * the side-process is sealed with them, so no inference runs in the retired + * resident's name afterwards, from any wake path (the tune-out coordinator's + * pushes in production; an operator nudge here, which reaches the same + * scheduler). + */ +async function waitFor(cond: () => boolean, ms = 1500): Promise { + const start = Date.now(); + while (!cond()) { if (Date.now() - start > ms) return false; await new Promise((r) => setTimeout(r, 10)); } + return true; +} + +describe('retirement seals the subconscious with the resident', () => { + it('after retireResident, a wake of the subconscious starts no inference', async () => { + const tempDir = mkdtempSync(join(tmpdir(), 'retire-sub-')); + const membrane = new MockMembrane(); + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'Nothing to report.' }])); + const framework = await AgentFramework.create({ + storePath: join(tempDir, 'test.chronicle'), + membrane: membrane.asMembrane(), + agents: [{ name: 'resident', model: 'test-model', systemPrompt: 'test', retirement: { enabled: true } }], + subconscious: { enabled: true, systemPrompt: 'You are the Subconscious. Report to the resident in second person.' }, + modules: [new ApiModule()], + gate: { config: { policies: [], default: 'always' } }, + syncIntervalMs: 0, + maintenanceIntervalMs: 0, + }); + try { + framework.start(); + assert.ok(framework.getAgent('Subconscious'), 'the subconscious is registered beside the resident'); + + framework.retireResident('resident', 'seals the side-process too'); + assert.equal(framework.getResidentLifecycleStatus('resident').status, 'retired'); + + const before = membrane.calls.length; + const nudge = framework.nudgeAgent('Subconscious', 'operator'); + const inferred = await waitFor(() => membrane.calls.length > before); + assert.ok(!nudge.ok || !inferred, + `the retired resident's subconscious still infers: nudge=${JSON.stringify(nudge)}, membrane calls ${before} → ${membrane.calls.length}`); + assert.equal(membrane.calls.length, before, 'no inference ran in the retired resident\'s name'); + } finally { + await framework.stop(); + rmSync(tempDir, { recursive: true, force: true }); + } + }); +}); From be7326f1d8f3fe82996f33c1e0467afa8f3c26e8 Mon Sep 17 00:00:00 2001 From: slimepriestess Date: Thu, 24 Sep 2026 08:26:14 -0700 Subject: [PATCH 11/11] fix(lifecycle): the subconscious terminates with its resident MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The subconscious is a same-model side-process serving exactly one resident: built from the primary's inference config, reading the primary's shared slot. By the retirement doctrine it is on the fork side — a dependent continuation of the template identity — so retireResident now tombstones, seals and unregisters it alongside the conversation forks when the retired resident is the primary it attends. Its `subconscious/` Chronicle namespace stays. At boot, seals load before agents are created; a retired primary gets no subconscious re-created for it (logged, not an error — the config stays valid for a host that keeps a sealed resident's history). The fork tombstone set becomes `terminatedDependentAgents`, since it now holds both kinds of continuation; the nudge refusal keeps its tail phrase. Retiring a resident the subconscious does not attend leaves it alive. Tests: the red case from 185e331 now passes on this head; two more pin the boot path and the non-primary case. Each mechanism's revert turns exactly its own case red. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01CoQK2cP55YhezE6ajSx58h --- changelog.d/resident-retirement.added.md | 3 +- docs/resident-lifecycle.md | 13 +++- src/framework.ts | 84 ++++++++++++++++++++-- test/subconscious-retirement.test.ts | 90 ++++++++++++++++++++---- 4 files changed, 165 insertions(+), 25 deletions(-) diff --git a/changelog.d/resident-retirement.added.md b/changelog.d/resident-retirement.added.md index eddc3d9e..5d7aed77 100644 --- a/changelog.d/resident-retirement.added.md +++ b/changelog.d/resident-retirement.added.md @@ -1,4 +1,5 @@ - Add an opt-in irreversible resident seal and imperative enforcement API, plus a provider-stream-only module tool surface for host-owned lifecycle ceremonies. Retirement preserves history while blocking future inference, - scheduled wakes, and conversation-fork resurrection. + scheduled wakes, conversation-fork resurrection, and the subconscious + side-process (sealed with the primary it attends; not re-created at boot). diff --git a/docs/resident-lifecycle.md b/docs/resident-lifecycle.md index 0259d563..101b8759 100644 --- a/docs/resident-lifecycle.md +++ b/docs/resident-lifecycle.md @@ -96,11 +96,15 @@ framework then: - freezes the resident conversation against later message appends; - seals, unbinds, and unregisters conversation forks already derived from the retired template while preserving their Chronicle namespaces; -- refuses to create any later conversation fork from that template; and +- refuses to create any later conversation fork from that template; +- seals and unregisters the subconscious side-process when the retired + resident is the primary it attends, and does not re-create one at boot for + an already-sealed primary (its `subconscious/` namespace stays); and - appends `framework/resident-lifecycle` in Chronicle and emits a `resident:retired` trace. -Terminal state and every dependent conversation-fork tombstone are installed +Terminal state and every dependent tombstone (conversation forks, the +subconscious) are installed before provider- or module-owned cleanup runs. Cleanup attempts are isolated: one cancellation or disposal failure does not prevent later resident/fork cleanup. If cleanup does fail, `retireResident` surfaces the error only after @@ -116,7 +120,10 @@ must track and cancel those ephemeral jobs before invoking `retireResident`. Conversation forks use a different policy because they are persistent, addressable continuations of one configured template identity. They terminate -with that template rather than finishing independently. A stale public +with that template rather than finishing independently. The subconscious is on +the same side of that line: a same-model side-process built from the primary's +inference config and reading its shared slot, serving that one resident. It +terminates with the primary. A stale public reference to such a fork remains inference-sealed, and its generation-unique name remains tombstoned against later tool provenance. diff --git a/src/framework.ts b/src/framework.ts index a479b115..fdfb88b7 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -1307,7 +1307,9 @@ export class AgentFramework { * Names are generation-unique, so retaining the tombstone prevents stale * public call provenance from becoming usable after the Agent is disposed. */ - private terminatedConversationAgents: Set = new Set(); + /** Dependent continuations of a retired template — conversation forks and + * the subconscious — terminal by name even after their Agent is disposed. */ + private terminatedDependentAgents: Set = new Set(); /** Null only for app-owned stores whose host supplied no seal path. */ private readonly retirementPath: string | null; @@ -2786,7 +2788,7 @@ export class AgentFramework { // that construct a partial Framework object around one public method. // Fully-created Framework instances always initialize both collections. if (this.retiredResidents?.has(agentName)) return 'resident retired'; - if (this.terminatedConversationAgents?.has(agentName)) { + if (this.terminatedDependentAgents?.has(agentName)) { return 'template resident retired'; } return null; @@ -2873,7 +2875,7 @@ export class AgentFramework { if (!this.conversationRouter || this.conversationRouter.templateAgent !== templateAgent) return []; const forkNames = [...this.conversationAgentHomes.keys()]; for (const forkName of forkNames) { - this.terminatedConversationAgents.add(forkName); + this.terminatedDependentAgents.add(forkName); } return forkNames; } @@ -2913,6 +2915,57 @@ export class AgentFramework { } } + /** + * The subconscious (issue #77) is a same-model side-process serving exactly + * one resident: built from the primary's inference config, reading the + * primary's shared message slot. It is on the fork side of the retirement + * doctrine — a dependent continuation of the template identity — so it + * terminates with the primary. Tombstone first (before any provider-owned + * cancel can re-enter), like the forks. + */ + private tombstoneSubconsciousForPrimary(agentName: string): string | null { + const name = this.subconsciousAgentName; + if (!name || agentName !== this.primaryAgentName) return null; + this.terminatedDependentAgents.add(name); + return name; + } + + private terminateSubconsciousForPrimary(primaryName: string, name: string): void { + const cleanupErrors: unknown[] = []; + const attempt = (operation: () => void): void => { + try { + operation(); + } catch (error) { + cleanupErrors.push(error); + } + }; + attempt(() => this.stopResidentAuthoredActivity( + name, + `template resident ${primaryName} retired`, + 'template_retired', + )); + // Dispose like a fork: unregistered, its Chronicle namespace + // (`subconscious/`) left intact. With the name cleared, the + // tune-out coordinator has nothing to push to and says so. + const agent = this.agents.get(name); + this.agents.delete(name); + this.agentConfigs.delete(name); + this.toolImageLedgers.delete(name); + this.evictTurnCheckpoints(name); + if (agent) this.logicalTurnToolCalls.delete(agent); + this.subconsciousAgentName = null; + this.subconsciousStrategy = null; + this.subconsciousConfig = null; + console.error(`[subconscious-retired] agent=${name} template=${primaryName}`); + if (cleanupErrors.length === 1) throw cleanupErrors[0]; + if (cleanupErrors.length > 1) { + throw new AggregateError( + cleanupErrors, + `Multiple subconscious retirement operations failed for ${primaryName}`, + ); + } + } + /** * A seal append can fail after some or all bytes have become durable. Keep * that ambiguous identity terminal in this process before surfacing the @@ -2929,6 +2982,7 @@ export class AgentFramework { // the framework synchronously; every dependent identity must already be // terminal at that boundary. const forkNames = this.tombstoneConversationForksForTemplate(agentName); + const subconsciousName = this.tombstoneSubconsciousForPrimary(agentName); try { this.stopResidentAuthoredActivity(agentName); } catch (error) { @@ -2939,6 +2993,13 @@ export class AgentFramework { } catch (error) { cleanupErrors.push(error); } + if (subconsciousName) { + try { + this.terminateSubconsciousForPrimary(agentName, subconsciousName); + } catch (error) { + cleanupErrors.push(error); + } + } return cleanupErrors; } @@ -5195,10 +5256,10 @@ export class AgentFramework { quiesced?: boolean; } { const name = agentName ?? [...this.agents.keys()][0]; - if (name && this.terminatedConversationAgents.has(name)) { + if (name && this.terminatedDependentAgents.has(name)) { return { ok: false, - error: `Conversation fork "${name}" was terminated when its template resident retired.`, + error: `"${name}" was terminated when its template resident retired.`, }; } const agent = name ? this.agents.get(name) : undefined; @@ -6673,7 +6734,7 @@ export class AgentFramework { * passthrough, no memory pyramid: its durable output is what it delivers * into the resident's window, which accumulates there. */ - private async createSubconsciousAgent(cfg: SubconsciousConfig): Promise { + private async createSubconsciousAgent(cfg: SubconsciousConfig): Promise { const primaryName = this.primaryAgentName; const primaryConfig = primaryName ? this.agentConfigs.get(primaryName) : undefined; if (!primaryName || !primaryConfig) { @@ -6683,6 +6744,17 @@ export class AgentFramework { if (this.agents.has(name)) { throw new Error(`subconscious name "${name}" collides with a registered agent`); } + // Seals load before agents are created. A retired primary's side-process + // terminated with it; re-creating one at boot would be the resurrection + // path the seal exists to close. Not an error: the config is still valid + // for a host that keeps a retired resident's history around. + if (this.retiredResidents.has(primaryName)) { + this.terminatedDependentAgents.add(name); + console.error( + `[subconscious] not created: primary resident "${primaryName}" is retired`, + ); + return null; + } const strategy = new WindowedPassthroughStrategy({ reAnchorFraction: cfg.reAnchorFraction, diff --git a/test/subconscious-retirement.test.ts b/test/subconscious-retirement.test.ts index 367e9975..10c028a3 100644 --- a/test/subconscious-retirement.test.ts +++ b/test/subconscious-retirement.test.ts @@ -5,6 +5,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { AgentFramework, ApiModule } from '../src/index.js'; import { createMockResponse, MockMembrane } from './helpers/mock-membrane.js'; +import type { FrameworkConfig } from '../src/index.js'; /** * The subconscious terminates with its resident. @@ -15,49 +16,108 @@ import { createMockResponse, MockMembrane } from './helpers/mock-membrane.js'; * forks are "persistent, addressable continuations of one configured * template identity" and "terminate with that template" — the subconscious * is on the fork side: a same-model side-process serving one resident. - * retireResident seals the primary and terminates its forks; this pins that + * retireResident seals the primary and terminates its forks; these pin that * the side-process is sealed with them, so no inference runs in the retired * resident's name afterwards, from any wake path (the tune-out coordinator's * pushes in production; an operator nudge here, which reaches the same - * scheduler). + * scheduler) — in this process, and again after a restart against the same + * store. */ -async function waitFor(cond: () => boolean, ms = 1500): Promise { +async function waitFor(cond: () => boolean, ms = 300): Promise { const start = Date.now(); while (!cond()) { if (Date.now() - start > ms) return false; await new Promise((r) => setTimeout(r, 10)); } return true; } +function config( + tempDir: string, + membrane: MockMembrane, + agents: FrameworkConfig['agents'], +): FrameworkConfig { + return { + storePath: join(tempDir, 'test.chronicle'), + membrane: membrane.asMembrane(), + agents, + subconscious: { enabled: true, systemPrompt: 'You are the Subconscious. Report to the resident in second person.' }, + modules: [new ApiModule()], + gate: { config: { policies: [], default: 'always' } }, + syncIntervalMs: 0, + maintenanceIntervalMs: 0, + }; +} + +const resident = { name: 'resident', model: 'test-model', systemPrompt: 'test', retirement: { enabled: true } }; + describe('retirement seals the subconscious with the resident', () => { it('after retireResident, a wake of the subconscious starts no inference', async () => { const tempDir = mkdtempSync(join(tmpdir(), 'retire-sub-')); const membrane = new MockMembrane(); membrane.pushResponse(createMockResponse([{ type: 'text', text: 'Nothing to report.' }])); - const framework = await AgentFramework.create({ - storePath: join(tempDir, 'test.chronicle'), - membrane: membrane.asMembrane(), - agents: [{ name: 'resident', model: 'test-model', systemPrompt: 'test', retirement: { enabled: true } }], - subconscious: { enabled: true, systemPrompt: 'You are the Subconscious. Report to the resident in second person.' }, - modules: [new ApiModule()], - gate: { config: { policies: [], default: 'always' } }, - syncIntervalMs: 0, - maintenanceIntervalMs: 0, - }); + const framework = await AgentFramework.create(config(tempDir, membrane, [resident])); try { framework.start(); assert.ok(framework.getAgent('Subconscious'), 'the subconscious is registered beside the resident'); framework.retireResident('resident', 'seals the side-process too'); assert.equal(framework.getResidentLifecycleStatus('resident').status, 'retired'); + assert.equal(framework.getAgent('Subconscious'), null, 'the side-process is unregistered with its resident'); const before = membrane.calls.length; const nudge = framework.nudgeAgent('Subconscious', 'operator'); + assert.equal(nudge.ok, false); + assert.match(nudge.error ?? '', /terminated when its template resident retired/); const inferred = await waitFor(() => membrane.calls.length > before); - assert.ok(!nudge.ok || !inferred, - `the retired resident's subconscious still infers: nudge=${JSON.stringify(nudge)}, membrane calls ${before} → ${membrane.calls.length}`); + assert.ok(!inferred, `the retired resident's subconscious still infers: membrane calls ${before} → ${membrane.calls.length}`); assert.equal(membrane.calls.length, before, 'no inference ran in the retired resident\'s name'); } finally { await framework.stop(); rmSync(tempDir, { recursive: true, force: true }); } }); + + it('a restart against a sealed primary creates no subconscious', async () => { + const tempDir = mkdtempSync(join(tmpdir(), 'retire-sub-boot-')); + const first = new MockMembrane(); + let framework = await AgentFramework.create(config(tempDir, first, [resident])); + try { + framework.start(); + framework.retireResident('resident', 'sealed before the restart'); + await framework.stop(); + + const second = new MockMembrane(); + second.pushResponse(createMockResponse([{ type: 'text', text: 'Nothing to report.' }])); + framework = await AgentFramework.create(config(tempDir, second, [resident])); + assert.equal(framework.getResidentLifecycleStatus('resident').status, 'retired'); + assert.equal(framework.getAgent('Subconscious'), null, 'no side-process is re-created for a sealed primary'); + framework.start(); + + const nudge = framework.nudgeAgent('Subconscious', 'operator'); + assert.equal(nudge.ok, false); + assert.match(nudge.error ?? '', /terminated when its template resident retired/); + const inferred = await waitFor(() => second.calls.length > 0); + assert.ok(!inferred, `a subconscious inferred after the restart: ${second.calls.length} membrane call(s)`); + } finally { + await framework.stop(); + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it('retiring a resident the subconscious does not attend leaves it alive', async () => { + const tempDir = mkdtempSync(join(tmpdir(), 'retire-sub-other-')); + const membrane = new MockMembrane(); + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'Nothing to report.' }])); + const other = { name: 'other', model: 'test-model', systemPrompt: 'test', retirement: { enabled: true } }; + // The subconscious attends the primary: the first configured resident. + const framework = await AgentFramework.create(config(tempDir, membrane, [resident, other])); + try { + framework.start(); + framework.retireResident('other', 'not the primary'); + assert.ok(framework.getAgent('Subconscious'), 'the primary\'s side-process is untouched'); + assert.equal(framework.nudgeAgent('Subconscious', 'operator').ok, true); + assert.ok(await waitFor(() => membrane.calls.length > 0, 1500), 'the surviving subconscious still infers'); + } finally { + await framework.stop(); + rmSync(tempDir, { recursive: true, force: true }); + } + }); });