> ## Documentation Index
> Fetch the complete documentation index at: https://docs.instructorphp.com/llms.txt
> Use this file to discover all available pages before exploring further.

# 16 session runtime

## Introduction

Agents are stateless by default -- an `AgentLoop` takes an `AgentState`, runs to completion, and returns the updated state. There is no built-in persistence between requests. The `SessionRuntime` layer adds that persistence, turning an agent into a long-lived conversation that survives across HTTP requests, CLI invocations, or background jobs.

A session wraps an `AgentDefinition` (what the agent is) and an `AgentState` (what the agent has done) together with lifecycle metadata like status, version, and timestamps. The runtime manages loading, executing actions, and saving sessions through a transactional pipeline with optimistic locking and event emission.

This is the foundation for building multi-turn chat applications, resumable workflows, and any scenario where agent state must persist beyond a single process.

## Core Types

The session system is built around a small set of types, each with a focused responsibility:

| Type                | Purpose                                                                                                   |
| ------------------- | --------------------------------------------------------------------------------------------------------- |
| `AgentSession`      | Combines session info, agent definition, and agent state into one persistent unit                         |
| `AgentSessionInfo`  | Header data: session ID, agent name, status, version, timestamps, parent session ID                       |
| `SessionId`         | Value object wrapping a UUID string. Use `SessionId::generate()` to create new IDs.                       |
| `SessionStatus`     | Enum: `Active`, `Suspended`, `Completed`, `Failed`, `Deleted`                                             |
| `SessionRepository` | Thin wrapper over a `CanStoreSessions` implementation                                                     |
| `SessionRuntime`    | Preferred create/read/write boundary: creates sessions, executes actions, applies hooks, and emits events |
| `SessionFactory`    | Lower-level helper that builds fresh `AgentSession` instances from an `AgentDefinition`                   |

## The Runtime Contract

The `CanManageAgentSessions` interface defines the public API that `SessionRuntime` implements:

```php theme={null}
interface CanManageAgentSessions
{
    public function create(AgentDefinition $definition, ?AgentState $seed = null): AgentSession;
    public function listSessions(): SessionInfoList;
    public function getSessionInfo(SessionId $sessionId): AgentSessionInfo;
    public function getSession(SessionId $sessionId): AgentSession;
    public function execute(SessionId $sessionId, CanExecuteSessionAction $action): AgentSession;
}
// @doctest id="dfd0"
```

Use `create()` for brand-new root sessions. The read methods (`listSessions`, `getSessionInfo`, `getSession`) load data but do not persist any changes. The `execute()` method updates an existing persisted session by loading it, running an action, saving the result, and returning the updated session.

## Quick Start

The following example creates a session, sends a message, and retrieves the result:

```php theme={null}
use Cognesy\Agents\Capability\AgentCapabilityRegistry;
use Cognesy\Agents\Capability\Bash\UseBash;
use Cognesy\Agents\Session\Actions\SendMessage;
use Cognesy\Agents\Session\SessionRepository;
use Cognesy\Agents\Session\SessionRuntime;
use Cognesy\Agents\Session\Store\InMemorySessionStore;
use Cognesy\Agents\Template\Data\AgentDefinition;
use Cognesy\Agents\Template\Factory\DefinitionLoopFactory;
use Cognesy\Events\Dispatchers\EventDispatcher;

// 1. Define the agent
$definition = new AgentDefinition(
    name: 'assistant',
    description: 'A helpful general assistant',
    systemPrompt: 'You are a helpful assistant. Be concise and accurate.',
);

// 2. Set up the infrastructure
$repo = new SessionRepository(new InMemorySessionStore());
$events = new EventDispatcher('session-runtime');
$runtime = new SessionRuntime($repo, $events);

// 3. Create a session
$session = $runtime->create($definition);

// 4. Set up the loop factory
$capabilities = new AgentCapabilityRegistry();
$capabilities->register('use_bash', new UseBash());
$loopFactory = new DefinitionLoopFactory($capabilities, events: $events);

// 5. Send a message
$updated = $runtime->execute(
    $session->sessionId(),
    new SendMessage('What is 2 + 2?', $loopFactory),
);

// 6. The session now contains the agent's response
$state = $updated->state();
// @doctest id="f823"
```

