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 |
The main orchestrator: load, execute, save with hooks and events |
SessionFactory |
Creates fresh AgentSession instances from an AgentDefinition |
The Runtime Contract¶
The CanManageAgentSessions interface defines the public API that SessionRuntime implements:
interface CanManageAgentSessions
{
public function listSessions(): SessionInfoList;
public function getSessionInfo(SessionId $sessionId): AgentSessionInfo;
public function getSession(SessionId $sessionId): AgentSession;
public function execute(SessionId $sessionId, CanExecuteSessionAction $action): AgentSession;
}
The read methods (listSessions, getSessionInfo, getSession) load data but do not persist any changes. The execute() method is the write path -- it loads the session, runs an action, saves the result, and returns the updated session.
Quick Start¶
The following example creates a session, sends a message, and retrieves the result:
use Cognesy\Agents\Capability\AgentCapabilityRegistry;
use Cognesy\Agents\Capability\Bash\UseBash;
use Cognesy\Agents\Session\Actions\SendMessage;
use Cognesy\Agents\Session\SessionFactory;
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\Agents\Template\Factory\DefinitionStateFactory;
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
$stateFactory = new DefinitionStateFactory();
$sessionFactory = new SessionFactory($stateFactory);
$repo = new SessionRepository(new InMemorySessionStore());
$events = new EventDispatcher('session-runtime');
$runtime = new SessionRuntime($repo, $events);
// 3. Create a session
$session = $repo->create($sessionFactory->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();
The Execute Pipeline¶
When you call $runtime->execute($sessionId, $action), the following pipeline runs:
- Load -- The session is loaded from the repository. If not found,
SessionNotFoundExceptionis thrown. - AfterLoad hook -- The session controller's
onStage(AfterLoad, ...)is called, allowing pre-processing. - SessionLoaded event -- Emitted for observability.
- Action execution --
$action->executeOn($session)runs the action and returns the next session state. - AfterAction hook -- The session controller processes the post-action state.
- BeforeSave hook -- Last chance to modify the session before persistence (e.g., auto-suspend).
- SessionActionExecuted event -- Emitted with before/after status and version.
- Save -- The session is saved with optimistic version checking.
- AfterSave hook -- Post-persistence processing.
- 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:
interface CanExecuteSessionAction
{
public function executeOn(AgentSession $session): AgentSession;
}
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.
use Cognesy\Agents\Session\Actions\SendMessage;
$runtime->execute($sessionId, new SendMessage(
message: 'Explain how dependency injection works.',
loopFactory: $loopFactory,
));
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.
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());
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().
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.
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
Note that ForkSession is typically used outside the runtime's execute() pipeline because it creates a new session rather than modifying the existing one.
ChangeSystemPrompt¶
Updates the system prompt in the session's agent state. Accepts string|\Stringable -- Stringable values are cast to string at the boundary.
use Cognesy\Agents\Session\Actions\ChangeSystemPrompt;
$runtime->execute($sessionId, new ChangeSystemPrompt(
'You are concise and direct. Respond in bullet points.'
));
ChangeModel¶
Swaps the LLM configuration for future executions within the session.
use Cognesy\Agents\Session\Actions\ChangeModel;
use Cognesy\Polyglot\Inference\Config\LLMConfig;
$runtime->execute($sessionId, new ChangeModel(
LLMConfig::fromArray(['driver' => 'openai', 'model' => 'gpt-4o'])
));
WriteMetadata¶
Stores a key-value pair in the session's metadata. Useful for tracking external references, workflow state, or custom tags.
use Cognesy\Agents\Session\Actions\WriteMetadata;
$runtime->execute($sessionId, new WriteMetadata('ticket_id', 'OPS-142'));
$runtime->execute($sessionId, new WriteMetadata('priority', 'high'));
UpdateTask¶
Updates the task description associated with the session.
use Cognesy\Agents\Session\Actions\UpdateTask;
$runtime->execute($sessionId, new UpdateTask('Refactor the authentication module'));
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 version1. - 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.
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);
}
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.
use Cognesy\Agents\Session\Store\InMemorySessionStore;
$store = new InMemorySessionStore();
$repo = new SessionRepository($store);
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).
use Cognesy\Agents\Session\Store\FileSessionStore;
$store = new FileSessionStore('/var/data/sessions');
$repo = new SessionRepository($store);
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:
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 { /* ... */ }
}
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.
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¶
interface CanControlAgentSession
{
public function onStage(AgentSessionStage $stage, AgentSession $session): AgentSession;
}
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:
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);
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:
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}");
});
Writing Custom Actions¶
To create a custom action, implement the CanExecuteSessionAction interface:
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'));
Actions should be pure transformations on the session. Side effects (external API calls, notifications) are better handled through session controllers or event listeners.