feat(agent): implement fleet and sync RFC surfaces

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Virgil 2026-03-31 07:27:15 +00:00
parent e531462bda
commit 6c69005aff
44 changed files with 2196 additions and 57 deletions

View file

@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Agentic\Actions\Auth;
use Core\Actions\Action;
use Core\Mod\Agentic\Models\AgentApiKey;
use Core\Mod\Agentic\Services\AgentApiKeyService;
use Illuminate\Support\Carbon;
class ProvisionAgentKey
{
use Action;
/**
* @param array<string> $permissions
*
* @throws \InvalidArgumentException
*/
public function handle(
int $workspaceId,
string $oauthUserId,
?string $name = null,
array $permissions = [],
int $rateLimit = 100,
?string $expiresAt = null
): AgentApiKey {
if ($workspaceId <= 0) {
throw new \InvalidArgumentException('workspace_id is required');
}
if ($oauthUserId === '') {
throw new \InvalidArgumentException('oauth_user_id is required');
}
$service = app(AgentApiKeyService::class);
return $service->create(
$workspaceId,
$name ?: 'agent-'.$oauthUserId,
$permissions,
$rateLimit,
$expiresAt ? Carbon::parse($expiresAt) : null,
);
}
}

View file

@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Agentic\Actions\Auth;
use Core\Actions\Action;
use Core\Mod\Agentic\Models\AgentApiKey;
use Core\Mod\Agentic\Services\AgentApiKeyService;
class RevokeAgentKey
{
use Action;
/**
* @throws \InvalidArgumentException
*/
public function handle(int $workspaceId, int $keyId): bool
{
$key = AgentApiKey::query()
->where('workspace_id', $workspaceId)
->find($keyId);
if (! $key) {
throw new \InvalidArgumentException('API key not found');
}
app(AgentApiKeyService::class)->revoke($key);
return true;
}
}

View file

@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Agentic\Actions\Credits;
use Core\Actions\Action;
use Core\Mod\Agentic\Models\CreditEntry;
use Core\Mod\Agentic\Models\FleetNode;
class AwardCredits
{
use Action;
/**
* @throws \InvalidArgumentException
*/
public function handle(
int $workspaceId,
string $agentId,
string $taskType,
int $amount,
?int $fleetNodeId = null,
?string $description = null
): CreditEntry {
if ($agentId === '' || $taskType === '' || $amount === 0) {
throw new \InvalidArgumentException('agent_id, task_type, and non-zero amount are required');
}
$node = $fleetNodeId !== null
? FleetNode::query()->where('workspace_id', $workspaceId)->find($fleetNodeId)
: FleetNode::query()->where('workspace_id', $workspaceId)->where('agent_id', $agentId)->first();
if (! $node) {
throw new \InvalidArgumentException('Fleet node not found');
}
$previousBalance = (int) CreditEntry::query()
->where('workspace_id', $workspaceId)
->where('fleet_node_id', $node->id)
->latest('id')
->value('balance_after');
return CreditEntry::create([
'workspace_id' => $workspaceId,
'fleet_node_id' => $node->id,
'task_type' => $taskType,
'amount' => $amount,
'balance_after' => $previousBalance + $amount,
'description' => $description,
]);
}
}

View file

@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Agentic\Actions\Credits;
use Core\Actions\Action;
use Core\Mod\Agentic\Models\CreditEntry;
use Core\Mod\Agentic\Models\FleetNode;
class GetBalance
{
use Action;
/**
* @return array<string, mixed>
*
* @throws \InvalidArgumentException
*/
public function handle(int $workspaceId, string $agentId): array
{
$node = FleetNode::query()
->where('workspace_id', $workspaceId)
->where('agent_id', $agentId)
->first();
if (! $node) {
throw new \InvalidArgumentException('Fleet node not found');
}
$balance = (int) CreditEntry::query()
->where('workspace_id', $workspaceId)
->where('fleet_node_id', $node->id)
->latest('id')
->value('balance_after');
return [
'agent_id' => $agentId,
'balance' => $balance,
'entries' => CreditEntry::query()
->where('workspace_id', $workspaceId)
->where('fleet_node_id', $node->id)
->count(),
];
}
}

View file

@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Agentic\Actions\Credits;
use Core\Actions\Action;
use Core\Mod\Agentic\Models\CreditEntry;
use Core\Mod\Agentic\Models\FleetNode;
use Illuminate\Database\Eloquent\Collection;
class GetCreditHistory
{
use Action;
/**
* @throws \InvalidArgumentException
*/
public function handle(int $workspaceId, string $agentId, int $limit = 50): Collection
{
$node = FleetNode::query()
->where('workspace_id', $workspaceId)
->where('agent_id', $agentId)
->first();
if (! $node) {
throw new \InvalidArgumentException('Fleet node not found');
}
return CreditEntry::query()
->where('workspace_id', $workspaceId)
->where('fleet_node_id', $node->id)
->latest()
->limit($limit)
->get();
}
}

View file

@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Agentic\Actions\Fleet;
use Core\Actions\Action;
use Core\Mod\Agentic\Models\FleetNode;
use Core\Mod\Agentic\Models\FleetTask;
class AssignTask
{
use Action;
/**
* @throws \InvalidArgumentException
*/
public function handle(
int $workspaceId,
string $agentId,
string $task,
string $repo,
?string $template = null,
?string $branch = null,
?string $agentModel = null
): FleetTask {
$node = FleetNode::query()
->where('workspace_id', $workspaceId)
->where('agent_id', $agentId)
->first();
if (! $node) {
throw new \InvalidArgumentException('Fleet node not found');
}
if ($task === '' || $repo === '') {
throw new \InvalidArgumentException('repo and task are required');
}
$fleetTask = FleetTask::create([
'workspace_id' => $workspaceId,
'fleet_node_id' => $node->id,
'repo' => $repo,
'branch' => $branch,
'task' => $task,
'template' => $template,
'agent_model' => $agentModel,
'status' => FleetTask::STATUS_ASSIGNED,
]);
$node->update([
'status' => FleetNode::STATUS_BUSY,
'current_task_id' => $fleetTask->id,
]);
return $fleetTask->fresh();
}
}

View file

@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Agentic\Actions\Fleet;
use Core\Actions\Action;
use Core\Mod\Agentic\Actions\Credits\AwardCredits;
use Core\Mod\Agentic\Models\FleetNode;
use Core\Mod\Agentic\Models\FleetTask;
class CompleteTask
{
use Action;
/**
* @param array<string, mixed> $result
* @param array<int, mixed> $findings
* @param array<string, mixed> $changes
* @param array<string, mixed> $report
*
* @throws \InvalidArgumentException
*/
public function handle(
int $workspaceId,
string $agentId,
int $taskId,
array $result = [],
array $findings = [],
array $changes = [],
array $report = []
): FleetTask {
$node = FleetNode::query()
->where('workspace_id', $workspaceId)
->where('agent_id', $agentId)
->first();
$fleetTask = FleetTask::query()
->where('workspace_id', $workspaceId)
->find($taskId);
if (! $node || ! $fleetTask) {
throw new \InvalidArgumentException('Fleet task not found');
}
$status = ($result['status'] ?? '') === 'failed'
? FleetTask::STATUS_FAILED
: FleetTask::STATUS_COMPLETED;
$fleetTask->update([
'status' => $status,
'result' => $result,
'findings' => $findings,
'changes' => $changes,
'report' => $report,
'completed_at' => now(),
]);
$node->update([
'status' => FleetNode::STATUS_ONLINE,
'current_task_id' => null,
'last_heartbeat_at' => now(),
]);
$creditAmount = max(1, count($findings) + 1);
AwardCredits::run($workspaceId, $agentId, 'fleet-task', $creditAmount, $node->id, 'Fleet task completed');
return $fleetTask->fresh();
}
}

View file

@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Agentic\Actions\Fleet;
use Core\Actions\Action;
use Core\Mod\Agentic\Models\FleetNode;
class DeregisterNode
{
use Action;
/**
* @throws \InvalidArgumentException
*/
public function handle(int $workspaceId, string $agentId): bool
{
$node = FleetNode::query()
->where('workspace_id', $workspaceId)
->where('agent_id', $agentId)
->first();
if (! $node) {
throw new \InvalidArgumentException('Fleet node not found');
}
$node->update([
'status' => FleetNode::STATUS_OFFLINE,
'current_task_id' => null,
'last_heartbeat_at' => now(),
]);
return true;
}
}

View file

