feat: add plan/session/phase/task Actions + slim MCP tools
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:
parent
8b8a9c26e5
commit
6f0618692a
35 changed files with 1814 additions and 551 deletions
|
|
@ -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.
|
||||
|
|
|
|||
69
Actions/Phase/AddCheckpoint.php
Normal file
69
Actions/Phase/AddCheckpoint.php
Normal 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();
|
||||
}
|
||||
}
|
||||
66
Actions/Phase/GetPhase.php
Normal file
66
Actions/Phase/GetPhase.php
Normal 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();
|
||||
}
|
||||
}
|
||||
79
Actions/Phase/UpdatePhaseStatus.php
Normal file
79
Actions/Phase/UpdatePhaseStatus.php
Normal 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();
|
||||
}
|
||||
}
|
||||
51
Actions/Plan/ArchivePlan.php
Normal file
51
Actions/Plan/ArchivePlan.php
Normal 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();
|
||||
}
|
||||
}
|
||||
89
Actions/Plan/CreatePlan.php
Normal file
89
Actions/Plan/CreatePlan.php
Normal 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
50
Actions/Plan/GetPlan.php
Normal 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;
|
||||
}
|
||||
}
|
||||
59
Actions/Plan/ListPlans.php
Normal file
59
Actions/Plan/ListPlans.php
Normal 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();
|
||||
}
|
||||
}
|
||||
54
Actions/Plan/UpdatePlanStatus.php
Normal file
54
Actions/Plan/UpdatePlanStatus.php
Normal 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();
|
||||
}
|
||||
}
|
||||
56
Actions/Session/ContinueSession.php
Normal file
56
Actions/Session/ContinueSession.php
Normal 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;
|
||||
}
|
||||
}
|
||||
56
Actions/Session/EndSession.php
Normal file
56
Actions/Session/EndSession.php
Normal 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;
|
||||
}
|
||||
}
|
||||
49
Actions/Session/GetSession.php
Normal file
49
Actions/Session/GetSession.php
Normal 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;
|
||||
}
|
||||
}
|
||||
68
Actions/Session/ListSessions.php
Normal file
68
Actions/Session/ListSessions.php
Normal 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();
|
||||
}
|
||||
}
|
||||
56
Actions/Session/StartSession.php
Normal file
56
Actions/Session/StartSession.php
Normal 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);
|
||||
}
|
||||
}
|
||||
90
Actions/Task/ToggleTask.php
Normal file
90
Actions/Task/ToggleTask.php
Normal 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
101
Actions/Task/UpdateTask.php
Normal 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();
|
||||
}
|
||||
}
|
||||
115
Controllers/Api/PhaseController.php
Normal file
115
Controllers/Api/PhaseController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
170
Controllers/Api/PlanController.php
Normal file
170
Controllers/Api/PlanController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
173
Controllers/Api/SessionController.php
Normal file
173
Controllers/Api/SessionController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
68
Controllers/Api/TaskController.php
Normal file
68
Controllers/Api/TaskController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Reference in a new issue