## The Create and Execute Pipelines

### The Create Pipeline

When you call `$runtime->create($definition, $seed)`, the following pipeline runs:

1. **Instantiate session** -- A fresh `AgentSession` is created from the `AgentDefinition` and optional seed state.
2. **BeforeCreate hook** -- The session controller's `onStage(BeforeCreate, ...)` is called. Use this for create-only logic such as setting defaults or validating creation-time policy.
3. **BeforeSave hook** -- The session controller's `onStage(BeforeSave, ...)` is called. This fires on both create and execute, so use it for logic that should run before every persist.
4. **Create** -- The session is persisted through the repository. Stores require a fresh session with version `0` and persist it as version `1`.
5. **AfterSave hook** -- Post-persistence processing runs on the persisted session returned by the store. Fires on both create and execute.
6. **AfterCreate hook** -- The session controller's `onStage(AfterCreate, ...)` is called. Use this for create-only post-persist logic such as sending notifications.
7. **SessionSaved event** -- Emitted to confirm successful persistence.

If persistence fails, `SessionSaveFailed` is emitted and the original exception is rethrown.

Use this path for new root sessions. Reach for `SessionFactory` + repository `create()` only when you already have a concrete `AgentSession` instance to persist, such as a forked branch.

### The Execute Pipeline

When you call `$runtime->execute($sessionId, $action)`, the following pipeline runs:

1. **Load** -- The session is loaded from the repository. If not found, `SessionNotFoundException` is thrown.
2. **AfterLoad hook** -- The session controller's `onStage(AfterLoad, ...)` is called, allowing pre-processing.
3. **SessionLoaded event** -- Emitted for observability.
4. **Action execution** -- `$action->executeOn($session)` runs the action and returns the next session state.
5. **AfterAction hook** -- The session controller processes the post-action state.
6. **BeforeSave hook** -- Last chance to modify the session before persistence (e.g., auto-suspend).
7. **SessionActionExecuted event** -- Emitted with before/after status and version.
8. **Save** -- The session is saved with optimistic version checking.
9. **AfterSave hook** -- Post-persistence processing.
10. **SessionSaved event** -- Emitted to confirm successful persistence.

If loading fails, `SessionLoadFailed` is emitted. If saving fails (e.g., version conflict), `SessionSaveFailed` is emitted. In both cases, the original exception is rethrown after the event.

## Built-in Actions

Actions implement the `CanExecuteSessionAction` interface, which defines a single method:

```php theme={null}
interface CanExecuteSessionAction
{
    public function executeOn(AgentSession $session): AgentSession;
}
// @doctest id="bf59"
```

Each action receives the current session and returns a new session with the desired changes applied.

### SendMessage

The primary action for agent interaction. Appends a user message to the session's state, instantiates an `AgentLoop` from the session's definition, runs the loop to completion, and stores the resulting state.

```php theme={null}
use Cognesy\Agents\Session\Actions\SendMessage;

$runtime->execute($sessionId, new SendMessage(
    message: 'Explain how dependency injection works.',
    loopFactory: $loopFactory,
));
// @doctest id="6296"
```

The `message` parameter accepts a `string`, `\Stringable`, or `Message` object. `Stringable` values are cast to string at the boundary. The `loopFactory` must implement `CanInstantiateAgentLoop` -- typically a `DefinitionLoopFactory`.

### SuspendSession and ResumeSession

Pause and resume a session. Suspended sessions are preserved but not actively processing.

```php theme={null}
use Cognesy\Agents\Session\Actions\SuspendSession;
use Cognesy\Agents\Session\Actions\ResumeSession;

// Pause the session
$runtime->execute($sessionId, new SuspendSession());

// Resume it later
$runtime->execute($sessionId, new ResumeSession());
// @doctest id="5e15"
```