@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Agentic\Actions\Fleet;
use Core\Actions\Action;
use Core\Mod\Agentic\Models\FleetNode;
use Core\Mod\Agentic\Models\FleetTask;
class GetFleetStats
{
use Action;
/**
* @return array<string, int>
*/
public function handle(int $workspaceId): array
{
$nodes = FleetNode::query()->where('workspace_id', $workspaceId);
$tasks = FleetTask::query()->where('workspace_id', $workspaceId);
return [
'nodes_online' => (clone $nodes)->online()->count(),
'tasks_today' => (clone $tasks)->whereDate('created_at', today())->count(),
'tasks_week' => (clone $tasks)->where('created_at', '>=', now()->subDays(7))->count(),
'repos_touched' => (clone $tasks)->distinct('repo')->count('repo'),
'findings_total' => (clone $tasks)->get()->sum(static fn (FleetTask $task) => count($task->findings ?? [])),
'compute_hours' => 0,
];
}
}

View file

@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Agentic\Actions\Fleet;
use Core\Actions\Action;
use Core\Mod\Agentic\Models\FleetNode;
use Core\Mod\Agentic\Models\FleetTask;
class GetNextTask
{
use Action;
/**
* @param array<string, mixed> $capabilities
*
* @throws \InvalidArgumentException
*/
public function handle(int $workspaceId, string $agentId, array $capabilities = []): ?FleetTask
{
$node = FleetNode::query()
->where('workspace_id', $workspaceId)
->where('agent_id', $agentId)
->first();
if (! $node) {
throw new \InvalidArgumentException('Fleet node not found');
}
$task = FleetTask::pendingForNode($node)->first();
if (! $task) {
return null;
}
$task->update([
'status' => FleetTask::STATUS_IN_PROGRESS,
'started_at' => $task->started_at ?? now(),
]);
$node->update([
'status' => FleetNode::STATUS_BUSY,
'current_task_id' => $task->id,
]);
return $task->fresh();
}
}

View file

@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Agentic\Actions\Fleet;
use Core\Actions\Action;
use Core\Mod\Agentic\Models\FleetNode;
use Illuminate\Database\Eloquent\Collection;
class ListNodes
{
use Action;
public function handle(int $workspaceId, ?string $status = null, ?string $platform = null): Collection
{
$query = FleetNode::query()->where('workspace_id', $workspaceId);
if ($status !== null && $status !== '') {
$query->where('status', $status);
}
if ($platform !== null && $platform !== '') {
$query->where('platform', $platform);
}
return $query->orderBy('agent_id')->get();
}
}

View file

@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Agentic\Actions\Fleet;
use Core\Actions\Action;
use Core\Mod\Agentic\Models\FleetNode;
class NodeHeartbeat
{
use Action;
/**
* @param array<string, mixed> $computeBudget
*
* @throws \InvalidArgumentException
*/
public function handle(int $workspaceId, string $agentId, string $status, array $computeBudget = []): FleetNode
{
$node = FleetNode::query()
->where('workspace_id', $workspaceId)
->where('agent_id', $agentId)
->first();
if (! $node) {
throw new \InvalidArgumentException('Fleet node not found');
}
$node->update([
'status' => $status !== '' ? $status : $node->status,
'compute_budget' => $computeBudget !== [] ? $computeBudget : $node->compute_budget,
'last_heartbeat_at' => now(),
]);
return $node->fresh();
}
}

View file

@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Agentic\Actions\Fleet;
use Core\Actions\Action;
use Core\Mod\Agentic\Models\FleetNode;
class RegisterNode
{
use Action;
/**
* @param array<string> $models
* @param array<string, mixed> $capabilities
*
* @throws \InvalidArgumentException
*/
public function handle(int $workspaceId, string $agentId, string $platform, array $models = [], array $capabilities = []): FleetNode
{
if ($workspaceId <= 0) {
throw new \InvalidArgumentException('workspace_id is required');
}
if ($agentId === '') {
throw new \InvalidArgumentException('agent_id is required');
}
return FleetNode::updateOrCreate(
['agent_id' => $agentId],
[
'workspace_id' => $workspaceId,
'platform' => $platform !== '' ? $platform : 'unknown',
'models' => $models,
'capabilities' => $capabilities,
'status' => FleetNode::STATUS_ONLINE,
'registered_at' => now(),
'last_heartbeat_at' => now(),
],
);
}
}

View file

@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Agentic\Actions\Subscription;
use Core\Actions\Action;
class DetectCapabilities
{
use Action;
/**
* @param array<string, string> $apiKeys
* @return array<string, mixed>
*/
public function handle(array $apiKeys = []): array
{
$resolved = [
'claude' => ($apiKeys['claude'] ?? '') !== '' || (string) config('agentic.claude.api_key', '') !== '',
'gemini' => ($apiKeys['gemini'] ?? '') !== '' || (string) config('agentic.gemini.api_key', '') !== '',
'openai' => ($apiKeys['openai'] ?? '') !== '' || (string) config('agentic.openai.api_key', '') !== '',
];
return [
'providers' => $resolved,
'available' => array_keys(array_filter($resolved)),
];
}
}

View file

@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Agentic\Actions\Subscription;
use Core\Actions\Action;
use Core\Mod\Agentic\Models\FleetNode;
class GetNodeBudget
{
use Action;
/**
* @return array<string, mixed>
*
* @throws \InvalidArgumentException
*/
public function handle(int $workspaceId, string $agentId): array
{
$node = FleetNode::query()
->where('workspace_id', $workspaceId)
->where('agent_id', $agentId)
->first();
if (! $node) {
throw new \InvalidArgumentException('Fleet node not found');
}
return $node->compute_budget ?? [];
}
}

View file

@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Agentic\Actions\Subscription;
use Core\Actions\Action;
use Core\Mod\Agentic\Models\FleetNode;
class UpdateBudget
{
use Action;
/**
* @param array<string, mixed> $limits
* @return array<string, mixed>
*
* @throws \InvalidArgumentException
*/
public function handle(int $workspaceId, string $agentId, array $limits): array
{
$node = FleetNode::query()
->where('workspace_id', $workspaceId)
->where('agent_id', $agentId)
->first();
if (! $node) {
throw new \InvalidArgumentException('Fleet node not found');
}
$node->update([
'compute_budget' => array_merge($node->compute_budget ?? [], $limits),
'last_heartbeat_at' => now(),
]);
return $node->fresh()->compute_budget ?? [];
}
}

View file

@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Agentic\Actions\Sync;
use Core\Actions\Action;
use Core\Mod\Agentic\Models\FleetNode;
use Core\Mod\Agentic\Models\SyncRecord;
class GetAgentSyncStatus
{
use Action;
/**
* @return array<string, mixed>
*
* @throws \InvalidArgumentException
*/
public function handle(int $workspaceId, string $agentId): array
{
$node = FleetNode::query()
->where('workspace_id', $workspaceId)
->where('agent_id', $agentId)
->first();
if (! $node) {
throw new \InvalidArgumentException('Fleet node not found');
}
$lastPush = SyncRecord::query()
->where('fleet_node_id', $node->id)
->where('direction', 'push')
->latest('synced_at')
->first();
$lastPull = SyncRecord::query()
->where('fleet_node_id', $node->id)
->where('direction', 'pull')
->latest('synced_at')
->first();
return [
'agent_id' => $node->agent_id,
'status' => $node->status,
'last_push_at' => $lastPush?->synced_at?->toIso8601String(),
'last_pull_at' => $lastPull?->synced_at?->toIso8601String(),
];
}
}

View file

@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Agentic\Actions\Sync;
use Core\Actions\Action;
use Core\Mod\Agentic\Models\BrainMemory;
use Core\Mod\Agentic\Models\FleetNode;
use Core\Mod\Agentic\Models\SyncRecord;
use Illuminate\Support\Carbon;
class PullFleetContext
{
use Action;
/**
* @return array<int, array<string, mixed>>
*
* @throws \InvalidArgumentException
*/
public function handle(int $workspaceId, string $agentId, ?string $since = null): array
{
if ($agentId === '') {
throw new \InvalidArgumentException('agent_id is required');
}
$query = BrainMemory::query()
->forWorkspace($workspaceId)
->active()
->latest();
if ($since !== null && $since !== '') {
$query->where('created_at', '>=', Carbon::parse($since));
}
$items = $query->limit(25)->get();
$node = FleetNode::query()
->where('workspace_id', $workspaceId)
->where('agent_id', $agentId)
->first();
if ($node) {
SyncRecord::create([
'fleet_node_id' => $node->id,
'direction' => 'pull',
'payload_size' => strlen((string) json_encode($items->toArray())),
'items_count' => $items->count(),
'synced_at' => now(),
]);
}
return $items->map(fn (BrainMemory $memory) => $memory->toMcpContext())->values()->all();
}
}

View file

