php-agentic/Models/AgentSession.php
2026-01-27 00:28:29 +00:00

553 lines
16 KiB
PHP

<?php
declare(strict_types=1);
namespace Core\Agentic\Models;
use Core\Mod\Tenant\Concerns\BelongsToWorkspace;
use Core\Mod\Tenant\Models\Workspace;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Core\Agentic\Database\Factories\AgentSessionFactory;
use Ramsey\Uuid\Uuid;
/**
* Agent Session - tracks agent work sessions for handoff.
*
* Enables context recovery and multi-agent collaboration
* by logging actions, artifacts, and handoff notes.
*
* @property int $id
* @property int $workspace_id
* @property int|null $agent_plan_id
* @property string $session_id
* @property string|null $agent_type
* @property string $status
* @property array|null $context_summary
* @property array|null $work_log
* @property array|null $artifacts
* @property array|null $handoff_notes
* @property string|null $final_summary
* @property \Carbon\Carbon $started_at
* @property \Carbon\Carbon $last_active_at
* @property \Carbon\Carbon|null $ended_at
* @property \Carbon\Carbon|null $created_at
* @property \Carbon\Carbon|null $updated_at
*/
class AgentSession extends Model
{
use BelongsToWorkspace;
/** @use HasFactory<AgentSessionFactory> */
use HasFactory;
protected static function newFactory(): AgentSessionFactory
{
return AgentSessionFactory::new();
}
protected $fillable = [
'workspace_id',
'agent_api_key_id',
'agent_plan_id',
'session_id',
'agent_type',
'status',
'context_summary',
'work_log',
'artifacts',
'handoff_notes',
'final_summary',
'started_at',
'last_active_at',
'ended_at',
];
protected $casts = [
'context_summary' => 'array',
'work_log' => 'array',
'artifacts' => 'array',
'handoff_notes' => 'array',
'started_at' => 'datetime',
'last_active_at' => 'datetime',
'ended_at' => 'datetime',
];
// Status constants
public const STATUS_ACTIVE = 'active';
public const STATUS_PAUSED = 'paused';
public const STATUS_COMPLETED = 'completed';
public const STATUS_FAILED = 'failed';
// Agent types
public const AGENT_OPUS = 'opus';
public const AGENT_SONNET = 'sonnet';
public const AGENT_HAIKU = 'haiku';
// Relationships
public function workspace(): BelongsTo
{
return $this->belongsTo(Workspace::class);
}
public function plan(): BelongsTo
{
return $this->belongsTo(AgentPlan::class, 'agent_plan_id');
}
public function apiKey(): BelongsTo
{
return $this->belongsTo(AgentApiKey::class, 'agent_api_key_id');
}
// Scopes
public function scopeActive($query)
{
return $query->where('status', self::STATUS_ACTIVE);
}
public function scopeForPlan($query, AgentPlan|int $plan)
{
$planId = $plan instanceof AgentPlan ? $plan->id : $plan;
return $query->where('agent_plan_id', $planId);
}
// Factory
public static function start(?AgentPlan $plan = null, ?string $agentType = null, ?Workspace $workspace = null): self
{
$workspaceId = $workspace?->id ?? $plan?->workspace_id;
return static::create([
'workspace_id' => $workspaceId,
'agent_plan_id' => $plan?->id,
'session_id' => 'sess_'.Uuid::uuid4()->toString(),
'agent_type' => $agentType,
'status' => self::STATUS_ACTIVE,
'work_log' => [],
'artifacts' => [],
'started_at' => now(),
'last_active_at' => now(),
]);
}
// Status helpers
public function isActive(): bool
{
return $this->status === self::STATUS_ACTIVE;
}
public function isPaused(): bool
{
return $this->status === self::STATUS_PAUSED;
}
public function isEnded(): bool
{
return in_array($this->status, [self::STATUS_COMPLETED, self::STATUS_FAILED]);
}
// Actions
public function touchActivity(): self
{
$this->update(['last_active_at' => now()]);
return $this;
}
public function pause(): self
{
$this->update(['status' => self::STATUS_PAUSED]);
return $this;
}
public function resume(): self
{
$this->update([
'status' => self::STATUS_ACTIVE,
'last_active_at' => now(),
]);
return $this;
}
public function complete(?string $summary = null): self
{
$this->update([
'status' => self::STATUS_COMPLETED,
'final_summary' => $summary,
'ended_at' => now(),
]);
return $this;
}
public function fail(?string $reason = null): self
{
$this->update([
'status' => self::STATUS_FAILED,
'final_summary' => $reason,
'ended_at' => now(),
]);
return $this;
}
// Work log
public function logAction(string $action, ?array $details = null): self
{
$log = $this->work_log ?? [];
$log[] = [
'action' => $action,
'details' => $details,
'timestamp' => now()->toIso8601String(),
];
$this->update([
'work_log' => $log,
'last_active_at' => now(),
]);
return $this;
}
/**
* Add a typed work log entry.
*/
public function addWorkLogEntry(string $message, string $type = 'info', array $data = []): self
{
$log = $this->work_log ?? [];
$log[] = [
'message' => $message,
'type' => $type, // info, warning, error, success, checkpoint
'data' => $data,
'timestamp' => now()->toIso8601String(),
];
$this->update([
'work_log' => $log,
'last_active_at' => now(),
]);
return $this;
}
/**
* End the session with a status.
*/
public function end(string $status, ?string $summary = null): self
{
$validStatuses = [self::STATUS_COMPLETED, self::STATUS_FAILED];
if (! in_array($status, $validStatuses)) {
$status = self::STATUS_COMPLETED;
}
$this->update([
'status' => $status,
'final_summary' => $summary,
'ended_at' => now(),
]);
return $this;
}
public function getRecentActions(int $limit = 10): array
{
$log = $this->work_log ?? [];
return array_slice(array_reverse($log), 0, $limit);
}
// Artifacts
public function addArtifact(string $path, string $action = 'modified', ?array $metadata = null): self
{
$artifacts = $this->artifacts ?? [];
$artifacts[] = [
'path' => $path,
'action' => $action, // created, modified, deleted
'metadata' => $metadata,
'timestamp' => now()->toIso8601String(),
];
$this->update(['artifacts' => $artifacts]);
return $this;
}
public function getArtifactsByAction(string $action): array
{
$artifacts = $this->artifacts ?? [];
return array_filter($artifacts, fn ($a) => ($a['action'] ?? '') === $action);
}
// Context summary
public function updateContextSummary(array $summary): self
{
$this->update(['context_summary' => $summary]);
return $this;
}
public function addToContext(string $key, mixed $value): self
{
$context = $this->context_summary ?? [];
$context[$key] = $value;
$this->update(['context_summary' => $context]);
return $this;
}
// Handoff
public function prepareHandoff(
string $summary,
array $nextSteps = [],
array $blockers = [],
array $contextForNext = []
): self {
$this->update([
'handoff_notes' => [
'summary' => $summary,
'next_steps' => $nextSteps,
'blockers' => $blockers,
'context_for_next' => $contextForNext,
],
'status' => self::STATUS_PAUSED,
]);
return $this;
}
public function getHandoffContext(): array
{
$context = [
'session_id' => $this->session_id,
'agent_type' => $this->agent_type,
'started_at' => $this->started_at?->toIso8601String(),
'last_active_at' => $this->last_active_at?->toIso8601String(),
'context_summary' => $this->context_summary,
'recent_actions' => $this->getRecentActions(20),
'artifacts' => $this->artifacts,
'handoff_notes' => $this->handoff_notes,
];
if ($this->plan) {
$context['plan'] = [
'slug' => $this->plan->slug,
'title' => $this->plan->title,
'current_phase' => $this->plan->current_phase,
'progress' => $this->plan->getProgress(),
];
}
return $context;
}
// Replay functionality
/**
* Get the replay context - reconstructs session state from work log.
*
* This provides the data needed to resume/replay a session by analysing
* the work log entries to understand what was done and what state the
* session was in.
*/
public function getReplayContext(): array
{
$workLog = $this->work_log ?? [];
$artifacts = $this->artifacts ?? [];
// Extract checkpoints from work log
$checkpoints = array_values(array_filter(
$workLog,
fn ($entry) => ($entry['type'] ?? '') === 'checkpoint'
));
// Get the last checkpoint if any
$lastCheckpoint = ! empty($checkpoints) ? end($checkpoints) : null;
// Extract decisions made during the session
$decisions = array_values(array_filter(
$workLog,
fn ($entry) => ($entry['type'] ?? '') === 'decision'
));
// Extract errors encountered
$errors = array_values(array_filter(
$workLog,
fn ($entry) => ($entry['type'] ?? '') === 'error'
));
// Build a progress summary from the work log
$progressSummary = $this->buildProgressSummary($workLog);
return [
'session_id' => $this->session_id,
'status' => $this->status,
'agent_type' => $this->agent_type,
'plan' => $this->plan ? [
'slug' => $this->plan->slug,
'title' => $this->plan->title,
'current_phase' => $this->plan->current_phase,
] : null,
'started_at' => $this->started_at?->toIso8601String(),
'last_active_at' => $this->last_active_at?->toIso8601String(),
'duration' => $this->getDurationFormatted(),
// Reconstructed state
'context_summary' => $this->context_summary,
'progress_summary' => $progressSummary,
'last_checkpoint' => $lastCheckpoint,
'checkpoints' => $checkpoints,
'decisions' => $decisions,
'errors' => $errors,
// Artifacts created during session
'artifacts' => $artifacts,
'artifacts_by_action' => [
'created' => $this->getArtifactsByAction('created'),
'modified' => $this->getArtifactsByAction('modified'),
'deleted' => $this->getArtifactsByAction('deleted'),
],
// Recent work for context
'recent_actions' => $this->getRecentActions(20),
'total_actions' => count($workLog),
// Handoff state if available
'handoff_notes' => $this->handoff_notes,
'final_summary' => $this->final_summary,
];
}
/**
* Build a progress summary from work log entries.
*/
protected function buildProgressSummary(array $workLog): array
{
if (empty($workLog)) {
return [
'completed_steps' => 0,
'last_action' => null,
'summary' => 'No work recorded',
];
}
$lastEntry = end($workLog);
$checkpointCount = count(array_filter($workLog, fn ($e) => ($e['type'] ?? '') === 'checkpoint'));
$errorCount = count(array_filter($workLog, fn ($e) => ($e['type'] ?? '') === 'error'));
return [
'completed_steps' => count($workLog),
'checkpoint_count' => $checkpointCount,
'error_count' => $errorCount,
'last_action' => $lastEntry['action'] ?? $lastEntry['message'] ?? 'Unknown',
'last_action_at' => $lastEntry['timestamp'] ?? null,
'summary' => sprintf(
'%d actions recorded, %d checkpoints, %d errors',
count($workLog),
$checkpointCount,
$errorCount
),
];
}
/**
* Create a new session that continues from this one (replay).
*
* This creates a fresh session with the context from this session,
* allowing an agent to pick up where this session left off.
*/
public function createReplaySession(?string $agentType = null): self
{
$replayContext = $this->getReplayContext();
$newSession = static::create([
'workspace_id' => $this->workspace_id,
'agent_plan_id' => $this->agent_plan_id,
'session_id' => 'ses_replay_'.now()->format('Ymd_His').'_'.substr(md5((string) $this->id), 0, 8),
'agent_type' => $agentType ?? $this->agent_type,
'status' => self::STATUS_ACTIVE,
'started_at' => now(),
'last_active_at' => now(),
'context_summary' => [
'replayed_from' => $this->session_id,
'original_started_at' => $this->started_at?->toIso8601String(),
'original_status' => $this->status,
'inherited_context' => $this->context_summary,
'replay_checkpoint' => $replayContext['last_checkpoint'],
'original_progress' => $replayContext['progress_summary'],
],
'work_log' => [
[
'message' => sprintf('Replayed from session %s', $this->session_id),
'type' => 'info',
'data' => [
'original_session' => $this->session_id,
'original_actions' => $replayContext['total_actions'],
'original_checkpoints' => count($replayContext['checkpoints']),
],
'timestamp' => now()->toIso8601String(),
],
],
'artifacts' => [],
'handoff_notes' => $this->handoff_notes,
]);
return $newSession;
}
// Duration helpers
public function getDuration(): ?int
{
if (! $this->started_at) {
return null;
}
$end = $this->ended_at ?? now();
return (int) $this->started_at->diffInMinutes($end);
}
public function getDurationFormatted(): string
{
$minutes = $this->getDuration();
if ($minutes === null) {
return 'Unknown';
}
if ($minutes < 60) {
return "{$minutes}m";
}
$hours = floor($minutes / 60);
$mins = $minutes % 60;
return "{$hours}h {$mins}m";
}
// Output
public function toMcpContext(): array
{
return [
'session_id' => $this->session_id,
'agent_type' => $this->agent_type,
'status' => $this->status,
'workspace_id' => $this->workspace_id,
'plan_slug' => $this->plan?->slug,
'started_at' => $this->started_at?->toIso8601String(),
'last_active_at' => $this->last_active_at?->toIso8601String(),
'ended_at' => $this->ended_at?->toIso8601String(),
'duration' => $this->getDurationFormatted(),
'action_count' => count($this->work_log ?? []),
'artifact_count' => count($this->artifacts ?? []),
'context_summary' => $this->context_summary,
'handoff_notes' => $this->handoff_notes,
];
}
}