feat: add plan/session/phase/task Actions + slim MCP tools
Some checks failed
CI / PHP 8.3 (push) Failing after 2s
CI / PHP 8.4 (push) Failing after 2s

Extract business logic from MCP tool handlers into 15 Action classes
(Plan 5, Session 5, Phase 3, Task 2) following the Brain pattern.
MCP tools become thin wrappers calling Action::run(). Add framework-level
REST controllers and routes as sensible defaults for consumers.

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Snider 2026-03-04 13:58:45 +00:00
parent 8b8a9c26e5
commit 6f0618692a
35 changed files with 1814 additions and 551 deletions

View file

@ -6,7 +6,6 @@ namespace Core\Mod\Agentic\Actions\Brain;
use Core\Actions\Action;
use Core\Mod\Agentic\Models\BrainMemory;
use Illuminate\Support\Collection;
/**
* List memories in the shared OpenBrain knowledge store.

View file

@ -0,0 +1,69 @@
<?php
/*
* Core PHP Framework
*
* Licensed under the European Union Public Licence (EUPL) v1.2.
* See LICENSE file for details.
*/
declare(strict_types=1);
namespace Core\Mod\Agentic\Actions\Phase;
use Core\Actions\Action;
use Core\Mod\Agentic\Models\AgentPhase;
use Core\Mod\Agentic\Models\AgentPlan;
/**
* Add a checkpoint note to a phase.
*
* Checkpoints record milestones, decisions, and progress notes
* within a phase's metadata for later review.
*
* Usage:
* $phase = AddCheckpoint::run('deploy-v2', '1', 'Tests passing', 1);
*/
class AddCheckpoint
{
use Action;
/**
* @throws \InvalidArgumentException
*/
public function handle(string $planSlug, string|int $phase, string $note, int $workspaceId, array $context = []): AgentPhase
{
if ($note === '') {
throw new \InvalidArgumentException('note is required');
}
$plan = AgentPlan::forWorkspace($workspaceId)
->where('slug', $planSlug)
->first();
if (! $plan) {
throw new \InvalidArgumentException("Plan not found: {$planSlug}");
}
$resolved = $this->resolvePhase($plan, $phase);
if (! $resolved) {
throw new \InvalidArgumentException("Phase not found: {$phase}");
}
$resolved->addCheckpoint($note, $context);
return $resolved->fresh();
}
private function resolvePhase(AgentPlan $plan, string|int $identifier): ?AgentPhase
{
if (is_numeric($identifier)) {
return $plan->agentPhases()->where('order', (int) $identifier)->first();
}
return $plan->agentPhases()
->where('name', $identifier)
->first();
}
}

View file

@ -0,0 +1,66 @@
<?php
/*
* Core PHP Framework
*
* Licensed under the European Union Public Licence (EUPL) v1.2.
* See LICENSE file for details.
*/
declare(strict_types=1);
namespace Core\Mod\Agentic\Actions\Phase;
use Core\Actions\Action;
use Core\Mod\Agentic\Models\AgentPhase;
use Core\Mod\Agentic\Models\AgentPlan;
/**
* Get details of a specific phase within a plan.
*
* Resolves the phase by order number or name.
*
* Usage:
* $phase = GetPhase::run('deploy-v2', '1', 1);
* $phase = GetPhase::run('deploy-v2', 'Build', 1);
*/
class GetPhase
{
use Action;
/**
* @throws \InvalidArgumentException
*/
public function handle(string $planSlug, string|int $phase, int $workspaceId): AgentPhase
{
$plan = AgentPlan::forWorkspace($workspaceId)
->where('slug', $planSlug)
->first();
if (! $plan) {
throw new \InvalidArgumentException("Plan not found: {$planSlug}");
}
$resolved = $this->resolvePhase($plan, $phase);
if (! $resolved) {
throw new \InvalidArgumentException("Phase not found: {$phase}");
}
return $resolved;
}
private function resolvePhase(AgentPlan $plan, string|int $identifier): ?AgentPhase
{
if (is_numeric($identifier)) {
return $plan->agentPhases()->where('order', (int) $identifier)->first();
}
return $plan->agentPhases()
->where(function ($query) use ($identifier) {
$query->where('name', $identifier)
->orWhere('order', $identifier);
})
->first();
}
}

View file

@ -0,0 +1,79 @@
<?php
/*
* Core PHP Framework
*
* Licensed under the European Union Public Licence (EUPL) v1.2.
* See LICENSE file for details.
*/
declare(strict_types=1);
namespace Core\Mod\Agentic\Actions\Phase;
use Core\Actions\Action;
use Core\Mod\Agentic\Models\AgentPhase;
use Core\Mod\Agentic\Models\AgentPlan;
/**
* Update the status of a phase within a plan.
*
* Optionally adds a checkpoint note when the status changes.
*
* Usage:
* $phase = UpdatePhaseStatus::run('deploy-v2', '1', 'in_progress', 1);
* $phase = UpdatePhaseStatus::run('deploy-v2', 'Build', 'completed', 1, 'All tests pass');
*/
class UpdatePhaseStatus
{
use Action;
/**
* @throws \InvalidArgumentException
*/
public function handle(string $planSlug, string|int $phase, string $status, int $workspaceId, ?string $notes = null): AgentPhase
{
$valid = ['pending', 'in_progress', 'completed', 'blocked', 'skipped'];
if (! in_array($status, $valid, true)) {
throw new \InvalidArgumentException(
sprintf('status must be one of: %s', implode(', ', $valid))
);
}
$plan = AgentPlan::forWorkspace($workspaceId)
->where('slug', $planSlug)
->first();
if (! $plan) {
throw new \InvalidArgumentException("Plan not found: {$planSlug}");
}
$resolved = $this->resolvePhase($plan, $phase);
if (! $resolved) {
throw new \InvalidArgumentException("Phase not found: {$phase}");
}
if ($notes !== null && $notes !== '') {
$resolved->addCheckpoint($notes, ['status_change' => $status]);
}
$resolved->update(['status' => $status]);
return $resolved->fresh();
}
private function resolvePhase(AgentPlan $plan, string|int $identifier): ?AgentPhase
{
if (is_numeric($identifier)) {
return $plan->agentPhases()->where('order', (int) $identifier)->first();
}
return $plan->agentPhases()
->where(function ($query) use ($identifier) {
$query->where('name', $identifier)
->orWhere('order', $identifier);
})
->first();
}
}

View file

@ -0,0 +1,51 @@
<?php
/*
* Core PHP Framework
*
* Licensed under the European Union Public Licence (EUPL) v1.2.
* See LICENSE file for details.
*/
declare(strict_types=1);
namespace Core\Mod\Agentic\Actions\Plan;
use Core\Actions\Action;
use Core\Mod\Agentic\Models\AgentPlan;
/**
* Archive a completed or abandoned plan.
*
* Sets the plan status to archived with an optional reason.
* Scoped to workspace for tenant isolation.
*
* Usage:
* $plan = ArchivePlan::run('deploy-v2', 1, 'Superseded by v3');
*/
class ArchivePlan
{
use Action;
/**
* @throws \InvalidArgumentException
*/
public function handle(string $slug, int $workspaceId, ?string $reason = null): AgentPlan
{
if ($slug === '') {
throw new \InvalidArgumentException('slug is required');
}
$plan = AgentPlan::forWorkspace($workspaceId)
->where('slug', $slug)
->first();
if (! $plan) {
throw new \InvalidArgumentException("Plan not found: {$slug}");
}
$plan->archive($reason);
return $plan->fresh();
}
}

View file

@ -0,0 +1,89 @@
<?php
/*
* Core PHP Framework
*
* Licensed under the European Union Public Licence (EUPL) v1.2.
* See LICENSE file for details.
*/
declare(strict_types=1);
namespace Core\Mod\Agentic\Actions\Plan;
use Core\Actions\Action;
use Core\Mod\Agentic\Models\AgentPhase;
use Core\Mod\Agentic\Models\AgentPlan;
use Illuminate\Support\Str;
/**
* Create a new work plan with phases and tasks.
*
* Validates input, generates a unique slug, creates the plan
* and any associated phases with their tasks.
*
* Usage:
* $plan = CreatePlan::run([
* 'title' => 'Deploy v2',
* 'phases' => [['name' => 'Build', 'tasks' => ['compile', 'test']]],
* ], 1);
*/
class CreatePlan
{
use Action;
/**
* @param array{title: string, slug?: string, description?: string, context?: array, phases?: array} $data
*
* @throws \InvalidArgumentException
*/
public function handle(array $data, int $workspaceId): AgentPlan
{
$title = $data['title'] ?? null;
if (! is_string($title) || $title === '' || mb_strlen($title) > 255) {
throw new \InvalidArgumentException('title is required and must be a non-empty string (max 255 characters)');
}
$slug = $data['slug'] ?? null;
if ($slug !== null) {
if (! is_string($slug) || mb_strlen($slug) > 255) {
throw new \InvalidArgumentException('slug must be a string (max 255 characters)');
}
} else {
$slug = Str::slug($title).'-'.Str::random(6);
}
if (AgentPlan::where('slug', $slug)->exists()) {
throw new \InvalidArgumentException("Plan with slug '{$slug}' already exists");
}
$plan = AgentPlan::create([
'slug' => $slug,
'title' => $title,
'description' => $data['description'] ?? null,
'status' => AgentPlan::STATUS_DRAFT,
'context' => $data['context'] ?? [],
'workspace_id' => $workspaceId,
]);
if (! empty($data['phases'])) {
foreach ($data['phases'] as $order => $phaseData) {
$tasks = collect($phaseData['tasks'] ?? [])->map(fn ($task) => [
'name' => $task,
'status' => 'pending',
])->all();
AgentPhase::create([
'agent_plan_id' => $plan->id,
'name' => $phaseData['name'] ?? 'Phase '.($order + 1),
'description' => $phaseData['description'] ?? null,
'order' => $order + 1,
'status' => AgentPhase::STATUS_PENDING,
'tasks' => $tasks,
]);
}
}
return $plan->load('agentPhases');
}
}