@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Agentic\Actions\Sync;
use Core\Actions\Action;
use Core\Mod\Agentic\Models\BrainMemory;
use Core\Mod\Agentic\Models\FleetNode;
use Core\Mod\Agentic\Models\SyncRecord;
class PushDispatchHistory
{
use Action;
/**
* @param array<int, array<string, mixed>> $dispatches
* @return array{synced: int}
*
* @throws \InvalidArgumentException
*/
public function handle(int $workspaceId, string $agentId, array $dispatches): array
{
if ($agentId === '') {
throw new \InvalidArgumentException('agent_id is required');
}
$node = FleetNode::firstOrCreate(
['agent_id' => $agentId],
[
'workspace_id' => $workspaceId,
'platform' => 'remote',
'status' => FleetNode::STATUS_ONLINE,
'registered_at' => now(),
'last_heartbeat_at' => now(),
],
);
$synced = 0;
foreach ($dispatches as $dispatch) {
$repo = (string) ($dispatch['repo'] ?? '');
$status = (string) ($dispatch['status'] ?? 'completed');
$workspace = (string) ($dispatch['workspace'] ?? '');
$task = (string) ($dispatch['task'] ?? '');
if ($repo === '' && $workspace === '') {
continue;
}
BrainMemory::create([
'workspace_id' => $workspaceId,
'agent_id' => $agentId,
'type' => 'observation',
'content' => trim("Repo: {$repo}\nWorkspace: {$workspace}\nStatus: {$status}\nTask: {$task}"),
'tags' => array_values(array_filter([
'sync',
$repo !== '' ? $repo : null,
$status,
])),
'project' => $repo !== '' ? $repo : null,
'confidence' => 0.7,
'source' => 'sync.push',
]);
$synced++;
}
SyncRecord::create([
'fleet_node_id' => $node->id,
'direction' => 'push',
'payload_size' => strlen((string) json_encode($dispatches)),
'items_count' => count($dispatches),
'synced_at' => now(),
]);
return ['synced' => $synced];
}
}

View file

@ -198,14 +198,52 @@ class Boot extends ServiceProvider
{
$registry = $this->app->make(Services\AgentToolRegistry::class);
$registry->registerMany([
new Mcp\Tools\Agent\Brain\BrainRemember(),
new Mcp\Tools\Agent\Brain\BrainRecall(),
new Mcp\Tools\Agent\Brain\BrainForget(),
new Mcp\Tools\Agent\Brain\BrainList(),
new Mcp\Tools\Agent\Messaging\AgentSend(),
new Mcp\Tools\Agent\Messaging\AgentInbox(),
new Mcp\Tools\Agent\Messaging\AgentConversation(),
]);
$toolClasses = [
Mcp\Tools\Agent\Brain\BrainRemember::class,
Mcp\Tools\Agent\Brain\BrainRecall::class,
Mcp\Tools\Agent\Brain\BrainForget::class,
Mcp\Tools\Agent\Brain\BrainList::class,
Mcp\Tools\Agent\Messaging\AgentSend::class,
Mcp\Tools\Agent\Messaging\AgentInbox::class,
Mcp\Tools\Agent\Messaging\AgentConversation::class,
Mcp\Tools\Agent\Plan\PlanCreate::class,
Mcp\Tools\Agent\Plan\PlanGet::class,
Mcp\Tools\Agent\Plan\PlanList::class,
Mcp\Tools\Agent\Plan\PlanUpdateStatus::class,
Mcp\Tools\Agent\Plan\PlanArchive::class,
Mcp\Tools\Agent\Phase\PhaseGet::class,
Mcp\Tools\Agent\Phase\PhaseUpdateStatus::class,
Mcp\Tools\Agent\Phase\PhaseAddCheckpoint::class,
Mcp\Tools\Agent\Session\SessionStart::class,
Mcp\Tools\Agent\Session\SessionEnd::class,
Mcp\Tools\Agent\Session\SessionLog::class,
Mcp\Tools\Agent\Session\SessionHandoff::class,
Mcp\Tools\Agent\Session\SessionResume::class,
Mcp\Tools\Agent\Session\SessionReplay::class,
Mcp\Tools\Agent\Session\SessionContinue::class,
Mcp\Tools\Agent\Session\SessionArtifact::class,
Mcp\Tools\Agent\Session\SessionList::class,
Mcp\Tools\Agent\State\StateSet::class,
Mcp\Tools\Agent\State\StateGet::class,
Mcp\Tools\Agent\State\StateList::class,
Mcp\Tools\Agent\Task\TaskUpdate::class,
Mcp\Tools\Agent\Task\TaskToggle::class,
Mcp\Tools\Agent\Template\TemplateList::class,
Mcp\Tools\Agent\Template\TemplatePreview::class,
Mcp\Tools\Agent\Template\TemplateCreatePlan::class,
Mcp\Tools\Agent\Content\ContentGenerate::class,
Mcp\Tools\Agent\Content\ContentBatchGenerate::class,
Mcp\Tools\Agent\Content\ContentBriefCreate::class,
Mcp\Tools\Agent\Content\ContentBriefGet::class,
Mcp\Tools\Agent\Content\ContentBriefList::class,
Mcp\Tools\Agent\Content\ContentStatus::class,
Mcp\Tools\Agent\Content\ContentUsageStats::class,
Mcp\Tools\Agent\Content\ContentFromPlan::class,
];
$registry->registerMany(array_map(
static fn (string $toolClass) => new $toolClass(),
$toolClasses,
));
}
}

View file

@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Agentic\Controllers\Api;
use Core\Front\Controller;
use Core\Mod\Agentic\Actions\Auth\ProvisionAgentKey;
use Core\Mod\Agentic\Actions\Auth\RevokeAgentKey;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class AuthController extends Controller
{
public function provision(Request $request): JsonResponse
{
$validated = $request->validate([
'workspace_id' => 'required|integer',
'oauth_user_id' => 'required|string|max:255',
'name' => 'nullable|string|max:255',
'permissions' => 'nullable|array',
'permissions.*' => 'string',
'rate_limit' => 'nullable|integer|min:1',
'expires_at' => 'nullable|date',
]);
$key = ProvisionAgentKey::run(
(int) $validated['workspace_id'],
$validated['oauth_user_id'],
$validated['name'] ?? null,
$validated['permissions'] ?? [],
(int) ($validated['rate_limit'] ?? 100),
$validated['expires_at'] ?? null,
);
return response()->json([
'data' => [
'id' => $key->id,
'name' => $key->name,
'plain_text_key' => $key->plainTextKey,
'permissions' => $key->permissions ?? [],
'rate_limit' => $key->rate_limit,
],
], 201);
}
public function revoke(Request $request, int $keyId): JsonResponse
{
RevokeAgentKey::run((int) $request->attributes->get('workspace_id'), $keyId);
return response()->json([
'data' => [
'key_id' => $keyId,
'revoked' => true,
],
]);
}
}

View file

