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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,43 @@ 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 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
`<storePath>/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`. Newly created directories in a custom seal path are
also fsynced through their existing ancestor. See
[Resident lifecycle](docs/resident-lifecycle.md).

## API Server

WebSocket server for external clients (UIs, scripts, other agents).
Expand Down
4 changes: 4 additions & 0 deletions changelog.d/resident-retirement.added.md
Original file line number Diff line number Diff line change
@@ -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.
132 changes: 132 additions & 0 deletions docs/resident-lifecycle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
# 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 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.

## Terminal enforcement

`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:

- 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;
- 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.

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`.

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
`<storePath>/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.
45 changes: 44 additions & 1 deletion src/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,11 +105,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;
Expand All @@ -126,6 +129,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;
Expand Down Expand Up @@ -475,6 +479,7 @@ export class Agent {
budget?: TokenBudget,
options?: InferenceOptions
): Promise<InferenceResult> {
this.assertInferenceAllowed();
if (this._state.status === 'inferring') {
throw new Error(`Agent ${this.name} is already inferring`);
}
Expand All @@ -488,6 +493,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') {
Expand Down Expand Up @@ -684,6 +692,7 @@ export class Agent {
injections?: ContextInjection[],
budget?: TokenBudget
): Promise<StartStreamResult> {
this.assertInferenceAllowed();
if (this._state.status !== 'idle') {
throw new Error(`Agent ${this.name} cannot start stream in state ${this._state.status}`);
}
Expand All @@ -701,6 +710,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 stream = this.membrane.streamYielding(request, {
emitTokens: true,
Expand Down Expand Up @@ -740,6 +752,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
Expand Down Expand Up @@ -811,6 +826,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.
*/
Expand Down Expand Up @@ -860,6 +889,20 @@ export class Agent {
): Promise<InferenceResult> {
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);
Expand Down
Loading