50
Actions/Plan/GetPlan.php Normal file
View file

@ -0,0 +1,50 @@
<?php
/*
* Core PHP Framework
*
* Licensed under the European Union Public Licence (EUPL) v1.2.
* See LICENSE file for details.
*/
declare(strict_types=1);
namespace Core\Mod\Agentic\Actions\Plan;
use Core\Actions\Action;
use Core\Mod\Agentic\Models\AgentPlan;
/**
* Get detailed information about a specific plan.
*
* Returns the plan with all phases, progress, and context data.
* Scoped to workspace for tenant isolation.
*
* Usage:
* $plan = GetPlan::run('deploy-v2', 1);
*/
class GetPlan
{
use Action;
/**
* @throws \InvalidArgumentException
*/
public function handle(string $slug, int $workspaceId): AgentPlan
{
if ($slug === '') {
throw new \InvalidArgumentException('slug is required');
}
$plan = AgentPlan::with('agentPhases')
->forWorkspace($workspaceId)
->where('slug', $slug)
->first();
if (! $plan) {
throw new \InvalidArgumentException("Plan not found: {$slug}");
}
return $plan;
}
}

View file

@ -0,0 +1,59 @@
<?php
/*
* Core PHP Framework
*
* Licensed under the European Union Public Licence (EUPL) v1.2.
* See LICENSE file for details.
*/
declare(strict_types=1);
namespace Core\Mod\Agentic\Actions\Plan;
use Core\Actions\Action;
use Core\Mod\Agentic\Models\AgentPlan;
use Illuminate\Support\Collection;
/**
* List work plans for a workspace with optional filtering.
*
* Returns plans ordered by most recently updated, with progress data.
*
* Usage:
* $plans = ListPlans::run(1);
* $plans = ListPlans::run(1, 'active');
*/
class ListPlans
{
use Action;
/**
* @return Collection<int, AgentPlan>
*/
public function handle(int $workspaceId, ?string $status = null, bool $includeArchived = false): Collection
{
if ($status !== null) {
$valid = ['draft', 'active', 'paused', 'completed', 'archived'];
if (! in_array($status, $valid, true)) {
throw new \InvalidArgumentException(
sprintf('status must be one of: %s', implode(', ', $valid))
);
}
}
$query = AgentPlan::with('agentPhases')
->forWorkspace($workspaceId)
->orderBy('updated_at', 'desc');
if (! $includeArchived && $status !== 'archived') {
$query->notArchived();
}
if ($status !== null) {
$query->where('status', $status);
}
return $query->get();
}
}

View file

@ -0,0 +1,54 @@
<?php
/*
* Core PHP Framework
*
* Licensed under the European Union Public Licence (EUPL) v1.2.
* See LICENSE file for details.
*/
declare(strict_types=1);
namespace Core\Mod\Agentic\Actions\Plan;
use Core\Actions\Action;
use Core\Mod\Agentic\Models\AgentPlan;
/**
* Update the status of a plan.
*
* Validates the transition and updates the plan status.
* Scoped to workspace for tenant isolation.
*
* Usage:
* $plan = UpdatePlanStatus::run('deploy-v2', 'active', 1);
*/
class UpdatePlanStatus
{
use Action;
/**
* @throws \InvalidArgumentException
*/
public function handle(string $slug, string $status, int $workspaceId): AgentPlan
{
$valid = ['draft', 'active', 'paused', 'completed'];
if (! in_array($status, $valid, true)) {
throw new \InvalidArgumentException(
sprintf('status must be one of: %s', implode(', ', $valid))
);
}
$plan = AgentPlan::forWorkspace($workspaceId)
->where('slug', $slug)
->first();
if (! $plan) {
throw new \InvalidArgumentException("Plan not found: {$slug}");
}
$plan->update(['status' => $status]);
return $plan->fresh();
}
}

View file

@ -0,0 +1,56 @@
<?php
/*
* Core PHP Framework
*
* Licensed under the European Union Public Licence (EUPL) v1.2.
* See LICENSE file for details.
*/
declare(strict_types=1);
namespace Core\Mod\Agentic\Actions\Session;
use Core\Actions\Action;
use Core\Mod\Agentic\Models\AgentSession;
use Core\Mod\Agentic\Services\AgentSessionService;
/**
* Continue from a previous session (multi-agent handoff).
*
* Creates a new session with context inherited from the previous one
* and marks the previous session as handed off.
*
* Usage:
* $session = ContinueSession::run('ses_abc123', 'opus');
*/
class ContinueSession
{
use Action;
public function __construct(
private AgentSessionService $sessionService,
) {}
/**
* @throws \InvalidArgumentException
*/
public function handle(string $previousSessionId, string $agentType): AgentSession
{
if ($previousSessionId === '') {
throw new \InvalidArgumentException('previous_session_id is required');
}
if ($agentType === '') {
throw new \InvalidArgumentException('agent_type is required');
}
$session = $this->sessionService->continueFrom($previousSessionId, $agentType);
if (! $session) {
throw new \InvalidArgumentException("Previous session not found: {$previousSessionId}");
}
return $session;
}
}

View file

@ -0,0 +1,56 @@
<?php
/*
* Core PHP Framework
*
* Licensed under the European Union Public Licence (EUPL) v1.2.
* See LICENSE file for details.
*/
declare(strict_types=1);
namespace Core\Mod\Agentic\Actions\Session;
use Core\Actions\Action;
use Core\Mod\Agentic\Models\AgentSession;
use Core\Mod\Agentic\Services\AgentSessionService;
/**
* End an agent session with a final status and optional summary.
*
* Usage:
* $session = EndSession::run('ses_abc123', 'completed', 'All phases done');
*/
class EndSession
{
use Action;
public function __construct(
private AgentSessionService $sessionService,
) {}
/**
* @throws \InvalidArgumentException
*/
public function handle(string $sessionId, string $status, ?string $summary = null): AgentSession
{
if ($sessionId === '') {
throw new \InvalidArgumentException('session_id is required');
}
$valid = ['completed', 'handed_off', 'paused', 'failed'];
if (! in_array($status, $valid, true)) {
throw new \InvalidArgumentException(
sprintf('status must be one of: %s', implode(', ', $valid))
);
}
$session = $this->sessionService->end($sessionId, $status, $summary);
if (! $session) {
throw new \InvalidArgumentException("Session not found: {$sessionId}");
}
return $session;
}
}

View file

@ -0,0 +1,49 @@
<?php
/*
* Core PHP Framework
*
* Licensed under the European Union Public Licence (EUPL) v1.2.
* See LICENSE file for details.
*/
declare(strict_types=1);
namespace Core\Mod\Agentic\Actions\Session;
use Core\Actions\Action;
use Core\Mod\Agentic\Models\AgentSession;
/**
* Get detailed information about a specific session.
*
* Returns the session with plan context, scoped to workspace.
*
* Usage:
* $session = GetSession::run('ses_abc123', 1);
*/
class GetSession
{
use Action;
/**
* @throws \InvalidArgumentException
*/
public function handle(string $sessionId, int $workspaceId): AgentSession
{
if ($sessionId === '') {
throw new \InvalidArgumentException('session_id is required');
}
$session = AgentSession::with('plan')
->where('session_id', $sessionId)
->where('workspace_id', $workspaceId)
->first();
if (! $session) {
throw new \InvalidArgumentException("Session not found: {$sessionId}");
}
return $session;
}
}

View file

