376 lines
9.8 KiB
PHP
376 lines
9.8 KiB
PHP
|
|
<?php
|
||
|
|
|
||
|
|
declare(strict_types=1);
|
||
|
|
|
||
|
|
namespace Core\Agentic\Services;
|
||
|
|
|
||
|
|
use Illuminate\Support\Collection;
|
||
|
|
use Illuminate\Support\Facades\Cache;
|
||
|
|
use Core\Agentic\Models\AgentPlan;
|
||
|
|
use Core\Agentic\Models\AgentSession;
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Agent Session Service - manages session persistence for agent continuity.
|
||
|
|
*
|
||
|
|
* Provides session creation, retrieval, and resumption capabilities
|
||
|
|
* for multi-agent handoff and long-running tasks.
|
||
|
|
*/
|
||
|
|
class AgentSessionService
|
||
|
|
{
|
||
|
|
/**
|
||
|
|
* Cache prefix for session state.
|
||
|
|
*/
|
||
|
|
protected const CACHE_PREFIX = 'mcp_session:';
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Get the cache TTL from config.
|
||
|
|
*/
|
||
|
|
protected function getCacheTtl(): int
|
||
|
|
{
|
||
|
|
return (int) config('mcp.session.cache_ttl', 86400);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Start a new session.
|
||
|
|
*/
|
||
|
|
public function start(
|
||
|
|
string $agentType,
|
||
|
|
?AgentPlan $plan = null,
|
||
|
|
?int $workspaceId = null,
|
||
|
|
array $initialContext = []
|
||
|
|
): AgentSession {
|
||
|
|
$session = AgentSession::start($plan, $agentType);
|
||
|
|
|
||
|
|
if ($workspaceId !== null) {
|
||
|
|
$session->update(['workspace_id' => $workspaceId]);
|
||
|
|
}
|
||
|
|
|
||
|
|
if (! empty($initialContext)) {
|
||
|
|
$session->updateContextSummary($initialContext);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Cache the active session ID for quick lookup
|
||
|
|
$this->cacheActiveSession($session);
|
||
|
|
|
||
|
|
return $session;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Get an active session by ID.
|
||
|
|
*/
|
||
|
|
public function get(string $sessionId): ?AgentSession
|
||
|
|
{
|
||
|
|
return AgentSession::where('session_id', $sessionId)->first();
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Resume an existing session.
|
||
|
|
*/
|
||
|
|
public function resume(string $sessionId): ?AgentSession
|
||
|
|
{
|
||
|
|
$session = $this->get($sessionId);
|
||
|
|
|
||
|
|
if (! $session) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Only resume if paused or was handed off
|
||
|
|
if ($session->status === AgentSession::STATUS_PAUSED) {
|
||
|
|
$session->resume();
|
||
|
|
}
|
||
|
|
|
||
|
|
// Update activity timestamp
|
||
|
|
$session->touchActivity();
|
||
|
|
|
||
|
|
// Cache as active
|
||
|
|
$this->cacheActiveSession($session);
|
||
|
|
|
||
|
|
return $session;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Get active sessions for a workspace.
|
||
|
|
*/
|
||
|
|
public function getActiveSessions(?int $workspaceId = null): Collection
|
||
|
|
{
|
||
|
|
$query = AgentSession::active();
|
||
|
|
|
||
|
|
if ($workspaceId !== null) {
|
||
|
|
$query->where('workspace_id', $workspaceId);
|
||
|
|
}
|
||
|
|
|
||
|
|
return $query->orderBy('last_active_at', 'desc')->get();
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Get sessions for a specific plan.
|
||
|
|
*/
|
||
|
|
public function getSessionsForPlan(AgentPlan $plan): Collection
|
||
|
|
{
|
||
|
|
return AgentSession::forPlan($plan)
|
||
|
|
->orderBy('created_at', 'desc')
|
||
|
|
->get();
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Get the most recent session for a plan.
|
||
|
|
*/
|
||
|
|
public function getLatestSessionForPlan(AgentPlan $plan): ?AgentSession
|
||
|
|
{
|
||
|
|
return AgentSession::forPlan($plan)
|
||
|
|
->orderBy('created_at', 'desc')
|
||
|
|
->first();
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* End a session.
|
||
|
|
*/
|
||
|
|
public function end(string $sessionId, string $status = AgentSession::STATUS_COMPLETED, ?string $summary = null): ?AgentSession
|
||
|
|
{
|
||
|
|
$session = $this->get($sessionId);
|
||
|
|
|
||
|
|
if (! $session) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
$session->end($status, $summary);
|
||
|
|
|
||
|
|
// Remove from active cache
|
||
|
|
$this->clearCachedSession($session);
|
||
|
|
|
||
|
|
return $session;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Pause a session for later resumption.
|
||
|
|
*/
|
||
|
|
public function pause(string $sessionId): ?AgentSession
|
||
|
|
{
|
||
|
|
$session = $this->get($sessionId);
|
||
|
|
|
||
|
|
if (! $session) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
$session->pause();
|
||
|
|
|
||
|
|
return $session;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Prepare a session for handoff to another agent.
|
||
|
|
*/
|
||
|
|
public function prepareHandoff(
|
||
|
|
string $sessionId,
|
||
|
|
string $summary,
|
||
|
|
array $nextSteps = [],
|
||
|
|
array $blockers = [],
|
||
|
|
array $contextForNext = []
|
||
|
|
): ?AgentSession {
|
||
|
|
$session = $this->get($sessionId);
|
||
|
|
|
||
|
|
if (! $session) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
$session->prepareHandoff($summary, $nextSteps, $blockers, $contextForNext);
|
||
|
|
|
||
|
|
return $session;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Get handoff context from a session.
|
||
|
|
*/
|
||
|
|
public function getHandoffContext(string $sessionId): ?array
|
||
|
|
{
|
||
|
|
$session = $this->get($sessionId);
|
||
|
|
|
||
|
|
if (! $session) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
return $session->getHandoffContext();
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Create a follow-up session continuing from a previous one.
|
||
|
|
*/
|
||
|
|
public function continueFrom(string $previousSessionId, string $newAgentType): ?AgentSession
|
||
|
|
{
|
||
|
|
$previousSession = $this->get($previousSessionId);
|
||
|
|
|
||
|
|
if (! $previousSession) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Get the handoff context
|
||
|
|
$handoffContext = $previousSession->getHandoffContext();
|
||
|
|
|
||
|
|
// Create new session with context from previous
|
||
|
|
$newSession = $this->start(
|
||
|
|
$newAgentType,
|
||
|
|
$previousSession->plan,
|
||
|
|
$previousSession->workspace_id,
|
||
|
|
[
|
||
|
|
'continued_from' => $previousSessionId,
|
||
|
|
'previous_agent' => $previousSession->agent_type,
|
||
|
|
'handoff_notes' => $handoffContext['handoff_notes'] ?? null,
|
||
|
|
'inherited_context' => $handoffContext['context_summary'] ?? null,
|
||
|
|
]
|
||
|
|
);
|
||
|
|
|
||
|
|
// Mark previous session as handed off
|
||
|
|
$previousSession->end('handed_off', 'Handed off to '.$newAgentType);
|
||
|
|
|
||
|
|
return $newSession;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Store custom state in session cache for fast access.
|
||
|
|
*/
|
||
|
|
public function setState(string $sessionId, string $key, mixed $value, ?int $ttl = null): void
|
||
|
|
{
|
||
|
|
$cacheKey = self::CACHE_PREFIX.$sessionId.':'.$key;
|
||
|
|
Cache::put($cacheKey, $value, $ttl ?? $this->getCacheTtl());
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Get custom state from session cache.
|
||
|
|
*/
|
||
|
|
public function getState(string $sessionId, string $key, mixed $default = null): mixed
|
||
|
|
{
|
||
|
|
$cacheKey = self::CACHE_PREFIX.$sessionId.':'.$key;
|
||
|
|
|
||
|
|
return Cache::get($cacheKey, $default);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Check if a session exists and is valid.
|
||
|
|
*/
|
||
|
|
public function exists(string $sessionId): bool
|
||
|
|
{
|
||
|
|
return AgentSession::where('session_id', $sessionId)->exists();
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Check if a session is active.
|
||
|
|
*/
|
||
|
|
public function isActive(string $sessionId): bool
|
||
|
|
{
|
||
|
|
$session = $this->get($sessionId);
|
||
|
|
|
||
|
|
return $session !== null && $session->isActive();
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Get session statistics.
|
||
|
|
*/
|
||
|
|
public function getSessionStats(?int $workspaceId = null, int $days = 7): array
|
||
|
|
{
|
||
|
|
$query = AgentSession::where('created_at', '>=', now()->subDays($days));
|
||
|
|
|
||
|
|
if ($workspaceId !== null) {
|
||
|
|
$query->where('workspace_id', $workspaceId);
|
||
|
|
}
|
||
|
|
|
||
|
|
$sessions = $query->get();
|
||
|
|
|
||
|
|
$byStatus = $sessions->groupBy('status')->map->count();
|
||
|
|
$byAgent = $sessions->groupBy('agent_type')->map->count();
|
||
|
|
|
||
|
|
$completedSessions = $sessions->where('status', AgentSession::STATUS_COMPLETED);
|
||
|
|
$avgDuration = $completedSessions->avg(fn ($s) => $s->getDuration() ?? 0);
|
||
|
|
|
||
|
|
return [
|
||
|
|
'total' => $sessions->count(),
|
||
|
|
'active' => $sessions->where('status', AgentSession::STATUS_ACTIVE)->count(),
|
||
|
|
'by_status' => $byStatus->toArray(),
|
||
|
|
'by_agent_type' => $byAgent->toArray(),
|
||
|
|
'avg_duration_minutes' => round($avgDuration, 1),
|
||
|
|
'period_days' => $days,
|
||
|
|
];
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Get replay context for a session.
|
||
|
|
*
|
||
|
|
* Returns the reconstructed state from the session's work log,
|
||
|
|
* useful for understanding what happened and resuming work.
|
||
|
|
*/
|
||
|
|
public function getReplayContext(string $sessionId): ?array
|
||
|
|
{
|
||
|
|
$session = $this->get($sessionId);
|
||
|
|
|
||
|
|
if (! $session) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
return $session->getReplayContext();
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Create a replay session from an existing session.
|
||
|
|
*
|
||
|
|
* This creates a new active session with the context from the original,
|
||
|
|
* allowing an agent to continue from where the original left off.
|
||
|
|
*/
|
||
|
|
public function replay(string $sessionId, ?string $agentType = null): ?AgentSession
|
||
|
|
{
|
||
|
|
$session = $this->get($sessionId);
|
||
|
|
|
||
|
|
if (! $session) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
$replaySession = $session->createReplaySession($agentType);
|
||
|
|
|
||
|
|
// Cache the new session as active
|
||
|
|
$this->cacheActiveSession($replaySession);
|
||
|
|
|
||
|
|
return $replaySession;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Clean up stale sessions (active but not touched in X hours).
|
||
|
|
*/
|
||
|
|
public function cleanupStaleSessions(int $hoursInactive = 24): int
|
||
|
|
{
|
||
|
|
$cutoff = now()->subHours($hoursInactive);
|
||
|
|
|
||
|
|
$staleSessions = AgentSession::active()
|
||
|
|
->where('last_active_at', '<', $cutoff)
|
||
|
|
->get();
|
||
|
|
|
||
|
|
foreach ($staleSessions as $session) {
|
||
|
|
$session->fail('Session timed out due to inactivity');
|
||
|
|
$this->clearCachedSession($session);
|
||
|
|
}
|
||
|
|
|
||
|
|
return $staleSessions->count();
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Cache the active session for quick lookup.
|
||
|
|
*/
|
||
|
|
protected function cacheActiveSession(AgentSession $session): void
|
||
|
|
{
|
||
|
|
$cacheKey = self::CACHE_PREFIX.'active:'.$session->session_id;
|
||
|
|
Cache::put($cacheKey, [
|
||
|
|
'session_id' => $session->session_id,
|
||
|
|
'agent_type' => $session->agent_type,
|
||
|
|
'plan_id' => $session->agent_plan_id,
|
||
|
|
'workspace_id' => $session->workspace_id,
|
||
|
|
'started_at' => $session->started_at?->toIso8601String(),
|
||
|
|
], $this->getCacheTtl());
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Clear cached session data.
|
||
|
|
*/
|
||
|
|
protected function clearCachedSession(AgentSession $session): void
|
||
|
|
{
|
||
|
|
$cacheKey = self::CACHE_PREFIX.'active:'.$session->session_id;
|
||
|
|
Cache::forget($cacheKey);
|
||
|
|
}
|
||
|
|
}
|