554 lines
16 KiB
PHP
554 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,
|
||
|
|
];
|
||
|
|
}
|
||
|
|
}
|