@ -0,0 +1,68 @@
<?php
/*
* Core PHP Framework
*
* Licensed under the European Union Public Licence (EUPL) v1.2.
* See LICENSE file for details.
*/
declare(strict_types=1);
namespace Core\Mod\Agentic\Actions\Session;
use Core\Actions\Action;
use Core\Mod\Agentic\Models\AgentSession;
use Core\Mod\Agentic\Services\AgentSessionService;
use Illuminate\Support\Collection;
/**
* List sessions for a workspace, with optional filtering.
*
* Usage:
* $sessions = ListSessions::run(1);
* $sessions = ListSessions::run(1, 'active', 'deploy-v2', 20);
*/
class ListSessions
{
use Action;
public function __construct(
private AgentSessionService $sessionService,
) {}
/**
* @return Collection<int, AgentSession>
*/
public function handle(int $workspaceId, ?string $status = null, ?string $planSlug = null, ?int $limit = null): Collection
{
if ($status !== null) {
$valid = ['active', 'paused', 'completed', 'failed'];
if (! in_array($status, $valid, true)) {
throw new \InvalidArgumentException(
sprintf('status must be one of: %s', implode(', ', $valid))
);
}
}
// Active sessions use the optimised service method
if ($status === 'active' || $status === null) {
return $this->sessionService->getActiveSessions($workspaceId);
}
$query = AgentSession::query()
->where('workspace_id', $workspaceId)
->where('status', $status)
->orderBy('last_active_at', 'desc');
if ($planSlug !== null) {
$query->whereHas('plan', fn ($q) => $q->where('slug', $planSlug));
}
if ($limit !== null && $limit > 0) {
$query->limit(min($limit, 1000));
}
return $query->get();
}
}

View file

@ -0,0 +1,56 @@
<?php
/*
* Core PHP Framework
*
* Licensed under the European Union Public Licence (EUPL) v1.2.
* See LICENSE file for details.
*/
declare(strict_types=1);
namespace Core\Mod\Agentic\Actions\Session;
use Core\Actions\Action;
use Core\Mod\Agentic\Models\AgentPlan;
use Core\Mod\Agentic\Models\AgentSession;
use Core\Mod\Agentic\Services\AgentSessionService;
/**
* Start a new agent session, optionally linked to a plan.
*
* Creates an active session and caches it for fast lookup.
* Workspace can be provided directly or inferred from the plan.
*
* Usage:
* $session = StartSession::run('opus', null, 1);
* $session = StartSession::run('sonnet', 'deploy-v2', 1, ['goal' => 'testing']);
*/
class StartSession
{
use Action;
public function __construct(
private AgentSessionService $sessionService,
) {}
/**
* @throws \InvalidArgumentException
*/
public function handle(string $agentType, ?string $planSlug, int $workspaceId, array $context = []): AgentSession
{
if ($agentType === '') {
throw new \InvalidArgumentException('agent_type is required');
}
$plan = null;
if ($planSlug !== null && $planSlug !== '') {
$plan = AgentPlan::where('slug', $planSlug)->first();
if (! $plan) {
throw new \InvalidArgumentException("Plan not found: {$planSlug}");
}
}
return $this->sessionService->start($agentType, $plan, $workspaceId, $context);
}
}

View file

@ -0,0 +1,90 @@
<?php
/*
* Core PHP Framework
*
* Licensed under the European Union Public Licence (EUPL) v1.2.
* See LICENSE file for details.
*/
declare(strict_types=1);
namespace Core\Mod\Agentic\Actions\Task;
use Core\Actions\Action;
use Core\Mod\Agentic\Models\AgentPhase;
use Core\Mod\Agentic\Models\AgentPlan;
/**
* Toggle a task's completion status (pending <-> completed).
*
* Quick convenience method for marking tasks done or undone.
*
* Usage:
* $result = ToggleTask::run('deploy-v2', '1', 0, 1);
*/
class ToggleTask
{
use Action;
/**
* @return array{task: array, plan_progress: array}
*
* @throws \InvalidArgumentException
*/
public function handle(string $planSlug, string|int $phase, int $taskIndex, int $workspaceId): array
{
$plan = AgentPlan::forWorkspace($workspaceId)
->where('slug', $planSlug)
->first();
if (! $plan) {
throw new \InvalidArgumentException("Plan not found: {$planSlug}");
}
$resolved = $this->resolvePhase($plan, $phase);
if (! $resolved) {
throw new \InvalidArgumentException("Phase not found: {$phase}");
}
$tasks = $resolved->tasks ?? [];
if (! isset($tasks[$taskIndex])) {
throw new \InvalidArgumentException("Task not found at index: {$taskIndex}");
}
$currentStatus = is_string($tasks[$taskIndex])
? 'pending'
: ($tasks[$taskIndex]['status'] ?? 'pending');
$newStatus = $currentStatus === 'completed' ? 'pending' : 'completed';
if (is_string($tasks[$taskIndex])) {
$tasks[$taskIndex] = [
'name' => $tasks[$taskIndex],
'status' => $newStatus,
];
} else {
$tasks[$taskIndex]['status'] = $newStatus;
}
$resolved->update(['tasks' => $tasks]);
return [
'task' => $tasks[$taskIndex],
'plan_progress' => $plan->fresh()->getProgress(),
];
}
private function resolvePhase(AgentPlan $plan, string|int $identifier): ?AgentPhase
{
if (is_numeric($identifier)) {
return $plan->agentPhases()->where('order', (int) $identifier)->first();
}
return $plan->agentPhases()
->where('name', $identifier)
->first();
}
}

101
Actions/Task/UpdateTask.php Normal file
View file

@ -0,0 +1,101 @@
<?php
/*
* Core PHP Framework
*
* Licensed under the European Union Public Licence (EUPL) v1.2.
* See LICENSE file for details.
*/
declare(strict_types=1);
namespace Core\Mod\Agentic\Actions\Task;
use Core\Actions\Action;
use Core\Mod\Agentic\Models\AgentPhase;
use Core\Mod\Agentic\Models\AgentPlan;
/**
* Update a task's status or notes within a phase.
*
* Tasks are stored as a JSON array on the phase model.
* Handles legacy string-format tasks by normalising to {name, status}.
*
* Usage:
* $task = UpdateTask::run('deploy-v2', '1', 0, 1, 'in_progress', 'Started build');
*/
class UpdateTask
{
use Action;
/**
* @return array{task: array, plan_progress: array}
*
* @throws \InvalidArgumentException
*/
public function handle(string $planSlug, string|int $phase, int $taskIndex, int $workspaceId, ?string $status = null, ?string $notes = null): array
{
if ($status !== null) {
$valid = ['pending', 'in_progress', 'completed', 'blocked', 'skipped'];
if (! in_array($status, $valid, true)) {
throw new \InvalidArgumentException(
sprintf('status must be one of: %s', implode(', ', $valid))
);
}
}
$plan = AgentPlan::forWorkspace($workspaceId)
->where('slug', $planSlug)
->first();
if (! $plan) {
throw new \InvalidArgumentException("Plan not found: {$planSlug}");
}
$resolved = $this->resolvePhase($plan, $phase);
if (! $resolved) {
throw new \InvalidArgumentException("Phase not found: {$phase}");
}
$tasks = $resolved->tasks ?? [];
if (! isset($tasks[$taskIndex])) {
throw new \InvalidArgumentException("Task not found at index: {$taskIndex}");
}
// Normalise legacy string-format tasks
if (is_string($tasks[$taskIndex])) {
$tasks[$taskIndex] = ['name' => $tasks[$taskIndex], 'status' => 'pending'];
}
if ($status !== null) {
$tasks[$taskIndex]['status'] = $status;
}
if ($notes !== null) {
$tasks[$taskIndex]['notes'] = $notes;
}
$resolved->update(['tasks' => $tasks]);
return [
'task' => $tasks[$taskIndex],
'plan_progress' => $plan->fresh()->getProgress(),
];
}
private function resolvePhase(AgentPlan $plan, string|int $identifier): ?AgentPhase
{
if (is_numeric($identifier)) {
return $plan->agentPhases()->where('order', (int) $identifier)->first();
}
return $plan->agentPhases()
->where(function ($query) use ($identifier) {
$query->where('name', $identifier)
->orWhere('order', $identifier);
})
->first();
}
}

View file

@ -0,0 +1,115 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Agentic\Controllers\Api;
use Core\Front\Controller;
use Core\Mod\Agentic\Actions\Phase\AddCheckpoint;
use Core\Mod\Agentic\Actions\Phase\GetPhase;
use Core\Mod\Agentic\Actions\Phase\UpdatePhaseStatus;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class PhaseController extends Controller
{
/**
* GET /api/plans/{slug}/phases/{phase}
*/
public function show(Request $request, string $slug, string $phase): JsonResponse
{
$workspace = $request->attributes->get('workspace');
try {
$resolved = GetPhase::run($slug, $phase, $workspace->id);
return response()->json([
'data' => [
'order' => $resolved->order,
'name' => $resolved->name,
'description' => $resolved->description,
'status' => $resolved->status,
'tasks' => $resolved->tasks,
'checkpoints' => $resolved->getCheckpoints(),
'dependencies' => $resolved->dependencies,
'task_progress' => $resolved->getTaskProgress(),
],
]);
} catch (\InvalidArgumentException $e) {
return response()->json([
'error' => 'not_found',
'message' => $e->getMessage(),
], 404);
}
}
/**
* PATCH /api/plans/{slug}/phases/{phase}
*/
public function update(Request $request, string $slug, string $phase): JsonResponse
{
$validated = $request->validate([
'status' => 'required|string|in:pending,in_progress,completed,blocked,skipped',
'notes' => 'nullable|string|max:5000',
]);
$workspace = $request->attributes->get('workspace');
try {
$resolved = UpdatePhaseStatus::run(
$slug,
$phase,
$validated['status'],
$workspace->id,
$validated['notes'] ?? null,
);
return response()->json([
'data' => [
'order' => $resolved->order,
'name' => $resolved->name,
'status' => $resolved->status,
],
]);
} catch (\InvalidArgumentException $e) {
return response()->json([
'error' => 'not_found',
'message' => $e->getMessage(),
], 404);
}
}
/**
* POST /api/plans/{slug}/phases/{phase}/checkpoint
*/
public function checkpoint(Request $request, string $slug, string $phase): JsonResponse
{
$validated = $request->validate([
'note' => 'required|string|max:5000',
'context' => 'nullable|array',
]);
$workspace = $request->attributes->get('workspace');
try {
$resolved = AddCheckpoint::run(
$slug,
$phase,
$validated['note'],
$workspace->id,
$validated['context'] ?? [],
);
return response()->json([
'data' => [
'checkpoints' => $resolved->getCheckpoints(),
],
]);
} catch (\InvalidArgumentException $e) {
return response()->json([
'error' => 'not_found',
'message' => $e->getMessage(),
], 404);
}
}
}