`SuspendSession` sets the status to `Suspended`. `ResumeSession` sets it back to `Active`.

### ClearSession

Resets the session's agent state while preserving the session identity and definition. The state is prepared for the next execution via `forNextExecution()`.

```php theme={null}
use Cognesy\Agents\Session\Actions\ClearSession;

$runtime->execute($sessionId, new ClearSession());
// @doctest id="f69d"
```

### ForkSession

Creates a new session that inherits the state and definition of the source session. The forked session gets a fresh `SessionId` and its parent is set to the source session's ID.

```php theme={null}
use Cognesy\Agents\Session\Actions\ForkSession;

// Fork returns a new session (not persisted automatically)
$source = $runtime->getSession($sessionId);
$forked = (new ForkSession())->executeOn($source);
$forked = $repo->create($forked);

// The forked session has a parent reference
echo $forked->info()->parentId(); // original session ID
// @doctest id="a7ec"
```

Note that `ForkSession` is typically used outside the runtime's `execute()` pipeline because it creates a new session rather than modifying the existing one. This is the main case where persisting via repository `create()` is still appropriate: you already have a fully constructed `AgentSession`, so you persist that branch directly instead of calling `SessionRuntime::create()`.

### ChangeSystemPrompt

Updates the system prompt in the session's agent state. Accepts `string|\Stringable` -- `Stringable` values are cast to string at the boundary.

```php theme={null}
use Cognesy\Agents\Session\Actions\ChangeSystemPrompt;

$runtime->execute($sessionId, new ChangeSystemPrompt(
    'You are concise and direct. Respond in bullet points.'
));
// @doctest id="9f19"
```

### ChangeModel

Swaps the LLM configuration for future executions within the session.

```php theme={null}
use Cognesy\Agents\Session\Actions\ChangeModel;
use Cognesy\Polyglot\Inference\Config\LLMConfig;

$runtime->execute($sessionId, new ChangeModel(
    LLMConfig::fromArray(['driver' => 'openai', 'model' => 'gpt-4o'])
));
// @doctest id="1a35"
```

### WriteMetadata

Stores a key-value pair in the session's metadata. Useful for tracking external references, workflow state, or custom tags.

```php theme={null}
use Cognesy\Agents\Session\Actions\WriteMetadata;

$runtime->execute($sessionId, new WriteMetadata('ticket_id', 'OPS-142'));
$runtime->execute($sessionId, new WriteMetadata('priority', 'high'));
// @doctest id="92be"
```

### UpdateTask

Updates the task description associated with the session.

```php theme={null}
use Cognesy\Agents\Session\Actions\UpdateTask;

$runtime->execute($sessionId, new UpdateTask('Refactor the authentication module'));
// @doctest id="d02a"
```

## Versioning and Optimistic Locking

Sessions use optimistic locking to prevent concurrent modifications from silently overwriting each other. Every session has a monotonically increasing version number.

### Version Lifecycle

* **Create** -- The session must have version `0`. It is persisted as version `1`.
* **Save** -- The incoming session's version must match the stored version. The persisted session is returned with version incremented by `1`.
* **Read** -- Loading a session returns it with the stored version, which must be used for the next write.

### Conflict Handling

If two processes load the same session (both see version `5`), the first to save succeeds and advances the version to `6`. The second process's save fails because it still has version `5`, which no longer matches the stored version `6`. This triggers a `SessionConflictException`.

```php theme={null}
use Cognesy\Agents\Session\Exceptions\SessionConflictException;

try {
    $runtime->execute($sessionId, $action);
} catch (SessionConflictException $e) {
    // Reload and retry, or inform the user
    $fresh = $runtime->getSession($sessionId);
}
// @doctest id="9255"
```

### Exception Types