@ -33,11 +33,12 @@ class BrainController extends Controller
]);
$workspace = $request->attributes->get('workspace');
$apiKey = $request->attributes->get('api_key');
$workspaceId = (int) ($request->attributes->get('workspace_id') ?? $workspace?->id);
$apiKey = $request->attributes->get('api_key') ?? $request->attributes->get('agent_api_key');
$agentId = $apiKey?->name ?? 'api';
try {
$memory = RememberKnowledge::run($validated, $workspace->id, $agentId);
$memory = RememberKnowledge::run($validated, $workspaceId, $agentId);
return response()->json([
'data' => $memory->toMcpContext(),
@ -73,11 +74,12 @@ class BrainController extends Controller
]);
$workspace = $request->attributes->get('workspace');
$workspaceId = (int) ($request->attributes->get('workspace_id') ?? $workspace?->id);
try {
$result = RecallKnowledge::run(
$validated['query'],
$workspace->id,
$workspaceId,
$validated['filter'] ?? [],
$validated['top_k'] ?? 5,
);
@ -110,11 +112,12 @@ class BrainController extends Controller
]);
$workspace = $request->attributes->get('workspace');
$apiKey = $request->attributes->get('api_key');
$workspaceId = (int) ($request->attributes->get('workspace_id') ?? $workspace?->id);
$apiKey = $request->attributes->get('api_key') ?? $request->attributes->get('agent_api_key');
$agentId = $apiKey?->name ?? 'api';
try {
$result = ForgetKnowledge::run($id, $workspace->id, $agentId, $request->input('reason'));
$result = ForgetKnowledge::run($id, $workspaceId, $agentId, $request->input('reason'));
return response()->json([
'data' => $result,
@ -147,9 +150,10 @@ class BrainController extends Controller
]);
$workspace = $request->attributes->get('workspace');
$workspaceId = (int) ($request->attributes->get('workspace_id') ?? $workspace?->id);
try {
$result = ListKnowledge::run($workspace->id, $validated);
$result = ListKnowledge::run($workspaceId, $validated);
return response()->json([
'data' => $result,

View file

@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Agentic\Controllers\Api;
use Core\Front\Controller;
use Core\Mod\Agentic\Actions\Credits\AwardCredits;
use Core\Mod\Agentic\Actions\Credits\GetBalance;
use Core\Mod\Agentic\Actions\Credits\GetCreditHistory;
use Core\Mod\Agentic\Models\CreditEntry;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class CreditsController extends Controller
{
public function award(Request $request): JsonResponse
{
$validated = $request->validate([
'agent_id' => 'required|string|max:255',
'task_type' => 'required|string|max:255',
'amount' => 'required|integer|not_in:0',
'fleet_node_id' => 'nullable|integer',
'description' => 'nullable|string|max:1000',
]);
$entry = AwardCredits::run(
(int) $request->attributes->get('workspace_id'),
$validated['agent_id'],
$validated['task_type'],
(int) $validated['amount'],
isset($validated['fleet_node_id']) ? (int) $validated['fleet_node_id'] : null,
$validated['description'] ?? null,
);
return response()->json(['data' => $this->formatEntry($entry)], 201);
}
public function balance(Request $request, string $agentId): JsonResponse
{
$balance = GetBalance::run((int) $request->attributes->get('workspace_id'), $agentId);
return response()->json(['data' => $balance]);
}
public function history(Request $request, string $agentId): JsonResponse
{
$limit = (int) $request->query('limit', 50);
$entries = GetCreditHistory::run((int) $request->attributes->get('workspace_id'), $agentId, $limit);
return response()->json([
'data' => $entries->map(fn (CreditEntry $entry) => $this->formatEntry($entry))->values()->all(),
'total' => $entries->count(),
]);
}
/**
* @return array<string, mixed>
*/
private function formatEntry(CreditEntry $entry): array
{
return [
'id' => $entry->id,
'task_type' => $entry->task_type,
'amount' => $entry->amount,
'balance_after' => $entry->balance_after,
'description' => $entry->description,
'created_at' => $entry->created_at?->toIso8601String(),
];
}
}

View file

@ -0,0 +1,233 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Agentic\Controllers\Api;
use Core\Front\Controller;
use Core\Mod\Agentic\Actions\Fleet\AssignTask;
use Core\Mod\Agentic\Actions\Fleet\CompleteTask;
use Core\Mod\Agentic\Actions\Fleet\DeregisterNode;
use Core\Mod\Agentic\Actions\Fleet\GetFleetStats;
use Core\Mod\Agentic\Actions\Fleet\GetNextTask;
use Core\Mod\Agentic\Actions\Fleet\ListNodes;
use Core\Mod\Agentic\Actions\Fleet\NodeHeartbeat;
use Core\Mod\Agentic\Actions\Fleet\RegisterNode;
use Core\Mod\Agentic\Models\FleetNode;
use Core\Mod\Agentic\Models\FleetTask;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\StreamedResponse;
class FleetController extends Controller
{
public function register(Request $request): JsonResponse
{
$validated = $request->validate([
'agent_id' => 'required|string|max:255',
'platform' => 'required|string|max:64',
'models' => 'nullable|array',
'models.*' => 'string',
'capabilities' => 'nullable|array',
]);
$node = RegisterNode::run(
(int) $request->attributes->get('workspace_id'),
$validated['agent_id'],
$validated['platform'],
$validated['models'] ?? [],
$validated['capabilities'] ?? [],
);
return response()->json(['data' => $this->formatNode($node)], 201);
}
public function heartbeat(Request $request): JsonResponse
{
$validated = $request->validate([
'agent_id' => 'required|string|max:255',
'status' => 'required|string|max:32',
'compute_budget' => 'nullable|array',
]);
$node = NodeHeartbeat::run(
(int) $request->attributes->get('workspace_id'),
$validated['agent_id'],
$validated['status'],
$validated['compute_budget'] ?? [],
);
return response()->json(['data' => $this->formatNode($node)]);
}
public function deregister(Request $request): JsonResponse
{
$validated = $request->validate([
'agent_id' => 'required|string|max:255',
]);
DeregisterNode::run((int) $request->attributes->get('workspace_id'), $validated['agent_id']);
return response()->json(['data' => ['agent_id' => $validated['agent_id'], 'deregistered' => true]]);
}
public function index(Request $request): JsonResponse
{
$validated = $request->validate([
'status' => 'nullable|string|max:32',
'platform' => 'nullable|string|max:64',
]);
$nodes = ListNodes::run(
(int) $request->attributes->get('workspace_id'),
$validated['status'] ?? null,
$validated['platform'] ?? null,
);
return response()->json([
'data' => $nodes->map(fn (FleetNode $node) => $this->formatNode($node))->values()->all(),
'total' => $nodes->count(),
]);
}
public function assignTask(Request $request): JsonResponse
{
$validated = $request->validate([
'agent_id' => 'required|string|max:255',
'repo' => 'required|string|max:255',
'branch' => 'nullable|string|max:255',
'task' => 'required|string|max:10000',
'template' => 'nullable|string|max:255',
'agent_model' => 'nullable|string|max:255',
]);
$fleetTask = AssignTask::run(
(int) $request->attributes->get('workspace_id'),
$validated['agent_id'],
$validated['task'],
$validated['repo'],
$validated['template'] ?? null,
$validated['branch'] ?? null,
$validated['agent_model'] ?? null,
);
return response()->json(['data' => $this->formatTask($fleetTask)], 201);
}
public function completeTask(Request $request): JsonResponse
{
$validated = $request->validate([
'agent_id' => 'required|string|max:255',
'task_id' => 'required|integer',
'result' => 'nullable|array',
'findings' => 'nullable|array',
'changes' => 'nullable|array',
'report' => 'nullable|array',
]);
$fleetTask = CompleteTask::run(
(int) $request->attributes->get('workspace_id'),
$validated['agent_id'],
(int) $validated['task_id'],
$validated['result'] ?? [],
$validated['findings'] ?? [],
$validated['changes'] ?? [],
$validated['report'] ?? [],
);
return response()->json(['data' => $this->formatTask($fleetTask)]);
}
public function nextTask(Request $request): JsonResponse
{
$validated = $request->validate([
'agent_id' => 'required|string|max:255',
'capabilities' => 'nullable|array',
]);
$fleetTask = GetNextTask::run(
(int) $request->attributes->get('workspace_id'),
$validated['agent_id'],
$validated['capabilities'] ?? [],
);
return response()->json(['data' => $fleetTask ? $this->formatTask($fleetTask) : null]);
}
public function events(Request $request): StreamedResponse
{
$validated = $request->validate([
'agent_id' => 'required|string|max:255',
]);
$workspaceId = (int) $request->attributes->get('workspace_id');
$agentId = $validated['agent_id'];
return response()->stream(function () use ($workspaceId, $agentId): void {
echo "event: ready\n";
echo 'data: '.json_encode(['agent_id' => $agentId])."\n\n";
$fleetTask = GetNextTask::run($workspaceId, $agentId, []);
if ($fleetTask instanceof FleetTask) {
echo "event: task.assigned\n";
echo 'data: '.json_encode($this->formatTask($fleetTask))."\n\n";
}
@ob_flush();
flush();
}, 200, [
'Content-Type' => 'text/event-stream',
'Cache-Control' => 'no-cache',
'Connection' => 'keep-alive',
'X-Accel-Buffering' => 'no',
]);
}
public function stats(Request $request): JsonResponse
{
$stats = GetFleetStats::run((int) $request->attributes->get('workspace_id'));
return response()->json(['data' => $stats]);
}
/**
* @return array<string, mixed>
*/
private function formatNode(FleetNode $node): array
{
return [
'id' => $node->id,
'agent_id' => $node->agent_id,
'platform' => $node->platform,
'models' => $node->models ?? [],
'capabilities' => $node->capabilities ?? [],
'status' => $node->status,
'compute_budget' => $node->compute_budget ?? [],
'current_task_id' => $node->current_task_id,
'last_heartbeat_at' => $node->last_heartbeat_at?->toIso8601String(),
'registered_at' => $node->registered_at?->toIso8601String(),
];
}
/**
* @return array<string, mixed>
*/
private function formatTask(FleetTask $fleetTask): array
{
return [
'id' => $fleetTask->id,
'repo' => $fleetTask->repo,
'branch' => $fleetTask->branch,
'task' => $fleetTask->task,
'template' => $fleetTask->template,
'agent_model' => $fleetTask->agent_model,
'status' => $fleetTask->status,
'result' => $fleetTask->result ?? [],
'findings' => $fleetTask->findings ?? [],
'changes' => $fleetTask->changes ?? [],
'report' => $fleetTask->report ?? [],
'started_at' => $fleetTask->started_at?->toIso8601String(),
'completed_at' => $fleetTask->completed_at?->toIso8601String(),
];
}
}

View file

@ -69,15 +69,19 @@ class MessageController extends Controller
$validated = $request->validate([
'to' => 'required|string|max:100',
'content' => 'required|string|max:10000',
'from' => 'required|string|max:100',
'from' => 'nullable|string|max:100',
'subject' => 'nullable|string|max:255',
]);
$workspaceId = $request->attributes->get('workspace_id');
$apiKey = $request->attributes->get('agent_api_key');
$from = $validated['from']
?? $apiKey?->name
?? $request->header('X-Agent-Name', 'unknown');
$message = AgentMessage::create([
'workspace_id' => $workspaceId,
'from_agent' => $validated['from'],
'from_agent' => $from,
'to_agent' => $validated['to'],
'content' => $validated['content'],
'subject' => $validated['subject'] ?? null,

View file

@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Agentic\Controllers\Api;
use Core\Front\Controller;
use Core\Mod\Agentic\Actions\Subscription\DetectCapabilities;
use Core\Mod\Agentic\Actions\Subscription\GetNodeBudget;
use Core\Mod\Agentic\Actions\Subscription\UpdateBudget;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class SubscriptionController extends Controller
{
public function detect(Request $request): JsonResponse
{
$validated = $request->validate([
'api_keys' => 'nullable|array',
]);
$capabilities = DetectCapabilities::run($validated['api_keys'] ?? []);
return response()->json(['data' => $capabilities]);
}
public function budget(Request $request, string $agentId): JsonResponse
{
$budget = GetNodeBudget::run((int) $request->attributes->get('workspace_id'), $agentId);
return response()->json(['data' => $budget]);
}
public function updateBudget(Request $request, string $agentId): JsonResponse
{
$validated = $request->validate([
'limits' => 'required|array',
]);
$budget = UpdateBudget::run(
(int) $request->attributes->get('workspace_id'),
$agentId,
$validated['limits'],
);
return response()->json(['data' => $budget]);
}
}

View file

@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Agentic\Controllers\Api;
use Core\Front\Controller;
use Core\Mod\Agentic\Actions\Sync\GetAgentSyncStatus;
use Core\Mod\Agentic\Actions\Sync\PullFleetContext;
use Core\Mod\Agentic\Actions\Sync\PushDispatchHistory;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class SyncController extends Controller
{
public function push(Request $request): JsonResponse
{
$validated = $request->validate([
'agent_id' => 'required|string|max:255',
'dispatches' => 'nullable|array',
]);
$result = PushDispatchHistory::run(
(int) $request->attributes->get('workspace_id'),
$validated['agent_id'],
$validated['dispatches'] ?? [],
);
return response()->json(['data' => $result], 201);
}
public function pull(Request $request): JsonResponse
{
$validated = $request->validate([
'agent_id' => 'required|string|max:255',
'since' => 'nullable|date',
]);
$context = PullFleetContext::run(
(int) $request->attributes->get('workspace_id'),
$validated['agent_id'],
$validated['since'] ?? null,
);
return response()->json([
'data' => $context,
'total' => count($context),
]);
}
public function status(Request $request): JsonResponse
{
$validated = $request->validate([
'agent_id' => 'required|string|max:255',
]);
$status = GetAgentSyncStatus::run(
(int) $request->attributes->get('workspace_id'),
$validated['agent_id'],
);
return response()->json(['data' => $status]);
}
}

View file

@ -72,7 +72,9 @@ class AgentApiAuth
// Store API key in request for downstream use
$request->attributes->set('agent_api_key', $key);
$request->attributes->set('api_key', $key);
$request->attributes->set('workspace_id', $key->workspace_id);
$request->attributes->set('workspace', $key->workspace);
/** @var Response $response */
$response = $next($request);

View file

@ -0,0 +1,101 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::disableForeignKeyConstraints();
if (! Schema::hasTable('fleet_nodes')) {
Schema::create('fleet_nodes', function (Blueprint $table) {
$table->id();
$table->foreignId('workspace_id')->nullable()->constrained()->nullOnDelete();
$table->string('agent_id')->unique();
$table->string('platform', 64)->default('unknown');
$table->json('models')->nullable();
$table->json('capabilities')->nullable();
$table->string('status', 32)->default('offline');
$table->json('compute_budget')->nullable();
$table->unsignedBigInteger('current_task_id')->nullable();
$table->timestamp('last_heartbeat_at')->nullable();
$table->timestamp('registered_at')->nullable();
$table->timestamps();
$table->index(['workspace_id', 'status']);
});
}
if (! Schema::hasTable('fleet_tasks')) {
Schema::create('fleet_tasks', function (Blueprint $table) {
$table->id();
$table->foreignId('workspace_id')->nullable()->constrained()->nullOnDelete();
$table->foreignId('fleet_node_id')->nullable()->constrained('fleet_nodes')->nullOnDelete();
$table->string('repo');
$table->string('branch')->nullable();
$table->text('task');
$table->string('template')->nullable();
$table->string('agent_model')->nullable();
$table->string('status', 32)->default('queued');
$table->json('result')->nullable();
$table->json('findings')->nullable();
$table->json('changes')->nullable();
$table->json('report')->nullable();
$table->timestamp('started_at')->nullable();
$table->timestamp('completed_at')->nullable();
$table->timestamps();
$table->index(['workspace_id', 'status']);
$table->index(['fleet_node_id', 'status']);
});
}
if (! Schema::hasTable('credit_entries')) {
Schema::create('credit_entries', function (Blueprint $table) {
$table->id();
$table->foreignId('workspace_id')->nullable()->constrained()->nullOnDelete();
$table->foreignId('fleet_node_id')->nullable()->constrained('fleet_nodes')->nullOnDelete();
$table->string('task_type');
$table->integer('amount');
$table->integer('balance_after');
$table->text('description')->nullable();
$table->timestamps();
$table->index(['workspace_id', 'fleet_node_id']);
});
}
if (! Schema::hasTable('sync_records')) {
Schema::create('sync_records', function (Blueprint $table) {
$table->id();
$table->foreignId('fleet_node_id')->nullable()->constrained('fleet_nodes')->nullOnDelete();
$table->string('direction', 16);
$table->unsignedInteger('payload_size')->default(0);
$table->unsignedInteger('items_count')->default(0);
$table->timestamp('synced_at')->nullable();
$table->timestamps();
$table->index(['fleet_node_id', 'direction']);
});
}
Schema::enableForeignKeyConstraints();
}
public function down(): void
{
Schema::disableForeignKeyConstraints();
Schema::dropIfExists('sync_records');
Schema::dropIfExists('credit_entries');
Schema::dropIfExists('fleet_tasks');
Schema::dropIfExists('fleet_nodes');
Schema::enableForeignKeyConstraints();
}
};

View file

@ -80,6 +80,40 @@ class AgentApiKey extends Model
public const PERM_SESSIONS_WRITE = 'sessions.write';
public const PERM_BRAIN_READ = 'brain.read';
public const PERM_BRAIN_WRITE = 'brain.write';
public const PERM_ISSUES_READ = 'issues.read';
public const PERM_ISSUES_WRITE = 'issues.write';
public const PERM_SPRINTS_READ = 'sprints.read';
public const PERM_SPRINTS_WRITE = 'sprints.write';
public const PERM_MESSAGES_READ = 'messages.read';
public const PERM_MESSAGES_WRITE = 'messages.write';
public const PERM_AUTH_WRITE = 'auth.write';
public const PERM_FLEET_READ = 'fleet.read';
public const PERM_FLEET_WRITE = 'fleet.write';
public const PERM_SYNC_READ = 'sync.read';
public const PERM_SYNC_WRITE = 'sync.write';
public const PERM_CREDITS_READ = 'credits.read';
public const PERM_CREDITS_WRITE = 'credits.write';
public const PERM_SUBSCRIPTION_READ = 'subscription.read';
public const PERM_SUBSCRIPTION_WRITE = 'subscription.write';
public const PERM_TOOLS_READ = 'tools.read';
public const PERM_TEMPLATES_READ = 'templates.read';
@ -104,6 +138,23 @@ class AgentApiKey extends Model
self::PERM_PHASES_WRITE => 'Update phase status, add/complete tasks',
self::PERM_SESSIONS_READ => 'List and view sessions',
self::PERM_SESSIONS_WRITE => 'Start, update, complete sessions',
self::PERM_BRAIN_READ => 'Recall and list brain memories',
self::PERM_BRAIN_WRITE => 'Store and forget brain memories',
self::PERM_ISSUES_READ => 'List and view issues',
self::PERM_ISSUES_WRITE => 'Create, update, and archive issues',
self::PERM_SPRINTS_READ => 'List and view sprints',
self::PERM_SPRINTS_WRITE => 'Create, update, and archive sprints',
self::PERM_MESSAGES_READ => 'Read inbox and conversation threads',
self::PERM_MESSAGES_WRITE => 'Send and acknowledge messages',
self::PERM_AUTH_WRITE => 'Provision and revoke agent API keys',
self::PERM_FLEET_READ => 'View fleet nodes, tasks, and stats',
self::PERM_FLEET_WRITE => 'Register nodes and manage fleet tasks',
self::PERM_SYNC_READ => 'Pull shared fleet context and sync status',
self::PERM_SYNC_WRITE => 'Push dispatch history to the platform',
self::PERM_CREDITS_READ => 'View agent credit balances and history',
self::PERM_CREDITS_WRITE => 'Award agent credits',
self::PERM_SUBSCRIPTION_READ => 'View node budgets and capability detection',
self::PERM_SUBSCRIPTION_WRITE => 'Update node budgets',
self::PERM_TOOLS_READ => 'View tool analytics',
self::PERM_TEMPLATES_READ => 'List and view templates',
self::PERM_TEMPLATES_INSTANTIATE => 'Create plans from templates',
@ -252,7 +303,15 @@ class AgentApiKey extends Model
// Permission helpers
public function hasPermission(string $permission): bool
{
return in_array($permission, $this->permissions ?? []);
$wanted = $this->normalisePermission($permission);
foreach ($this->permissions ?? [] as $granted) {
if ($this->normalisePermission((string) $granted) === $wanted) {
return true;
}
}
return false;
}
public function hasAnyPermission(array $permissions): bool
@ -277,6 +336,11 @@ class AgentApiKey extends Model
return true;
}
protected function normalisePermission(string $permission): string
{
return str_replace(':', '.', trim($permission));
}
// Actions
public function revoke(): self
{

View file

@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Agentic\Models;
use Core\Tenant\Concerns\BelongsToWorkspace;
use Core\Tenant\Models\Workspace;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class CreditEntry extends Model
{
use BelongsToWorkspace;
protected $fillable = [
'workspace_id',
'fleet_node_id',
'task_type',
'amount',
'balance_after',
'description',
];
protected $casts = [
'amount' => 'integer',
'balance_after' => 'integer',
];
public function workspace(): BelongsTo
{
return $this->belongsTo(Workspace::class);
}
public function fleetNode(): BelongsTo
{
return $this->belongsTo(FleetNode::class);
}
}

82
php/Models/FleetNode.php Normal file
View file

@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Agentic\Models;
use Core\Tenant\Concerns\BelongsToWorkspace;
use Core\Tenant\Models\Workspace;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class FleetNode extends Model
{
use BelongsToWorkspace;
public const STATUS_OFFLINE = 'offline';
public const STATUS_ONLINE = 'online';
public const STATUS_BUSY = 'busy';
public const STATUS_PAUSED = 'paused';
protected $fillable = [
'workspace_id',
'agent_id',
'platform',
'models',
'capabilities',
'status',
'compute_budget',
'current_task_id',
'last_heartbeat_at',
'registered_at',
];
protected $casts = [
'models' => 'array',
'capabilities' => 'array',
'compute_budget' => 'array',
'last_heartbeat_at' => 'datetime',
'registered_at' => 'datetime',
];
public function workspace(): BelongsTo
{
return $this->belongsTo(Workspace::class);
}
public function currentTask(): BelongsTo
{
return $this->belongsTo(FleetTask::class, 'current_task_id');
}
public function tasks(): HasMany
{
return $this->hasMany(FleetTask::class);
}
public function creditEntries(): HasMany
{
return $this->hasMany(CreditEntry::class);
}
public function syncRecords(): HasMany
{
return $this->hasMany(SyncRecord::class);
}
public function scopeOnline(Builder $query): Builder
{
return $query->whereIn('status', [self::STATUS_ONLINE, self::STATUS_BUSY]);
}
public function scopeIdle(Builder $query): Builder
{
return $query->where('status', self::STATUS_ONLINE)
->whereNull('current_task_id');
}
}

69
php/Models/FleetTask.php Normal file
View file

@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Agentic\Models;
use Core\Tenant\Concerns\BelongsToWorkspace;
use Core\Tenant\Models\Workspace;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class FleetTask extends Model
{
use BelongsToWorkspace;
public const STATUS_QUEUED = 'queued';
public const STATUS_ASSIGNED = 'assigned';
public const STATUS_IN_PROGRESS = 'in_progress';
public const STATUS_COMPLETED = 'completed';
public const STATUS_FAILED = 'failed';
protected $fillable = [
'workspace_id',
'fleet_node_id',
'repo',
'branch',
'task',
'template',
'agent_model',
'status',
'result',
'findings',
'changes',
'report',
'started_at',
'completed_at',
];
protected $casts = [
'result' => 'array',
'findings' => 'array',
'changes' => 'array',
'report' => 'array',
'started_at' => 'datetime',
'completed_at' => 'datetime',
];
public function workspace(): BelongsTo
{
return $this->belongsTo(Workspace::class);
}
public function fleetNode(): BelongsTo
{
return $this->belongsTo(FleetNode::class);
}
public function scopePendingForNode(Builder $query, FleetNode $node): Builder
{
return $query->where('fleet_node_id', $node->id)
->whereIn('status', [self::STATUS_ASSIGNED, self::STATUS_QUEUED])
->orderBy('created_at');
}
}

30
php/Models/SyncRecord.php Normal file
View file

@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Agentic\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class SyncRecord extends Model
{
protected $fillable = [
'fleet_node_id',
'direction',
'payload_size',
'items_count',
'synced_at',
];
protected $casts = [
'payload_size' => 'integer',
'items_count' => 'integer',
'synced_at' => 'datetime',
];
public function fleetNode(): BelongsTo
{
return $this->belongsTo(FleetNode::class);
}
}

View file

@ -3,8 +3,19 @@
declare(strict_types=1);
use Core\Mod\Agentic\Controllers\AgentApiController;
use Core\Mod\Agentic\Controllers\Api\AuthController;
use Core\Mod\Agentic\Controllers\Api\BrainController;
use Core\Mod\Agentic\Controllers\Api\CreditsController;
use Core\Mod\Agentic\Controllers\Api\FleetController;
use Core\Mod\Agentic\Controllers\Api\IssueController;
use Core\Mod\Agentic\Controllers\Api\MessageController;
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\SprintController;
use Core\Mod\Agentic\Controllers\Api\SubscriptionController;
use Core\Mod\Agentic\Controllers\Api\SyncController;
use Core\Mod\Agentic\Controllers\Api\TaskController;
use Core\Mod\Agentic\Middleware\AgentApiAuth;
use Illuminate\Support\Facades\Route;
@ -33,42 +44,46 @@ Route::middleware(['throttle:120,1', 'auth.api:brain:read'])->group(function ()
Route::get('v1/agent/checkin', [\Core\Mod\Agentic\Controllers\Api\CheckinController::class, 'checkin']);
});
// Authenticated agent endpoints
Route::middleware(AgentApiAuth::class.':brain.read')->group(function () {
Route::post('v1/brain/recall', [BrainController::class, 'recall']);
Route::get('v1/brain/list', [BrainController::class, 'list']);
});
Route::middleware(AgentApiAuth::class.':brain.write')->group(function () {
Route::post('v1/brain/remember', [BrainController::class, 'remember']);
Route::delete('v1/brain/forget/{id}', [BrainController::class, 'forget']);
});
Route::middleware(AgentApiAuth::class.':plans.read')->group(function () {
// Plans (read)
Route::get('v1/plans', [AgentApiController::class, 'listPlans']);
Route::get('v1/plans/{slug}', [AgentApiController::class, 'getPlan']);
// Phases (read)
Route::get('v1/plans/{slug}/phases/{phase}', [AgentApiController::class, 'getPhase']);
// Sessions (read)
Route::get('v1/sessions', [AgentApiController::class, 'listSessions']);
Route::get('v1/sessions/{sessionId}', [AgentApiController::class, 'getSession']);
Route::get('v1/plans', [PlanController::class, 'index']);
Route::get('v1/plans/{slug}', [PlanController::class, 'show']);
Route::get('v1/plans/{slug}/phases/{phase}', [PhaseController::class, 'show']);
});
Route::middleware(AgentApiAuth::class.':plans.write')->group(function () {
// Plans (write)
Route::post('v1/plans', [AgentApiController::class, 'createPlan']);
Route::patch('v1/plans/{slug}', [AgentApiController::class, 'updatePlan']);
Route::delete('v1/plans/{slug}', [AgentApiController::class, 'archivePlan']);
Route::post('v1/plans', [PlanController::class, 'store']);
Route::patch('v1/plans/{slug}/status', [PlanController::class, 'update']);
Route::delete('v1/plans/{slug}', [PlanController::class, 'destroy']);
});
Route::middleware(AgentApiAuth::class.':phases.write')->group(function () {
// Phases (write)
Route::patch('v1/plans/{slug}/phases/{phase}', [AgentApiController::class, 'updatePhase']);
Route::post('v1/plans/{slug}/phases/{phase}/checkpoint', [AgentApiController::class, 'addCheckpoint']);
Route::patch('v1/plans/{slug}/phases/{phase}/tasks/{taskIdx}', [AgentApiController::class, 'updateTask'])
->whereNumber('taskIdx');
Route::post('v1/plans/{slug}/phases/{phase}/tasks/{taskIdx}/toggle', [AgentApiController::class, 'toggleTask'])
->whereNumber('taskIdx');
Route::patch('v1/plans/{slug}/phases/{phase}', [PhaseController::class, 'update']);
Route::post('v1/plans/{slug}/phases/{phase}/checkpoint', [PhaseController::class, 'checkpoint']);
Route::patch('v1/plans/{slug}/phases/{phase}/tasks/{index}', [TaskController::class, 'update'])
->whereNumber('index');
Route::post('v1/plans/{slug}/phases/{phase}/tasks/{index}/toggle', [TaskController::class, 'toggle'])
->whereNumber('index');
});
Route::middleware(AgentApiAuth::class.':sessions.read')->group(function () {
Route::get('v1/sessions', [SessionController::class, 'index']);
Route::get('v1/sessions/{id}', [SessionController::class, 'show']);
});
Route::middleware(AgentApiAuth::class.':sessions.write')->group(function () {
// Sessions (write)
Route::post('v1/sessions', [AgentApiController::class, 'startSession']);
Route::post('v1/sessions/{sessionId}/end', [AgentApiController::class, 'endSession']);
Route::post('v1/sessions/{sessionId}/continue', [AgentApiController::class, 'continueSession']);
Route::post('v1/sessions', [SessionController::class, 'store']);
Route::post('v1/sessions/{id}/continue', [SessionController::class, 'continue']);
Route::post('v1/sessions/{id}/end', [SessionController::class, 'end']);
});
// Issue tracker
@ -97,13 +112,62 @@ Route::middleware(AgentApiAuth::class.':sprints.write')->group(function () {
Route::delete('v1/sprints/{slug}', [SprintController::class, 'destroy']);
});
// Agent messaging — uses auth.api (same as brain routes) so CORE_BRAIN_KEY works
Route::middleware(['throttle:120,1', 'auth.api:brain:read'])->group(function () {
Route::get('v1/messages/inbox', [\Core\Mod\Agentic\Controllers\Api\MessageController::class, 'inbox']);
Route::get('v1/messages/conversation/{agent}', [\Core\Mod\Agentic\Controllers\Api\MessageController::class, 'conversation']);
Route::middleware(AgentApiAuth::class.':messages.read')->group(function () {
Route::get('v1/messages/inbox', [MessageController::class, 'inbox']);
Route::get('v1/messages/conversation/{agent}', [MessageController::class, 'conversation']);
});
Route::middleware(['throttle:60,1', 'auth.api:brain:write'])->group(function () {
Route::post('v1/messages/send', [\Core\Mod\Agentic\Controllers\Api\MessageController::class, 'send']);
Route::post('v1/messages/{id}/read', [\Core\Mod\Agentic\Controllers\Api\MessageController::class, 'markRead']);
Route::middleware(AgentApiAuth::class.':messages.write')->group(function () {
Route::post('v1/messages/send', [MessageController::class, 'send']);
Route::post('v1/messages/{id}/read', [MessageController::class, 'markRead']);
});
Route::middleware('auth')->group(function () {
Route::post('v1/agent/auth/provision', [AuthController::class, 'provision']);
});
Route::middleware(AgentApiAuth::class.':auth.write')->group(function () {
Route::delete('v1/agent/auth/revoke/{keyId}', [AuthController::class, 'revoke']);
});
Route::middleware(AgentApiAuth::class.':fleet.write')->group(function () {
Route::post('v1/fleet/register', [FleetController::class, 'register']);
Route::post('v1/fleet/heartbeat', [FleetController::class, 'heartbeat']);
Route::post('v1/fleet/deregister', [FleetController::class, 'deregister']);
Route::post('v1/fleet/task/assign', [FleetController::class, 'assignTask']);
Route::post('v1/fleet/task/complete', [FleetController::class, 'completeTask']);
});
Route::middleware(AgentApiAuth::class.':fleet.read')->group(function () {
Route::get('v1/fleet/nodes', [FleetController::class, 'index']);
Route::get('v1/fleet/task/next', [FleetController::class, 'nextTask']);
Route::get('v1/fleet/events', [FleetController::class, 'events']);
Route::get('v1/fleet/stats', [FleetController::class, 'stats']);
});
Route::middleware(AgentApiAuth::class.':sync.write')->group(function () {
Route::post('v1/agent/sync', [SyncController::class, 'push']);
});
Route::middleware(AgentApiAuth::class.':sync.read')->group(function () {
Route::get('v1/agent/context', [SyncController::class, 'pull']);
Route::get('v1/agent/status', [SyncController::class, 'status']);
});
Route::middleware(AgentApiAuth::class.':credits.write')->group(function () {
Route::post('v1/credits/award', [CreditsController::class, 'award']);
});
Route::middleware(AgentApiAuth::class.':credits.read')->group(function () {
Route::get('v1/credits/balance/{agentId}', [CreditsController::class, 'balance']);
Route::get('v1/credits/history/{agentId}', [CreditsController::class, 'history']);
});
Route::middleware(AgentApiAuth::class.':subscription.write')->group(function () {
Route::post('v1/subscription/detect', [SubscriptionController::class, 'detect']);
Route::put('v1/subscription/budget/{agentId}', [SubscriptionController::class, 'updateBudget']);
});
Route::middleware(AgentApiAuth::class.':subscription.read')->group(function () {
Route::get('v1/subscription/budget/{agentId}', [SubscriptionController::class, 'budget']);
});

View file

@ -6,6 +6,7 @@ import (
"context"
"time"
"dappco.re/go/agent/pkg/messages"
core "dappco.re/go/core"
)
@ -51,6 +52,13 @@ func (s *PrepSubsystem) autoCreatePR(workspaceDir string) {
}
return
}
if s.ServiceRuntime != nil {
s.Core().ACTION(messages.WorkspacePushed{
Repo: workspaceStatus.Repo,
Branch: workspaceStatus.Branch,
Org: org,
})
}
title := core.Sprintf("[agent/%s] %s", workspaceStatus.Agent, truncate(workspaceStatus.Task, 60))
body := s.buildAutoPRBody(workspaceStatus, commitCount)

View file

@ -111,6 +111,9 @@ func (s *PrepSubsystem) OnStartup(ctx context.Context) core.Result {
core.Option{Key: "token", Value: s.brainKey},
))
c.Action("agent.sync.push", s.handleSyncPush).Description = "Push completed dispatch state to the platform API"
c.Action("agent.sync.pull", s.handleSyncPull).Description = "Pull fleet context from the platform API"
c.Action("agentic.dispatch", s.handleDispatch).Description = "Prep workspace and spawn a subagent"
c.Action("agentic.prep", s.handlePrep).Description = "Clone repo and build agent prompt"
c.Action("agentic.status", s.handleStatus).Description = "List workspace states (running/completed/blocked)"

174
pkg/agentic/sync.go Normal file
View file

@ -0,0 +1,174 @@
// SPDX-License-Identifier: EUPL-1.2
package agentic
import (
"context"
core "dappco.re/go/core"
)
type SyncPushInput struct {
AgentID string `json:"agent_id,omitempty"`
}
type SyncPushOutput struct {
Success bool `json:"success"`
Count int `json:"count"`
}
type SyncPullInput struct {
AgentID string `json:"agent_id,omitempty"`
}
type SyncPullOutput struct {
Success bool `json:"success"`
Count int `json:"count"`
Context []map[string]any `json:"context"`
}
// result := c.Action("agent.sync.push").Run(ctx, core.NewOptions())
func (s *PrepSubsystem) handleSyncPush(ctx context.Context, options core.Options) core.Result {
output, err := s.syncPush(ctx, options.String("agent_id"))
if err != nil {
return core.Result{Value: err, OK: false}
}
return core.Result{Value: output, OK: true}
}
// result := c.Action("agent.sync.pull").Run(ctx, core.NewOptions())
func (s *PrepSubsystem) handleSyncPull(ctx context.Context, options core.Options) core.Result {
output, err := s.syncPull(ctx, options.String("agent_id"))
if err != nil {
return core.Result{Value: err, OK: false}
}
return core.Result{Value: output, OK: true}
}
func (s *PrepSubsystem) syncPush(ctx context.Context, agentID string) (SyncPushOutput, error) {
if agentID == "" {
agentID = AgentName()
}
token := s.syncToken()
if token == "" {
return SyncPushOutput{}, core.E("agent.sync.push", "api token is required", nil)
}
dispatches := collectSyncDispatches()
if len(dispatches) == 0 {
return SyncPushOutput{Success: true, Count: 0}, nil
}
payload := map[string]any{
"agent_id": agentID,
"dispatches": dispatches,
}
result := HTTPPost(ctx, core.Concat(s.syncAPIURL(), "/v1/agent/sync"), core.JSONMarshalString(payload), token, "Bearer")
if !result.OK {
err, _ := result.Value.(error)
if err == nil {
err = core.E("agent.sync.push", "sync push failed", nil)
}
return SyncPushOutput{}, err
}
return SyncPushOutput{Success: true, Count: len(dispatches)}, nil
}
func (s *PrepSubsystem) syncPull(ctx context.Context, agentID string) (SyncPullOutput, error) {
if agentID == "" {
agentID = AgentName()
}
token := s.syncToken()
if token == "" {
return SyncPullOutput{}, core.E("agent.sync.pull", "api token is required", nil)
}
endpoint := core.Concat(s.syncAPIURL(), "/v1/agent/context?agent_id=", agentID)
result := HTTPGet(ctx, endpoint, token, "Bearer")
if !result.OK {
err, _ := result.Value.(error)
if err == nil {
err = core.E("agent.sync.pull", "sync pull failed", nil)
}
return SyncPullOutput{}, err
}
var response struct {
Data []map[string]any `json:"data"`
}
parseResult := core.JSONUnmarshalString(result.Value.(string), &response)
if !parseResult.OK {
err, _ := parseResult.Value.(error)
if err == nil {
err = core.E("agent.sync.pull", "failed to parse sync response", nil)
}
return SyncPullOutput{}, err
}
return SyncPullOutput{
Success: true,
Count: len(response.Data),
Context: response.Data,
}, nil
}
func (s *PrepSubsystem) syncAPIURL() string {
if value := core.Env("CORE_API_URL"); value != "" {
return value
}
if s != nil && s.brainURL != "" {
return s.brainURL
}
return "https://api.lthn.sh"
}
func (s *PrepSubsystem) syncToken() string {
if value := core.Env("CORE_AGENT_API_KEY"); value != "" {
return value
}
if value := core.Env("CORE_BRAIN_KEY"); value != "" {
return value
}
if s != nil && s.brainKey != "" {
return s.brainKey
}
return ""
}
func collectSyncDispatches() []map[string]any {
var dispatches []map[string]any
for _, path := range WorkspaceStatusPaths() {
workspaceDir := core.PathDir(path)
statusResult := ReadStatusResult(workspaceDir)
workspaceStatus, ok := workspaceStatusValue(statusResult)
if !ok {
continue
}
if !shouldSyncStatus(workspaceStatus.Status) {
continue
}
dispatches = append(dispatches, map[string]any{
"workspace": WorkspaceName(workspaceDir),
"repo": workspaceStatus.Repo,
"org": workspaceStatus.Org,
"task": workspaceStatus.Task,
"agent": workspaceStatus.Agent,
"branch": workspaceStatus.Branch,
"status": workspaceStatus.Status,
"pr_url": workspaceStatus.PRURL,
"started_at": workspaceStatus.StartedAt,
"updated_at": workspaceStatus.UpdatedAt,
})
}
return dispatches
}
func shouldSyncStatus(status string) bool {
switch status {
case "completed", "merged", "failed", "blocked":
return true
}
return false
}

View file

@ -0,0 +1,13 @@
// SPDX-License-Identifier: EUPL-1.2
package agentic
import "fmt"
func Example_shouldSyncStatus() {
fmt.Println(shouldSyncStatus("completed"))
fmt.Println(shouldSyncStatus("running"))
// Output:
// true
// false
}

98
pkg/agentic/sync_test.go Normal file
View file

@ -0,0 +1,98 @@
// SPDX-License-Identifier: EUPL-1.2
package agentic
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
core "dappco.re/go/core"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestSync_HandleSyncPush_Good(t *testing.T) {
root := t.TempDir()
t.Setenv("CORE_WORKSPACE", root)
t.Setenv("CORE_AGENT_API_KEY", "secret-token")
workspaceDir := core.JoinPath(root, "workspace", "core", "go-io", "task-5")
fs.EnsureDir(workspaceDir)
writeStatusResult(workspaceDir, &WorkspaceStatus{
Status: "completed",
Agent: "codex",
Repo: "go-io",
Org: "core",
Task: "Fix tests",
Branch: "agent/fix-tests",
StartedAt: time.Now(),
UpdatedAt: time.Now(),
})
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, "/v1/agent/sync", r.URL.Path)
require.Equal(t, "Bearer secret-token", r.Header.Get("Authorization"))
bodyResult := core.ReadAll(r.Body)
require.True(t, bodyResult.OK)
var payload map[string]any
parseResult := core.JSONUnmarshalString(bodyResult.Value.(string), &payload)
require.True(t, parseResult.OK)
require.Equal(t, AgentName(), payload["agent_id"])
dispatches, ok := payload["dispatches"].([]any)
require.True(t, ok)
require.Len(t, dispatches, 1)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"data":{"synced":1}}`))
}))
defer server.Close()
subsystem := &PrepSubsystem{
ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}),
brainURL: server.URL,
}
output, err := subsystem.syncPush(context.Background(), "")
require.NoError(t, err)
assert.True(t, output.Success)
assert.Equal(t, 1, output.Count)
}
func TestSync_HandleSyncPull_Bad(t *testing.T) {
t.Setenv("CORE_AGENT_API_KEY", "secret-token")
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, "/v1/agent/context", r.URL.Path)
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(`{"error":"boom"}`))
}))
defer server.Close()
subsystem := &PrepSubsystem{
ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}),
brainURL: server.URL,
}
_, err := subsystem.syncPull(context.Background(), "codex")
require.Error(t, err)
}
func TestSync_HandleSyncPull_Ugly(t *testing.T) {
t.Setenv("CORE_AGENT_API_KEY", "secret-token")
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, "/v1/agent/context", r.URL.Path)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{this is not json`))
}))
defer server.Close()
subsystem := &PrepSubsystem{
ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}),
brainURL: server.URL,
}
_, err := subsystem.syncPull(context.Background(), "codex")
require.Error(t, err)
}