View file

@ -0,0 +1,170 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Agentic\Controllers\Api;
use Core\Front\Controller;
use Core\Mod\Agentic\Actions\Plan\ArchivePlan;
use Core\Mod\Agentic\Actions\Plan\CreatePlan;
use Core\Mod\Agentic\Actions\Plan\GetPlan;
use Core\Mod\Agentic\Actions\Plan\ListPlans;
use Core\Mod\Agentic\Actions\Plan\UpdatePlanStatus;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class PlanController extends Controller
{
/**
* GET /api/plans
*/
public function index(Request $request): JsonResponse
{
$validated = $request->validate([
'status' => 'nullable|string|in:draft,active,paused,completed,archived',
'include_archived' => 'nullable|boolean',
]);
$workspace = $request->attributes->get('workspace');
try {
$plans = ListPlans::run(
$workspace->id,
$validated['status'] ?? null,
(bool) ($validated['include_archived'] ?? false),
);
return response()->json([
'data' => $plans->map(fn ($plan) => [
'slug' => $plan->slug,
'title' => $plan->title,
'status' => $plan->status,
'progress' => $plan->getProgress(),
'updated_at' => $plan->updated_at->toIso8601String(),
])->values()->all(),
'total' => $plans->count(),
]);
} catch (\InvalidArgumentException $e) {
return response()->json([
'error' => 'validation_error',
'message' => $e->getMessage(),
], 422);
}
}
/**
* GET /api/plans/{slug}
*/
public function show(Request $request, string $slug): JsonResponse
{
$workspace = $request->attributes->get('workspace');
try {
$plan = GetPlan::run($slug, $workspace->id);
return response()->json([
'data' => $plan->toMcpContext(),
]);
} catch (\InvalidArgumentException $e) {
return response()->json([
'error' => 'not_found',
'message' => $e->getMessage(),
], 404);
}
}
/**
* POST /api/plans
*/
public function store(Request $request): JsonResponse
{
$validated = $request->validate([
'title' => 'required|string|max:255',
'slug' => 'nullable|string|max:255',
'description' => 'nullable|string|max:10000',
'context' => 'nullable|array',
'phases' => 'nullable|array',
'phases.*.name' => 'required_with:phases|string',
'phases.*.description' => 'nullable|string',
'phases.*.tasks' => 'nullable|array',
'phases.*.tasks.*' => 'string',
]);
$workspace = $request->attributes->get('workspace');
try {
$plan = CreatePlan::run($validated, $workspace->id);
return response()->json([
'data' => [
'slug' => $plan->slug,
'title' => $plan->title,
'status' => $plan->status,
'phases' => $plan->agentPhases->count(),
],
], 201);
} catch (\InvalidArgumentException $e) {
return response()->json([
'error' => 'validation_error',
'message' => $e->getMessage(),
], 422);
}
}
/**
* PATCH /api/plans/{slug}
*/
public function update(Request $request, string $slug): JsonResponse
{
$validated = $request->validate([
'status' => 'required|string|in:draft,active,paused,completed',
]);
$workspace = $request->attributes->get('workspace');
try {
$plan = UpdatePlanStatus::run($slug, $validated['status'], $workspace->id);
return response()->json([
'data' => [
'slug' => $plan->slug,
'status' => $plan->status,
],
]);
} catch (\InvalidArgumentException $e) {
return response()->json([
'error' => 'not_found',
'message' => $e->getMessage(),
], 404);
}
}
/**
* DELETE /api/plans/{slug}
*/
public function destroy(Request $request, string $slug): JsonResponse
{
$request->validate([
'reason' => 'nullable|string|max:500',
]);
$workspace = $request->attributes->get('workspace');
try {
$plan = ArchivePlan::run($slug, $workspace->id, $request->input('reason'));
return response()->json([
'data' => [
'slug' => $plan->slug,
'status' => 'archived',
'archived_at' => $plan->archived_at?->toIso8601String(),
],
]);
} catch (\InvalidArgumentException $e) {
return response()->json([
'error' => 'not_found',
'message' => $e->getMessage(),
], 404);
}
}
}

View file

@ -0,0 +1,173 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Agentic\Controllers\Api;
use Core\Front\Controller;
use Core\Mod\Agentic\Actions\Session\ContinueSession;
use Core\Mod\Agentic\Actions\Session\EndSession;
use Core\Mod\Agentic\Actions\Session\GetSession;
use Core\Mod\Agentic\Actions\Session\ListSessions;
use Core\Mod\Agentic\Actions\Session\StartSession;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class SessionController extends Controller
{
/**
* GET /api/sessions
*/
public function index(Request $request): JsonResponse
{
$validated = $request->validate([
'status' => 'nullable|string|in:active,paused,completed,failed',
'plan_slug' => 'nullable|string|max:255',
'limit' => 'nullable|integer|min:1|max:1000',
]);
$workspace = $request->attributes->get('workspace');
try {
$sessions = ListSessions::run(
$workspace->id,
$validated['status'] ?? null,
$validated['plan_slug'] ?? null,
$validated['limit'] ?? null,
);
return response()->json([
'data' => $sessions->map(fn ($session) => [
'session_id' => $session->session_id,
'agent_type' => $session->agent_type,
'status' => $session->status,
'plan' => $session->plan?->slug,
'duration' => $session->getDurationFormatted(),
'started_at' => $session->started_at->toIso8601String(),
'last_active_at' => $session->last_active_at->toIso8601String(),
])->values()->all(),
'total' => $sessions->count(),
]);
} catch (\InvalidArgumentException $e) {
return response()->json([
'error' => 'validation_error',
'message' => $e->getMessage(),
], 422);
}
}
/**
* GET /api/sessions/{id}
*/
public function show(Request $request, string $id): JsonResponse
{
$workspace = $request->attributes->get('workspace');
try {
$session = GetSession::run($id, $workspace->id);
return response()->json([
'data' => $session->toMcpContext(),
]);
} catch (\InvalidArgumentException $e) {
return response()->json([
'error' => 'not_found',
'message' => $e->getMessage(),
], 404);
}
}
/**
* POST /api/sessions
*/
public function store(Request $request): JsonResponse
{
$validated = $request->validate([
'agent_type' => 'required|string|max:50',
'plan_slug' => 'nullable|string|max:255',
'context' => 'nullable|array',
]);
$workspace = $request->attributes->get('workspace');
try {
$session = StartSession::run(
$validated['agent_type'],
$validated['plan_slug'] ?? null,
$workspace->id,
$validated['context'] ?? [],
);
return response()->json([
'data' => [
'session_id' => $session->session_id,
'agent_type' => $session->agent_type,
'plan' => $session->plan?->slug,
'status' => $session->status,
],
], 201);
} catch (\InvalidArgumentException $e) {
return response()->json([
'error' => 'validation_error',
'message' => $e->getMessage(),
], 422);
}
}
/**
* POST /api/sessions/{id}/end
*/
public function end(Request $request, string $id): JsonResponse
{
$validated = $request->validate([
'status' => 'required|string|in:completed,handed_off,paused,failed',
'summary' => 'nullable|string|max:10000',
]);
try {
$session = EndSession::run($id, $validated['status'], $validated['summary'] ?? null);
return response()->json([
'data' => [
'session_id' => $session->session_id,
'status' => $session->status,
'duration' => $session->getDurationFormatted(),
],
]);
} catch (\InvalidArgumentException $e) {
return response()->json([
'error' => 'not_found',
'message' => $e->getMessage(),
], 404);
}
}
/**
* POST /api/sessions/{id}/continue
*/
public function continue(Request $request, string $id): JsonResponse
{
$validated = $request->validate([
'agent_type' => 'required|string|max:50',
]);
try {
$session = ContinueSession::run($id, $validated['agent_type']);
return response()->json([
'data' => [
'session_id' => $session->session_id,
'agent_type' => $session->agent_type,
'plan' => $session->plan?->slug,
'status' => $session->status,
'continued_from' => $session->context_summary['continued_from'] ?? null,
],
], 201);
} catch (\InvalidArgumentException $e) {
return response()->json([
'error' => 'not_found',
'message' => $e->getMessage(),
], 404);
}
}
}