| Exception                     | Condition                                                                 |
| ----------------------------- | ------------------------------------------------------------------------- |
| `SessionNotFoundException`    | The session ID does not exist in the store                                |
| `SessionConflictException`    | Version mismatch during save, or attempting to create an existing session |
| `InvalidSessionFileException` | File-based store encountered a corrupt or unreadable file                 |

## Persistence Stores

The session system ships with two `CanStoreSessions` implementations.

### InMemorySessionStore

Stores sessions in a PHP array. Useful for testing, prototyping, and single-process applications.

```php theme={null}
use Cognesy\Agents\Session\Store\InMemorySessionStore;

$store = new InMemorySessionStore();
$repo = new SessionRepository($store);
// @doctest id="aa47"
```

Sessions are lost when the process ends. All version checks and conflict detection still work correctly.

### FileSessionStore

Stores each session as a JSON file on disk. Supports concurrent access through file locking (`flock`).

```php theme={null}
use Cognesy\Agents\Session\Store\FileSessionStore;

$store = new FileSessionStore('/var/data/sessions');
$repo = new SessionRepository($store);
// @doctest id="e446"
```

The store creates the directory if it does not exist. Each session is stored as `{session_id}.json` with atomic writes (write to `.tmp`, then rename). Lock files (`{session_id}.lock`) are used for mutual exclusion during create and save operations.

### Custom Stores

Implement the `CanStoreSessions` interface to integrate with any persistence backend:

```php theme={null}
use Cognesy\Agents\Session\Contracts\CanStoreSessions;
use Cognesy\Agents\Session\Collections\SessionInfoList;
use Cognesy\Agents\Session\Data\AgentSession;
use Cognesy\Agents\Session\Data\SessionId;

class RedisSessionStore implements CanStoreSessions
{
    public function create(AgentSession $session): AgentSession { /* ... */ }
    public function save(AgentSession $session): AgentSession { /* ... */ }
    public function load(SessionId $sessionId): ?AgentSession { /* ... */ }
    public function exists(SessionId $sessionId): bool { /* ... */ }
    public function delete(SessionId $sessionId): void { /* ... */ }
    public function listHeaders(): SessionInfoList { /* ... */ }
}
// @doctest id="9d01"
```

Your implementation must enforce the version semantics: `create()` requires version `0`, and `save()` must match the stored version. Use `AgentSession::reconstitute()` to set the next version and timestamp before persisting.

## Session Lifecycle vs Execution Lifecycle

The session system has two distinct lifecycle models that operate independently.

### Session Lifecycle

The session lifecycle tracks the overall status of the agent conversation across multiple requests. Status transitions are explicit -- they only happen when an action explicitly changes the status.

```
Active -> Suspended -> Active -> Completed
                              -> Failed
                              -> Deleted
// @doctest id="9686"
```

The `AgentSession::withState()` method updates the agent state without changing the session status. This is intentional: the session status represents a cross-run concern (is this conversation still active?), while the execution status represents a per-run concern (did this particular run succeed?).

### Execution Lifecycle

Each call to `SendMessage` creates a new execution within the session. The `AgentState` tracks execution status (`Pending`, `InProgress`, `Completed`, `Stopped`, `Failed`) independently of the session status. Between executions, the state is reset via `forNextExecution()`.

A session can be `Active` while its last execution was `Failed` -- the session is still open for new messages, even though the most recent run encountered an error.

## Session Controllers

Session controllers intercept the runtime pipeline at four stages, allowing you to modify the session at each point. This is how you implement cross-cutting session concerns like auto-suspend, validation, or audit logging.

### The CanControlAgentSession Interface

```php theme={null}
interface CanControlAgentSession
{
    public function onStage(AgentSessionStage $stage, AgentSession $session): AgentSession;
}
// @doctest id="1189"
```

The `AgentSessionStage` enum defines the four interception points:

| Stage         | When                           | Typical Use                     |
| ------------- | ------------------------------ | ------------------------------- |
| `AfterLoad`   | After loading from the store   | Validation, enrichment          |
| `AfterAction` | After the action has executed  | Post-processing, derived state  |
| `BeforeSave`  | Before persisting to the store | Auto-suspend, status derivation |
| `AfterSave`   | After successful persistence   | Notifications, audit logging    |

### Using SessionHookStack

The `SessionHookStack` composes multiple controllers into a priority-ordered pipeline:

```php theme={null}
use Cognesy\Agents\Session\Contracts\CanControlAgentSession;
use Cognesy\Agents\Session\Data\AgentSession;
use Cognesy\Agents\Session\Enums\AgentSessionStage;
use Cognesy\Agents\Session\SessionHookStack;
use Cognesy\Agents\Session\SessionRuntime;

// Auto-suspend after every action
$autoSuspend = new class implements CanControlAgentSession {
    public function onStage(AgentSessionStage $stage, AgentSession $session): AgentSession {
        return match ($stage) {
            AgentSessionStage::BeforeSave => $session->suspended(),
            default => $session,
        };
    }
};

$hooks = SessionHookStack::empty()->with($autoSuspend, priority: 100);
$runtime = new SessionRuntime($repo, $events, $hooks);
// @doctest id="8b77"
```

Higher priority hooks run first. The `SessionHookStack` itself implements `CanControlAgentSession`, so you can also pass a single controller directly to the runtime constructor.

If no controller is provided, the runtime uses `PassThroughSessionController`, which returns the session unchanged at every stage.

## Events

The `SessionRuntime` emits events at key points in the pipeline. All events are dispatched through the `CanHandleEvents` instance passed to the runtime constructor.

| Event                   | When                                    | Key Data                                                          |
| ----------------------- | --------------------------------------- | ----------------------------------------------------------------- |
| `SessionLoaded`         | After successfully loading a session    | `sessionId`, `version`, `status`                                  |
| `SessionActionExecuted` | After an action completes (before save) | `sessionId`, `action` class name, before/after version and status |
| `SessionSaved`          | After successful persistence            | `sessionId`, `version`, `status`                                  |
| `SessionLoadFailed`     | When loading throws an exception        | `sessionId`, `error`, `errorType`                                 |
| `SessionSaveFailed`     | When saving throws an exception         | `sessionId`, `error`, `errorType`                                 |

You can listen for these events to build dashboards, audit logs, or monitoring alerts:

```php theme={null}
use Cognesy\Agents\Session\Events\SessionActionExecuted;
use Cognesy\Agents\Session\Events\SessionSaveFailed;

$events->addListener(SessionActionExecuted::class, function (SessionActionExecuted $e) {
    logger()->info("Session {$e->sessionId}: {$e->action} executed, "
        . "version {$e->beforeVersion} -> {$e->afterVersion}");
});

$events->addListener(SessionSaveFailed::class, function (SessionSaveFailed $e) {
    logger()->error("Session {$e->sessionId}: save failed - {$e->error}");
});
// @doctest id="2f75"
```

## Writing Custom Actions

To create a custom action, implement the `CanExecuteSessionAction` interface:

```php theme={null}
use Cognesy\Agents\Session\Contracts\CanExecuteSessionAction;
use Cognesy\Agents\Session\Data\AgentSession;

final readonly class ArchiveSession implements CanExecuteSessionAction
{
    public function __construct(
        private string $archiveReason,
    ) {}

    public function executeOn(AgentSession $session): AgentSession
    {
        // Store the reason in metadata, then mark as completed
        $state = $session->state()->withMetadata('archive_reason', $this->archiveReason);

        return $session->withState($state)->completed();
    }
}

// Usage
$runtime->execute($sessionId, new ArchiveSession('Ticket resolved'));
// @doctest id="a830"
```

Actions should be pure transformations on the session. Side effects (external API calls, notifications) are better handled through session controllers or event listeners.

## Related

* [AgentBuilder & Capabilities](13-agent-builder)
* [Agent Templates](14-agent-templates)
* [Subagents](15-subagents)