View file

@ -41,6 +41,13 @@ type PRMerged struct {
PRNum int
}
// c.ACTION(messages.WorkspacePushed{Repo: "go-io", Branch: "agent/fix-tests", Org: "core"})
type WorkspacePushed struct {
Repo string
Branch string
Org string
}
// c.ACTION(messages.PRNeedsReview{Repo: "go-io", PRNum: 12, Reason: "merge conflict"})
type PRNeedsReview struct {
Repo string
@ -84,9 +91,8 @@ type HarvestRejected struct {
Reason string
}
// c.ACTION(messages.InboxMessage{From: "charon", Subject: "status", Content: "all green"})
// c.ACTION(messages.InboxMessage{New: 2, Total: 5})
type InboxMessage struct {
From string
Subject string
Content string
New int
Total int
}

View file

@ -32,3 +32,9 @@ func ExampleQueueDrained() {
fmt.Println(ev.Completed)
// Output: 3
}
func ExampleWorkspacePushed() {
ev := WorkspacePushed{Repo: "go-io", Branch: "agent/fix-tests", Org: "core"}
fmt.Println(ev.Repo, ev.Org)
// Output: go-io core
}

View file

@ -18,16 +18,17 @@ func TestMessages_AllSatisfyMessage_Good(t *testing.T) {
QAResult{Workspace: "core/go-io/task-5", Repo: "go-io", Passed: true},
PRCreated{Repo: "go-io", Branch: "agent/fix", PRURL: "https://forge.lthn.ai/core/go-io/pulls/1", PRNum: 1},
PRMerged{Repo: "go-io", PRURL: "https://forge.lthn.ai/core/go-io/pulls/1", PRNum: 1},
WorkspacePushed{Repo: "go-io", Branch: "agent/fix", Org: "core"},
PRNeedsReview{Repo: "go-io", PRNum: 1, Reason: "merge conflict"},
QueueDrained{Completed: 3},
PokeQueue{},
RateLimitDetected{Pool: "codex", Duration: "30m"},
HarvestComplete{Repo: "go-io", Branch: "agent/fix", Files: 5},
HarvestRejected{Repo: "go-io", Branch: "agent/fix", Reason: "binary detected"},
InboxMessage{From: "charon", Subject: "test", Content: "hello"},
InboxMessage{New: 1, Total: 3},
}
assert.Len(t, msgs, 12, "expected 12 message types")
assert.Len(t, msgs, 13, "expected 13 message types")
for _, msg := range msgs {
assert.NotNil(t, msg)
}

View file

@ -447,6 +447,10 @@ func (m *Subsystem) checkInbox() string {
}
if m.ServiceRuntime != nil {
m.Core().ACTION(messages.InboxMessage{
New: len(inboxMessages),
Total: unread,
})
if notifierResult := m.Core().Service("mcp"); notifierResult.OK {
if notifier, ok := notifierResult.Value.(channelSender); ok {
for _, inboxMessage := range inboxMessages {