View file

@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Agentic\Controllers\Api;
use Core\Front\Controller;
use Core\Mod\Agentic\Actions\Task\ToggleTask;
use Core\Mod\Agentic\Actions\Task\UpdateTask;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class TaskController extends Controller
{
/**
* PATCH /api/plans/{slug}/phases/{phase}/tasks/{index}
*/
public function update(Request $request, string $slug, string $phase, int $index): JsonResponse
{
$validated = $request->validate([
'status' => 'nullable|string|in:pending,in_progress,completed,blocked,skipped',
'notes' => 'nullable|string|max:5000',
]);
$workspace = $request->attributes->get('workspace');
try {
$result = UpdateTask::run(
$slug,
$phase,
$index,
$workspace->id,
$validated['status'] ?? null,
$validated['notes'] ?? null,
);
return response()->json([
'data' => $result,
]);
} catch (\InvalidArgumentException $e) {
return response()->json([
'error' => 'not_found',
'message' => $e->getMessage(),
], 404);
}
}
/**
* POST /api/plans/{slug}/phases/{phase}/tasks/{index}/toggle
*/
public function toggle(Request $request, string $slug, string $phase, int $index): JsonResponse
{
$workspace = $request->attributes->get('workspace');
try {
$result = ToggleTask::run($slug, $phase, $index, $workspace->id);
return response()->json([
'data' => $result,
]);
} catch (\InvalidArgumentException $e) {
return response()->json([
'error' => 'not_found',
'message' => $e->getMessage(),
], 404);
}
}
}

View file

@ -4,9 +4,8 @@ declare(strict_types=1);
namespace Core\Mod\Agentic\Mcp\Tools\Agent\Phase;
use Core\Mod\Agentic\Actions\Phase\AddCheckpoint;
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
use Core\Mod\Agentic\Models\AgentPhase;
use Core\Mod\Agentic\Models\AgentPlan;
/**
* Add a checkpoint note to a phase.
@ -55,44 +54,25 @@ class PhaseAddCheckpoint extends AgentTool
public function handle(array $args, array $context = []): array
{
$workspaceId = $context['workspace_id'] ?? null;
if ($workspaceId === null) {
return $this->error('workspace_id is required');
}
try {
$planSlug = $this->require($args, 'plan_slug');
$phaseIdentifier = $this->require($args, 'phase');
$note = $this->require($args, 'note');
$phase = AddCheckpoint::run(
$args['plan_slug'] ?? '',
$args['phase'] ?? '',
$args['note'] ?? '',
(int) $workspaceId,
$args['context'] ?? [],
);
return $this->success([
'checkpoints' => $phase->getCheckpoints(),
]);
} catch (\InvalidArgumentException $e) {
return $this->error($e->getMessage());
}
$plan = AgentPlan::where('slug', $planSlug)->first();
if (! $plan) {
return $this->error("Plan not found: {$planSlug}");
}
$phase = $this->findPhase($plan, $phaseIdentifier);
if (! $phase) {
return $this->error("Phase not found: {$phaseIdentifier}");
}
$phase->addCheckpoint($note, $args['context'] ?? []);
return $this->success([
'checkpoints' => $phase->fresh()->checkpoints,
]);
}
/**
* Find a phase by order number or name.
*/
protected function findPhase(AgentPlan $plan, string|int $identifier): ?AgentPhase
{
if (is_numeric($identifier)) {
return $plan->agentPhases()->where('order', (int) $identifier)->first();
}
return $plan->agentPhases()
->where('name', $identifier)
->first();
}
}

View file

@ -4,9 +4,8 @@ declare(strict_types=1);
namespace Core\Mod\Agentic\Mcp\Tools\Agent\Phase;
use Core\Mod\Agentic\Actions\Phase\GetPhase;
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
use Core\Mod\Agentic\Models\AgentPhase;
use Core\Mod\Agentic\Models\AgentPlan;
/**
* Get details of a specific phase within a plan.
@ -47,52 +46,31 @@ class PhaseGet extends AgentTool
public function handle(array $args, array $context = []): array
{
$workspaceId = $context['workspace_id'] ?? null;
if ($workspaceId === null) {
return $this->error('workspace_id is required');
}
try {
$planSlug = $this->require($args, 'plan_slug');
$phaseIdentifier = $this->require($args, 'phase');
$phase = GetPhase::run(
$args['plan_slug'] ?? '',
$args['phase'] ?? '',
(int) $workspaceId,
);
return $this->success([
'phase' => [
'order' => $phase->order,
'name' => $phase->name,
'description' => $phase->description,
'status' => $phase->status,
'tasks' => $phase->tasks,
'checkpoints' => $phase->getCheckpoints(),
'dependencies' => $phase->dependencies,
],
]);
} catch (\InvalidArgumentException $e) {
return $this->error($e->getMessage());
}
$plan = AgentPlan::where('slug', $planSlug)->first();
if (! $plan) {
return $this->error("Plan not found: {$planSlug}");
}
$phase = $this->findPhase($plan, $phaseIdentifier);
if (! $phase) {
return $this->error("Phase not found: {$phaseIdentifier}");
}
return [
'phase' => [
'order' => $phase->order,
'name' => $phase->name,
'description' => $phase->description,
'status' => $phase->status,
'tasks' => $phase->tasks,
'checkpoints' => $phase->checkpoints,
'dependencies' => $phase->dependencies,
],
];
}
/**
* Find a phase by order number or name.
*/
protected function findPhase(AgentPlan $plan, string|int $identifier): ?AgentPhase
{
if (is_numeric($identifier)) {
return $plan->agentPhases()->where('order', (int) $identifier)->first();
}
return $plan->agentPhases()
->where(function ($query) use ($identifier) {
$query->where('name', $identifier)
->orWhere('order', $identifier);
})
->first();
}
}

View file

@ -5,9 +5,8 @@ declare(strict_types=1);
namespace Core\Mod\Agentic\Mcp\Tools\Agent\Phase;
use Core\Mcp\Dependencies\ToolDependency;
use Core\Mod\Agentic\Actions\Phase\UpdatePhaseStatus;
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
use Core\Mod\Agentic\Models\AgentPhase;
use Core\Mod\Agentic\Models\AgentPlan;
/**
* Update the status of a phase.
@ -69,55 +68,29 @@ class PhaseUpdateStatus extends AgentTool
public function handle(array $args, array $context = []): array
{
$workspaceId = $context['workspace_id'] ?? null;
if ($workspaceId === null) {
return $this->error('workspace_id is required');
}
try {
$planSlug = $this->require($args, 'plan_slug');
$phaseIdentifier = $this->require($args, 'phase');
$status = $this->require($args, 'status');
$phase = UpdatePhaseStatus::run(
$args['plan_slug'] ?? '',
$args['phase'] ?? '',
$args['status'] ?? '',
(int) $workspaceId,
$args['notes'] ?? null,
);
return $this->success([
'phase' => [
'order' => $phase->order,
'name' => $phase->name,
'status' => $phase->status,
],
]);
} catch (\InvalidArgumentException $e) {
return $this->error($e->getMessage());
}
$plan = AgentPlan::where('slug', $planSlug)->first();
if (! $plan) {
return $this->error("Plan not found: {$planSlug}");
}
$phase = $this->findPhase($plan, $phaseIdentifier);
if (! $phase) {
return $this->error("Phase not found: {$phaseIdentifier}");
}
if (! empty($args['notes'])) {
$phase->addCheckpoint($args['notes'], ['status_change' => $status]);
}
$phase->update(['status' => $status]);
return $this->success([
'phase' => [
'order' => $phase->order,
'name' => $phase->name,
'status' => $phase->fresh()->status,
],
]);
}
/**
* Find a phase by order number or name.
*/
protected function findPhase(AgentPlan $plan, string|int $identifier): ?AgentPhase
{
if (is_numeric($identifier)) {
return $plan->agentPhases()->where('order', (int) $identifier)->first();
}
return $plan->agentPhases()
->where(function ($query) use ($identifier) {
$query->where('name', $identifier)
->orWhere('order', $identifier);
})
->first();
}
}

View file

@ -4,8 +4,8 @@ declare(strict_types=1);
namespace Core\Mod\Agentic\Mcp\Tools\Agent\Plan;
use Core\Mod\Agentic\Actions\Plan\ArchivePlan;
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
use Core\Mod\Agentic\Models\AgentPlan;
/**
* Archive a completed or abandoned plan.
@ -46,26 +46,27 @@ class PlanArchive extends AgentTool
public function handle(array $args, array $context = []): array
{
$workspaceId = $context['workspace_id'] ?? null;
if ($workspaceId === null) {
return $this->error('workspace_id is required');
}
try {
$slug = $this->require($args, 'slug');
$plan = ArchivePlan::run(
$args['slug'] ?? '',
(int) $workspaceId,
$args['reason'] ?? null,
);
return $this->success([
'plan' => [
'slug' => $plan->slug,
'status' => 'archived',
'archived_at' => $plan->archived_at?->toIso8601String(),
],
]);
} catch (\InvalidArgumentException $e) {
return $this->error($e->getMessage());
}
$plan = AgentPlan::where('slug', $slug)->first();
if (! $plan) {
return $this->error("Plan not found: {$slug}");
}
$plan->archive($args['reason'] ?? null);
return $this->success([
'plan' => [
'slug' => $plan->slug,
'status' => 'archived',
'archived_at' => $plan->archived_at?->toIso8601String(),
],
]);
}
}

View file

@ -5,10 +5,8 @@ declare(strict_types=1);
namespace Core\Mod\Agentic\Mcp\Tools\Agent\Plan;
use Core\Mcp\Dependencies\ToolDependency;
use Core\Mod\Agentic\Actions\Plan\CreatePlan;
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
use Core\Mod\Agentic\Models\AgentPhase;
use Core\Mod\Agentic\Models\AgentPlan;
use Illuminate\Support\Str;
/**
* Create a new work plan with phases and tasks.
@ -84,61 +82,24 @@ class PlanCreate extends AgentTool
public function handle(array $args, array $context = []): array
{
try {
$title = $this->requireString($args, 'title', 255);
$slug = $this->optionalString($args, 'slug', null, 255) ?? Str::slug($title).'-'.Str::random(6);
$description = $this->optionalString($args, 'description', null, 10000);
} catch (\InvalidArgumentException $e) {
return $this->error($e->getMessage());
}
if (AgentPlan::where('slug', $slug)->exists()) {
return $this->error("Plan with slug '{$slug}' already exists");
}
// Determine workspace_id - never fall back to hardcoded value in multi-tenant environment
$workspaceId = $context['workspace_id'] ?? null;
if ($workspaceId === null) {
return $this->error('workspace_id is required. Ensure you have authenticated with a valid API key and started a session. See: https://host.uk.com/ai');
}
$plan = AgentPlan::create([
'slug' => $slug,
'title' => $title,
'description' => $description,
'status' => 'draft',
'context' => $args['context'] ?? [],
'workspace_id' => $workspaceId,
]);
try {
$plan = CreatePlan::run($args, (int) $workspaceId);
// Create phases if provided
if (! empty($args['phases'])) {
foreach ($args['phases'] as $order => $phaseData) {
$tasks = collect($phaseData['tasks'] ?? [])->map(fn ($task) => [
'name' => $task,
'status' => 'pending',
])->all();
AgentPhase::create([
'agent_plan_id' => $plan->id,
'name' => $phaseData['name'],
'description' => $phaseData['description'] ?? null,
'order' => $order + 1,
'status' => 'pending',
'tasks' => $tasks,
]);
}
return $this->success([
'plan' => [
'slug' => $plan->slug,
'title' => $plan->title,
'status' => $plan->status,
'phases' => $plan->agentPhases->count(),
],
]);
} catch (\InvalidArgumentException $e) {
return $this->error($e->getMessage());
}
$plan->load('agentPhases');
return $this->success([
'plan' => [
'slug' => $plan->slug,
'title' => $plan->title,
'status' => $plan->status,
'phases' => $plan->agentPhases->count(),
],
]);
}
}

View file

@ -5,8 +5,8 @@ declare(strict_types=1);
namespace Core\Mod\Agentic\Mcp\Tools\Agent\Plan;
use Core\Mcp\Dependencies\ToolDependency;
use Core\Mod\Agentic\Actions\Plan\GetPlan;
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
use Core\Mod\Agentic\Models\AgentPlan;
/**
* Get detailed information about a specific plan.
@ -62,56 +62,23 @@ class PlanGet extends AgentTool
public function handle(array $args, array $context = []): array
{
try {
$slug = $this->require($args, 'slug');
} catch (\InvalidArgumentException $e) {
return $this->error($e->getMessage());
}
// Validate workspace context for tenant isolation
$workspaceId = $context['workspace_id'] ?? null;
if ($workspaceId === null) {
return $this->error('workspace_id is required. Ensure you have authenticated with a valid API key and started a session. See: https://host.uk.com/ai');
}
$format = $this->optional($args, 'format', 'json');
try {
$plan = GetPlan::run($args['slug'] ?? '', (int) $workspaceId);
} catch (\InvalidArgumentException $e) {
return $this->error($e->getMessage());
}
// Use circuit breaker for Agentic module database calls
return $this->withCircuitBreaker('agentic', function () use ($slug, $format, $workspaceId) {
// Query plan with workspace scope to prevent cross-tenant access
$plan = AgentPlan::with('agentPhases')
->forWorkspace($workspaceId)
->where('slug', $slug)
->first();
$format = $args['format'] ?? 'json';
if (! $plan) {
return $this->error("Plan not found: {$slug}");
}
if ($format === 'markdown') {
return $this->success(['markdown' => $plan->toMarkdown()]);
}
if ($format === 'markdown') {
return $this->success(['markdown' => $plan->toMarkdown()]);
}
return $this->success([
'plan' => [
'slug' => $plan->slug,
'title' => $plan->title,
'description' => $plan->description,
'status' => $plan->status,
'context' => $plan->context,
'progress' => $plan->getProgress(),
'phases' => $plan->agentPhases->map(fn ($phase) => [
'order' => $phase->order,
'name' => $phase->name,
'description' => $phase->description,
'status' => $phase->status,
'tasks' => $phase->tasks,
'checkpoints' => $phase->checkpoints,
])->all(),
'created_at' => $plan->created_at->toIso8601String(),
'updated_at' => $plan->updated_at->toIso8601String(),
],
]);
}, fn () => $this->error('Agentic service temporarily unavailable', 'service_unavailable'));
return $this->success(['plan' => $plan->toMcpContext()]);
}
}

View file

@ -5,8 +5,8 @@ declare(strict_types=1);
namespace Core\Mod\Agentic\Mcp\Tools\Agent\Plan;
use Core\Mcp\Dependencies\ToolDependency;
use Core\Mod\Agentic\Actions\Plan\ListPlans;
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
use Core\Mod\Agentic\Models\AgentPlan;
/**
* List all work plans with their current status and progress.
@ -61,43 +61,30 @@ class PlanList extends AgentTool
public function handle(array $args, array $context = []): array
{
try {
$status = $this->optionalEnum($args, 'status', ['draft', 'active', 'paused', 'completed', 'archived']);
$includeArchived = (bool) ($args['include_archived'] ?? false);
} catch (\InvalidArgumentException $e) {
return $this->error($e->getMessage());
}
// Validate workspace context for tenant isolation
$workspaceId = $context['workspace_id'] ?? null;
if ($workspaceId === null) {
return $this->error('workspace_id is required. Ensure you have authenticated with a valid API key and started a session. See: https://host.uk.com/ai');
}
// Query plans with workspace scope to prevent cross-tenant access
$query = AgentPlan::with('agentPhases')
->forWorkspace($workspaceId)
->orderBy('updated_at', 'desc');
try {
$plans = ListPlans::run(
(int) $workspaceId,
$args['status'] ?? null,
(bool) ($args['include_archived'] ?? false),
);
if (! $includeArchived && $status !== 'archived') {
$query->notArchived();
return $this->success([
'plans' => $plans->map(fn ($plan) => [
'slug' => $plan->slug,
'title' => $plan->title,
'status' => $plan->status,
'progress' => $plan->getProgress(),
'updated_at' => $plan->updated_at->toIso8601String(),
])->all(),
'total' => $plans->count(),
]);
} catch (\InvalidArgumentException $e) {
return $this->error($e->getMessage());
}
if ($status !== null) {
$query->where('status', $status);
}
$plans = $query->get();
return $this->success([
'plans' => $plans->map(fn ($plan) => [
'slug' => $plan->slug,
'title' => $plan->title,
'status' => $plan->status,
'progress' => $plan->getProgress(),
'updated_at' => $plan->updated_at->toIso8601String(),
])->all(),
'total' => $plans->count(),
]);
}
}

View file

@ -4,8 +4,8 @@ declare(strict_types=1);
namespace Core\Mod\Agentic\Mcp\Tools\Agent\Plan;
use Core\Mod\Agentic\Actions\Plan\UpdatePlanStatus;
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
use Core\Mod\Agentic\Models\AgentPlan;
/**
* Update the status of a plan.
@ -47,26 +47,26 @@ class PlanUpdateStatus extends AgentTool
public function handle(array $args, array $context = []): array
{
$workspaceId = $context['workspace_id'] ?? null;
if ($workspaceId === null) {
return $this->error('workspace_id is required');
}
try {
$slug = $this->require($args, 'slug');
$status = $this->require($args, 'status');
$plan = UpdatePlanStatus::run(
$args['slug'] ?? '',
$args['status'] ?? '',
(int) $workspaceId,
);
return $this->success([
'plan' => [
'slug' => $plan->slug,
'status' => $plan->status,
],
]);
} catch (\InvalidArgumentException $e) {
return $this->error($e->getMessage());
}
$plan = AgentPlan::where('slug', $slug)->first();
if (! $plan) {
return $this->error("Plan not found: {$slug}");
}
$plan->update(['status' => $status]);
return $this->success([
'plan' => [
'slug' => $plan->slug,
'status' => $plan->fresh()->status,
],
]);
}
}

View file

@ -4,8 +4,8 @@ declare(strict_types=1);
namespace Core\Mod\Agentic\Mcp\Tools\Agent\Session;
use Core\Mod\Agentic\Actions\Session\ContinueSession;
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
use Core\Mod\Agentic\Services\AgentSessionService;
/**
* Continue from a previous session (multi-agent handoff).
@ -47,32 +47,27 @@ class SessionContinue extends AgentTool
public function handle(array $args, array $context = []): array
{
try {
$previousSessionId = $this->require($args, 'previous_session_id');
$agentType = $this->require($args, 'agent_type');
$session = ContinueSession::run(
$args['previous_session_id'] ?? '',
$args['agent_type'] ?? '',
);
$inheritedContext = $session->context_summary ?? [];
return $this->success([
'session' => [
'session_id' => $session->session_id,
'agent_type' => $session->agent_type,
'status' => $session->status,
'plan' => $session->plan?->slug,
],
'continued_from' => $inheritedContext['continued_from'] ?? null,
'previous_agent' => $inheritedContext['previous_agent'] ?? null,
'handoff_notes' => $inheritedContext['handoff_notes'] ?? null,
'inherited_context' => $inheritedContext['inherited_context'] ?? null,
]);
} catch (\InvalidArgumentException $e) {
return $this->error($e->getMessage());
}
$sessionService = app(AgentSessionService::class);
$session = $sessionService->continueFrom($previousSessionId, $agentType);
if (! $session) {
return $this->error("Previous session not found: {$previousSessionId}");
}
$inheritedContext = $session->context_summary ?? [];
return $this->success([
'session' => [
'session_id' => $session->session_id,
'agent_type' => $session->agent_type,
'status' => $session->status,
'plan' => $session->plan?->slug,
],
'continued_from' => $inheritedContext['continued_from'] ?? null,
'previous_agent' => $inheritedContext['previous_agent'] ?? null,
'handoff_notes' => $inheritedContext['handoff_notes'] ?? null,
'inherited_context' => $inheritedContext['inherited_context'] ?? null,
]);
}
}

View file

@ -4,8 +4,8 @@ declare(strict_types=1);
namespace Core\Mod\Agentic\Mcp\Tools\Agent\Session;
use Core\Mod\Agentic\Actions\Session\EndSession;
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
use Core\Mod\Agentic\Models\AgentSession;
/**
* End the current session.
@ -47,32 +47,27 @@ class SessionEnd extends AgentTool
public function handle(array $args, array $context = []): array
{
try {
$status = $this->require($args, 'status');
} catch (\InvalidArgumentException $e) {
return $this->error($e->getMessage());
}
$sessionId = $context['session_id'] ?? null;
if (! $sessionId) {
return $this->error('No active session');
}
$session = AgentSession::where('session_id', $sessionId)->first();
try {
$session = EndSession::run(
$sessionId,
$args['status'] ?? '',
$args['summary'] ?? null,
);
if (! $session) {
return $this->error('Session not found');
return $this->success([
'session' => [
'session_id' => $session->session_id,
'status' => $session->status,
'duration' => $session->getDurationFormatted(),
],
]);
} catch (\InvalidArgumentException $e) {
return $this->error($e->getMessage());
}
$session->end($status, $this->optional($args, 'summary'));
return $this->success([
'session' => [
'session_id' => $session->session_id,
'status' => $session->status,
'duration' => $session->getDurationFormatted(),
],
]);
}
}

View file

@ -4,8 +4,8 @@ declare(strict_types=1);
namespace Core\Mod\Agentic\Mcp\Tools\Agent\Session;
use Core\Mod\Agentic\Actions\Session\ListSessions;
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
use Core\Mod\Agentic\Services\AgentSessionService;
/**
* List sessions, optionally filtered by status.
@ -50,54 +50,34 @@ class SessionList extends AgentTool
public function handle(array $args, array $context = []): array
{
$workspaceId = $context['workspace_id'] ?? null;
if ($workspaceId === null) {
return $this->error('workspace_id is required');
}
try {
$status = $this->optionalEnum($args, 'status', ['active', 'paused', 'completed', 'failed']);
$planSlug = $this->optionalString($args, 'plan_slug', null, 255);
$limit = $this->optionalInt($args, 'limit', null, min: 1, max: 1000);
$sessions = ListSessions::run(
(int) $workspaceId,
$args['status'] ?? null,
$args['plan_slug'] ?? null,
isset($args['limit']) ? (int) $args['limit'] : null,
);
return $this->success([
'sessions' => $sessions->map(fn ($session) => [
'session_id' => $session->session_id,
'agent_type' => $session->agent_type,
'status' => $session->status,
'plan' => $session->plan?->slug,
'duration' => $session->getDurationFormatted(),
'started_at' => $session->started_at->toIso8601String(),
'last_active_at' => $session->last_active_at->toIso8601String(),
'has_handoff' => ! empty($session->handoff_notes),
])->all(),
'total' => $sessions->count(),
]);
} catch (\InvalidArgumentException $e) {
return $this->error($e->getMessage());
}
$sessionService = app(AgentSessionService::class);
// Get active sessions (default)
if ($status === 'active' || $status === null) {
$sessions = $sessionService->getActiveSessions($context['workspace_id'] ?? null);
} else {
// Query with filters
$query = \Core\Mod\Agentic\Models\AgentSession::query()
->orderBy('last_active_at', 'desc');
// Apply workspace filter if provided
if (! empty($context['workspace_id'])) {
$query->where('workspace_id', $context['workspace_id']);
}
$query->where('status', $status);
if ($planSlug !== null) {
$query->whereHas('plan', fn ($q) => $q->where('slug', $planSlug));
}
if ($limit !== null) {
$query->limit($limit);
}
$sessions = $query->get();
}
return [
'sessions' => $sessions->map(fn ($session) => [
'session_id' => $session->session_id,
'agent_type' => $session->agent_type,
'status' => $session->status,
'plan' => $session->plan?->slug,
'duration' => $session->getDurationFormatted(),
'started_at' => $session->started_at->toIso8601String(),
'last_active_at' => $session->last_active_at->toIso8601String(),
'has_handoff' => ! empty($session->handoff_notes),
])->all(),
'total' => $sessions->count(),
];
}
}

View file

@ -5,10 +5,8 @@ declare(strict_types=1);
namespace Core\Mod\Agentic\Mcp\Tools\Agent\Session;
use Core\Mcp\Dependencies\ToolDependency;
use Core\Mod\Agentic\Actions\Session\StartSession;
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
use Core\Mod\Agentic\Models\AgentPlan;
use Core\Mod\Agentic\Models\AgentSession;
use Illuminate\Support\Str;
/**
* Start a new agent session for a plan.
@ -70,48 +68,29 @@ class SessionStart extends AgentTool
public function handle(array $args, array $context = []): array
{
try {
$agentType = $this->require($args, 'agent_type');
} catch (\InvalidArgumentException $e) {
return $this->error($e->getMessage());
$workspaceId = $context['workspace_id'] ?? null;
if ($workspaceId === null) {
return $this->error('workspace_id is required. Ensure you have authenticated with a valid API key and started a session, or provide a valid plan_slug to infer workspace context. See: https://host.uk.com/ai');
}
// Use circuit breaker for Agentic module database calls
return $this->withCircuitBreaker('agentic', function () use ($args, $context, $agentType) {
$plan = null;
if (! empty($args['plan_slug'])) {
$plan = AgentPlan::where('slug', $args['plan_slug'])->first();
}
$sessionId = 'ses_'.Str::random(12);
// Determine workspace_id - never fall back to hardcoded value in multi-tenant environment
$workspaceId = $context['workspace_id'] ?? $plan?->workspace_id ?? null;
if ($workspaceId === null) {
return $this->error('workspace_id is required. Ensure you have authenticated with a valid API key and started a session, or provide a valid plan_slug to infer workspace context. See: https://host.uk.com/ai');
}
$session = AgentSession::create([
'session_id' => $sessionId,
'agent_plan_id' => $plan?->id,
'workspace_id' => $workspaceId,
'agent_type' => $agentType,
'status' => 'active',
'started_at' => now(),
'last_active_at' => now(),
'context_summary' => $args['context'] ?? [],
'work_log' => [],
'artifacts' => [],
]);
try {
$session = StartSession::run(
$args['agent_type'] ?? '',
$args['plan_slug'] ?? null,
(int) $workspaceId,
$args['context'] ?? [],
);
return $this->success([
'session' => [
'session_id' => $session->session_id,
'agent_type' => $session->agent_type,
'plan' => $plan?->slug,
'plan' => $session->plan?->slug,
'status' => $session->status,
],
]);
}, fn () => $this->error('Agentic service temporarily unavailable. Session cannot be created.', 'service_unavailable'));
} catch (\InvalidArgumentException $e) {
return $this->error($e->getMessage());
}
}
}

View file

@ -5,9 +5,8 @@ declare(strict_types=1);
namespace Core\Mod\Agentic\Mcp\Tools\Agent\Task;
use Core\Mcp\Dependencies\ToolDependency;
use Core\Mod\Agentic\Actions\Task\ToggleTask;
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
use Core\Mod\Agentic\Models\AgentPhase;
use Core\Mod\Agentic\Models\AgentPlan;
/**
* Toggle a task completion status.
@ -64,66 +63,22 @@ class TaskToggle extends AgentTool
public function handle(array $args, array $context = []): array
{
$workspaceId = $context['workspace_id'] ?? null;
if ($workspaceId === null) {
return $this->error('workspace_id is required');
}
try {
$planSlug = $this->requireString($args, 'plan_slug', 255);
$phaseIdentifier = $this->requireString($args, 'phase', 255);
$taskIndex = $this->requireInt($args, 'task_index', min: 0, max: 1000);
$result = ToggleTask::run(
$args['plan_slug'] ?? '',
$args['phase'] ?? '',
(int) ($args['task_index'] ?? 0),
(int) $workspaceId,
);
return $this->success($result);
} catch (\InvalidArgumentException $e) {
return $this->error($e->getMessage());
}
$plan = AgentPlan::where('slug', $planSlug)->first();
if (! $plan) {
return $this->error("Plan not found: {$planSlug}");
}
$phase = $this->findPhase($plan, $phaseIdentifier);
if (! $phase) {
return $this->error("Phase not found: {$phaseIdentifier}");
}
$tasks = $phase->tasks ?? [];
if (! isset($tasks[$taskIndex])) {
return $this->error("Task not found at index: {$taskIndex}");
}
$currentStatus = is_string($tasks[$taskIndex])
? 'pending'
: ($tasks[$taskIndex]['status'] ?? 'pending');
$newStatus = $currentStatus === 'completed' ? 'pending' : 'completed';
if (is_string($tasks[$taskIndex])) {
$tasks[$taskIndex] = [
'name' => $tasks[$taskIndex],
'status' => $newStatus,
];
} else {
$tasks[$taskIndex]['status'] = $newStatus;
}
$phase->update(['tasks' => $tasks]);
return $this->success([
'task' => $tasks[$taskIndex],
'plan_progress' => $plan->fresh()->getProgress(),
]);
}
/**
* Find a phase by order number or name.
*/
protected function findPhase(AgentPlan $plan, string|int $identifier): ?AgentPhase
{
if (is_numeric($identifier)) {
return $plan->agentPhases()->where('order', (int) $identifier)->first();
}
return $plan->agentPhases()
->where('name', $identifier)
->first();
}
}

View file

@ -5,9 +5,8 @@ declare(strict_types=1);
namespace Core\Mod\Agentic\Mcp\Tools\Agent\Task;
use Core\Mcp\Dependencies\ToolDependency;
use Core\Mod\Agentic\Actions\Task\UpdateTask;
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
use Core\Mod\Agentic\Models\AgentPhase;
use Core\Mod\Agentic\Models\AgentPlan;
/**
* Update task details (status, notes).
@ -73,71 +72,24 @@ class TaskUpdate extends AgentTool
public function handle(array $args, array $context = []): array
{
try {
$planSlug = $this->requireString($args, 'plan_slug', 255);
$phaseIdentifier = $this->requireString($args, 'phase', 255);
$taskIndex = $this->requireInt($args, 'task_index', min: 0, max: 1000);
$workspaceId = $context['workspace_id'] ?? null;
if ($workspaceId === null) {
return $this->error('workspace_id is required');
}
// Validate optional status enum
$status = $this->optionalEnum($args, 'status', ['pending', 'in_progress', 'completed', 'blocked', 'skipped']);
$notes = $this->optionalString($args, 'notes', null, 5000);
try {
$result = UpdateTask::run(
$args['plan_slug'] ?? '',
$args['phase'] ?? '',
(int) ($args['task_index'] ?? 0),
(int) $workspaceId,
$args['status'] ?? null,
$args['notes'] ?? null,
);
return $this->success($result);
} catch (\InvalidArgumentException $e) {
return $this->error($e->getMessage());
}
$plan = AgentPlan::where('slug', $planSlug)->first();
if (! $plan) {
return $this->error("Plan not found: {$planSlug}");
}
$phase = $this->findPhase($plan, $phaseIdentifier);
if (! $phase) {
return $this->error("Phase not found: {$phaseIdentifier}");
}
$tasks = $phase->tasks ?? [];
if (! isset($tasks[$taskIndex])) {
return $this->error("Task not found at index: {$taskIndex}");
}
// Normalise task to array format
if (is_string($tasks[$taskIndex])) {
$tasks[$taskIndex] = ['name' => $tasks[$taskIndex], 'status' => 'pending'];
}
// Update fields using pre-validated values
if ($status !== null) {
$tasks[$taskIndex]['status'] = $status;
}
if ($notes !== null) {
$tasks[$taskIndex]['notes'] = $notes;
}
$phase->update(['tasks' => $tasks]);
return $this->success([
'task' => $tasks[$taskIndex],
]);
}
/**
* Find a phase by order number or name.
*/
protected function findPhase(AgentPlan $plan, string|int $identifier): ?AgentPhase
{
if (is_numeric($identifier)) {
return $plan->agentPhases()->where('order', (int) $identifier)->first();
}
return $plan->agentPhases()
->where(function ($query) use ($identifier) {
$query->where('name', $identifier)
->orWhere('order', $identifier);
})
->first();
}
}

View file

@ -3,6 +3,10 @@
declare(strict_types=1);
use Core\Mod\Agentic\Controllers\Api\BrainController;
use Core\Mod\Agentic\Controllers\Api\PhaseController;
use Core\Mod\Agentic\Controllers\Api\PlanController;
use Core\Mod\Agentic\Controllers\Api\SessionController;
use Core\Mod\Agentic\Controllers\Api\TaskController;
use Illuminate\Support\Facades\Route;
/*
@ -10,18 +14,56 @@ use Illuminate\Support\Facades\Route;
| Agentic API Routes
|--------------------------------------------------------------------------
|
| Brain (OpenBrain knowledge store) endpoints.
| Brain, Plans, Sessions, Phases, and Tasks endpoints.
| Auto-wrapped with 'api' middleware and /api prefix by ApiRoutesRegistering.
|
*/
Route::middleware(['api.auth', 'api.scope.enforce'])
->prefix('brain')
->name('brain.')
->group(function () {
// Health check (no auth required)
Route::get('health', fn () => response()->json(['status' => 'ok', 'timestamp' => now()->toIso8601String()]));
// Authenticated endpoints
Route::middleware(['api.auth', 'api.scope.enforce'])->group(function () {
// Brain (OpenBrain knowledge store)
Route::prefix('brain')->name('brain.')->group(function () {
Route::post('remember', [BrainController::class, 'remember'])->name('remember');
Route::post('recall', [BrainController::class, 'recall'])->name('recall');
Route::delete('forget/{id}', [BrainController::class, 'forget'])->name('forget')
->where('id', '[0-9a-f-]+');
Route::get('list', [BrainController::class, 'list'])->name('list');
});
// Plans
Route::prefix('plans')->name('plans.')->group(function () {
Route::get('/', [PlanController::class, 'index'])->name('index');
Route::get('{slug}', [PlanController::class, 'show'])->name('show');
Route::post('/', [PlanController::class, 'store'])->name('store');
Route::patch('{slug}', [PlanController::class, 'update'])->name('update');
Route::delete('{slug}', [PlanController::class, 'destroy'])->name('destroy');
// Phases (nested under plans)
Route::prefix('{slug}/phases')->name('phases.')->group(function () {
Route::get('{phase}', [PhaseController::class, 'show'])->name('show');
Route::patch('{phase}', [PhaseController::class, 'update'])->name('update');
Route::post('{phase}/checkpoint', [PhaseController::class, 'checkpoint'])->name('checkpoint');
// Tasks (nested under phases)
Route::prefix('{phase}/tasks')->name('tasks.')->group(function () {
Route::patch('{index}', [TaskController::class, 'update'])->name('update')
->where('index', '[0-9]+');
Route::post('{index}/toggle', [TaskController::class, 'toggle'])->name('toggle')
->where('index', '[0-9]+');
});
});
});
// Sessions
Route::prefix('sessions')->name('sessions.')->group(function () {
Route::get('/', [SessionController::class, 'index'])->name('index');
Route::get('{id}', [SessionController::class, 'show'])->name('show');
Route::post('/', [SessionController::class, 'store'])->name('store');
Route::post('{id}/end', [SessionController::class, 'end'])->name('end');
Route::post('{id}/continue', [SessionController::class, 'continue'])->name('continue');
});
});