Compare commits

..

1 commit

Author SHA1 Message Date
darbs-claude
8c1cd9ed4f fix: verify prompt_versions migration exists, add model docblock
Investigation confirms migration exists at
Migrations/0001_01_01_000004_create_prompt_tables.php with all
required columns, foreign keys, and indexes:

- prompt_id FK (cascadeOnDelete)
- version (unsignedInteger)
- system_prompt, user_template (text, nullable)
- variables (json, nullable)
- created_by FK to users (nullOnDelete)
- composite index on [prompt_id, version]

Feature tests at tests/Feature/PromptVersionTest.php cover
creation, relationships, restore, and version history.

Adds @property docblock to PromptVersion model for IDE support,
consistent with AgentWorkspaceState.

Closes #22

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-23 06:41:45 +00:00
120 changed files with 891 additions and 11822 deletions

View file

@ -10,8 +10,6 @@ jobs:
test:
name: PHP ${{ matrix.php }}
runs-on: ubuntu-latest
container:
image: lthn/build:php-${{ matrix.php }}
strategy:
fail-fast: true
@ -21,6 +19,13 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Setup PHP
uses: https://github.com/shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite
coverage: pcov
- name: Clone sister packages
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View file

@ -1,38 +0,0 @@
name: Publish Composer Package
on:
push:
tags:
- 'v*'
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Create package archive
run: |
apt-get update && apt-get install -y zip
zip -r package.zip . \
-x ".forgejo/*" \
-x ".git/*" \
-x "tests/*" \
-x "docker/*" \
-x "*.yaml" \
-x "infection.json5" \
-x "phpstan.neon" \
-x "phpunit.xml" \
-x "psalm.xml" \
-x "rector.php" \
-x "TODO.md" \
-x "ROADMAP.md" \
-x "CONTRIBUTING.md" \
-x "package.json" \
-x "package-lock.json"
- name: Publish to Forgejo Composer registry
run: |
curl --fail --user "${{ secrets.REGISTRY_USER }}:${{ secrets.REGISTRY_TOKEN }}" \
--upload-file package.zip \
"https://forge.lthn.ai/api/packages/core/composer?version=${FORGEJO_REF_NAME#v}"

View file

@ -1,62 +0,0 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Agentic\Actions\Brain;
use Core\Actions\Action;
use Core\Mod\Agentic\Models\BrainMemory;
use Core\Mod\Agentic\Services\BrainService;
use Illuminate\Support\Facades\Log;
/**
* Remove a memory from the shared OpenBrain knowledge store.
*
* Deletes from both MariaDB and Qdrant. Workspace-scoped.
*
* Usage:
* ForgetKnowledge::run('uuid-here', 1, 'virgil', 'outdated info');
*/
class ForgetKnowledge
{
use Action;
public function __construct(
private BrainService $brain,
) {}
/**
* @return array{forgotten: string, type: string}
*
* @throws \InvalidArgumentException
* @throws \RuntimeException
*/
public function handle(string $id, int $workspaceId, string $agentId = 'anonymous', ?string $reason = null): array
{
if ($id === '') {
throw new \InvalidArgumentException('id is required');
}
$memory = BrainMemory::where('id', $id)
->where('workspace_id', $workspaceId)
->first();
if (! $memory) {
throw new \InvalidArgumentException("Memory '{$id}' not found in this workspace");
}
Log::info('OpenBrain: memory forgotten', [
'id' => $id,
'type' => $memory->type,
'agent_id' => $agentId,
'reason' => $reason,
]);
$this->brain->forget($id);
return [
'forgotten' => $id,
'type' => $memory->type,
];
}
}

View file

@ -1,56 +0,0 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Agentic\Actions\Brain;
use Core\Actions\Action;
use Core\Mod\Agentic\Models\BrainMemory;
/**
* List memories in the shared OpenBrain knowledge store.
*
* Pure MariaDB query using model scopes no vector search.
* Use RecallKnowledge for semantic queries.
*
* Usage:
* $memories = ListKnowledge::run(1, ['type' => 'decision']);
*/
class ListKnowledge
{
use Action;
/**
* @param array{project?: string, type?: string, agent_id?: string, limit?: int} $filter
* @return array{memories: array, count: int}
*/
public function handle(int $workspaceId, array $filter = []): array
{
$limit = min(max((int) ($filter['limit'] ?? 20), 1), 100);
$query = BrainMemory::forWorkspace($workspaceId)
->active()
->latestVersions()
->forProject($filter['project'] ?? null)
->byAgent($filter['agent_id'] ?? null);
$type = $filter['type'] ?? null;
if ($type !== null) {
if (is_string($type) && ! in_array($type, BrainMemory::VALID_TYPES, true)) {
throw new \InvalidArgumentException(
sprintf('type must be one of: %s', implode(', ', BrainMemory::VALID_TYPES))
);
}
$query->ofType($type);
}
$memories = $query->orderByDesc('created_at')
->limit($limit)
->get();
return [
'memories' => $memories->map(fn (BrainMemory $m) => $m->toMcpContext())->all(),
'count' => $memories->count(),
];
}
}

View file

@ -1,85 +0,0 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Agentic\Actions\Brain;
use Core\Actions\Action;
use Core\Mod\Agentic\Models\BrainMemory;
use Core\Mod\Agentic\Services\BrainService;
/**
* Semantic search across the shared OpenBrain knowledge store.
*
* Uses vector similarity to find memories relevant to a natural
* language query, with optional filtering by project, type, agent,
* or minimum confidence.
*
* Usage:
* $results = RecallKnowledge::run('how does auth work?', 1);
*/
class RecallKnowledge
{
use Action;
public function __construct(
private BrainService $brain,
) {}
/**
* @param array{project?: string, type?: string|array, agent_id?: string, min_confidence?: float} $filter
* @return array{memories: array, scores: array<string, float>, count: int}
*
* @throws \InvalidArgumentException
* @throws \RuntimeException
*/
public function handle(string $query, int $workspaceId, array $filter = [], int $topK = 5): array
{
if ($query === '') {
throw new \InvalidArgumentException('query is required and must be a non-empty string');
}
if (mb_strlen($query) > 2000) {
throw new \InvalidArgumentException('query must not exceed 2,000 characters');
}
if ($topK < 1 || $topK > 20) {
throw new \InvalidArgumentException('top_k must be between 1 and 20');
}
if (isset($filter['type'])) {
$typeValue = $filter['type'];
$validTypes = BrainMemory::VALID_TYPES;
if (is_string($typeValue)) {
if (! in_array($typeValue, $validTypes, true)) {
throw new \InvalidArgumentException(
sprintf('filter.type must be one of: %s', implode(', ', $validTypes))
);
}
} elseif (is_array($typeValue)) {
foreach ($typeValue as $t) {
if (! is_string($t) || ! in_array($t, $validTypes, true)) {
throw new \InvalidArgumentException(
sprintf('Each filter.type value must be one of: %s', implode(', ', $validTypes))
);
}
}
}
}
if (isset($filter['min_confidence'])) {
$mc = $filter['min_confidence'];
if (! is_numeric($mc) || $mc < 0.0 || $mc > 1.0) {
throw new \InvalidArgumentException('filter.min_confidence must be between 0.0 and 1.0');
}
}
$result = $this->brain->recall($query, $topK, $filter, $workspaceId);
return [
'memories' => $result['memories'],
'scores' => $result['scores'],
'count' => count($result['memories']),
];
}
}

View file

@ -1,94 +0,0 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Agentic\Actions\Brain;
use Core\Actions\Action;
use Core\Mod\Agentic\Models\BrainMemory;
use Core\Mod\Agentic\Services\BrainService;
/**
* Store a memory in the shared OpenBrain knowledge store.
*
* Persists content with embeddings to both MariaDB and Qdrant.
* Handles supersession (replacing old memories) and expiry.
*
* Usage:
* $memory = RememberKnowledge::run($data, 1, 'virgil');
*/
class RememberKnowledge
{
use Action;
public function __construct(
private BrainService $brain,
) {}
/**
* @param array{content: string, type: string, tags?: array, project?: string, confidence?: float, supersedes?: string, expires_in?: int} $data
* @return BrainMemory The created memory
*
* @throws \InvalidArgumentException
* @throws \RuntimeException
*/
public function handle(array $data, int $workspaceId, string $agentId = 'anonymous'): BrainMemory
{
$content = $data['content'] ?? null;
if (! is_string($content) || $content === '') {
throw new \InvalidArgumentException('content is required and must be a non-empty string');
}
if (mb_strlen($content) > 50000) {
throw new \InvalidArgumentException('content must not exceed 50,000 characters');
}
$type = $data['type'] ?? null;
if (! is_string($type) || ! in_array($type, BrainMemory::VALID_TYPES, true)) {
throw new \InvalidArgumentException(
sprintf('type must be one of: %s', implode(', ', BrainMemory::VALID_TYPES))
);
}
$confidence = (float) ($data['confidence'] ?? 0.8);
if ($confidence < 0.0 || $confidence > 1.0) {
throw new \InvalidArgumentException('confidence must be between 0.0 and 1.0');
}
$tags = $data['tags'] ?? null;
if (is_array($tags)) {
foreach ($tags as $tag) {
if (! is_string($tag)) {
throw new \InvalidArgumentException('Each tag must be a string');
}
}
}
$supersedes = $data['supersedes'] ?? null;
if ($supersedes !== null) {
$existing = BrainMemory::where('id', $supersedes)
->where('workspace_id', $workspaceId)
->first();
if (! $existing) {
throw new \InvalidArgumentException("Memory '{$supersedes}' not found in this workspace");
}
}
$expiresIn = isset($data['expires_in']) ? (int) $data['expires_in'] : null;
if ($expiresIn !== null && $expiresIn < 1) {
throw new \InvalidArgumentException('expires_in must be at least 1 hour');
}
return $this->brain->remember([
'workspace_id' => $workspaceId,
'agent_id' => $agentId,
'type' => $type,
'content' => $content,
'tags' => $tags,
'project' => $data['project'] ?? null,
'confidence' => $confidence,
'supersedes_id' => $supersedes,
'expires_at' => $expiresIn ? now()->addHours($expiresIn) : null,
]);
}
}

View file

@ -1,40 +0,0 @@
<?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\Forge;
use Core\Actions\Action;
use Core\Mod\Agentic\Actions\Session\StartSession;
use Core\Mod\Agentic\Models\AgentPlan;
use Core\Mod\Agentic\Models\AgentSession;
/**
* Assign an agent to a plan and start a session.
*
* Activates the plan if it is still in draft status, then
* delegates to StartSession to create the working session.
*
* Usage:
* $session = AssignAgent::run($plan, 'opus', $workspaceId);
*/
class AssignAgent
{
use Action;
public function handle(AgentPlan $plan, string $agentType, int $workspaceId): AgentSession
{
if ($plan->status !== AgentPlan::STATUS_ACTIVE) {
$plan->activate();
}
return StartSession::run($agentType, $plan->slug, $workspaceId);
}
}

View file

@ -1,102 +0,0 @@
<?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\Forge;
use Core\Actions\Action;
use Core\Mod\Agentic\Actions\Plan\CreatePlan;
use Core\Mod\Agentic\Models\AgentPlan;
/**
* Convert a Forgejo work item into an AgentPlan.
*
* Accepts the structured work item array produced by ScanForWork,
* extracts checklist tasks from the issue body, and creates a plan
* with a single phase. Returns an existing plan if one already
* matches the same issue.
*
* Usage:
* $plan = CreatePlanFromIssue::run($workItem, $workspaceId);
*/
class CreatePlanFromIssue
{
use Action;
/**
* @param array{epic_number: int, issue_number: int, issue_title: string, issue_body: string, assignee: string|null, repo_owner: string, repo_name: string, needs_coding: bool, has_pr: bool} $workItem
*/
public function handle(array $workItem, int $workspaceId): AgentPlan
{
$issueNumber = (int) $workItem['issue_number'];
$owner = (string) $workItem['repo_owner'];
$repo = (string) $workItem['repo_name'];
// Check for an existing plan for this issue (not archived)
$existing = AgentPlan::where('status', '!=', AgentPlan::STATUS_ARCHIVED)
->whereJsonContains('metadata->issue_number', $issueNumber)
->whereJsonContains('metadata->repo_owner', $owner)
->whereJsonContains('metadata->repo_name', $repo)
->first();
if ($existing !== null) {
return $existing->load('agentPhases');
}
$tasks = $this->extractTasks((string) $workItem['issue_body']);
$plan = CreatePlan::run([
'title' => (string) $workItem['issue_title'],
'slug' => "forge-{$owner}-{$repo}-{$issueNumber}",
'description' => (string) $workItem['issue_body'],
'phases' => [
[
'name' => "Resolve issue #{$issueNumber}",
'description' => "Complete all tasks for issue #{$issueNumber}",
'tasks' => $tasks,
],
],
], $workspaceId);
$plan->update([
'metadata' => [
'source' => 'forgejo',
'epic_number' => (int) $workItem['epic_number'],
'issue_number' => $issueNumber,
'repo_owner' => $owner,
'repo_name' => $repo,
'assignee' => $workItem['assignee'] ?? null,
],
]);
return $plan->load('agentPhases');
}
/**
* Extract task names from markdown checklist items.
*
* Matches lines like `- [ ] Create picker UI` and returns
* just the task name portion.
*
* @return array<int, string>
*/
private function extractTasks(string $body): array
{
$tasks = [];
if (preg_match_all('/- \[[ xX]\] (.+)/', $body, $matches)) {
foreach ($matches[1] as $taskName) {
$tasks[] = trim($taskName);
}
}
return $tasks;
}
}

View file

@ -1,59 +0,0 @@
<?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\Forge;
use Core\Actions\Action;
use Core\Mod\Agentic\Services\ForgejoService;
/**
* Evaluate and merge a Forgejo pull request when ready.
*
* Checks the PR state, mergeability, and CI status before
* attempting the merge. Returns a result array describing
* the outcome.
*
* Usage:
* $result = ManagePullRequest::run('core', 'app', 10);
*/
class ManagePullRequest
{
use Action;
/**
* @return array{merged: bool, pr_number?: int, reason?: string}
*/
public function handle(string $owner, string $repo, int $prNumber): array
{
$forge = app(ForgejoService::class);
$pr = $forge->getPullRequest($owner, $repo, $prNumber);
if (($pr['state'] ?? '') !== 'open') {
return ['merged' => false, 'reason' => 'not_open'];
}
if (empty($pr['mergeable'])) {
return ['merged' => false, 'reason' => 'conflicts'];
}
$headSha = $pr['head']['sha'] ?? '';
$status = $forge->getCombinedStatus($owner, $repo, $headSha);
if (($status['state'] ?? '') !== 'success') {
return ['merged' => false, 'reason' => 'checks_pending'];
}
$forge->mergePullRequest($owner, $repo, $prNumber);
return ['merged' => true, 'pr_number' => $prNumber];
}
}

View file

@ -1,34 +0,0 @@
<?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\Forge;
use Core\Actions\Action;
use Core\Mod\Agentic\Services\ForgejoService;
/**
* Post a progress comment on a Forgejo issue.
*
* Wraps ForgejoService::createComment() for use as a
* standalone action within the orchestration pipeline.
*
* Usage:
* ReportToIssue::run('core', 'app', 5, 'Phase 1 complete.');
*/
class ReportToIssue
{
use Action;
public function handle(string $owner, string $repo, int $issueNumber, string $message): void
{
app(ForgejoService::class)->createComment($owner, $repo, $issueNumber, $message);
}
}

View file

@ -1,145 +0,0 @@
<?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\Forge;
use Core\Actions\Action;
use Core\Mod\Agentic\Services\ForgejoService;
/**
* Scan Forgejo for epic issues and identify unchecked children that need coding.
*
* Parses epic issue bodies for checklist syntax (`- [ ] #N` / `- [x] #N`),
* cross-references with open pull requests, and returns structured work items
* for any unchecked child issue that has no linked PR.
*
* Usage:
* $workItems = ScanForWork::run('core', 'app');
*/
class ScanForWork
{
use Action;
/**
* Scan a repository for actionable work from epic issues.
*
* @return array<int, array{
* epic_number: int,
* issue_number: int,
* issue_title: string,
* issue_body: string,
* assignee: string|null,
* repo_owner: string,
* repo_name: string,
* needs_coding: bool,
* has_pr: bool,
* }>
*/
public function handle(string $owner, string $repo): array
{
$forge = app(ForgejoService::class);
$epics = $forge->listIssues($owner, $repo, 'open', 'epic');
if ($epics === []) {
return [];
}
$pullRequests = $forge->listPullRequests($owner, $repo, 'all');
$linkedIssues = $this->extractLinkedIssues($pullRequests);
$workItems = [];
foreach ($epics as $epic) {
$checklist = $this->parseChecklist((string) ($epic['body'] ?? ''));
foreach ($checklist as $item) {
if ($item['checked']) {
continue;
}
if (in_array($item['number'], $linkedIssues, true)) {
continue;
}
$child = $forge->getIssue($owner, $repo, $item['number']);
$assignee = null;
if (! empty($child['assignees']) && is_array($child['assignees'])) {
$assignee = $child['assignees'][0]['login'] ?? null;
}
$workItems[] = [
'epic_number' => (int) $epic['number'],
'issue_number' => (int) $child['number'],
'issue_title' => (string) ($child['title'] ?? ''),
'issue_body' => (string) ($child['body'] ?? ''),
'assignee' => $assignee,
'repo_owner' => $owner,
'repo_name' => $repo,
'needs_coding' => true,
'has_pr' => false,
];
}
}
return $workItems;
}
/**
* Parse a checklist body into structured items.
*
* Matches lines like `- [ ] #2` (unchecked) and `- [x] #3` (checked).
*
* @return array<int, array{number: int, checked: bool}>
*/
private function parseChecklist(string $body): array
{
$items = [];
if (preg_match_all('/- \[([ xX])\] #(\d+)/', $body, $matches, PREG_SET_ORDER)) {
foreach ($matches as $match) {
$items[] = [
'number' => (int) $match[2],
'checked' => $match[1] !== ' ',
];
}
}
return $items;
}
/**
* Extract issue numbers referenced in PR bodies.
*
* Matches common linking patterns: "Closes #N", "Fixes #N", "Resolves #N",
* and bare "#N" references.
*
* @param array<int, array<string, mixed>> $pullRequests
* @return array<int, int>
*/
private function extractLinkedIssues(array $pullRequests): array
{
$linked = [];
foreach ($pullRequests as $pr) {
$body = (string) ($pr['body'] ?? '');
if (preg_match_all('/#(\d+)/', $body, $matches)) {
foreach ($matches[1] as $number) {
$linked[] = (int) $number;
}
}
}
return array_unique($linked);
}
}

View file

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

View file

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

View file

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

View file

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

View file

@ -1,89 +0,0 @@
<?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');
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,90 +0,0 @@
<?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();
}
}

View file

@ -1,101 +0,0 @@
<?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();
}
}

110
Boot.php
View file

@ -5,11 +5,9 @@ declare(strict_types=1);
namespace Core\Mod\Agentic;
use Core\Events\AdminPanelBooting;
use Core\Events\ApiRoutesRegistering;
use Core\Events\ConsoleBooting;
use Core\Events\McpToolsRegistering;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\ServiceProvider;
@ -25,7 +23,6 @@ class Boot extends ServiceProvider
*/
public static array $listens = [
AdminPanelBooting::class => 'onAdminPanel',
ApiRoutesRegistering::class => 'onApiRoutes',
ConsoleBooting::class => 'onConsole',
McpToolsRegistering::class => 'onMcpTools',
];
@ -35,25 +32,6 @@ class Boot extends ServiceProvider
$this->loadMigrationsFrom(__DIR__.'/Migrations');
$this->loadTranslationsFrom(__DIR__.'/Lang', 'agentic');
$this->configureRateLimiting();
$this->scheduleCommands();
}
/**
* Register all scheduled commands.
*/
protected function scheduleCommands(): void
{
$this->app->booted(function (): void {
$schedule = $this->app->make(Schedule::class);
$schedule->command('agentic:plan-cleanup')->daily();
// Forgejo pipeline — only active when a token is configured
if (config('agentic.forge_token') !== '') {
$schedule->command('agentic:scan')->everyFiveMinutes();
$schedule->command('agentic:dispatch')->everyTwoMinutes();
$schedule->command('agentic:pr-manage')->everyFiveMinutes();
}
});
}
/**
@ -75,71 +53,13 @@ class Boot extends ServiceProvider
'mcp'
);
$this->mergeConfigFrom(
__DIR__.'/agentic.php',
'agentic'
);
// Register the dedicated brain database connection.
// Falls back to the app's default DB when no BRAIN_DB_* env vars are set.
$brainDb = config('mcp.brain.database');
if (is_array($brainDb) && ! empty($brainDb['host'])) {
config(['database.connections.brain' => $brainDb]);
}
$this->app->singleton(\Core\Mod\Agentic\Services\AgenticManager::class);
$this->app->singleton(\Core\Mod\Agentic\Services\AgentToolRegistry::class);
$this->app->singleton(\Core\Mod\Agentic\Services\ForgejoService::class, function ($app) {
return new \Core\Mod\Agentic\Services\ForgejoService(
baseUrl: (string) config('agentic.forge_url', 'https://forge.lthn.ai'),
token: (string) config('agentic.forge_token', ''),
);
});
$this->app->singleton(\Core\Mod\Agentic\Services\BrainService::class, function ($app) {
$ollamaUrl = config('mcp.brain.ollama_url', 'http://localhost:11434');
$qdrantUrl = config('mcp.brain.qdrant_url', 'http://localhost:6334');
// Skip TLS verification for non-public TLDs (self-signed certs behind Traefik)
$hasLocalTld = static fn (string $url): bool => (bool) preg_match(
'/\.(lan|lab|local|test)(?:[:\/]|$)/',
parse_url($url, PHP_URL_HOST) ?? ''
);
$verifySsl = ! ($hasLocalTld($ollamaUrl) || $hasLocalTld($qdrantUrl));
return new \Core\Mod\Agentic\Services\BrainService(
ollamaUrl: $ollamaUrl,
qdrantUrl: $qdrantUrl,
collection: config('mcp.brain.collection', 'openbrain'),
embeddingModel: config('mcp.brain.embedding_model', 'nomic-embed-text'),
verifySsl: $verifySsl,
);
});
}
// -------------------------------------------------------------------------
// Event-driven handlers (for lazy loading once event system is integrated)
// -------------------------------------------------------------------------
/**
* Handle API routes registration event.
*
* Registers REST API endpoints for go-agentic Client consumption.
* Routes at /v1/* Go client uses BaseURL + "/v1/plans" directly.
*/
public function onApiRoutes(ApiRoutesRegistering $event): void
{
// Register agent API auth middleware alias
$event->middleware('agent.auth', Middleware\AgentApiAuth::class);
// Scoped via event — only loaded for API requests
if (file_exists(__DIR__.'/Routes/api.php')) {
$event->routes(fn () => require __DIR__.'/Routes/api.php');
}
}
/**
* Handle admin panel booting event.
*/
@ -167,32 +87,14 @@ class Boot extends ServiceProvider
// in the existing boot() method until we migrate to pure event-driven nav.
}
/**
* Handle API routes registration event.
*/
public function onApiRoutes(ApiRoutesRegistering $event): void
{
$event->routes(fn () => require __DIR__.'/Routes/api.php');
}
/**
* Handle console booting event.
*/
public function onConsole(ConsoleBooting $event): void
{
// Register middleware alias for CLI context (artisan route:list)
$event->middleware('agent.auth', Middleware\AgentApiAuth::class);
$event->command(Console\Commands\TaskCommand::class);
$event->command(Console\Commands\PlanCommand::class);
$event->command(Console\Commands\GenerateCommand::class);
$event->command(Console\Commands\PlanRetentionCommand::class);
$event->command(Console\Commands\BrainSeedMemoryCommand::class);
$event->command(Console\Commands\BrainIngestCommand::class);
$event->command(Console\Commands\ScanCommand::class);
$event->command(Console\Commands\DispatchCommand::class);
$event->command(Console\Commands\PrManageCommand::class);
$event->command(Console\Commands\PrepWorkspaceCommand::class);
}
/**
@ -200,17 +102,11 @@ class Boot extends ServiceProvider
*
* Note: Agent tools (plan_create, session_start, etc.) are implemented in
* the Mcp module at Mod\Mcp\Tools\Agent\* and registered via AgentToolRegistry.
* Brain tools are registered here as they belong to the Agentic module.
* This method is available for Agentic-specific MCP tools if needed in future.
*/
public function onMcpTools(McpToolsRegistering $event): void
{
$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(),
]);
// Agent tools are registered in Mcp module via AgentToolRegistry
// No additional MCP tools needed from Agentic module at this time
}
}

View file

@ -1,699 +0,0 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Agentic\Console\Commands;
use Core\Mod\Agentic\Services\BrainService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Http;
use Symfony\Component\Finder\Finder;
/**
* Comprehensive knowledge ingestion into OpenBrain.
*
* Discovers markdown files across multiple source types and ingests
* them as sectioned memories with embedded vectors. Designed to
* archive scattered knowledge before filesystem cleanup.
*/
class BrainIngestCommand extends Command
{
protected $signature = 'brain:ingest
{--workspace= : Workspace ID to import into (required)}
{--agent=virgil : Agent ID to attribute memories to}
{--source=all : Source type: memory, plans, claude-md, tasks, docs, wiki, all}
{--code-path= : Root code directory (default: ~/Code)}
{--dry-run : Preview what would be imported without storing}
{--fresh : Clear the Qdrant collection before ingesting}';
protected $description = 'Ingest markdown knowledge from across the filesystem into OpenBrain';
/** @var array<string, int> */
private array $stats = ['imported' => 0, 'skipped' => 0, 'errors' => 0];
public function handle(BrainService $brain): int
{
$workspaceId = $this->option('workspace');
if (! $workspaceId) {
$this->error('--workspace is required.');
return self::FAILURE;
}
$source = $this->option('source') ?? 'all';
$codePath = $this->option('code-path') ?? $this->expandHome('~/Code');
$isDryRun = (bool) $this->option('dry-run');
$sources = $source === 'all'
? ['memory', 'plans', 'claude-md', 'tasks', 'docs', 'wiki']
: [strtolower($source)];
// Separate file-based and API-based sources
$fileSources = array_filter($sources, fn ($s) => $s !== 'wiki');
$apiSources = array_filter($sources, fn ($s) => $s === 'wiki');
// Gather file-based sources
$filesBySource = [];
foreach ($fileSources as $src) {
$files = match ($src) {
'memory' => $this->discoverMemoryFiles(),
'plans' => $this->discoverPlanFiles($codePath),
'claude-md' => $this->discoverClaudeMdFiles($codePath),
'tasks' => $this->discoverTaskFiles(),
'docs' => $this->discoverDocFiles($codePath),
default => [],
};
$filesBySource[$src] = $files;
$this->info(sprintf(' [%s] %d file(s)', $src, count($files)));
}
// Discover wiki pages from Forge API
$wikiPages = [];
if (in_array('wiki', $apiSources, true)) {
$wikiPages = $this->discoverWikiPages();
$this->info(sprintf(' [wiki] %d page(s) across %d repo(s)', count($wikiPages), count(array_unique(array_column($wikiPages, 'repo')))));
}
$totalFiles = array_sum(array_map('count', $filesBySource)) + count($wikiPages);
$this->newLine();
$this->info("Total: {$totalFiles} item(s) to process.");
if ($totalFiles === 0) {
return self::SUCCESS;
}
if (! $isDryRun) {
if ($this->option('fresh')) {
$this->warn('Clearing existing collection...');
$this->clearCollection($brain);
}
$brain->ensureCollection();
}
foreach ($filesBySource as $src => $files) {
$this->newLine();
$this->comment("--- {$src} ---");
foreach ($files as $file) {
$this->processFile($brain, $file, $src, (int) $workspaceId, $this->option('agent') ?? 'virgil', $isDryRun);
}
}
if (! empty($wikiPages)) {
$this->newLine();
$this->comment('--- wiki ---');
$this->processWikiPages($brain, $wikiPages, (int) $workspaceId, $this->option('agent') ?? 'virgil', $isDryRun);
}
$this->newLine();
$prefix = $isDryRun ? '[DRY RUN] ' : '';
$this->info("{$prefix}Done. Imported: {$this->stats['imported']}, Skipped: {$this->stats['skipped']}, Errors: {$this->stats['errors']}");
return self::SUCCESS;
}
/**
* Process a single file into sectioned memories.
*/
private function processFile(BrainService $brain, string $file, string $source, int $workspaceId, string $agentId, bool $isDryRun): void
{
$sections = $this->parseMarkdownSections($file);
$filename = basename($file, '.md');
$project = $this->extractProject($file, $source);
if (empty($sections)) {
$this->stats['skipped']++;
return;
}
foreach ($sections as $section) {
if (trim($section['content']) === '') {
$this->stats['skipped']++;
continue;
}
$type = $this->inferType($section['heading'], $section['content'], $source);
$tags = $this->buildTags($section['heading'], $filename, $source, $project);
if ($isDryRun) {
$this->line(sprintf(
' %s :: %s (%s) — %d chars [%s]',
$filename,
$section['heading'],
$type,
strlen($section['content']),
implode(', ', $tags),
));
$this->stats['imported']++;
continue;
}
try {
$text = $section['heading']."\n\n".$section['content'];
// embeddinggemma has a 2048-token context (~4K chars).
// Truncate oversized sections to avoid Ollama 500 errors.
if (strlen($text) > 3800) {
$text = mb_substr($text, 0, 3800).'…';
}
$brain->remember([
'workspace_id' => $workspaceId,
'agent_id' => $agentId,
'type' => $type,
'content' => $text,
'tags' => $tags,
'project' => $project,
'confidence' => $this->confidenceForSource($source),
]);
$this->stats['imported']++;
} catch (\Throwable $e) {
$this->warn(" Error: {$filename} :: {$section['heading']}{$e->getMessage()}");
$this->stats['errors']++;
}
}
}
// -------------------------------------------------------------------------
// File discovery
// -------------------------------------------------------------------------
/** @return array<string> */
private function discoverMemoryFiles(): array
{
$pattern = $this->expandHome('~/.claude/projects/*/memory/*.md');
return glob($pattern) ?: [];
}
/** @return array<string> */
private function discoverPlanFiles(string $codePath): array
{
$files = [];
// ~/.claude/plans (superpowers plans)
$claudePlans = $this->expandHome('~/.claude/plans');
if (is_dir($claudePlans)) {
$files = array_merge($files, $this->findMd($claudePlans));
}
// docs/plans across all repos in ~/Code
if (is_dir($codePath)) {
$finder = Finder::create()
->files()
->name('*.md')
->in($codePath)
->path('/docs\/plans\//')
->notPath('node_modules')
->notPath('vendor')
->sortByName();
foreach ($finder as $file) {
$files[] = $file->getRealPath();
}
}
return $files;
}
/** @return array<string> */
private function discoverClaudeMdFiles(string $codePath): array
{
if (! is_dir($codePath)) {
return [];
}
$finder = Finder::create()
->files()
->name('CLAUDE.md')
->in($codePath)
->depth('< 4')
->notPath('node_modules')
->notPath('vendor')
->notPath('.claude')
->sortByName();
$files = [];
foreach ($finder as $file) {
$files[] = $file->getRealPath();
}
return $files;
}
/** @return array<string> */
private function discoverTaskFiles(): array
{
$tasksDir = $this->expandHome('~/Code/host-uk/core/tasks');
if (! is_dir($tasksDir)) {
return [];
}
$finder = Finder::create()
->files()
->name('*.md')
->in($tasksDir)
->notPath('recovered-hostuk')
->notPath('recovered-root')
->sortByName();
$files = [];
foreach ($finder as $file) {
$files[] = $file->getRealPath();
}
return $files;
}
/** @return array<string> */
private function discoverDocFiles(string $codePath): array
{
$files = [];
// CorePHP framework docs (build/php + packages)
$docRoots = [
$codePath.'/host-uk/core-php/docs/build/php',
$codePath.'/host-uk/core-php/docs/packages',
];
foreach ($docRoots as $root) {
if (! is_dir($root)) {
continue;
}
$finder = Finder::create()
->files()
->name('*.md')
->in($root)
->sortByName();
foreach ($finder as $file) {
$files[] = $file->getRealPath();
}
}
return $files;
}
// -------------------------------------------------------------------------
// Wiki (Forge API)
// -------------------------------------------------------------------------
/**
* Discover wiki pages from all repos in the Forge org.
*
* Returns flat array of ['repo' => name, 'title' => title, 'content' => markdown].
*
* @return array<array{repo: string, title: string, content: string}>
*/
private function discoverWikiPages(): array
{
$baseUrl = config('upstream.gitea.url', 'https://forge.lthn.ai');
$token = config('upstream.gitea.token');
$org = config('upstream.gitea.org', 'core');
if (! $token) {
$this->warn('No Forge token — skipping wiki source.');
return [];
}
// Fetch all repos in org
$repos = [];
$page = 1;
do {
$response = Http::withHeaders(['Authorization' => 'token ' . $token])
->timeout(15)
->get("{$baseUrl}/api/v1/orgs/{$org}/repos", ['page' => $page, 'limit' => 50]);
if (! $response->successful()) {
$this->warn('Failed to fetch repos: ' . $response->status());
break;
}
$batch = $response->json();
if (empty($batch)) {
break;
}
foreach ($batch as $r) {
$repos[] = $r['name'];
}
$page++;
} while (count($batch) === 50);
// Fetch wiki pages for each repo
$pages = [];
foreach ($repos as $repo) {
$response = Http::withHeaders(['Authorization' => 'token ' . $token])
->timeout(10)
->get("{$baseUrl}/api/v1/repos/{$org}/{$repo}/wiki/pages");
if (! $response->successful() || $response->status() === 404) {
continue;
}
$wikiList = $response->json();
if (empty($wikiList)) {
continue;
}
foreach ($wikiList as $wiki) {
$title = $wiki['title'] ?? 'Untitled';
// Fetch full page content
$pageResponse = Http::withHeaders(['Authorization' => 'token ' . $token])
->timeout(10)
->get("{$baseUrl}/api/v1/repos/{$org}/{$repo}/wiki/page/{$title}");
if (! $pageResponse->successful()) {
continue;
}
$content = $pageResponse->json('content_base64');
if ($content) {
$content = base64_decode($content, true) ?: '';
} else {
$content = '';
}
if (trim($content) === '') {
continue;
}
$pages[] = [
'repo' => $repo,
'title' => $title,
'content' => $content,
];
}
}
return $pages;
}
/**
* Process wiki pages into contextual memories.
*
* Each page is tagged with its repo and language, typed as service
* documentation so the PHP orchestrator can reason about Go services.
*
* @param array<array{repo: string, title: string, content: string}> $pages
*/
private function processWikiPages(BrainService $brain, array $pages, int $workspaceId, string $agentId, bool $isDryRun): void
{
foreach ($pages as $page) {
$sections = $this->parseMarkdownFromString($page['content'], $page['title']);
$repo = $page['repo'];
// Detect language from repo name
$lang = str_starts_with($repo, 'php-') ? 'php' : (str_starts_with($repo, 'go-') || $repo === 'go' ? 'go' : 'mixed');
foreach ($sections as $section) {
if (trim($section['content']) === '') {
$this->stats['skipped']++;
continue;
}
$tags = [
'source:wiki',
'repo:' . $repo,
'lang:' . $lang,
str_replace(['-', '_'], ' ', $page['title']),
];
if ($isDryRun) {
$this->line(sprintf(
' %s/%s :: %s — %d chars [%s]',
$repo,
$page['title'],
$section['heading'],
strlen($section['content']),
implode(', ', $tags),
));
$this->stats['imported']++;
continue;
}
try {
// Prefix with repo context so embeddings understand the service
$text = "[{$repo}] {$section['heading']}\n\n{$section['content']}";
if (strlen($text) > 3800) {
$text = mb_substr($text, 0, 3800) . '…';
}
$brain->remember([
'workspace_id' => $workspaceId,
'agent_id' => $agentId,
'type' => 'service',
'content' => $text,
'tags' => $tags,
'project' => $repo,
'confidence' => 0.8,
]);
$this->stats['imported']++;
} catch (\Throwable $e) {
$this->warn(' Error: ' . $repo . '/' . $page['title'] . ' :: ' . $section['heading'] . ' — ' . $e->getMessage());
$this->stats['errors']++;
}
}
}
}
/**
* Parse markdown sections from a string (not a file).
*
* @return array<array{heading: string, content: string}>
*/
private function parseMarkdownFromString(string $content, string $fallbackHeading): array
{
if (trim($content) === '') {
return [];
}
$sections = [];
$lines = explode("\n", $content);
$currentHeading = '';
$currentContent = [];
foreach ($lines as $line) {
if (preg_match('/^#{1,3}\s+(.+)$/', $line, $matches)) {
if ($currentHeading !== '' && ! empty($currentContent)) {
$text = trim(implode("\n", $currentContent));
if ($text !== '') {
$sections[] = ['heading' => $currentHeading, 'content' => $text];
}
}
$currentHeading = trim($matches[1]);
$currentContent = [];
} else {
$currentContent[] = $line;
}
}
if ($currentHeading !== '' && ! empty($currentContent)) {
$text = trim(implode("\n", $currentContent));
if ($text !== '') {
$sections[] = ['heading' => $currentHeading, 'content' => $text];
}
}
if (empty($sections) && trim($content) !== '') {
$sections[] = ['heading' => $fallbackHeading, 'content' => trim($content)];
}
return $sections;
}
/** @return array<string> */
private function findMd(string $dir): array
{
$files = [];
foreach (glob("{$dir}/*.md") ?: [] as $f) {
$files[] = $f;
}
// Include subdirectories (e.g. completed/)
foreach (glob("{$dir}/*/*.md") ?: [] as $f) {
$files[] = $f;
}
return $files;
}
// -------------------------------------------------------------------------
// Parsing
// -------------------------------------------------------------------------
/** @return array<array{heading: string, content: string}> */
private function parseMarkdownSections(string $filePath): array
{
$content = file_get_contents($filePath);
if ($content === false || trim($content) === '') {
return [];
}
$sections = [];
$lines = explode("\n", $content);
$currentHeading = '';
$currentContent = [];
foreach ($lines as $line) {
if (preg_match('/^#{1,3}\s+(.+)$/', $line, $matches)) {
if ($currentHeading !== '' && ! empty($currentContent)) {
$text = trim(implode("\n", $currentContent));
if ($text !== '') {
$sections[] = ['heading' => $currentHeading, 'content' => $text];
}
}
$currentHeading = trim($matches[1]);
$currentContent = [];
} else {
$currentContent[] = $line;
}
}
// Flush last section
if ($currentHeading !== '' && ! empty($currentContent)) {
$text = trim(implode("\n", $currentContent));
if ($text !== '') {
$sections[] = ['heading' => $currentHeading, 'content' => $text];
}
}
// If no headings found, treat entire file as one section
if (empty($sections) && trim($content) !== '') {
$sections[] = [
'heading' => basename($filePath, '.md'),
'content' => trim($content),
];
}
return $sections;
}
// -------------------------------------------------------------------------
// Metadata
// -------------------------------------------------------------------------
private function extractProject(string $filePath, string $source): ?string
{
// Memory files: ~/.claude/projects/-Users-snider-Code-{project}/memory/
if (preg_match('/projects\/[^\/]*-([^-\/]+)\/memory\//', $filePath, $m)) {
return $m[1];
}
// Code repos: ~/Code/{project}/ or ~/Code/host-uk/{project}/
if (preg_match('#/Code/host-uk/([^/]+)/#', $filePath, $m)) {
return $m[1];
}
if (preg_match('#/Code/([^/]+)/#', $filePath, $m)) {
return $m[1];
}
return null;
}
private function inferType(string $heading, string $content, string $source): string
{
// Source-specific defaults
if ($source === 'plans') {
return 'plan';
}
if ($source === 'claude-md') {
return 'convention';
}
if ($source === 'docs') {
return 'documentation';
}
$lower = strtolower($heading.' '.$content);
$patterns = [
'architecture' => ['architecture', 'stack', 'infrastructure', 'layer', 'service mesh'],
'convention' => ['convention', 'standard', 'naming', 'pattern', 'rule', 'coding'],
'decision' => ['decision', 'chose', 'strategy', 'approach', 'domain'],
'bug' => ['bug', 'fix', 'broken', 'error', 'issue', 'lesson'],
'plan' => ['plan', 'todo', 'roadmap', 'milestone', 'phase', 'task'],
'research' => ['research', 'finding', 'discovery', 'analysis', 'rfc'],
];
foreach ($patterns as $type => $keywords) {
foreach ($keywords as $keyword) {
if (str_contains($lower, $keyword)) {
return $type;
}
}
}
return 'observation';
}
/** @return array<string> */
private function buildTags(string $heading, string $filename, string $source, ?string $project): array
{
$tags = ["source:{$source}"];
if ($project) {
$tags[] = "project:{$project}";
}
if ($filename !== 'MEMORY' && $filename !== 'CLAUDE') {
$tags[] = str_replace(['-', '_'], ' ', $filename);
}
return $tags;
}
private function confidenceForSource(string $source): float
{
return match ($source) {
'claude-md' => 0.9,
'docs' => 0.85,
'memory' => 0.8,
'plans' => 0.6,
'tasks' => 0.5,
default => 0.5,
};
}
// -------------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------------
private function clearCollection(BrainService $brain): void
{
$reflection = new \ReflectionClass($brain);
$prop = $reflection->getProperty('qdrantUrl');
$qdrantUrl = $prop->getValue($brain);
$prop = $reflection->getProperty('collection');
$collection = $prop->getValue($brain);
// Clear Qdrant collection.
\Illuminate\Support\Facades\Http::withoutVerifying()
->timeout(10)
->delete("{$qdrantUrl}/collections/{$collection}");
// Truncate the DB table so rows stay in sync with Qdrant.
\Core\Mod\Agentic\Models\BrainMemory::query()->forceDelete();
}
private function expandHome(string $path): string
{
if (str_starts_with($path, '~/')) {
$home = getenv('HOME') ?: ('/Users/'.get_current_user());
return $home.substr($path, 1);
}
return $path;
}
}

View file

@ -1,230 +0,0 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Agentic\Console\Commands;
use Core\Mod\Agentic\Services\BrainService;
use Illuminate\Console\Command;
/**
* Import MEMORY.md files from Claude Code project memory directories
* into the OpenBrain knowledge store.
*
* Scans Claude Code project memory directories (~/.claude/projects)
* for MEMORY.md and topic-specific markdown files, parses them into
* individual memories, and stores each via BrainService::remember().
*/
class BrainSeedMemoryCommand extends Command
{
protected $signature = 'brain:seed-memory
{--workspace= : Workspace ID to import into (required)}
{--agent=virgil : Agent ID to attribute memories to}
{--path= : Override scan path (default: ~/.claude/projects/*/memory/)}
{--dry-run : Preview what would be imported without storing}';
protected $description = 'Import MEMORY.md files from Claude Code project memory into OpenBrain';
public function handle(BrainService $brain): int
{
$workspaceId = $this->option('workspace');
if (! $workspaceId) {
$this->error('--workspace is required. Pass the workspace ID to import memories into.');
return self::FAILURE;
}
$agentId = $this->option('agent') ?? 'virgil';
$isDryRun = (bool) $this->option('dry-run');
$scanPath = $this->option('path')
?? $this->expandHome('~/.claude/projects/*/memory/');
$files = glob($scanPath.'*.md');
if (empty($files)) {
$this->info("No markdown files found in: {$scanPath}");
return self::SUCCESS;
}
$this->info(sprintf('Found %d markdown file(s) to process.', count($files)));
if (! $isDryRun) {
$brain->ensureCollection();
}
$imported = 0;
$skipped = 0;
foreach ($files as $file) {
$filename = basename($file, '.md');
$project = $this->extractProject($file);
$sections = $this->parseMarkdownSections($file);
if (empty($sections)) {
$this->line(" Skipped {$filename} (no sections found)");
$skipped++;
continue;
}
foreach ($sections as $section) {
$type = $this->inferType($section['heading'], $section['content']);
if ($isDryRun) {
$this->line(sprintf(
' [DRY RUN] %s :: %s (%s) — %d chars',
$filename,
$section['heading'],
$type,
strlen($section['content']),
));
$imported++;
continue;
}
try {
$brain->remember([
'workspace_id' => (int) $workspaceId,
'agent_id' => $agentId,
'type' => $type,
'content' => $section['heading']."\n\n".$section['content'],
'tags' => $this->extractTags($section['heading'], $filename),
'project' => $project,
'confidence' => 0.7,
]);
$imported++;
} catch (\Throwable $e) {
$this->warn(" Failed to import '{$section['heading']}': {$e->getMessage()}");
$skipped++;
}
}
}
$prefix = $isDryRun ? '[DRY RUN] ' : '';
$this->info("{$prefix}Imported {$imported} memories, skipped {$skipped}.");
return self::SUCCESS;
}
/**
* Parse a markdown file into sections based on ## headings.
*
* @return array<array{heading: string, content: string}>
*/
private function parseMarkdownSections(string $filePath): array
{
$content = file_get_contents($filePath);
if ($content === false || trim($content) === '') {
return [];
}
$sections = [];
$lines = explode("\n", $content);
$currentHeading = '';
$currentContent = [];
foreach ($lines as $line) {
if (preg_match('/^#{1,3}\s+(.+)$/', $line, $matches)) {
if ($currentHeading !== '' && ! empty($currentContent)) {
$sections[] = [
'heading' => $currentHeading,
'content' => trim(implode("\n", $currentContent)),
];
}
$currentHeading = trim($matches[1]);
$currentContent = [];
} else {
$currentContent[] = $line;
}
}
// Flush last section
if ($currentHeading !== '' && ! empty($currentContent)) {
$text = trim(implode("\n", $currentContent));
if ($text !== '') {
$sections[] = [
'heading' => $currentHeading,
'content' => $text,
];
}
}
return $sections;
}
/**
* Extract a project name from the file path.
*
* Paths like ~/.claude/projects/-Users-snider-Code-eaas/memory/MEMORY.md
* yield "eaas".
*/
private function extractProject(string $filePath): ?string
{
if (preg_match('/projects\/[^\/]*-([^-\/]+)\/memory\//', $filePath, $matches)) {
return $matches[1];
}
return null;
}
/**
* Infer the memory type from the heading and content.
*/
private function inferType(string $heading, string $content): string
{
$lower = strtolower($heading.' '.$content);
$patterns = [
'architecture' => ['architecture', 'stack', 'infrastructure', 'layer', 'service mesh'],
'convention' => ['convention', 'standard', 'naming', 'pattern', 'rule', 'coding'],
'decision' => ['decision', 'chose', 'strategy', 'approach', 'domain'],
'bug' => ['bug', 'fix', 'broken', 'error', 'issue', 'lesson'],
'plan' => ['plan', 'todo', 'roadmap', 'milestone', 'phase'],
'research' => ['research', 'finding', 'discovery', 'analysis', 'rfc'],
];
foreach ($patterns as $type => $keywords) {
foreach ($keywords as $keyword) {
if (str_contains($lower, $keyword)) {
return $type;
}
}
}
return 'observation';
}
/**
* Extract topic tags from the heading and filename.
*
* @return array<string>
*/
private function extractTags(string $heading, string $filename): array
{
$tags = [];
if ($filename !== 'MEMORY') {
$tags[] = str_replace(['-', '_'], ' ', $filename);
}
$tags[] = 'memory-import';
return $tags;
}
/**
* Expand ~ to the user's home directory.
*/
private function expandHome(string $path): string
{
if (str_starts_with($path, '~/')) {
$home = getenv('HOME') ?: ('/Users/'.get_current_user());
return $home.substr($path, 1);
}
return $path;
}
}

View file

@ -1,80 +0,0 @@
<?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\Console\Commands;
use Core\Mod\Agentic\Actions\Forge\AssignAgent;
use Core\Mod\Agentic\Actions\Forge\ReportToIssue;
use Core\Mod\Agentic\Models\AgentPlan;
use Illuminate\Console\Command;
class DispatchCommand extends Command
{
protected $signature = 'agentic:dispatch
{--workspace=1 : Workspace ID}
{--agent-type=opus : Default agent type}
{--dry-run : Show what would be dispatched}';
protected $description = 'Dispatch agents to draft plans sourced from Forgejo';
public function handle(): int
{
$workspaceId = (int) $this->option('workspace');
$defaultAgentType = (string) $this->option('agent-type');
$isDryRun = (bool) $this->option('dry-run');
$plans = AgentPlan::where('status', AgentPlan::STATUS_DRAFT)
->whereJsonContains('metadata->source', 'forgejo')
->whereDoesntHave('sessions')
->get();
if ($plans->isEmpty()) {
$this->info('No draft Forgejo plans awaiting dispatch.');
return self::SUCCESS;
}
$dispatched = 0;
foreach ($plans as $plan) {
$assignee = $plan->metadata['assignee'] ?? $defaultAgentType;
$issueNumber = $plan->metadata['issue_number'] ?? null;
$owner = $plan->metadata['repo_owner'] ?? null;
$repo = $plan->metadata['repo_name'] ?? null;
if ($isDryRun) {
$this->line("DRY RUN: Would dispatch '{$assignee}' to plan #{$plan->id}{$plan->title}");
$dispatched++;
continue;
}
$session = AssignAgent::run($plan, $assignee, $workspaceId);
if ($issueNumber !== null && $owner !== null && $repo !== null) {
ReportToIssue::run(
(string) $owner,
(string) $repo,
(int) $issueNumber,
"Agent **{$assignee}** dispatched. Session: #{$session->id}"
);
}
$this->line("Dispatched '{$assignee}' to plan #{$plan->id}: {$plan->title} (session #{$session->id})");
$dispatched++;
}
$action = $isDryRun ? 'would be dispatched' : 'dispatched';
$this->info("{$dispatched} plan(s) {$action}.");
return self::SUCCESS;
}
}

View file

@ -1,61 +0,0 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Agentic\Console\Commands;
use Core\Mod\Agentic\Models\AgentPlan;
use Illuminate\Console\Command;
class PlanRetentionCommand extends Command
{
protected $signature = 'agentic:plan-cleanup
{--dry-run : Preview deletions without making changes}
{--days= : Override retention period (overrides agentic.plan_retention_days config)}';
protected $description = 'Permanently delete archived plans past the retention period';
public function handle(): int
{
$days = (int) ($this->option('days') ?? config('agentic.plan_retention_days', 90));
if ($days <= 0) {
$this->info('Retention cleanup is disabled (plan_retention_days is 0).');
return self::SUCCESS;
}
$cutoff = now()->subDays($days);
$query = AgentPlan::where('status', AgentPlan::STATUS_ARCHIVED)
->whereNotNull('archived_at')
->where('archived_at', '<', $cutoff);
$count = $query->count();
if ($count === 0) {
$this->info('No archived plans found past the retention period.');
return self::SUCCESS;
}
if ($this->option('dry-run')) {
$this->info("DRY RUN: {$count} archived plan(s) would be permanently deleted (archived before {$cutoff->toDateString()}).");
return self::SUCCESS;
}
$deleted = 0;
$query->chunkById(100, function ($plans) use (&$deleted): void {
foreach ($plans as $plan) {
$plan->forceDelete();
$deleted++;
}
});
$this->info("Permanently deleted {$deleted} archived plan(s) archived before {$cutoff->toDateString()}.");
return self::SUCCESS;
}
}

View file

@ -1,94 +0,0 @@
<?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\Console\Commands;
use Core\Mod\Agentic\Actions\Forge\ManagePullRequest;
use Core\Mod\Agentic\Services\ForgejoService;
use Illuminate\Console\Command;
class PrManageCommand extends Command
{
protected $signature = 'agentic:pr-manage
{--repos=* : Repos to manage (owner/name format)}
{--dry-run : Show what would be merged}';
protected $description = 'Review and merge ready pull requests on Forgejo repositories';
public function handle(): int
{
$repos = $this->option('repos');
if (empty($repos)) {
$repos = config('agentic.scan_repos', []);
}
$repos = array_filter($repos);
if (empty($repos)) {
$this->warn('No repositories configured. Pass --repos or set AGENTIC_SCAN_REPOS.');
return self::SUCCESS;
}
$isDryRun = (bool) $this->option('dry-run');
$forge = app(ForgejoService::class);
$totalProcessed = 0;
foreach ($repos as $repoSpec) {
$parts = explode('/', $repoSpec, 2);
if (count($parts) !== 2) {
$this->error("Invalid repo format: {$repoSpec} (expected owner/name)");
continue;
}
[$owner, $repo] = $parts;
$this->info("Checking PRs for {$owner}/{$repo}...");
$pullRequests = $forge->listPullRequests($owner, $repo, 'open');
if (empty($pullRequests)) {
$this->line(" No open PRs.");
continue;
}
foreach ($pullRequests as $pr) {
$prNumber = (int) $pr['number'];
$prTitle = (string) ($pr['title'] ?? '');
$totalProcessed++;
if ($isDryRun) {
$this->line(" DRY RUN: Would evaluate PR #{$prNumber}{$prTitle}");
continue;
}
$result = ManagePullRequest::run($owner, $repo, $prNumber);
if ($result['merged']) {
$this->line(" Merged PR #{$prNumber}: {$prTitle}");
} else {
$reason = $result['reason'] ?? 'unknown';
$this->line(" Skipped PR #{$prNumber}: {$prTitle} ({$reason})");
}
}
}
$action = $isDryRun ? 'found' : 'processed';
$this->info("PR management complete: {$totalProcessed} PR(s) {$action}.");
return self::SUCCESS;
}
}

View file

@ -1,493 +0,0 @@
<?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\Console\Commands;
use Core\Mod\Agentic\Services\BrainService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Http;
/**
* Prepare an agent workspace with KB, specs, TODO, and vector context.
*
* Automates the "domain expert" prep that was previously manual:
* pulls repo wiki pages, copies protocol specs, generates a task
* file from a Forge issue, and queries the vector DB for context.
*/
class PrepWorkspaceCommand extends Command
{
protected $signature = 'agentic:prep-workspace
{--workspace=1 : Workspace ID}
{--repo= : Forge repo (e.g. go-ai)}
{--issue= : Issue number to build TODO from}
{--org=core : Forge organisation}
{--output= : Output directory (default: ./workspace)}
{--specs-path= : Path to specs dir (default: ~/Code/host-uk/specs)}
{--dry-run : Preview without writing files}';
protected $description = 'Prepare an agent workspace with wiki KB, specs, TODO, and vector context';
private string $baseUrl;
private string $token;
private string $org;
private string $outputDir;
private bool $dryRun;
public function handle(): int
{
$this->baseUrl = rtrim((string) config('upstream.gitea.url', 'https://forge.lthn.ai'), '/');
$this->token = (string) config('upstream.gitea.token', config('agentic.forge_token', ''));
$this->org = (string) $this->option('org');
$this->outputDir = (string) ($this->option('output') ?? getcwd() . '/workspace');
$this->dryRun = (bool) $this->option('dry-run');
$repo = $this->option('repo');
$issueNumber = $this->option('issue') ? (int) $this->option('issue') : null;
$specsPath = (string) ($this->option('specs-path') ?? $this->expandHome('~/Code/host-uk/specs'));
$workspaceId = (int) $this->option('workspace');
if (! $this->token) {
$this->error('No Forge token configured. Set GITEA_TOKEN or FORGE_TOKEN in .env');
return self::FAILURE;
}
if (! $repo) {
$this->error('--repo is required (e.g. --repo=go-ai)');
return self::FAILURE;
}
$this->info('Preparing workspace for ' . $this->org . '/' . $repo);
$this->info('Output: ' . $this->outputDir);
if ($this->dryRun) {
$this->warn('[DRY RUN] No files will be written.');
}
$this->newLine();
// Create output directory structure
if (! $this->dryRun) {
File::ensureDirectoryExists($this->outputDir . '/kb');
File::ensureDirectoryExists($this->outputDir . '/specs');
}
// Step 1: Pull wiki pages
$wikiCount = $this->pullWiki($repo);
// Step 2: Copy spec files
$specsCount = $this->copySpecs($specsPath);
// Step 3: Generate TODO from issue
$issueTitle = null;
$issueBody = null;
if ($issueNumber) {
[$issueTitle, $issueBody] = $this->generateTodo($repo, $issueNumber);
} else {
$this->generateTodoSkeleton($repo);
}
// Step 4: Generate context from vector DB
$contextCount = $this->generateContext($repo, $workspaceId, $issueTitle, $issueBody);
// Summary
$this->newLine();
$prefix = $this->dryRun ? '[DRY RUN] ' : '';
$this->info($prefix . 'Workspace prep complete:');
$this->line(' Wiki pages: ' . $wikiCount);
$this->line(' Spec files: ' . $specsCount);
$this->line(' TODO: ' . ($issueTitle ? 'from issue #' . $issueNumber : 'skeleton'));
$this->line(' Context: ' . $contextCount . ' memories');
return self::SUCCESS;
}
/**
* Fetch wiki pages from Forge API and write to kb/ directory.
*/
private function pullWiki(string $repo): int
{
$this->info('Fetching wiki pages for ' . $this->org . '/' . $repo . '...');
$response = Http::withHeaders(['Authorization' => 'token ' . $this->token])
->timeout(30)
->get($this->baseUrl . '/api/v1/repos/' . $this->org . '/' . $repo . '/wiki/pages');
if (! $response->successful()) {
if ($response->status() === 404) {
$this->warn(' No wiki found for ' . $repo);
if (! $this->dryRun) {
File::put(
$this->outputDir . '/kb/README.md',
'# No wiki found for ' . $repo . "\n\nThis repo has no wiki pages on Forge.\n"
);
}
return 0;
}
$this->error(' Wiki API error: ' . $response->status());
return 0;
}
$pages = $response->json() ?? [];
if (empty($pages)) {
$this->warn(' Wiki exists but has no pages.');
if (! $this->dryRun) {
File::put(
$this->outputDir . '/kb/README.md',
'# No wiki found for ' . $repo . "\n\nThis repo has no wiki pages on Forge.\n"
);
}
return 0;
}
$count = 0;
foreach ($pages as $page) {
$title = $page['title'] ?? 'Untitled';
$subUrl = $page['sub_url'] ?? $title;
if ($this->dryRun) {
$this->line(' [would fetch] ' . $title);
$count++;
continue;
}
// Fetch individual page content using sub_url (Forgejo's internal page identifier)
$pageResponse = Http::withHeaders(['Authorization' => 'token ' . $this->token])
->timeout(30)
->get($this->baseUrl . '/api/v1/repos/' . $this->org . '/' . $repo . '/wiki/page/' . urlencode($subUrl));
if (! $pageResponse->successful()) {
$this->warn(' Failed to fetch: ' . $title);
continue;
}
$pageData = $pageResponse->json();
$contentBase64 = $pageData['content_base64'] ?? '';
if (empty($contentBase64)) {
continue;
}
$content = base64_decode($contentBase64);
$filename = preg_replace('/[^a-zA-Z0-9_\-.]/', '-', $title) . '.md';
File::put($this->outputDir . '/kb/' . $filename, $content);
$this->line(' ' . $title);
$count++;
}
$this->info(' ' . $count . ' wiki page(s) saved to kb/');
return $count;
}
/**
* Copy protocol spec files to specs/ directory.
*/
private function copySpecs(string $specsPath): int
{
$this->info('Copying spec files...');
$specFiles = ['AGENT_CONTEXT.md', 'TASK_PROTOCOL.md'];
$count = 0;
foreach ($specFiles as $file) {
$source = $specsPath . '/' . $file;
if (! File::exists($source)) {
$this->warn(' Not found: ' . $source);
continue;
}
if ($this->dryRun) {
$this->line(' [would copy] ' . $file);
$count++;
continue;
}
File::copy($source, $this->outputDir . '/specs/' . $file);
$this->line(' ' . $file);
$count++;
}
$this->info(' ' . $count . ' spec file(s) copied.');
return $count;
}
/**
* Fetch a Forge issue and generate TODO.md in TASK_PROTOCOL format.
*
* @return array{0: string|null, 1: string|null} [title, body]
*/
private function generateTodo(string $repo, int $issueNumber): array
{
$this->info('Generating TODO from issue #' . $issueNumber . '...');
$response = Http::withHeaders(['Authorization' => 'token ' . $this->token])
->timeout(30)
->get($this->baseUrl . '/api/v1/repos/' . $this->org . '/' . $repo . '/issues/' . $issueNumber);
if (! $response->successful()) {
$this->error(' Failed to fetch issue #' . $issueNumber . ': ' . $response->status());
$this->generateTodoSkeleton($repo);
return [null, null];
}
$issue = $response->json();
$title = $issue['title'] ?? 'Untitled';
$body = $issue['body'] ?? '';
// Extract objective (first paragraph or up to 500 chars)
$objective = $this->extractObjective($body);
// Extract checklist items
$checklistItems = $this->extractChecklist($body);
$todoContent = '# TASK: ' . $title . "\n\n";
$todoContent .= '**Status:** ready' . "\n";
$todoContent .= '**Source:** ' . $this->baseUrl . '/' . $this->org . '/' . $repo . '/issues/' . $issueNumber . "\n";
$todoContent .= '**Created:** ' . now()->toDateTimeString() . "\n";
$todoContent .= '**Repo:** ' . $this->org . '/' . $repo . "\n";
$todoContent .= "\n---\n\n";
$todoContent .= "## Objective\n\n" . $objective . "\n";
$todoContent .= "\n---\n\n";
$todoContent .= "## Acceptance Criteria\n\n";
if (! empty($checklistItems)) {
foreach ($checklistItems as $item) {
$todoContent .= '- [ ] ' . $item . "\n";
}
} else {
$todoContent .= "_No checklist items found in issue. Agent should define acceptance criteria._\n";
}
$todoContent .= "\n---\n\n";
$todoContent .= "## Implementation Checklist\n\n";
$todoContent .= "_To be filled by the agent during planning._\n";
$todoContent .= "\n---\n\n";
$todoContent .= "## Notes\n\n";
$todoContent .= "Full issue body preserved below for reference.\n\n";
$todoContent .= "<details>\n<summary>Original Issue</summary>\n\n";
$todoContent .= $body . "\n\n";
$todoContent .= "</details>\n";
if ($this->dryRun) {
$this->line(' [would write] TODO.md from: ' . $title);
if (! empty($checklistItems)) {
$this->line(' Checklist items: ' . count($checklistItems));
}
} else {
File::put($this->outputDir . '/TODO.md', $todoContent);
$this->line(' TODO.md generated from: ' . $title);
}
return [$title, $body];
}
/**
* Generate a minimal TODO.md skeleton when no issue is provided.
*/
private function generateTodoSkeleton(string $repo): void
{
$content = "# TASK: [Define task]\n\n";
$content .= '**Status:** ready' . "\n";
$content .= '**Created:** ' . now()->toDateTimeString() . "\n";
$content .= '**Repo:** ' . $this->org . '/' . $repo . "\n";
$content .= "\n---\n\n";
$content .= "## Objective\n\n_Define the objective._\n";
$content .= "\n---\n\n";
$content .= "## Acceptance Criteria\n\n- [ ] _Define criteria_\n";
$content .= "\n---\n\n";
$content .= "## Implementation Checklist\n\n_To be filled by the agent._\n";
if ($this->dryRun) {
$this->line(' [would write] TODO.md skeleton');
} else {
File::put($this->outputDir . '/TODO.md', $content);
$this->line(' TODO.md skeleton generated (no --issue provided)');
}
}
/**
* Query BrainService for relevant context and write CONTEXT.md.
*/
private function generateContext(string $repo, int $workspaceId, ?string $issueTitle, ?string $issueBody): int
{
$this->info('Querying vector DB for context...');
try {
$brain = app(BrainService::class);
// Query 1: Repo-specific knowledge
$repoResults = $brain->recall(
'How does ' . $repo . ' work? Architecture and key interfaces.',
10,
['project' => $repo],
$workspaceId
);
$repoMemories = $repoResults['memories'] ?? [];
$repoScoreMap = $repoResults['scores'] ?? [];
// Query 2: Issue-specific context
$issueMemories = [];
$issueScoreMap = [];
if ($issueTitle) {
$query = $issueTitle . ' ' . mb_substr((string) $issueBody, 0, 500);
$issueResults = $brain->recall($query, 5, [], $workspaceId);
$issueMemories = $issueResults['memories'] ?? [];
$issueScoreMap = $issueResults['scores'] ?? [];
}
$totalMemories = count($repoMemories) + count($issueMemories);
$content = '# Agent Context — ' . $repo . "\n\n";
$content .= '> Auto-generated by `agentic:prep-workspace`. Query the vector DB for more.' . "\n\n";
$content .= "## Repo Knowledge\n\n";
if (! empty($repoMemories)) {
foreach ($repoMemories as $i => $memory) {
$memId = $memory['id'] ?? '';
$score = $repoScoreMap[$memId] ?? 0;
$memContent = $memory['content'] ?? '';
$memProject = $memory['project'] ?? 'unknown';
$memType = $memory['type'] ?? 'memory';
$content .= '### ' . ($i + 1) . '. ' . $memProject . ' [' . $memType . '] (score: ' . round((float) $score, 3) . ")\n\n";
$content .= $memContent . "\n\n";
}
} else {
$content .= "_No repo-specific memories found. The vector DB may not have been seeded for this repo._\n\n";
}
$content .= "## Task-Relevant Context\n\n";
if (! empty($issueMemories)) {
foreach ($issueMemories as $i => $memory) {
$memId = $memory['id'] ?? '';
$score = $issueScoreMap[$memId] ?? 0;
$memContent = $memory['content'] ?? '';
$memProject = $memory['project'] ?? 'unknown';
$memType = $memory['type'] ?? 'memory';
$content .= '### ' . ($i + 1) . '. ' . $memProject . ' [' . $memType . '] (score: ' . round((float) $score, 3) . ")\n\n";
$content .= $memContent . "\n\n";
}
} elseif ($issueTitle) {
$content .= "_No task-relevant memories found._\n\n";
} else {
$content .= "_No issue provided — skipped task-specific recall._\n\n";
}
if ($this->dryRun) {
$this->line(' [would write] CONTEXT.md with ' . $totalMemories . ' memories');
} else {
File::put($this->outputDir . '/CONTEXT.md', $content);
$this->line(' CONTEXT.md generated with ' . $totalMemories . ' memories');
}
return $totalMemories;
} catch (\Throwable $e) {
$this->warn(' BrainService unavailable: ' . $e->getMessage());
$content = '# Agent Context — ' . $repo . "\n\n";
$content .= "> Vector DB was unavailable when this workspace was prepared.\n";
$content .= "> Run `agentic:prep-workspace` again once Ollama/Qdrant are reachable.\n";
if (! $this->dryRun) {
File::put($this->outputDir . '/CONTEXT.md', $content);
}
return 0;
}
}
/**
* Extract the first paragraph or up to 500 characters as the objective.
*/
private function extractObjective(string $body): string
{
if (empty($body)) {
return '_No description provided._';
}
// Find first paragraph (text before a blank line)
$paragraphs = preg_split('/\n\s*\n/', $body, 2);
$first = trim($paragraphs[0] ?? $body);
if (mb_strlen($first) > 500) {
return mb_substr($first, 0, 497) . '...';
}
return $first;
}
/**
* Extract checklist items from markdown body.
*
* Matches `- [ ] text` and `- [x] text` lines.
*
* @return array<int, string>
*/
private function extractChecklist(string $body): array
{
$items = [];
if (preg_match_all('/- \[[ xX]\] (.+)/', $body, $matches)) {
foreach ($matches[1] as $item) {
$items[] = trim($item);
}
}
return $items;
}
/**
* Truncate a string to a maximum length.
*/
private function truncate(string $text, int $length): string
{
if (mb_strlen($text) <= $length) {
return $text;
}
return mb_substr($text, 0, $length - 3) . '...';
}
/**
* Expand ~ to the user's home directory.
*/
private function expandHome(string $path): string
{
if (str_starts_with($path, '~/')) {
$home = $_SERVER['HOME'] ?? getenv('HOME') ?: '/tmp';
return $home . substr($path, 1);
}
return $path;
}
}

View file

@ -1,98 +0,0 @@
<?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\Console\Commands;
use Core\Mod\Agentic\Actions\Forge\CreatePlanFromIssue;
use Core\Mod\Agentic\Actions\Forge\ReportToIssue;
use Core\Mod\Agentic\Actions\Forge\ScanForWork;
use Illuminate\Console\Command;
class ScanCommand extends Command
{
protected $signature = 'agentic:scan
{--workspace=1 : Workspace ID}
{--repos=* : Repos to scan (owner/name format)}
{--dry-run : Show what would be created without acting}';
protected $description = 'Scan Forgejo repositories for actionable work from epic issues';
public function handle(): int
{
$workspaceId = (int) $this->option('workspace');
$repos = $this->option('repos');
if (empty($repos)) {
$repos = config('agentic.scan_repos', []);
}
$repos = array_filter($repos);
if (empty($repos)) {
$this->warn('No repositories configured. Pass --repos or set AGENTIC_SCAN_REPOS.');
return self::SUCCESS;
}
$isDryRun = (bool) $this->option('dry-run');
$totalItems = 0;
foreach ($repos as $repoSpec) {
$parts = explode('/', $repoSpec, 2);
if (count($parts) !== 2) {
$this->error("Invalid repo format: {$repoSpec} (expected owner/name)");
continue;
}
[$owner, $repo] = $parts;
$this->info("Scanning {$owner}/{$repo}...");
$workItems = ScanForWork::run($owner, $repo);
if (empty($workItems)) {
$this->line(" No actionable work found.");
continue;
}
foreach ($workItems as $item) {
$totalItems++;
$issueNumber = $item['issue_number'];
$title = $item['issue_title'];
if ($isDryRun) {
$this->line(" DRY RUN: Would create plan for #{$issueNumber}{$title}");
continue;
}
$plan = CreatePlanFromIssue::run($item, $workspaceId);
ReportToIssue::run(
$owner,
$repo,
$issueNumber,
"Plan created: **{$plan->title}** (#{$plan->id})"
);
$this->line(" Created plan #{$plan->id} for issue #{$issueNumber}: {$title}");
}
}
$action = $isDryRun ? 'found' : 'processed';
$this->info("Scan complete: {$totalItems} work item(s) {$action}.");
return self::SUCCESS;
}
}

View file

@ -1,649 +0,0 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Agentic\Controllers;
use Core\Mod\Agentic\Models\AgentPlan;
use Core\Mod\Agentic\Models\AgentPhase;
use Core\Mod\Agentic\Models\AgentSession;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
/**
* Agent API Controller.
*
* REST endpoints consumed by the go-agentic Client (dispatch watch).
* All routes are protected by AgentApiAuth middleware with Bearer token.
*
* Prefix: /api/v1
*/
class AgentApiController extends Controller
{
// -------------------------------------------------------------------------
// Health
// -------------------------------------------------------------------------
public function health(): JsonResponse
{
return response()->json([
'status' => 'ok',
'service' => 'core-agentic',
'timestamp' => now()->toIso8601String(),
]);
}
// -------------------------------------------------------------------------
// Plans
// -------------------------------------------------------------------------
/**
* GET /v1/plans
*
* List plans with optional status filter.
* Query params: status, include_archived
*/
public function listPlans(Request $request): JsonResponse
{
$workspaceId = $request->attributes->get('workspace_id');
$query = AgentPlan::where('workspace_id', $workspaceId);
if ($status = $request->query('status')) {
$query->where('status', $status);
}
if (! $request->boolean('include_archived')) {
$query->notArchived();
}
$plans = $query->orderByStatus()->latest()->get();
return response()->json([
'plans' => $plans->map(fn (AgentPlan $p) => $this->formatPlan($p)),
'total' => $plans->count(),
]);
}
/**
* GET /v1/plans/{slug}
*
* Get plan detail with phases.
*/
public function getPlan(Request $request, string $slug): JsonResponse
{
$workspaceId = $request->attributes->get('workspace_id');
$plan = AgentPlan::where('workspace_id', $workspaceId)
->where('slug', $slug)
->first();
if (! $plan) {
return response()->json(['error' => 'not_found', 'message' => 'Plan not found'], 404);
}
return response()->json($this->formatPlanDetail($plan));
}
/**
* POST /v1/plans
*
* Create a new plan with optional phases.
*/
public function createPlan(Request $request): JsonResponse
{
$workspaceId = $request->attributes->get('workspace_id');
$validated = $request->validate([
'title' => 'required|string|max:255',
'slug' => 'nullable|string|max:255',
'description' => 'nullable|string',
'context' => 'nullable|array',
'phases' => 'nullable|array',
'phases.*.name' => 'required|string',
'phases.*.description' => 'nullable|string',
'phases.*.tasks' => 'nullable|array',
]);
$slug = $validated['slug'] ?? AgentPlan::generateSlug($validated['title']);
$plan = AgentPlan::create([
'workspace_id' => $workspaceId,
'slug' => $slug,
'title' => $validated['title'],
'description' => $validated['description'] ?? null,
'context' => $validated['context'] ?? null,
'status' => AgentPlan::STATUS_DRAFT,
]);
// Create phases if provided
$phaseCount = 0;
if (! empty($validated['phases'])) {
foreach ($validated['phases'] as $order => $phaseData) {
$tasks = [];
foreach ($phaseData['tasks'] ?? [] as $taskName) {
$tasks[] = ['name' => $taskName, 'status' => 'pending'];
}
AgentPhase::create([
'agent_plan_id' => $plan->id,
'order' => $order,
'name' => $phaseData['name'],
'description' => $phaseData['description'] ?? null,
'tasks' => $tasks ?: null,
'status' => AgentPhase::STATUS_PENDING,
]);
$phaseCount++;
}
}
return response()->json([
'slug' => $plan->slug,
'title' => $plan->title,
'status' => $plan->status,
'phases' => $phaseCount,
], 201);
}
/**
* PATCH /v1/plans/{slug}
*
* Update plan status.
*/
public function updatePlan(Request $request, string $slug): JsonResponse
{
$workspaceId = $request->attributes->get('workspace_id');
$plan = AgentPlan::where('workspace_id', $workspaceId)
->where('slug', $slug)
->first();
if (! $plan) {
return response()->json(['error' => 'not_found', 'message' => 'Plan not found'], 404);
}
$validated = $request->validate([
'status' => 'required|string|in:draft,active,completed,archived',
]);
match ($validated['status']) {
'active' => $plan->activate(),
'completed' => $plan->complete(),
'archived' => $plan->archive(),
default => $plan->update(['status' => $validated['status']]),
};
return response()->json([
'slug' => $plan->slug,
'status' => $plan->fresh()->status,
]);
}
/**
* DELETE /v1/plans/{slug}
*
* Archive a plan with optional reason.
*/
public function archivePlan(Request $request, string $slug): JsonResponse
{
$workspaceId = $request->attributes->get('workspace_id');
$plan = AgentPlan::where('workspace_id', $workspaceId)
->where('slug', $slug)
->first();
if (! $plan) {
return response()->json(['error' => 'not_found', 'message' => 'Plan not found'], 404);
}
$reason = $request->input('reason');
$plan->archive($reason);
return response()->json([
'slug' => $plan->slug,
'status' => 'archived',
'archived_at' => now()->toIso8601String(),
]);
}
// -------------------------------------------------------------------------
// Phases
// -------------------------------------------------------------------------
/**
* GET /v1/plans/{slug}/phases/{phase}
*
* Get a phase by order number.
*/
public function getPhase(Request $request, string $slug, string $phase): JsonResponse
{
$plan = $this->findPlan($request, $slug);
if (! $plan) {
return response()->json(['error' => 'not_found', 'message' => 'Plan not found'], 404);
}
$agentPhase = $plan->agentPhases()->where('order', (int) $phase)->first();
if (! $agentPhase) {
return response()->json(['error' => 'not_found', 'message' => 'Phase not found'], 404);
}
return response()->json($this->formatPhase($agentPhase));
}
/**
* PATCH /v1/plans/{slug}/phases/{phase}
*
* Update phase status and/or notes.
*/
public function updatePhase(Request $request, string $slug, string $phase): JsonResponse
{
$plan = $this->findPlan($request, $slug);
if (! $plan) {
return response()->json(['error' => 'not_found', 'message' => 'Plan not found'], 404);
}
$agentPhase = $plan->agentPhases()->where('order', (int) $phase)->first();
if (! $agentPhase) {
return response()->json(['error' => 'not_found', 'message' => 'Phase not found'], 404);
}
$status = $request->input('status');
$notes = $request->input('notes');
if ($status) {
match ($status) {
'in_progress' => $agentPhase->start(),
'completed' => $agentPhase->complete(),
'blocked' => $agentPhase->block($notes),
'skipped' => $agentPhase->skip($notes),
'pending' => $agentPhase->reset(),
default => null,
};
}
if ($notes && ! in_array($status, ['blocked', 'skipped'])) {
$agentPhase->addCheckpoint($notes);
}
return response()->json([
'slug' => $slug,
'phase' => (int) $phase,
'status' => $agentPhase->fresh()->status,
]);
}
/**
* POST /v1/plans/{slug}/phases/{phase}/checkpoint
*
* Add a checkpoint to a phase.
*/
public function addCheckpoint(Request $request, string $slug, string $phase): JsonResponse
{
$plan = $this->findPlan($request, $slug);
if (! $plan) {
return response()->json(['error' => 'not_found', 'message' => 'Plan not found'], 404);
}
$agentPhase = $plan->agentPhases()->where('order', (int) $phase)->first();
if (! $agentPhase) {
return response()->json(['error' => 'not_found', 'message' => 'Phase not found'], 404);
}
$validated = $request->validate([
'note' => 'required|string',
'context' => 'nullable|array',
]);
$agentPhase->addCheckpoint($validated['note'], $validated['context'] ?? []);
return response()->json([
'slug' => $slug,
'phase' => (int) $phase,
'checkpoints' => count($agentPhase->fresh()->getCheckpoints()),
]);
}
/**
* PATCH /v1/plans/{slug}/phases/{phase}/tasks/{taskIdx}
*
* Update a task within a phase.
*/
public function updateTask(Request $request, string $slug, string $phase, int $taskIdx): JsonResponse
{
$plan = $this->findPlan($request, $slug);
if (! $plan) {
return response()->json(['error' => 'not_found', 'message' => 'Plan not found'], 404);
}
$agentPhase = $plan->agentPhases()->where('order', (int) $phase)->first();
if (! $agentPhase) {
return response()->json(['error' => 'not_found', 'message' => 'Phase not found'], 404);
}
$tasks = $agentPhase->tasks ?? [];
if (! isset($tasks[$taskIdx])) {
return response()->json(['error' => 'not_found', 'message' => 'Task not found'], 404);
}
$status = $request->input('status');
$notes = $request->input('notes');
if (is_string($tasks[$taskIdx])) {
$tasks[$taskIdx] = ['name' => $tasks[$taskIdx], 'status' => $status ?? 'pending'];
} else {
if ($status) {
$tasks[$taskIdx]['status'] = $status;
}
}
if ($notes) {
$tasks[$taskIdx]['notes'] = $notes;
}
$agentPhase->update(['tasks' => $tasks]);
return response()->json([
'slug' => $slug,
'phase' => (int) $phase,
'task' => $taskIdx,
'status' => $tasks[$taskIdx]['status'] ?? 'pending',
]);
}
/**
* POST /v1/plans/{slug}/phases/{phase}/tasks/{taskIdx}/toggle
*
* Toggle a task between pending and completed.
*/
public function toggleTask(Request $request, string $slug, string $phase, int $taskIdx): JsonResponse
{
$plan = $this->findPlan($request, $slug);
if (! $plan) {
return response()->json(['error' => 'not_found', 'message' => 'Plan not found'], 404);
}
$agentPhase = $plan->agentPhases()->where('order', (int) $phase)->first();
if (! $agentPhase) {
return response()->json(['error' => 'not_found', 'message' => 'Phase not found'], 404);
}
$tasks = $agentPhase->tasks ?? [];
if (! isset($tasks[$taskIdx])) {
return response()->json(['error' => 'not_found', 'message' => 'Task not found'], 404);
}
if (is_string($tasks[$taskIdx])) {
$tasks[$taskIdx] = ['name' => $tasks[$taskIdx], 'status' => 'completed'];
} else {
$current = $tasks[$taskIdx]['status'] ?? 'pending';
$tasks[$taskIdx]['status'] = $current === 'completed' ? 'pending' : 'completed';
}
$agentPhase->update(['tasks' => $tasks]);
return response()->json([
'slug' => $slug,
'phase' => (int) $phase,
'task' => $taskIdx,
'status' => $tasks[$taskIdx]['status'] ?? 'pending',
]);
}
// -------------------------------------------------------------------------
// Sessions
// -------------------------------------------------------------------------
/**
* GET /v1/sessions
*
* List sessions with optional filters.
* Query params: status, plan_slug, limit
*/
public function listSessions(Request $request): JsonResponse
{
$workspaceId = $request->attributes->get('workspace_id');
$query = AgentSession::where('workspace_id', $workspaceId);
if ($status = $request->query('status')) {
$query->where('status', $status);
}
if ($planSlug = $request->query('plan_slug')) {
$plan = AgentPlan::where('workspace_id', $workspaceId)
->where('slug', $planSlug)
->first();
if ($plan) {
$query->where('agent_plan_id', $plan->id);
} else {
return response()->json(['sessions' => [], 'total' => 0]);
}
}
$limit = (int) ($request->query('limit') ?: 50);
$sessions = $query->latest('started_at')->limit($limit)->get();
return response()->json([
'sessions' => $sessions->map(fn (AgentSession $s) => $this->formatSession($s)),
'total' => $sessions->count(),
]);
}
/**
* GET /v1/sessions/{sessionId}
*
* Get session detail.
*/
public function getSession(Request $request, string $sessionId): JsonResponse
{
$workspaceId = $request->attributes->get('workspace_id');
$session = AgentSession::where('workspace_id', $workspaceId)
->where('session_id', $sessionId)
->first();
if (! $session) {
return response()->json(['error' => 'not_found', 'message' => 'Session not found'], 404);
}
return response()->json($this->formatSession($session));
}
/**
* POST /v1/sessions
*
* Start a new session.
*/
public function startSession(Request $request): JsonResponse
{
$workspaceId = $request->attributes->get('workspace_id');
$apiKey = $request->attributes->get('agent_api_key');
$validated = $request->validate([
'agent_type' => 'required|string',
'plan_slug' => 'nullable|string',
'context' => 'nullable|array',
]);
$plan = null;
if (! empty($validated['plan_slug'])) {
$plan = AgentPlan::where('workspace_id', $workspaceId)
->where('slug', $validated['plan_slug'])
->first();
}
$session = AgentSession::create([
'workspace_id' => $workspaceId,
'agent_api_key_id' => $apiKey?->id,
'agent_plan_id' => $plan?->id,
'session_id' => 'sess_' . \Ramsey\Uuid\Uuid::uuid4()->toString(),
'agent_type' => $validated['agent_type'],
'status' => AgentSession::STATUS_ACTIVE,
'context_summary' => $validated['context'] ?? [],
'work_log' => [],
'artifacts' => [],
'started_at' => now(),
'last_active_at' => now(),
]);
return response()->json([
'session_id' => $session->session_id,
'agent_type' => $session->agent_type,
'plan' => $plan?->slug,
'status' => $session->status,
], 201);
}
/**
* POST /v1/sessions/{sessionId}/end
*
* End a session.
*/
public function endSession(Request $request, string $sessionId): JsonResponse
{
$workspaceId = $request->attributes->get('workspace_id');
$session = AgentSession::where('workspace_id', $workspaceId)
->where('session_id', $sessionId)
->first();
if (! $session) {
return response()->json(['error' => 'not_found', 'message' => 'Session not found'], 404);
}
$validated = $request->validate([
'status' => 'required|string|in:completed,failed',
'summary' => 'nullable|string',
]);
$session->end($validated['status'], $validated['summary'] ?? null);
return response()->json([
'session_id' => $session->session_id,
'status' => $session->fresh()->status,
'duration' => $session->getDurationFormatted(),
]);
}
/**
* POST /v1/sessions/{sessionId}/continue
*
* Continue from a previous session (multi-agent handoff).
*/
public function continueSession(Request $request, string $sessionId): JsonResponse
{
$workspaceId = $request->attributes->get('workspace_id');
$previousSession = AgentSession::where('workspace_id', $workspaceId)
->where('session_id', $sessionId)
->first();
if (! $previousSession) {
return response()->json(['error' => 'not_found', 'message' => 'Session not found'], 404);
}
$validated = $request->validate([
'agent_type' => 'required|string',
]);
$newSession = $previousSession->createReplaySession($validated['agent_type']);
return response()->json([
'session_id' => $newSession->session_id,
'agent_type' => $newSession->agent_type,
'plan' => $newSession->plan?->slug,
'status' => $newSession->status,
'continued_from' => $previousSession->session_id,
], 201);
}
// -------------------------------------------------------------------------
// Formatters (match go-agentic JSON contract)
// -------------------------------------------------------------------------
private function formatPlan(AgentPlan $plan): array
{
$progress = $plan->getProgress();
return [
'slug' => $plan->slug,
'title' => $plan->title,
'description' => $plan->description,
'status' => $plan->status,
'current_phase' => $plan->current_phase !== null ? (int) $plan->current_phase : null,
'progress' => $progress,
'metadata' => $plan->metadata,
'created_at' => $plan->created_at?->toIso8601String(),
'updated_at' => $plan->updated_at?->toIso8601String(),
];
}
private function formatPlanDetail(AgentPlan $plan): array
{
$data = $this->formatPlan($plan);
$data['phases'] = $plan->agentPhases->map(fn (AgentPhase $p) => $this->formatPhase($p))->all();
return $data;
}
private function formatPhase(AgentPhase $phase): array
{
$taskProgress = $phase->getTaskProgress();
return [
'id' => $phase->id,
'order' => $phase->order,
'name' => $phase->name,
'description' => $phase->description,
'status' => $phase->status,
'tasks' => $phase->tasks,
'task_progress' => [
'total' => $taskProgress['total'],
'completed' => $taskProgress['completed'],
'pending' => $taskProgress['remaining'],
'percentage' => (int) $taskProgress['percentage'],
],
'remaining_tasks' => $phase->getRemainingTasks(),
'dependencies' => $phase->dependencies,
'dependency_blockers' => $phase->checkDependencies(),
'can_start' => $phase->canStart(),
'checkpoints' => $phase->getCheckpoints(),
'started_at' => $phase->started_at?->toIso8601String(),
'completed_at' => $phase->completed_at?->toIso8601String(),
'metadata' => $phase->metadata,
];
}
private function formatSession(AgentSession $session): array
{
return [
'session_id' => $session->session_id,
'agent_type' => $session->agent_type,
'status' => $session->status,
'plan_slug' => $session->plan?->slug,
'plan' => $session->plan?->slug,
'duration' => $session->getDurationFormatted(),
'started_at' => $session->started_at?->toIso8601String(),
'last_active_at' => $session->last_active_at?->toIso8601String(),
'ended_at' => $session->ended_at?->toIso8601String(),
'action_count' => count($session->work_log ?? []),
'artifact_count' => count($session->artifacts ?? []),
'context_summary' => $session->context_summary,
'handoff_notes' => $session->handoff_notes ? ($session->handoff_notes['summary'] ?? '') : null,
];
}
// -------------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------------
private function findPlan(Request $request, string $slug): ?AgentPlan
{
$workspaceId = $request->attributes->get('workspace_id');
return AgentPlan::where('workspace_id', $workspaceId)
->where('slug', $slug)
->first();
}
}

View file

@ -1,164 +0,0 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Agentic\Controllers\Api;
use Core\Front\Controller;
use Core\Mod\Agentic\Actions\Brain\ForgetKnowledge;
use Core\Mod\Agentic\Actions\Brain\ListKnowledge;
use Core\Mod\Agentic\Actions\Brain\RecallKnowledge;
use Core\Mod\Agentic\Actions\Brain\RememberKnowledge;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class BrainController extends Controller
{
/**
* POST /api/brain/remember
*
* Store a memory in OpenBrain.
*/
public function remember(Request $request): JsonResponse
{
$validated = $request->validate([
'content' => 'required|string|max:50000',
'type' => 'required|string',
'tags' => 'nullable|array',
'tags.*' => 'string',
'project' => 'nullable|string|max:255',
'confidence' => 'nullable|numeric|min:0|max:1',
'supersedes' => 'nullable|uuid',
'expires_in' => 'nullable|integer|min:1',
]);
$workspace = $request->attributes->get('workspace');
$apiKey = $request->attributes->get('api_key');
$agentId = $apiKey?->name ?? 'api';
try {
$memory = RememberKnowledge::run($validated, $workspace->id, $agentId);
return response()->json([
'data' => $memory->toMcpContext(),
], 201);
} catch (\InvalidArgumentException $e) {
return response()->json([
'error' => 'validation_error',
'message' => $e->getMessage(),
], 422);
} catch (\RuntimeException $e) {
return response()->json([
'error' => 'service_error',
'message' => 'Brain service temporarily unavailable.',
], 503);
}
}
/**
* POST /api/brain/recall
*
* Semantic search across memories.
*/
public function recall(Request $request): JsonResponse
{
$validated = $request->validate([
'query' => 'required|string|max:2000',
'top_k' => 'nullable|integer|min:1|max:20',
'filter' => 'nullable|array',
'filter.project' => 'nullable|string',
'filter.type' => 'nullable',
'filter.agent_id' => 'nullable|string',
'filter.min_confidence' => 'nullable|numeric|min:0|max:1',
]);
$workspace = $request->attributes->get('workspace');
try {
$result = RecallKnowledge::run(
$validated['query'],
$workspace->id,
$validated['filter'] ?? [],
$validated['top_k'] ?? 5,
);
return response()->json([
'data' => $result,
]);
} catch (\InvalidArgumentException $e) {
return response()->json([
'error' => 'validation_error',
'message' => $e->getMessage(),
], 422);
} catch (\RuntimeException $e) {
return response()->json([
'error' => 'service_error',
'message' => 'Brain service temporarily unavailable.',
], 503);
}
}
/**
* DELETE /api/brain/forget/{id}
*
* Remove a memory.
*/
public function forget(Request $request, string $id): JsonResponse
{
$request->validate([
'reason' => 'nullable|string|max:500',
]);
$workspace = $request->attributes->get('workspace');
$apiKey = $request->attributes->get('api_key');
$agentId = $apiKey?->name ?? 'api';
try {
$result = ForgetKnowledge::run($id, $workspace->id, $agentId, $request->input('reason'));
return response()->json([
'data' => $result,
]);
} catch (\InvalidArgumentException $e) {
return response()->json([
'error' => 'not_found',
'message' => $e->getMessage(),
], 404);
} catch (\RuntimeException $e) {
return response()->json([
'error' => 'service_error',
'message' => 'Brain service temporarily unavailable.',
], 503);
}
}
/**
* GET /api/brain/list
*
* List memories with optional filters.
*/
public function list(Request $request): JsonResponse
{
$validated = $request->validate([
'project' => 'nullable|string',
'type' => 'nullable|string',
'agent_id' => 'nullable|string',
'limit' => 'nullable|integer|min:1|max:100',
]);
$workspace = $request->attributes->get('workspace');
try {
$result = ListKnowledge::run($workspace->id, $validated);
return response()->json([
'data' => $result,
]);
} catch (\InvalidArgumentException $e) {
return response()->json([
'error' => 'validation_error',
'message' => $e->getMessage(),
], 422);
}
}
}

View file

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

View file

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

View file

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

View file

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

View file

@ -111,12 +111,10 @@ class ProcessContentTask implements ShouldQueue
private function interpolateVariables(string $template, array $data): string
{
foreach ($data as $key => $value) {
$placeholder = '{{{'.$key.'}}}';
if (is_string($value)) {
$template = str_replace($placeholder, $value, $template);
$template = str_replace("{{{$key}}}", $value, $template);
} elseif (is_array($value)) {
$template = str_replace($placeholder, json_encode($value), $template);
$template = str_replace("{{{$key}}}", json_encode($value), $template);
}
}

View file

@ -54,7 +54,7 @@ class Marketing extends Server
#### Other Bio Tools
- `qr_tools` - Generate QR codes
- `pixel_tools` - Manage tracking pixels
- `project_tools` - Organise into projects
- `project_tools` - Organize into projects
- `notification_tools` - Manage notification handlers
- `submission_tools` - Manage form submissions
- `pwa_tools` - Configure PWA

View file

@ -1,78 +0,0 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Agentic\Mcp\Tools\Agent\Brain;
use Core\Mcp\Dependencies\ToolDependency;
use Core\Mod\Agentic\Actions\Brain\ForgetKnowledge;
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
use Core\Mod\Agentic\Models\BrainMemory;
/**
* Remove a memory from the shared OpenBrain knowledge store.
*
* Deletes the memory from both MariaDB and Qdrant.
* Workspace-scoped: agents can only forget memories in their own workspace.
*/
class BrainForget extends AgentTool
{
protected string $category = 'brain';
protected array $scopes = ['write'];
public function dependencies(): array
{
return [
ToolDependency::contextExists('workspace_id', 'Workspace context required to forget memories'),
];
}
public function name(): string
{
return 'brain_forget';
}
public function description(): string
{
return 'Remove a memory from the shared OpenBrain knowledge store. Permanently deletes from both database and vector index.';
}
public function inputSchema(): array
{
return [
'type' => 'object',
'properties' => [
'id' => [
'type' => 'string',
'format' => 'uuid',
'description' => 'UUID of the memory to remove',
],
'reason' => [
'type' => 'string',
'description' => 'Optional reason for forgetting this memory',
'maxLength' => 500,
],
],
'required' => ['id'],
];
}
public function handle(array $args, array $context = []): array
{
$workspaceId = $context['workspace_id'] ?? null;
if ($workspaceId === null) {
return $this->error('workspace_id is required. Ensure you have authenticated with a valid API key. See: https://host.uk.com/ai');
}
$id = $args['id'] ?? '';
$reason = $this->optionalString($args, 'reason', null, 500);
$agentId = $context['agent_id'] ?? $context['session_id'] ?? 'anonymous';
return $this->withCircuitBreaker('brain', function () use ($id, $workspaceId, $agentId, $reason) {
$result = ForgetKnowledge::run($id, (int) $workspaceId, $agentId, $reason);
return $this->success($result);
}, fn () => $this->error('Brain service temporarily unavailable. Memory could not be removed.', 'service_unavailable'));
}
}

View file

@ -1,81 +0,0 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Agentic\Mcp\Tools\Agent\Brain;
use Core\Mcp\Dependencies\ToolDependency;
use Core\Mod\Agentic\Actions\Brain\ListKnowledge;
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
use Core\Mod\Agentic\Models\BrainMemory;
/**
* List memories in the shared OpenBrain knowledge store.
*
* Pure MariaDB query using model scopes -- no vector search.
* Useful for browsing what an agent or project has stored.
*/
class BrainList extends AgentTool
{
protected string $category = 'brain';
protected array $scopes = ['read'];
public function dependencies(): array
{
return [
ToolDependency::contextExists('workspace_id', 'Workspace context required to list memories'),
];
}
public function name(): string
{
return 'brain_list';
}
public function description(): string
{
return 'List memories in the shared OpenBrain knowledge store. Supports filtering by project, type, and agent. No vector search -- use brain_recall for semantic queries.';
}
public function inputSchema(): array
{
return [
'type' => 'object',
'properties' => [
'project' => [
'type' => 'string',
'description' => 'Filter by project scope',
],
'type' => [
'type' => 'string',
'description' => 'Filter by memory type',
'enum' => BrainMemory::VALID_TYPES,
],
'agent_id' => [
'type' => 'string',
'description' => 'Filter by originating agent',
],
'limit' => [
'type' => 'integer',
'description' => 'Maximum results to return (default: 20, max: 100)',
'minimum' => 1,
'maximum' => 100,
'default' => 20,
],
],
];
}
public function handle(array $args, array $context = []): array
{
$workspaceId = $context['workspace_id'] ?? null;
if ($workspaceId === null) {
return $this->error('workspace_id is required. Ensure you have authenticated with a valid API key. See: https://host.uk.com/ai');
}
$result = ListKnowledge::run((int) $workspaceId, $args);
return $this->success($result);
}
}

View file

@ -1,119 +0,0 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Agentic\Mcp\Tools\Agent\Brain;
use Core\Mcp\Dependencies\ToolDependency;
use Core\Mod\Agentic\Actions\Brain\RecallKnowledge;
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
use Core\Mod\Agentic\Models\BrainMemory;
/**
* Semantic search across the shared OpenBrain knowledge store.
*
* Uses vector similarity to find memories relevant to a natural
* language query, with optional filtering by project, type, agent,
* or minimum confidence.
*/
class BrainRecall extends AgentTool
{
protected string $category = 'brain';
protected array $scopes = ['read'];
public function dependencies(): array
{
return [
ToolDependency::contextExists('workspace_id', 'Workspace context required to recall memories'),
];
}
public function name(): string
{
return 'brain_recall';
}
public function description(): string
{
return 'Semantic search across the shared OpenBrain knowledge store. Returns memories ranked by similarity to your query, with optional filtering.';
}
public function inputSchema(): array
{
return [
'type' => 'object',
'properties' => [
'query' => [
'type' => 'string',
'description' => 'Natural language search query (max 2,000 characters)',
'maxLength' => 2000,
],
'top_k' => [
'type' => 'integer',
'description' => 'Number of results to return (default: 5, max: 20)',
'minimum' => 1,
'maximum' => 20,
'default' => 5,
],
'filter' => [
'type' => 'object',
'description' => 'Optional filters to narrow results',
'properties' => [
'project' => [
'type' => 'string',
'description' => 'Filter by project scope',
],
'type' => [
'oneOf' => [
['type' => 'string', 'enum' => BrainMemory::VALID_TYPES],
[
'type' => 'array',
'items' => ['type' => 'string', 'enum' => BrainMemory::VALID_TYPES],
],
],
'description' => 'Filter by memory type (single or array)',
],
'agent_id' => [
'type' => 'string',
'description' => 'Filter by originating agent',
],
'min_confidence' => [
'type' => 'number',
'description' => 'Minimum confidence threshold (0.0-1.0)',
'minimum' => 0.0,
'maximum' => 1.0,
],
],
],
],
'required' => ['query'],
];
}
public function handle(array $args, array $context = []): array
{
$workspaceId = $context['workspace_id'] ?? null;
if ($workspaceId === null) {
return $this->error('workspace_id is required. Ensure you have authenticated with a valid API key. See: https://host.uk.com/ai');
}
$query = $args['query'] ?? '';
$topK = $this->optionalInt($args, 'top_k', 5, 1, 20);
$filter = $this->optional($args, 'filter', []);
if (! is_array($filter)) {
return $this->error('filter must be an object');
}
return $this->withCircuitBreaker('brain', function () use ($query, $workspaceId, $filter, $topK) {
$result = RecallKnowledge::run($query, (int) $workspaceId, $filter, $topK);
return $this->success([
'count' => $result['count'],
'memories' => $result['memories'],
'scores' => $result['scores'],
]);
}, fn () => $this->error('Brain service temporarily unavailable. Recall failed.', 'service_unavailable'));
}
}

View file

@ -1,103 +0,0 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Agentic\Mcp\Tools\Agent\Brain;
use Core\Mcp\Dependencies\ToolDependency;
use Core\Mod\Agentic\Actions\Brain\RememberKnowledge;
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
use Core\Mod\Agentic\Models\BrainMemory;
/**
* Store a memory in the shared OpenBrain knowledge store.
*
* Agents use this tool to persist decisions, observations, conventions,
* and other knowledge so that other agents can recall it later.
*/
class BrainRemember extends AgentTool
{
protected string $category = 'brain';
protected array $scopes = ['write'];
public function dependencies(): array
{
return [
ToolDependency::contextExists('workspace_id', 'Workspace context required to store memories'),
];
}
public function name(): string
{
return 'brain_remember';
}
public function description(): string
{
return 'Store a memory in the shared OpenBrain knowledge store. Use this to persist decisions, observations, conventions, research, plans, bugs, or architecture knowledge for other agents.';
}
public function inputSchema(): array
{
return [
'type' => 'object',
'properties' => [
'content' => [
'type' => 'string',
'description' => 'The knowledge to remember (max 50,000 characters)',
'maxLength' => 50000,
],
'type' => [
'type' => 'string',
'description' => 'Memory type classification',
'enum' => BrainMemory::VALID_TYPES,
],
'tags' => [
'type' => 'array',
'items' => ['type' => 'string'],
'description' => 'Optional tags for categorisation',
],
'project' => [
'type' => 'string',
'description' => 'Optional project scope (e.g. repo name)',
],
'confidence' => [
'type' => 'number',
'description' => 'Confidence level from 0.0 to 1.0 (default: 0.8)',
'minimum' => 0.0,
'maximum' => 1.0,
],
'supersedes' => [
'type' => 'string',
'format' => 'uuid',
'description' => 'UUID of an older memory this one replaces',
],
'expires_in' => [
'type' => 'integer',
'description' => 'Hours until this memory expires (null = never)',
'minimum' => 1,
],
],
'required' => ['content', 'type'],
];
}
public function handle(array $args, array $context = []): array
{
$workspaceId = $context['workspace_id'] ?? null;
if ($workspaceId === null) {
return $this->error('workspace_id is required. Ensure you have authenticated with a valid API key. See: https://host.uk.com/ai');
}
$agentId = $context['agent_id'] ?? $context['session_id'] ?? 'anonymous';
return $this->withCircuitBreaker('brain', function () use ($args, $workspaceId, $agentId) {
$memory = RememberKnowledge::run($args, (int) $workspaceId, $agentId);
return $this->success([
'memory' => $memory->toMcpContext(),
]);
}, fn () => $this->error('Brain service temporarily unavailable. Memory could not be stored.', 'service_unavailable'));
}
}

View file

@ -4,8 +4,9 @@ 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.
@ -54,25 +55,44 @@ 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 {
$phase = AddCheckpoint::run(
$args['plan_slug'] ?? '',
$args['phase'] ?? '',
$args['note'] ?? '',
(int) $workspaceId,
$args['context'] ?? [],
);
return $this->success([
'checkpoints' => $phase->getCheckpoints(),
]);
$planSlug = $this->require($args, 'plan_slug');
$phaseIdentifier = $this->require($args, 'phase');
$note = $this->require($args, 'note');
} catch (\InvalidArgumentException $e) {
return $this->error($e->getMessage());
}
$plan = AgentPlan::where('slug', $planSlug)->first();
if (! $plan) {
return $this->error("Plan not found: {$planSlug}");
}
$phase = $this->findPhase($plan, $phaseIdentifier);
if (! $phase) {
return $this->error("Phase not found: {$phaseIdentifier}");
}
$phase->addCheckpoint($note, $args['context'] ?? []);
return $this->success([
'checkpoints' => $phase->fresh()->checkpoints,
]);
}
/**
* Find a phase by order number or name.
*/
protected function findPhase(AgentPlan $plan, string|int $identifier): ?AgentPhase
{
if (is_numeric($identifier)) {
return $plan->agentPhases()->where('order', (int) $identifier)->first();
}
return $plan->agentPhases()
->where('name', $identifier)
->first();
}
}

View file

@ -4,8 +4,9 @@ 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.
@ -46,31 +47,52 @@ 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 {
$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,
],
]);
$planSlug = $this->require($args, 'plan_slug');
$phaseIdentifier = $this->require($args, 'phase');
} catch (\InvalidArgumentException $e) {
return $this->error($e->getMessage());
}
$plan = AgentPlan::where('slug', $planSlug)->first();
if (! $plan) {
return $this->error("Plan not found: {$planSlug}");
}
$phase = $this->findPhase($plan, $phaseIdentifier);
if (! $phase) {
return $this->error("Phase not found: {$phaseIdentifier}");
}
return [
'phase' => [
'order' => $phase->order,
'name' => $phase->name,
'description' => $phase->description,
'status' => $phase->status,
'tasks' => $phase->tasks,
'checkpoints' => $phase->checkpoints,
'dependencies' => $phase->dependencies,
],
];
}
/**
* Find a phase by order number or name.
*/
protected function findPhase(AgentPlan $plan, string|int $identifier): ?AgentPhase
{
if (is_numeric($identifier)) {
return $plan->agentPhases()->where('order', (int) $identifier)->first();
}
return $plan->agentPhases()
->where(function ($query) use ($identifier) {
$query->where('name', $identifier)
->orWhere('order', $identifier);
})
->first();
}
}

View file

@ -5,8 +5,9 @@ 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.
@ -68,29 +69,55 @@ 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 {
$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,
],
]);
$planSlug = $this->require($args, 'plan_slug');
$phaseIdentifier = $this->require($args, 'phase');
$status = $this->require($args, 'status');
} catch (\InvalidArgumentException $e) {
return $this->error($e->getMessage());
}
$plan = AgentPlan::where('slug', $planSlug)->first();
if (! $plan) {
return $this->error("Plan not found: {$planSlug}");
}
$phase = $this->findPhase($plan, $phaseIdentifier);
if (! $phase) {
return $this->error("Phase not found: {$phaseIdentifier}");
}
if (! empty($args['notes'])) {
$phase->addCheckpoint($args['notes'], ['status_change' => $status]);
}
$phase->update(['status' => $status]);
return $this->success([
'phase' => [
'order' => $phase->order,
'name' => $phase->name,
'status' => $phase->fresh()->status,
],
]);
}
/**
* Find a phase by order number or name.
*/
protected function findPhase(AgentPlan $plan, string|int $identifier): ?AgentPhase
{
if (is_numeric($identifier)) {
return $plan->agentPhases()->where('order', (int) $identifier)->first();
}
return $plan->agentPhases()
->where(function ($query) use ($identifier) {
$query->where('name', $identifier)
->orWhere('order', $identifier);
})
->first();
}
}

View file

@ -4,8 +4,8 @@ declare(strict_types=1);
namespace Core\Mod\Agentic\Mcp\Tools\Agent\Plan;
use Core\Mod\Agentic\Actions\Plan\ArchivePlan;
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
use Core\Mod\Agentic\Models\AgentPlan;
/**
* Archive a completed or abandoned plan.
@ -46,27 +46,26 @@ 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 {
$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(),
],
]);
$slug = $this->require($args, 'slug');
} catch (\InvalidArgumentException $e) {
return $this->error($e->getMessage());
}
$plan = AgentPlan::where('slug', $slug)->first();
if (! $plan) {
return $this->error("Plan not found: {$slug}");
}
$plan->archive($args['reason'] ?? null);
return $this->success([
'plan' => [
'slug' => $plan->slug,
'status' => 'archived',
'archived_at' => $plan->archived_at?->toIso8601String(),
],
]);
}
}

View file

@ -5,8 +5,10 @@ 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.
@ -82,24 +84,61 @@ class PlanCreate 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. Ensure you have authenticated with a valid API key and started a session. See: https://host.uk.com/ai');
}
try {
$plan = CreatePlan::run($args, (int) $workspaceId);
return $this->success([
'plan' => [
'slug' => $plan->slug,
'title' => $plan->title,
'status' => $plan->status,
'phases' => $plan->agentPhases->count(),
],
]);
$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 but could not be determined from context');
}
$plan = AgentPlan::create([
'slug' => $slug,
'title' => $title,
'description' => $description,
'status' => 'draft',
'context' => $args['context'] ?? [],
'workspace_id' => $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,
]);
}
}
$plan->load('agentPhases');
return $this->success([
'plan' => [
'slug' => $plan->slug,
'title' => $plan->title,
'status' => $plan->status,
'phases' => $plan->agentPhases->count(),
],
]);
}
}

View file

@ -5,8 +5,8 @@ declare(strict_types=1);
namespace Core\Mod\Agentic\Mcp\Tools\Agent\Plan;
use Core\Mcp\Dependencies\ToolDependency;
use Core\Mod\Agentic\Actions\Plan\GetPlan;
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
use Core\Mod\Agentic\Models\AgentPlan;
/**
* Get detailed information about a specific plan.
@ -62,23 +62,56 @@ class PlanGet 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. Ensure you have authenticated with a valid API key and started a session. See: https://host.uk.com/ai');
}
try {
$plan = GetPlan::run($args['slug'] ?? '', (int) $workspaceId);
$slug = $this->require($args, 'slug');
} catch (\InvalidArgumentException $e) {
return $this->error($e->getMessage());
}
$format = $args['format'] ?? 'json';
if ($format === 'markdown') {
return $this->success(['markdown' => $plan->toMarkdown()]);
// Validate workspace context for tenant isolation
$workspaceId = $context['workspace_id'] ?? null;
if ($workspaceId === null) {
return $this->error('workspace_id is required for plan operations');
}
return $this->success(['plan' => $plan->toMcpContext()]);
$format = $this->optional($args, 'format', 'json');
// 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();
if (! $plan) {
return $this->error("Plan not found: {$slug}");
}
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'));
}
}

View file

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

View file

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

View file

@ -1,279 +0,0 @@
# MCP Agent Tools
This directory contains MCP (Model Context Protocol) tool implementations for the agent orchestration system. All tools extend `AgentTool` and integrate with the `ToolDependency` system to declare and validate their execution prerequisites.
## Directory Structure
```
Mcp/Tools/Agent/
├── AgentTool.php # Base class — extend this for all new tools
├── Contracts/
│ └── AgentToolInterface.php # Tool contract
├── Content/ # Content generation tools
├── Phase/ # Plan phase management tools
├── Plan/ # Work plan CRUD tools
├── Session/ # Agent session lifecycle tools
├── State/ # Shared workspace state tools
├── Task/ # Task status and tracking tools
└── Template/ # Template listing and application tools
```
## ToolDependency System
`ToolDependency` (from `Core\Mcp\Dependencies\ToolDependency`) lets a tool declare what must be true in the execution context before it runs. The `AgentToolRegistry` validates these automatically — the tool's `handle()` method is never called if a dependency is unmet.
### How It Works
1. A tool declares its dependencies in a `dependencies()` method returning `ToolDependency[]`.
2. When the tool is registered, `AgentToolRegistry::register()` passes those dependencies to `ToolDependencyService`.
3. On each call, `AgentToolRegistry::execute()` calls `ToolDependencyService::validateDependencies()` before invoking `handle()`.
4. If any required dependency fails, a `MissingDependencyException` is thrown and the tool is never called.
5. After a successful call, `ToolDependencyService::recordToolCall()` logs the execution for audit purposes.
### Dependency Types
#### `contextExists` — Require a context field
Validates that a key is present in the `$context` array passed at execution time. Use this for multi-tenant isolation fields like `workspace_id` that come from API key authentication.
```php
ToolDependency::contextExists('workspace_id', 'Workspace context required')
```
Mark a dependency optional with `->asOptional()` when the tool can work without it (e.g. the value can be inferred from another argument):
```php
// SessionStart: workspace can be inferred from the plan if plan_slug is provided
ToolDependency::contextExists('workspace_id', 'Workspace context required (or provide plan_slug)')
->asOptional()
```
#### `sessionState` — Require an active session
Validates that a session is active. Use this for tools that must run within an established session context.
```php
ToolDependency::sessionState('session_id', 'Active session required. Call session_start first.')
```
#### `entityExists` — Require a database entity
Validates that an entity exists in the database before the tool runs. The `arg_key` maps to the tool argument that holds the entity identifier.
```php
ToolDependency::entityExists('plan', 'Plan must exist', ['arg_key' => 'plan_slug'])
```
## Context Requirements
The `$context` array is injected into every tool's `handle(array $args, array $context)` call. Context is set by API key authentication middleware — tools should never hardcode or fall back to default values.
| Key | Type | Set by | Used by |
|-----|------|--------|---------|
| `workspace_id` | `string\|int` | API key auth middleware | All workspace-scoped tools |
| `session_id` | `string` | Client (from `session_start` response) | Session-dependent tools |
**Multi-tenant safety:** Always validate `workspace_id` in `handle()` as a defence-in-depth measure, even when a `contextExists` dependency is declared. Use `forWorkspace($workspaceId)` scopes on all queries.
```php
$workspaceId = $context['workspace_id'] ?? null;
if ($workspaceId === null) {
return $this->error('workspace_id is required. Ensure you have authenticated with a valid API key. See: https://host.uk.com/ai');
}
$plan = AgentPlan::forWorkspace($workspaceId)->where('slug', $slug)->first();
```
## Creating a New Tool
### 1. Create the class
Place the file in the appropriate subdirectory and extend `AgentTool`:
```php
<?php
declare(strict_types=1);
namespace Core\Mod\Agentic\Mcp\Tools\Agent\Plan;
use Core\Mcp\Dependencies\ToolDependency;
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
class PlanPublish extends AgentTool
{
protected string $category = 'plan';
protected array $scopes = ['write']; // 'read' or 'write'
public function dependencies(): array
{
return [
ToolDependency::contextExists('workspace_id', 'Workspace context required'),
];
}
public function name(): string
{
return 'plan_publish'; // snake_case; must be unique across all tools
}
public function description(): string
{
return 'Publish a draft plan, making it active';
}
public function inputSchema(): array
{
return [
'type' => 'object',
'properties' => [
'plan_slug' => [
'type' => 'string',
'description' => 'Plan slug identifier',
],
],
'required' => ['plan_slug'],
];
}
public function handle(array $args, array $context = []): array
{
try {
$planSlug = $this->requireString($args, 'plan_slug', 255);
} catch (\InvalidArgumentException $e) {
return $this->error($e->getMessage());
}
$workspaceId = $context['workspace_id'] ?? null;
if ($workspaceId === null) {
return $this->error('workspace_id is required. See: https://host.uk.com/ai');
}
$plan = AgentPlan::forWorkspace($workspaceId)->where('slug', $planSlug)->first();
if (! $plan) {
return $this->error("Plan not found: {$planSlug}");
}
$plan->update(['status' => 'active']);
return $this->success(['plan' => ['slug' => $plan->slug, 'status' => $plan->status]]);
}
}
```
### 2. Register the tool
Add it to the tool registration list in the package boot sequence (see `Boot.php` and the `McpToolsRegistering` event handler).
### 3. Write tests
Add a Pest test file under `Tests/` covering success and failure paths, including missing dependency scenarios.
## AgentTool Base Class Reference
### Properties
| Property | Type | Default | Description |
|----------|------|---------|-------------|
| `$category` | `string` | `'general'` | Groups tools in the registry |
| `$scopes` | `string[]` | `['read']` | API key scopes required to call this tool |
| `$timeout` | `?int` | `null` | Per-tool timeout override in seconds (null uses config default of 30s) |
### Argument Helpers
All helpers throw `\InvalidArgumentException` on failure. Catch it in `handle()` and return `$this->error()`.
| Method | Description |
|--------|-------------|
| `requireString($args, $key, $maxLength, $label)` | Required string with optional max length |
| `requireInt($args, $key, $min, $max, $label)` | Required integer with optional bounds |
| `requireArray($args, $key, $label)` | Required array |
| `requireEnum($args, $key, $allowed, $label)` | Required string constrained to allowed values |
| `optionalString($args, $key, $default, $maxLength)` | Optional string |
| `optionalInt($args, $key, $default, $min, $max)` | Optional integer |
| `optionalEnum($args, $key, $allowed, $default)` | Optional enum string |
| `optional($args, $key, $default)` | Optional value of any type |
### Response Helpers
```php
return $this->success(['key' => 'value']); // merges ['success' => true]
return $this->error('Something went wrong');
return $this->error('Resource locked', 'resource_locked'); // with error code
```
### Circuit Breaker
Wrap calls to external services with `withCircuitBreaker()` for fault tolerance:
```php
return $this->withCircuitBreaker(
'agentic', // service name
fn () => $this->doWork(), // operation
fn () => $this->error('Service unavailable', 'service_unavailable') // fallback
);
```
If no fallback is provided and the circuit is open, `error()` is returned automatically.
### Timeout Override
For long-running tools (e.g. content generation), override the timeout:
```php
protected ?int $timeout = 300; // 5 minutes
```
## Dependency Resolution Order
Dependencies are validated in the order they are returned from `dependencies()`. All required dependencies must pass before the tool runs. Optional dependencies are checked but do not block execution.
Recommended declaration order:
1. `contextExists('workspace_id', ...)` — tenant isolation first
2. `sessionState('session_id', ...)` — session presence second
3. `entityExists(...)` — entity existence last (may query DB)
## Troubleshooting
### "Workspace context required"
The `workspace_id` key is missing from the execution context. This is injected by the API key authentication middleware. Causes:
- Request is unauthenticated or the API key is invalid.
- The API key has no workspace association.
- Dependency validation was bypassed but the tool checks it internally.
**Fix:** Authenticate with a valid API key. See https://host.uk.com/ai.
### "Active session required. Call session_start first."
The `session_id` context key is missing. The tool requires an active session.
**Fix:** Call `session_start` before calling session-dependent tools. Pass the returned `session_id` in the context of all subsequent calls.
### "Plan must exist" / "Plan not found"
The `plan_slug` argument does not match any plan. Either the plan was never created, the slug is misspelled, or the plan belongs to a different workspace.
**Fix:** Call `plan_list` to find valid slugs, then retry.
### "Permission denied: API key missing scope"
The API key does not have the required scope (`read` or `write`) for the tool.
**Fix:** Issue a new API key with the correct scopes, or use an existing key that has the required permissions.
### "Unknown tool: {name}"
The tool name does not match any registered tool.
**Fix:** Check `plan_list` / MCP tool discovery endpoint for the exact tool name. Names are snake_case.
### `MissingDependencyException` in logs
A required dependency was not met and the framework threw before calling `handle()`. The exception message will identify which dependency failed.
**Fix:** Inspect the `context` passed to `execute()`. Ensure required keys are present and the relevant entity exists.

View file

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

View file

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

View file

@ -4,8 +4,8 @@ declare(strict_types=1);
namespace Core\Mod\Agentic\Mcp\Tools\Agent\Session;
use Core\Mod\Agentic\Actions\Session\ListSessions;
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
use Core\Mod\Agentic\Services\AgentSessionService;
/**
* List sessions, optionally filtered by status.
@ -50,34 +50,54 @@ 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 {
$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(),
]);
$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);
} catch (\InvalidArgumentException $e) {
return $this->error($e->getMessage());
}
$sessionService = app(AgentSessionService::class);
// Get active sessions (default)
if ($status === 'active' || $status === null) {
$sessions = $sessionService->getActiveSessions($context['workspace_id'] ?? null);
} else {
// Query with filters
$query = \Core\Mod\Agentic\Models\AgentSession::query()
->orderBy('last_active_at', 'desc');
// Apply workspace filter if provided
if (! empty($context['workspace_id'])) {
$query->where('workspace_id', $context['workspace_id']);
}
$query->where('status', $status);
if ($planSlug !== null) {
$query->whereHas('plan', fn ($q) => $q->where('slug', $planSlug));
}
if ($limit !== null) {
$query->limit($limit);
}
$sessions = $query->get();
}
return [
'sessions' => $sessions->map(fn ($session) => [
'session_id' => $session->session_id,
'agent_type' => $session->agent_type,
'status' => $session->status,
'plan' => $session->plan?->slug,
'duration' => $session->getDurationFormatted(),
'started_at' => $session->started_at->toIso8601String(),
'last_active_at' => $session->last_active_at->toIso8601String(),
'has_handoff' => ! empty($session->handoff_notes),
])->all(),
'total' => $sessions->count(),
];
}
}

View file

@ -5,8 +5,10 @@ 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.
@ -68,29 +70,48 @@ class SessionStart 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. 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');
try {
$agentType = $this->require($args, 'agent_type');
} catch (\InvalidArgumentException $e) {
return $this->error($e->getMessage());
}
try {
$session = StartSession::run(
$args['agent_type'] ?? '',
$args['plan_slug'] ?? null,
(int) $workspaceId,
$args['context'] ?? [],
);
// 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 but could not be determined from context or plan');
}
$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' => [],
]);
return $this->success([
'session' => [
'session_id' => $session->session_id,
'agent_type' => $session->agent_type,
'plan' => $session->plan?->slug,
'plan' => $plan?->slug,
'status' => $session->status,
],
]);
} catch (\InvalidArgumentException $e) {
return $this->error($e->getMessage());
}
}, fn () => $this->error('Agentic service temporarily unavailable. Session cannot be created.', 'service_unavailable'));
}
}

View file

@ -71,7 +71,7 @@ class StateGet extends AgentTool
// 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');
return $this->error('workspace_id is required for state operations');
}
// Query plan with workspace scope to prevent cross-tenant access

View file

@ -70,7 +70,7 @@ class StateList extends AgentTool
// 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');
return $this->error('workspace_id is required for state operations');
}
// Query plan with workspace scope to prevent cross-tenant access

View file

@ -7,7 +7,7 @@ namespace Core\Mod\Agentic\Mcp\Tools\Agent\State;
use Core\Mcp\Dependencies\ToolDependency;
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
use Core\Mod\Agentic\Models\AgentPlan;
use Core\Mod\Agentic\Models\WorkspaceState;
use Core\Mod\Agentic\Models\AgentWorkspaceState;
/**
* Set a workspace state value.
@ -81,7 +81,7 @@ class StateSet extends AgentTool
// 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');
return $this->error('workspace_id is required for state operations');
}
// Query plan with workspace scope to prevent cross-tenant access
@ -93,7 +93,7 @@ class StateSet extends AgentTool
return $this->error("Plan not found: {$planSlug}");
}
$state = WorkspaceState::updateOrCreate(
$state = AgentWorkspaceState::updateOrCreate(
[
'agent_plan_id' => $plan->id,
'key' => $key,

View file

@ -5,8 +5,9 @@ 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.
@ -63,22 +64,66 @@ 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 {
$result = ToggleTask::run(
$args['plan_slug'] ?? '',
$args['phase'] ?? '',
(int) ($args['task_index'] ?? 0),
(int) $workspaceId,
);
return $this->success($result);
$planSlug = $this->requireString($args, 'plan_slug', 255);
$phaseIdentifier = $this->requireString($args, 'phase', 255);
$taskIndex = $this->requireInt($args, 'task_index', min: 0, max: 1000);
} catch (\InvalidArgumentException $e) {
return $this->error($e->getMessage());
}
$plan = AgentPlan::where('slug', $planSlug)->first();
if (! $plan) {
return $this->error("Plan not found: {$planSlug}");
}
$phase = $this->findPhase($plan, $phaseIdentifier);
if (! $phase) {
return $this->error("Phase not found: {$phaseIdentifier}");
}
$tasks = $phase->tasks ?? [];
if (! isset($tasks[$taskIndex])) {
return $this->error("Task not found at index: {$taskIndex}");
}
$currentStatus = is_string($tasks[$taskIndex])
? 'pending'
: ($tasks[$taskIndex]['status'] ?? 'pending');
$newStatus = $currentStatus === 'completed' ? 'pending' : 'completed';
if (is_string($tasks[$taskIndex])) {
$tasks[$taskIndex] = [
'name' => $tasks[$taskIndex],
'status' => $newStatus,
];
} else {
$tasks[$taskIndex]['status'] = $newStatus;
}
$phase->update(['tasks' => $tasks]);
return $this->success([
'task' => $tasks[$taskIndex],
'plan_progress' => $plan->fresh()->getProgress(),
]);
}
/**
* Find a phase by order number or name.
*/
protected function findPhase(AgentPlan $plan, string|int $identifier): ?AgentPhase
{
if (is_numeric($identifier)) {
return $plan->agentPhases()->where('order', (int) $identifier)->first();
}
return $plan->agentPhases()
->where('name', $identifier)
->first();
}
}

View file

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

View file

@ -1,33 +0,0 @@
<?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
{
/**
* Add soft delete support and archived_at timestamp to agent_plans.
*
* - archived_at: dedicated timestamp for when a plan was archived, used by
* the retention cleanup command to determine when to permanently delete.
* - deleted_at: standard Laravel soft-delete column.
*/
public function up(): void
{
Schema::table('agent_plans', function (Blueprint $table) {
$table->timestamp('archived_at')->nullable()->after('source_file');
$table->softDeletes();
});
}
public function down(): void
{
Schema::table('agent_plans', function (Blueprint $table) {
$table->dropColumn('archived_at');
$table->dropSoftDeletes();
});
}
};

View file

@ -1,69 +0,0 @@
<?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
{
/**
* Create plan_template_versions table and add template_version_id to agent_plans.
*
* Template versions snapshot YAML template content at plan-creation time so
* existing plans are never affected when a template file is updated.
*
* Deduplication: identical content reuses the same version row (same content_hash).
*
* Guarded with hasTable()/hasColumn() so this migration is idempotent and
* can coexist with a consolidated app-level migration.
*/
public function up(): void
{
Schema::disableForeignKeyConstraints();
if (! Schema::hasTable('plan_template_versions')) {
Schema::create('plan_template_versions', function (Blueprint $table) {
$table->id();
$table->string('slug');
$table->unsignedInteger('version');
$table->string('name');
$table->json('content');
$table->char('content_hash', 64);
$table->timestamps();
$table->unique(['slug', 'version']);
$table->index(['slug', 'content_hash']);
});
}
if (Schema::hasTable('agent_plans') && ! Schema::hasColumn('agent_plans', 'template_version_id')) {
Schema::table('agent_plans', function (Blueprint $table) {
$table->foreignId('template_version_id')
->nullable()
->constrained('plan_template_versions')
->nullOnDelete()
->after('source_file');
});
}
Schema::enableForeignKeyConstraints();
}
public function down(): void
{
Schema::disableForeignKeyConstraints();
if (Schema::hasTable('agent_plans') && Schema::hasColumn('agent_plans', 'template_version_id')) {
Schema::table('agent_plans', function (Blueprint $table) {
$table->dropForeign(['template_version_id']);
$table->dropColumn('template_version_id');
});
}
Schema::dropIfExists('plan_template_versions');
Schema::enableForeignKeyConstraints();
}
};

View file

@ -1,59 +0,0 @@
<?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
{
/** Use the dedicated brain connection so the table lands in the right database. */
protected $connection = 'brain';
/**
* Create brain_memories table for OpenBrain shared knowledge store.
*
* Guarded with hasTable() so this migration is idempotent and
* can coexist with the consolidated app-level migration.
*
* No FK to workspaces the brain database may be remote and
* the workspaces table only exists in the app database.
*/
public function up(): void
{
$schema = Schema::connection($this->getConnection());
if (! $schema->hasTable('brain_memories')) {
$schema->create('brain_memories', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->unsignedBigInteger('workspace_id');
$table->string('agent_id', 64);
$table->string('type', 32)->index();
$table->text('content');
$table->json('tags')->nullable();
$table->string('project', 128)->nullable()->index();
$table->float('confidence')->default(0.8);
$table->uuid('supersedes_id')->nullable();
$table->timestamp('expires_at')->nullable();
$table->timestamps();
$table->softDeletes();
$table->index('workspace_id');
$table->index('agent_id');
$table->index(['workspace_id', 'type']);
$table->index(['workspace_id', 'project']);
$table->foreign('supersedes_id')
->references('id')
->on('brain_memories')
->nullOnDelete();
});
}
}
public function down(): void
{
Schema::connection($this->getConnection())->dropIfExists('brain_memories');
}
};

View file

@ -1,41 +0,0 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Drop the workspace_id foreign key from brain_memories.
*
* The brain database may be remote (co-located with Qdrant on the homelab),
* so cross-database FK constraints to the app's workspaces table are not
* possible. The column stays as a plain indexed integer.
*/
return new class extends Migration
{
protected $connection = 'brain';
public function up(): void
{
$schema = Schema::connection($this->getConnection());
if (! $schema->hasTable('brain_memories')) {
return;
}
$schema->table('brain_memories', function (Blueprint $table) {
try {
$table->dropForeign(['workspace_id']);
} catch (\Throwable) {
// FK doesn't exist — fresh install, nothing to drop.
}
});
}
public function down(): void
{
// Not re-adding the FK — it was only valid when brain and app shared a database.
}
};

View file

@ -1,31 +0,0 @@
<?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::table('agent_sessions', function (Blueprint $table) {
$table->renameColumn('uuid', 'session_id');
$table->renameColumn('last_activity_at', 'last_active_at');
});
// Change column type from uuid to string to allow prefixed IDs (sess_...)
Schema::table('agent_sessions', function (Blueprint $table) {
$table->string('session_id')->unique()->change();
});
}
public function down(): void
{
Schema::table('agent_sessions', function (Blueprint $table) {
$table->renameColumn('session_id', 'uuid');
$table->renameColumn('last_active_at', 'last_activity_at');
});
}
};

View file

@ -317,17 +317,11 @@ class AgentPhase extends Model
public function checkDependencies(): array
{
$dependencies = $this->dependencies ?? [];
if (empty($dependencies)) {
return [];
}
$blockers = [];
$deps = AgentPhase::whereIn('id', $dependencies)->get();
foreach ($deps as $dep) {
if (! $dep->isCompleted() && ! $dep->isSkipped()) {
foreach ($dependencies as $depId) {
$dep = AgentPhase::find($depId);
if ($dep && ! $dep->isCompleted() && ! $dep->isSkipped()) {
$blockers[] = [
'phase_id' => $dep->id,
'phase_order' => $dep->order,

View file

@ -12,7 +12,6 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Str;
use Spatie\Activitylog\LogOptions;
use Spatie\Activitylog\Traits\LogsActivity;
@ -34,8 +33,6 @@ use Spatie\Activitylog\Traits\LogsActivity;
* @property string|null $current_phase
* @property array|null $metadata
* @property string|null $source_file
* @property \Carbon\Carbon|null $archived_at
* @property \Carbon\Carbon|null $deleted_at
* @property \Carbon\Carbon|null $created_at
* @property \Carbon\Carbon|null $updated_at
*/
@ -47,7 +44,6 @@ class AgentPlan extends Model
use HasFactory;
use LogsActivity;
use SoftDeletes;
protected static function newFactory(): AgentPlanFactory
{
@ -65,15 +61,12 @@ class AgentPlan extends Model
'current_phase',
'metadata',
'source_file',
'archived_at',
'template_version_id',
];
protected $casts = [
'context' => 'array',
'phases' => 'array',
'metadata' => 'array',
'archived_at' => 'datetime',
];
// Status constants
@ -103,12 +96,7 @@ class AgentPlan extends Model
public function states(): HasMany
{
return $this->hasMany(WorkspaceState::class);
}
public function templateVersion(): BelongsTo
{
return $this->belongsTo(PlanTemplateVersion::class, 'template_version_id');
return $this->hasMany(AgentWorkspaceState::class);
}
// Scopes
@ -178,11 +166,11 @@ class AgentPlan extends Model
$metadata = $this->metadata ?? [];
if ($reason) {
$metadata['archive_reason'] = $reason;
$metadata['archived_at'] = now()->toIso8601String();
}
$this->update([
'status' => self::STATUS_ARCHIVED,
'archived_at' => now(),
'metadata' => $metadata,
]);
@ -240,7 +228,7 @@ class AgentPlan extends Model
return $state?->value;
}
public function setState(string $key, mixed $value, string $type = 'json', ?string $description = null): WorkspaceState
public function setState(string $key, mixed $value, string $type = 'json', ?string $description = null): AgentWorkspaceState
{
return $this->states()->updateOrCreate(
['key' => $key],

View file

@ -0,0 +1,116 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Agentic\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* Agent Workspace State - shared context between sessions within a plan.
*
* Stores key-value data that persists across agent sessions,
* enabling context sharing and state recovery.
*
* @property int $id
* @property int $agent_plan_id
* @property string $key
* @property array $value
* @property string $type
* @property string|null $description
* @property \Carbon\Carbon|null $created_at
* @property \Carbon\Carbon|null $updated_at
*/
class AgentWorkspaceState extends Model
{
protected $table = 'agent_workspace_states';
protected $fillable = [
'agent_plan_id',
'key',
'value',
'type',
'description',
];
protected $casts = [
'value' => 'array',
];
// Type constants
public const TYPE_JSON = 'json';
public const TYPE_MARKDOWN = 'markdown';
public const TYPE_CODE = 'code';
public const TYPE_REFERENCE = 'reference';
// Relationships
public function plan(): BelongsTo
{
return $this->belongsTo(AgentPlan::class, 'agent_plan_id');
}
// Scopes
public function scopeForPlan(Builder $query, AgentPlan|int $plan): Builder
{
$planId = $plan instanceof AgentPlan ? $plan->id : $plan;
return $query->where('agent_plan_id', $planId);
}
public function scopeOfType(Builder $query, string $type): Builder
{
return $query->where('type', $type);
}
// Helpers
public function isJson(): bool
{
return $this->type === self::TYPE_JSON;
}
public function isMarkdown(): bool
{
return $this->type === self::TYPE_MARKDOWN;
}
public function isCode(): bool
{
return $this->type === self::TYPE_CODE;
}
public function isReference(): bool
{
return $this->type === self::TYPE_REFERENCE;
}
public function getValue(): mixed
{
return $this->value;
}
public function getFormattedValue(): string
{
if ($this->isMarkdown() || $this->isCode()) {
return is_string($this->value) ? $this->value : json_encode($this->value, JSON_PRETTY_PRINT);
}
return json_encode($this->value, JSON_PRETTY_PRINT);
}
// Output
public function toMcpContext(): array
{
return [
'key' => $this->key,
'type' => $this->type,
'description' => $this->description,
'value' => $this->value,
'updated_at' => $this->updated_at?->toIso8601String(),
];
}
}

View file

@ -1,190 +0,0 @@
<?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\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
/**
* Brain Memory - a unit of shared knowledge in the OpenBrain store.
*
* Agents write observations, decisions, conventions, and research
* into the brain so that other agents (and future sessions) can
* recall organisational knowledge without re-discovering it.
*
* @property string $id
* @property int $workspace_id
* @property string $agent_id
* @property string $type
* @property string $content
* @property array|null $tags
* @property string|null $project
* @property float $confidence
* @property string|null $supersedes_id
* @property \Carbon\Carbon|null $expires_at
* @property \Carbon\Carbon|null $created_at
* @property \Carbon\Carbon|null $updated_at
* @property \Carbon\Carbon|null $deleted_at
*/
class BrainMemory extends Model
{
use BelongsToWorkspace;
use HasUuids;
use SoftDeletes;
/** Valid memory types. */
public const VALID_TYPES = [
'decision',
'observation',
'convention',
'research',
'plan',
'bug',
'architecture',
];
protected $connection = 'brain';
protected $table = 'brain_memories';
protected $fillable = [
'workspace_id',
'agent_id',
'type',
'content',
'tags',
'project',
'confidence',
'supersedes_id',
'expires_at',
];
protected $casts = [
'tags' => 'array',
'confidence' => 'float',
'expires_at' => 'datetime',
];
// ----------------------------------------------------------------
// Relationships
// ----------------------------------------------------------------
public function workspace(): BelongsTo
{
return $this->belongsTo(Workspace::class);
}
/** The older memory this one replaces. */
public function supersedes(): BelongsTo
{
return $this->belongsTo(self::class, 'supersedes_id');
}
/** Newer memories that replaced this one. */
public function supersededBy(): HasMany
{
return $this->hasMany(self::class, 'supersedes_id');
}
// ----------------------------------------------------------------
// Scopes
// ----------------------------------------------------------------
public function scopeForWorkspace(Builder $query, int $workspaceId): Builder
{
return $query->where('workspace_id', $workspaceId);
}
public function scopeOfType(Builder $query, string|array $type): Builder
{
return is_array($type)
? $query->whereIn('type', $type)
: $query->where('type', $type);
}
public function scopeForProject(Builder $query, ?string $project): Builder
{
return $project
? $query->where('project', $project)
: $query;
}
public function scopeByAgent(Builder $query, ?string $agentId): Builder
{
return $agentId
? $query->where('agent_id', $agentId)
: $query;
}
/** Exclude memories whose TTL has passed. */
public function scopeActive(Builder $query): Builder
{
return $query->where(function (Builder $q) {
$q->whereNull('expires_at')
->orWhere('expires_at', '>', now());
});
}
/** Exclude memories that have been superseded by a newer version. */
public function scopeLatestVersions(Builder $query): Builder
{
return $query->whereDoesntHave('supersededBy', function (Builder $q) {
$q->whereNull('deleted_at');
});
}
// ----------------------------------------------------------------
// Helpers
// ----------------------------------------------------------------
/**
* Walk the supersession chain and return its depth.
*
* A memory that supersedes nothing returns 0.
* Capped at 50 to prevent runaway loops.
*/
public function getSupersessionDepth(): int
{
$depth = 0;
$current = $this;
$maxDepth = 50;
while ($current->supersedes_id !== null && $depth < $maxDepth) {
$current = $current->supersedes;
if ($current === null) {
break;
}
$depth++;
}
return $depth;
}
/** Format the memory for MCP tool responses. */
public function toMcpContext(): array
{
return [
'id' => $this->id,
'agent_id' => $this->agent_id,
'type' => $this->type,
'content' => $this->content,
'tags' => $this->tags,
'project' => $this->project,
'confidence' => $this->confidence,
'supersedes_id' => $this->supersedes_id,
'expires_at' => $this->expires_at?->toIso8601String(),
'created_at' => $this->created_at?->toIso8601String(),
'updated_at' => $this->updated_at?->toIso8601String(),
];
}
}

View file

@ -1,92 +0,0 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Agentic\Models;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
/**
* Plan Template Version - immutable snapshot of a YAML template's content.
*
* When a plan is created from a template, the template content is snapshotted
* here so future edits to the YAML file do not affect existing plans.
*
* Identical content is deduplicated via content_hash so no duplicate rows
* accumulate when the same (unchanged) template is used repeatedly.
*
* @property int $id
* @property string $slug Template file slug (filename without extension)
* @property int $version Sequential version number per slug
* @property string $name Template name at snapshot time
* @property array $content Full template content as JSON
* @property string $content_hash SHA-256 of json_encode($content)
* @property \Carbon\Carbon|null $created_at
* @property \Carbon\Carbon|null $updated_at
*/
class PlanTemplateVersion extends Model
{
protected $fillable = [
'slug',
'version',
'name',
'content',
'content_hash',
];
protected $casts = [
'content' => 'array',
'version' => 'integer',
];
/**
* Plans that were created from this template version.
*/
public function plans(): HasMany
{
return $this->hasMany(AgentPlan::class, 'template_version_id');
}
/**
* Find an existing version by content hash, or create a new one.
*
* Deduplicates identical template content so we don't store redundant rows
* when the same (unchanged) template is used multiple times.
*/
public static function findOrCreateFromTemplate(string $slug, array $content): self
{
$hash = hash('sha256', json_encode($content, JSON_UNESCAPED_UNICODE));
$existing = static::where('slug', $slug)
->where('content_hash', $hash)
->first();
if ($existing) {
return $existing;
}
$nextVersion = (static::where('slug', $slug)->max('version') ?? 0) + 1;
return static::create([
'slug' => $slug,
'version' => $nextVersion,
'name' => $content['name'] ?? $slug,
'content' => $content,
'content_hash' => $hash,
]);
}
/**
* Get all recorded versions for a template slug, newest first.
*
* @return Collection<int, static>
*/
public static function historyFor(string $slug): Collection
{
return static::where('slug', $slug)
->orderByDesc('version')
->get();
}
}

View file

@ -8,6 +8,22 @@ use Core\Tenant\Models\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* Prompt version snapshot for history and rollback.
*
* Captures the state of a prompt at a point in time, enabling
* version history and rollback via the restore() method.
*
* @property int $id
* @property int $prompt_id
* @property int $version
* @property string|null $system_prompt
* @property string|null $user_template
* @property array|null $variables
* @property int|null $created_by
* @property \Carbon\Carbon|null $created_at
* @property \Carbon\Carbon|null $updated_at
*/
class PromptVersion extends Model
{
protected $fillable = [

View file

@ -12,25 +12,12 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* Workspace State Model
*
* Persistent key-value state storage for agent plans.
* Stores typed values shared across agent sessions within a plan,
* enabling context sharing and state recovery.
*
* @property int $id
* @property int $agent_plan_id
* @property string $key
* @property array $value
* @property string $type
* @property string|null $description
* @property \Carbon\Carbon|null $created_at
* @property \Carbon\Carbon|null $updated_at
* Key-value state storage for agent plans with typed content.
*/
class WorkspaceState extends Model
{
use HasFactory;
protected $table = 'agent_workspace_states';
public const TYPE_JSON = 'json';
public const TYPE_MARKDOWN = 'markdown';
@ -44,27 +31,34 @@ class WorkspaceState extends Model
'key',
'value',
'type',
'description',
'metadata',
];
protected $casts = [
'value' => 'array',
'metadata' => 'array',
];
// Relationships
protected $attributes = [
'type' => self::TYPE_JSON,
'metadata' => '{}',
];
public function plan(): BelongsTo
{
return $this->belongsTo(AgentPlan::class, 'agent_plan_id');
}
// Scopes
public function scopeForPlan($query, AgentPlan|int $plan): mixed
/**
* Get typed value.
*/
public function getTypedValue(): mixed
{
$planId = $plan instanceof AgentPlan ? $plan->id : $plan;
return match ($this->type) {
self::TYPE_JSON => json_decode($this->value, true),
default => $this->value,
};
}
<<<<<<< HEAD
/**
* Set typed value.
*/
@ -151,71 +145,4 @@ class WorkspaceState extends Model
{
return $query->where('type', $type);
}
// Type helpers
public function isJson(): bool
{
return $this->type === self::TYPE_JSON;
}
public function isMarkdown(): bool
{
return $this->type === self::TYPE_MARKDOWN;
}
public function isCode(): bool
{
return $this->type === self::TYPE_CODE;
}
public function isReference(): bool
{
return $this->type === self::TYPE_REFERENCE;
}
public function getFormattedValue(): string
{
if ($this->isMarkdown() || $this->isCode()) {
return is_string($this->value) ? $this->value : json_encode($this->value, JSON_PRETTY_PRINT);
}
return json_encode($this->value, JSON_PRETTY_PRINT);
}
// Static helpers
/**
* Get a state value for a plan, returning $default if not set.
*/
public static function getValue(AgentPlan $plan, string $key, mixed $default = null): mixed
{
$state = static::where('agent_plan_id', $plan->id)->where('key', $key)->first();
return $state !== null ? $state->value : $default;
}
/**
* Set (upsert) a state value for a plan.
*/
public static function setValue(AgentPlan $plan, string $key, mixed $value, string $type = self::TYPE_JSON): self
{
return static::updateOrCreate(
['agent_plan_id' => $plan->id, 'key' => $key],
['value' => $value, 'type' => $type]
);
}
// MCP output
public function toMcpContext(): array
{
return [
'key' => $this->key,
'type' => $this->type,
'description' => $this->description,
'value' => $this->value,
'updated_at' => $this->updated_at?->toIso8601String(),
];
}
}

View file

@ -1,60 +0,0 @@
<?php
declare(strict_types=1);
use Core\Mod\Agentic\Controllers\AgentApiController;
use Core\Mod\Agentic\Middleware\AgentApiAuth;
use Illuminate\Support\Facades\Route;
/*
|--------------------------------------------------------------------------
| Agent API Routes
|--------------------------------------------------------------------------
|
| REST endpoints for the go-agentic Client (dispatch watch).
| Protected by AgentApiAuth middleware with Bearer token.
|
| Routes at /v1/* (Go client uses BaseURL + "/v1/...")
|
*/
// Health check (no auth required)
Route::get('v1/health', [AgentApiController::class, 'health']);
// Authenticated agent endpoints
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::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::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::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']);
});

View file

@ -156,9 +156,6 @@ class AgentApiKeyService
// Clear rate limit cache
Cache::forget($this->getRateLimitCacheKey($key));
// Clear permitted tools cache so the revoked key can no longer access tools
app(AgentToolRegistry::class)->flushCacheForApiKey($key->id);
}
/**
@ -167,9 +164,6 @@ class AgentApiKeyService
public function updatePermissions(AgentApiKey $key, array $permissions): void
{
$key->updatePermissions($permissions);
// Invalidate cached tool list so the new permissions take effect immediately
app(AgentToolRegistry::class)->flushCacheForApiKey($key->id);
}
/**

View file

@ -17,221 +17,106 @@ use Illuminate\Http\Request;
* - Absence of typical browser indicators
*
* Part of the Trees for Agents system for rewarding AI agent referrals.
*
* Detection priority (highest to lowest):
* 1. MCP token header (X-MCP-Token) registered agents with explicit identity
* 2. User-Agent provider patterns matches known AI client strings
* 3. Non-agent bot patterns rules out search crawlers and monitoring tools
* 4. Browser indicators rules out real browser traffic
* 5. Unknown agent fallback programmatic access with no identifying UA
*
* Usage:
* ```php
* $detection = app(AgentDetection::class);
*
* // From a full HTTP request (checks MCP token first, then User-Agent)
* $identity = $detection->identify($request);
*
* // From a User-Agent string directly
* $identity = $detection->identifyFromUserAgent('claude-code/1.0 anthropic-api');
*
* // Quick boolean check
* if ($detection->isAgent($request)) {
* // credit the referral tree
* }
*
* // Inspect the result
* echo $identity->provider; // e.g. "anthropic"
* echo $identity->model; // e.g. "claude-sonnet" or null
* echo $identity->confidence; // e.g. "high"
* echo $identity->isAgent(); // true / false
* ```
*/
class AgentDetection
{
/**
* User-Agent patterns for known AI providers.
*
* Each entry maps a provider key to an array of detection patterns and optional
* model-specific sub-patterns. Patterns are tested in order; the first match wins.
*
* Provider patterns (case-insensitive):
*
* - anthropic:
* Examples: "claude-code/1.0", "Anthropic-API/2.0 claude-sonnet",
* "Claude AI Assistant/1.0", "claude code (agentic)"
*
* - openai:
* Examples: "ChatGPT-User/1.0", "OpenAI/1.0 python-httpx/0.26",
* "GPT-4-turbo/2024-04", "o1-preview/2024-09", "o1-mini/1.0"
*
* - google:
* Examples: "Google-AI/1.0", "Gemini/1.5-pro", "Google Bard/0.1",
* "PaLM API/1.0 google-generativeai/0.3"
*
* - meta:
* Examples: "Meta AI/1.0", "LLaMA/2.0 meta-ai", "Llama-3/2024-04",
* "Llama-2-chat/70B"
*
* - mistral:
* Examples: "Mistral/0.1.0 mistralai-python/0.1", "Mixtral-8x7B/1.0",
* "MistralAI-Large/latest"
*
* Model patterns narrow the detection to a specific model variant within a provider
* when the User-Agent includes version/model information.
*
* @var array<string, array{patterns: string[], model_patterns: array<string, string>}>
* @var array<string, array{pattern: string, model_pattern: ?string}>
*/
protected const PROVIDER_PATTERNS = [
'anthropic' => [
'patterns' => [
'/claude[\s\-_]?code/i', // e.g. "claude-code/1.0", "claude code"
'/\banthopic\b/i', // e.g. "Anthropic/1.0" (intentional typo tolerance)
'/\banthropic[\s\-_]?api\b/i', // e.g. "Anthropic-API/2.0"
'/\bclaude\b.*\bai\b/i', // e.g. "Claude AI Assistant/1.0"
'/\bclaude\b.*\bassistant\b/i', // e.g. "Claude-Assistant/2.1"
'/claude[\s\-_]?code/i',
'/\banthopic\b/i',
'/\banthropic[\s\-_]?api\b/i',
'/\bclaude\b.*\bai\b/i',
'/\bclaude\b.*\bassistant\b/i',
],
'model_patterns' => [
'claude-opus' => '/claude[\s\-_]?opus/i', // e.g. "claude-opus-4-5"
'claude-sonnet' => '/claude[\s\-_]?sonnet/i', // e.g. "claude-sonnet-4-6"
'claude-haiku' => '/claude[\s\-_]?haiku/i', // e.g. "claude-haiku-4-5"
'claude-opus' => '/claude[\s\-_]?opus/i',
'claude-sonnet' => '/claude[\s\-_]?sonnet/i',
'claude-haiku' => '/claude[\s\-_]?haiku/i',
],
],
'openai' => [
'patterns' => [
'/\bChatGPT\b/i', // e.g. "ChatGPT-User/1.0"
'/\bOpenAI\b/i', // e.g. "OpenAI/1.0 python-httpx/0.26"
'/\bGPT[\s\-_]?4\b/i', // e.g. "GPT-4-turbo/2024-04"
'/\bGPT[\s\-_]?3\.?5\b/i', // e.g. "GPT-3.5-turbo/1.0"
'/\bo1[\s\-_]?preview\b/i', // e.g. "o1-preview/2024-09"
'/\bo1[\s\-_]?mini\b/i', // e.g. "o1-mini/1.0"
'/\bChatGPT\b/i',
'/\bOpenAI\b/i',
'/\bGPT[\s\-_]?4\b/i',
'/\bGPT[\s\-_]?3\.?5\b/i',
'/\bo1[\s\-_]?preview\b/i',
'/\bo1[\s\-_]?mini\b/i',
],
'model_patterns' => [
'gpt-4' => '/\bGPT[\s\-_]?4/i', // e.g. "GPT-4o", "GPT-4-turbo"
'gpt-3.5' => '/\bGPT[\s\-_]?3\.?5/i', // e.g. "GPT-3.5-turbo"
'o1' => '/\bo1[\s\-_]?(preview|mini)?\b/i', // e.g. "o1", "o1-preview", "o1-mini"
'gpt-4' => '/\bGPT[\s\-_]?4/i',
'gpt-3.5' => '/\bGPT[\s\-_]?3\.?5/i',
'o1' => '/\bo1[\s\-_]?(preview|mini)?\b/i',
],
],
'google' => [
'patterns' => [
'/\bGoogle[\s\-_]?AI\b/i', // e.g. "Google-AI/1.0"
'/\bGemini\b/i', // e.g. "Gemini/1.5-pro", "gemini-flash"
'/\bBard\b/i', // e.g. "Google Bard/0.1" (legacy)
'/\bPaLM\b/i', // e.g. "PaLM API/1.0" (legacy)
'/\bGoogle[\s\-_]?AI\b/i',
'/\bGemini\b/i',
'/\bBard\b/i',
'/\bPaLM\b/i',
],
'model_patterns' => [
'gemini-pro' => '/gemini[\s\-_]?(1\.5[\s\-_]?)?pro/i', // e.g. "gemini-1.5-pro"
'gemini-ultra' => '/gemini[\s\-_]?(1\.5[\s\-_]?)?ultra/i', // e.g. "gemini-ultra"
'gemini-flash' => '/gemini[\s\-_]?(1\.5[\s\-_]?)?flash/i', // e.g. "gemini-1.5-flash"
'gemini-pro' => '/gemini[\s\-_]?(1\.5[\s\-_]?)?pro/i',
'gemini-ultra' => '/gemini[\s\-_]?(1\.5[\s\-_]?)?ultra/i',
'gemini-flash' => '/gemini[\s\-_]?(1\.5[\s\-_]?)?flash/i',
],
],
'meta' => [
'patterns' => [
'/\bMeta[\s\-_]?AI\b/i', // e.g. "Meta AI/1.0"
'/\bLLaMA\b/i', // e.g. "LLaMA/2.0 meta-ai"
'/\bLlama[\s\-_]?[23]\b/i', // e.g. "Llama-3/2024-04", "Llama-2-chat"
'/\bMeta[\s\-_]?AI\b/i',
'/\bLLaMA\b/i',
'/\bLlama[\s\-_]?[23]\b/i',
],
'model_patterns' => [
'llama-3' => '/llama[\s\-_]?3/i', // e.g. "Llama-3-8B", "llama3-70b"
'llama-2' => '/llama[\s\-_]?2/i', // e.g. "Llama-2-chat/70B"
'llama-3' => '/llama[\s\-_]?3/i',
'llama-2' => '/llama[\s\-_]?2/i',
],
],
'mistral' => [
'patterns' => [
'/\bMistral\b/i', // e.g. "Mistral/0.1.0 mistralai-python/0.1"
'/\bMixtral\b/i', // e.g. "Mixtral-8x7B/1.0"
'/\bMistral\b/i',
'/\bMixtral\b/i',
],
'model_patterns' => [
'mistral-large' => '/mistral[\s\-_]?large/i', // e.g. "mistral-large-latest"
'mistral-medium' => '/mistral[\s\-_]?medium/i', // e.g. "mistral-medium"
'mixtral' => '/mixtral/i', // e.g. "Mixtral-8x7B-Instruct"
'mistral-large' => '/mistral[\s\-_]?large/i',
'mistral-medium' => '/mistral[\s\-_]?medium/i',
'mixtral' => '/mixtral/i',
],
],
];
/**
* Patterns that indicate a typical web browser.
*
* If none of these tokens appear in a User-Agent string, the request is likely
* programmatic (a script, CLI tool, or potential agent). The patterns cover all
* major browser families and legacy rendering engine identifiers.
*
* Examples of matching User-Agents:
* - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120.0"
* - "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_2) ... Safari/537.36"
* - "Mozilla/5.0 (X11; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/115.0"
* - "Mozilla/5.0 ... Edg/120.0" Microsoft Edge (Chromium)
* - "Opera/9.80 ... OPR/106.0" Opera
* - "Mozilla/4.0 (compatible; MSIE 8.0; Windows NT 6.1)" Internet Explorer
* - "Mozilla/5.0 ... Trident/7.0; rv:11.0" IE 11 (Trident engine)
* If none of these are present, it might be programmatic access.
*/
protected const BROWSER_INDICATORS = [
'/\bMozilla\b/i', // All Gecko/WebKit/Blink browsers include "Mozilla/5.0"
'/\bChrome\b/i', // Chrome, Chromium, and most Chromium-based browsers
'/\bSafari\b/i', // Safari and WebKit-based browsers
'/\bFirefox\b/i', // Mozilla Firefox
'/\bEdge\b/i', // Microsoft Edge (legacy "Edge/" and Chromium "Edg/")
'/\bOpera\b/i', // Opera ("Opera/" classic, "OPR/" modern)
'/\bMSIE\b/i', // Internet Explorer (e.g. "MSIE 11.0")
'/\bTrident\b/i', // IE 11 Trident rendering engine token
'/\bMozilla\b/i',
'/\bChrome\b/i',
'/\bSafari\b/i',
'/\bFirefox\b/i',
'/\bEdge\b/i',
'/\bOpera\b/i',
'/\bMSIE\b/i',
'/\bTrident\b/i',
];
/**
* Known bot patterns that are NOT AI agents.
*
* These should resolve to `AgentIdentity::notAnAgent()` rather than
* `AgentIdentity::unknownAgent()`, because we can positively identify them
* as a specific non-AI automated client (crawler, monitoring, HTTP library, etc.).
*
* Categories and example User-Agents:
*
* Search engine crawlers:
* - "Googlebot/2.1 (+http://www.google.com/bot.html)"
* - "Mozilla/5.0 (compatible; bingbot/2.0; +http://www.bing.com/bingbot.htm)"
* - "Mozilla/5.0 (compatible; YandexBot/3.0; +http://yandex.com/bots)"
* - "DuckDuckBot/1.0; (+http://duckduckgo.com/duckduckbot.html)"
* - "Mozilla/5.0 (compatible; Baiduspider/2.0; +http://www.baidu.com/search/spider.html)"
* - "Applebot/0.1 (+http://www.apple.com/go/applebot)"
*
* Social media / link-preview bots:
* - "facebookexternalhit/1.1 (+http://www.facebook.com/externalhit_uatext.php)"
* - "Twitterbot/1.0"
* - "LinkedInBot/1.0 (compatible; Mozilla/5.0; Apache-HttpClient/4.5)"
* - "Slackbot-LinkExpanding 1.0 (+https://api.slack.com/robots)"
* - "DiscordBot (https://discordapp.com) 1.0"
* - "TelegramBot (like TwitterBot)"
* - "WhatsApp/2.23.20 A"
*
* SEO / analytics crawlers:
* - "Mozilla/5.0 (compatible; SemrushBot/7~bl; +http://www.semrush.com/bot.html)"
* - "Mozilla/5.0 (compatible; AhrefsBot/7.0; +http://ahrefs.com/robot/)"
*
* Generic HTTP clients (scripts, developer tools):
* - "curl/7.88.1"
* - "Wget/1.21.4"
* - "python-requests/2.31.0"
* - "Go-http-client/2.0"
* - "PostmanRuntime/7.35.0"
* - "insomnia/2023.5.8"
* - "axios/1.6.0"
* - "node-fetch/2.6.11"
*
* Uptime / monitoring services:
* - "UptimeRobot/2.0 (+http://www.uptimerobot.com/)"
* - "Pingdom.com_bot_version_1.4 (http://www.pingdom.com/)"
* - "Datadog Agent/7.45.0"
* - "NewRelicPinger/v1 AccountId=12345"
* These should return notAnAgent, not unknown.
*/
protected const NON_AGENT_BOTS = [
// Search engine crawlers
'/\bGooglebot\b/i',
'/\bBingbot\b/i',
'/\bYandexBot\b/i',
'/\bDuckDuckBot\b/i',
'/\bBaiduspider\b/i',
'/\bApplebot\b/i',
// Social media / link-preview bots
'/\bfacebookexternalhit\b/i',
'/\bTwitterbot\b/i',
'/\bLinkedInBot\b/i',
@ -239,22 +124,17 @@ class AgentDetection
'/\bDiscordBot\b/i',
'/\bTelegramBot\b/i',
'/\bWhatsApp\//i',
// SEO / analytics crawlers
'/\bApplebot\b/i',
'/\bSEMrushBot\b/i',
'/\bAhrefsBot\b/i',
// Generic HTTP clients
'/\bcurl\b/i',
'/\bwget\b/i',
'/\bpython-requests\b/i',
'/\bgo-http-client\b/i',
'/\bPostman/i',
'/\bPostman\b/i',
'/\bInsomnia\b/i',
'/\baxios\b/i',
'/\bnode-fetch\b/i',
// Uptime / monitoring services
'/\bUptimeRobot\b/i',
'/\bPingdom\b/i',
'/\bDatadog\b/i',
@ -262,19 +142,7 @@ class AgentDetection
];
/**
* The MCP token header used to identify registered AI agents.
*
* Agents send this header to bypass User-Agent heuristics and declare their
* identity explicitly. Two token formats are supported:
*
* - Opaque AgentApiKey token (prefix "ak_"):
* Looked up in the database. Grants highest confidence when the key is active.
* Example: `X-MCP-Token: ak_a1b2c3d4e5f6...`
*
* - Structured provider:model:secret token:
* Encodes provider and model directly in the token value.
* Example: `X-MCP-Token: anthropic:claude-sonnet:mysecret`
* Example: `X-MCP-Token: openai:gpt-4:xyz789`
* The MCP token header name.
*/
protected const MCP_TOKEN_HEADER = 'X-MCP-Token';

View file

@ -9,7 +9,6 @@ use Core\Mcp\Dependencies\HasDependencies;
use Core\Mcp\Services\ToolDependencyService;
use Core\Mod\Agentic\Mcp\Tools\Agent\Contracts\AgentToolInterface;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
/**
* Registry for MCP Agent Server tools.
@ -99,57 +98,24 @@ class AgentToolRegistry
);
}
/**
* Cache TTL for permitted tool lists (1 hour).
*/
public const CACHE_TTL = 3600;
/**
* Get tools accessible by an API key.
*
* Results are cached per API key for {@see CACHE_TTL} seconds to avoid
* repeated O(n) filtering on every request (PERF-002).
* Use {@see flushCacheForApiKey()} to invalidate on permission changes.
*
* @return Collection<string, AgentToolInterface>
*/
public function forApiKey(ApiKey $apiKey): Collection
{
$cacheKey = $this->apiKeyCacheKey($apiKey->getKey());
$permittedNames = Cache::remember($cacheKey, self::CACHE_TTL, function () use ($apiKey) {
return $this->all()->filter(function (AgentToolInterface $tool) use ($apiKey) {
// Check if API key has required scopes
foreach ($tool->requiredScopes() as $scope) {
if (! $apiKey->hasScope($scope)) {
return false;
}
return $this->all()->filter(function (AgentToolInterface $tool) use ($apiKey) {
// Check if API key has required scopes
foreach ($tool->requiredScopes() as $scope) {
if (! $apiKey->hasScope($scope)) {
return false;
}
}
// Check if API key has tool-level permission
return $this->apiKeyCanAccessTool($apiKey, $tool->name());
})->keys()->all();
// Check if API key has tool-level permission
return $this->apiKeyCanAccessTool($apiKey, $tool->name());
});
return $this->all()->only($permittedNames);
}
/**
* Flush the cached tool list for an API key.
*
* Call this whenever an API key's permissions or tool scopes change.
*/
public function flushCacheForApiKey(int|string $apiKeyId): void
{
Cache::forget($this->apiKeyCacheKey($apiKeyId));
}
/**
* Build the cache key for a given API key ID.
*/
private function apiKeyCacheKey(int|string $apiKeyId): string
{
return "agent_tool_registry:api_key:{$apiKeyId}";
}
/**

View file

@ -4,7 +4,6 @@ declare(strict_types=1);
namespace Core\Mod\Agentic\Services;
use Illuminate\Support\Facades\Log;
use InvalidArgumentException;
class AgenticManager
@ -92,46 +91,22 @@ class AgenticManager
/**
* Register all AI providers.
*
* Logs a warning for each provider whose API key is absent so that
* misconfiguration is surfaced at boot time rather than on the first
* API call. Set the corresponding environment variable to silence it:
*
* ANTHROPIC_API_KEY Claude
* GOOGLE_AI_API_KEY Gemini
* OPENAI_API_KEY OpenAI
*/
private function registerProviders(): void
{
// Use null coalescing since config() returns null for missing env vars
$claudeKey = config('services.anthropic.api_key') ?? '';
$geminiKey = config('services.google.ai_api_key') ?? '';
$openaiKey = config('services.openai.api_key') ?? '';
if (empty($claudeKey)) {
Log::warning("Agentic: 'claude' provider has no API key configured. Set ANTHROPIC_API_KEY to enable it.");
}
if (empty($geminiKey)) {
Log::warning("Agentic: 'gemini' provider has no API key configured. Set GOOGLE_AI_API_KEY to enable it.");
}
if (empty($openaiKey)) {
Log::warning("Agentic: 'openai' provider has no API key configured. Set OPENAI_API_KEY to enable it.");
}
$this->providers['claude'] = new ClaudeService(
apiKey: $claudeKey,
apiKey: config('services.anthropic.api_key') ?? '',
model: config('services.anthropic.model') ?? 'claude-sonnet-4-20250514',
);
$this->providers['gemini'] = new GeminiService(
apiKey: $geminiKey,
apiKey: config('services.google.ai_api_key') ?? '',
model: config('services.google.ai_model') ?? 'gemini-2.0-flash',
);
$this->providers['openai'] = new OpenAIService(
apiKey: $openaiKey,
apiKey: config('services.openai.api_key') ?? '',
model: config('services.openai.model') ?? 'gpt-4o-mini',
);
}

View file

@ -1,277 +0,0 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Agentic\Services;
use Core\Mod\Agentic\Models\BrainMemory;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class BrainService
{
private const DEFAULT_MODEL = 'embeddinggemma';
private const VECTOR_DIMENSION = 768;
public function __construct(
private string $ollamaUrl = 'http://localhost:11434',
private string $qdrantUrl = 'http://localhost:6334',
private string $collection = 'openbrain',
private string $embeddingModel = self::DEFAULT_MODEL,
private bool $verifySsl = true,
) {}
/**
* Create an HTTP client with common settings.
*/
private function http(int $timeout = 10): \Illuminate\Http\Client\PendingRequest
{
return $this->verifySsl
? Http::timeout($timeout)
: Http::withoutVerifying()->timeout($timeout);
}
/**
* Generate an embedding vector for the given text.
*
* @return array<float>
*
* @throws \RuntimeException
*/
public function embed(string $text): array
{
$response = $this->http(30)
->post("{$this->ollamaUrl}/api/embeddings", [
'model' => $this->embeddingModel,
'prompt' => $text,
]);
if (! $response->successful()) {
throw new \RuntimeException("Ollama embedding failed: {$response->status()}");
}
$embedding = $response->json('embedding');
if (! is_array($embedding) || empty($embedding)) {
throw new \RuntimeException('Ollama returned no embedding vector');
}
return $embedding;
}
/**
* Store a memory in both MariaDB and Qdrant.
*
* Creates the MariaDB record and upserts the Qdrant vector within
* a single DB transaction. If the memory supersedes an older one,
* the old entry is soft-deleted from MariaDB and removed from Qdrant.
*
* @param array<string, mixed> $attributes Fillable attributes for BrainMemory
* @return BrainMemory The created memory
*/
public function remember(array $attributes): BrainMemory
{
$vector = $this->embed($attributes['content']);
return DB::connection('brain')->transaction(function () use ($attributes, $vector) {
$memory = BrainMemory::create($attributes);
$payload = $this->buildQdrantPayload($memory->id, [
'workspace_id' => $memory->workspace_id,
'agent_id' => $memory->agent_id,
'type' => $memory->type,
'tags' => $memory->tags ?? [],
'project' => $memory->project,
'confidence' => $memory->confidence,
'created_at' => $memory->created_at->toIso8601String(),
]);
$payload['vector'] = $vector;
$this->qdrantUpsert([$payload]);
if ($memory->supersedes_id) {
BrainMemory::where('id', $memory->supersedes_id)->delete();
$this->qdrantDelete([$memory->supersedes_id]);
}
return $memory;
});
}
/**
* Semantic search: find memories similar to the query.
*
* @param array<string, mixed> $filter Optional filter criteria
* @return array{memories: array, scores: array<string, float>}
*/
public function recall(string $query, int $topK, array $filter, int $workspaceId): array
{
$vector = $this->embed($query);
$filter['workspace_id'] = $workspaceId;
$qdrantFilter = $this->buildQdrantFilter($filter);
$response = $this->http(10)
->post("{$this->qdrantUrl}/collections/{$this->collection}/points/search", [
'vector' => $vector,
'filter' => $qdrantFilter,
'limit' => $topK,
'with_payload' => false,
]);
if (! $response->successful()) {
throw new \RuntimeException("Qdrant search failed: {$response->status()}");
}
$results = $response->json('result', []);
$ids = array_column($results, 'id');
$scoreMap = [];
foreach ($results as $r) {
$scoreMap[$r['id']] = $r['score'];
}
if (empty($ids)) {
return ['memories' => [], 'scores' => []];
}
$memories = BrainMemory::whereIn('id', $ids)
->forWorkspace($workspaceId)
->active()
->latestVersions()
->get()
->sortBy(fn (BrainMemory $m) => array_search($m->id, $ids))
->values();
return [
'memories' => $memories->map(fn (BrainMemory $m) => $m->toMcpContext())->all(),
'scores' => $scoreMap,
];
}
/**
* Remove a memory from both Qdrant and MariaDB.
*/
public function forget(string $id): void
{
DB::connection('brain')->transaction(function () use ($id) {
BrainMemory::where('id', $id)->delete();
$this->qdrantDelete([$id]);
});
}
/**
* Ensure the Qdrant collection exists, creating it if needed.
*/
public function ensureCollection(): void
{
$response = $this->http(5)
->get("{$this->qdrantUrl}/collections/{$this->collection}");
if ($response->status() === 404) {
$createResponse = $this->http(10)
->put("{$this->qdrantUrl}/collections/{$this->collection}", [
'vectors' => [
'size' => self::VECTOR_DIMENSION,
'distance' => 'Cosine',
],
]);
if (! $createResponse->successful()) {
throw new \RuntimeException("Qdrant collection creation failed: {$createResponse->status()}");
}
Log::info("OpenBrain: created Qdrant collection '{$this->collection}'");
}
}
/**
* Build a Qdrant point payload.
*
* @param array<string, mixed> $metadata
* @return array{id: string, payload: array<string, mixed>}
*/
public function buildQdrantPayload(string $id, array $metadata): array
{
return [
'id' => $id,
'payload' => $metadata,
];
}
/**
* Build a Qdrant filter from criteria.
*
* @param array<string, mixed> $criteria
* @return array{must: array}
*/
public function buildQdrantFilter(array $criteria): array
{
$must = [];
if (isset($criteria['workspace_id'])) {
$must[] = ['key' => 'workspace_id', 'match' => ['value' => $criteria['workspace_id']]];
}
if (isset($criteria['project'])) {
$must[] = ['key' => 'project', 'match' => ['value' => $criteria['project']]];
}
if (isset($criteria['type'])) {
if (is_array($criteria['type'])) {
$must[] = ['key' => 'type', 'match' => ['any' => $criteria['type']]];
} else {
$must[] = ['key' => 'type', 'match' => ['value' => $criteria['type']]];
}
}
if (isset($criteria['agent_id'])) {
$must[] = ['key' => 'agent_id', 'match' => ['value' => $criteria['agent_id']]];
}
if (isset($criteria['min_confidence'])) {
$must[] = ['key' => 'confidence', 'range' => ['gte' => $criteria['min_confidence']]];
}
return ['must' => $must];
}
/**
* Upsert points into Qdrant.
*
* @param array<array> $points
*
* @throws \RuntimeException
*/
private function qdrantUpsert(array $points): void
{
$response = $this->http(10)
->put("{$this->qdrantUrl}/collections/{$this->collection}/points", [
'points' => $points,
]);
if (! $response->successful()) {
Log::error("Qdrant upsert failed: {$response->status()}", ['body' => $response->body()]);
throw new \RuntimeException("Qdrant upsert failed: {$response->status()}");
}
}
/**
* Delete points from Qdrant by ID.
*
* @param array<string> $ids
*/
private function qdrantDelete(array $ids): void
{
$response = $this->http(10)
->post("{$this->qdrantUrl}/collections/{$this->collection}/points/delete", [
'points' => $ids,
]);
if (! $response->successful()) {
Log::warning("Qdrant delete failed: {$response->status()}", ['ids' => $ids, 'body' => $response->body()]);
}
}
}

View file

@ -9,8 +9,6 @@ use Core\Mod\Agentic\Services\Concerns\HasStreamParsing;
use Generator;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Throwable;
class ClaudeService implements AgenticProviderInterface
{
@ -60,47 +58,28 @@ class ClaudeService implements AgenticProviderInterface
);
}
/**
* Stream a completion from Claude.
*
* Yields text chunks as strings on success.
*
* On failure, yields a single error event array and terminates:
* ['type' => 'error', 'message' => string]
*
* @return Generator<string|array{type: 'error', message: string}>
*/
public function stream(
string $systemPrompt,
string $userPrompt,
array $config = []
): Generator {
try {
$response = $this->client()
->withOptions(['stream' => true])
->post(self::API_URL, [
'model' => $config['model'] ?? $this->model,
'max_tokens' => $config['max_tokens'] ?? 4096,
'temperature' => $config['temperature'] ?? 1.0,
'stream' => true,
'system' => $systemPrompt,
'messages' => [
['role' => 'user', 'content' => $userPrompt],
],
]);
yield from $this->parseSSEStream(
$response->getBody(),
fn (array $data) => $data['delta']['text'] ?? null
);
} catch (Throwable $e) {
Log::error('Claude stream error', [
'message' => $e->getMessage(),
'exception' => $e,
$response = $this->client()
->withOptions(['stream' => true])
->post(self::API_URL, [
'model' => $config['model'] ?? $this->model,
'max_tokens' => $config['max_tokens'] ?? 4096,
'temperature' => $config['temperature'] ?? 1.0,
'stream' => true,
'system' => $systemPrompt,
'messages' => [
['role' => 'user', 'content' => $userPrompt],
],
]);
yield ['type' => 'error', 'message' => $e->getMessage()];
}
yield from $this->parseSSEStream(
$response->getBody(),
fn (array $data) => $data['delta']['text'] ?? null
);
}
public function name(): string

View file

@ -119,7 +119,6 @@ trait HasStreamParsing
$inString = false;
$escape = false;
$objectStart = -1;
$scanPos = 0;
while (! $stream->eof()) {
$chunk = $stream->read(8192);
@ -130,10 +129,9 @@ trait HasStreamParsing
$buffer .= $chunk;
// Parse JSON objects from the buffer, continuing from where
// the previous iteration left off to preserve parser state.
// Parse JSON objects from the buffer
$length = strlen($buffer);
$i = $scanPos;
$i = 0;
while ($i < $length) {
$char = $buffer[$i];
@ -178,7 +176,6 @@ trait HasStreamParsing
$buffer = substr($buffer, $i + 1);
$length = strlen($buffer);
$i = -1; // Will be incremented to 0
$scanPos = 0;
$objectStart = -1;
}
}
@ -186,9 +183,6 @@ trait HasStreamParsing
$i++;
}
// Save scan position so we resume from here on the next chunk
$scanPos = $i;
}
}
}

View file

@ -118,21 +118,15 @@ class ContentService
/**
* Generate content for a batch.
*
* Progress is persisted to a state file after each article so the batch
* can be resumed after a partial failure. Call generateBatch() or
* resumeBatch() again to pick up from the last saved state.
*
* @param string $batchId Batch identifier (e.g., 'batch-001-link-getting-started')
* @param string $provider AI provider ('gemini' for bulk, 'claude' for refinement)
* @param bool $dryRun If true, shows what would be generated without creating files
* @param int $maxRetries Extra attempts per article on failure (0 = no retry)
* @return array Generation results
*/
public function generateBatch(
string $batchId,
string $provider = 'gemini',
bool $dryRun = false,
int $maxRetries = 1,
bool $dryRun = false
): array {
$spec = $this->loadBatch($batchId);
if (! $spec) {
@ -150,13 +144,6 @@ class ContentService
$promptTemplate = $this->loadPromptTemplate('help-article');
// Load or initialise progress state (skipped for dry runs)
$progress = null;
if (! $dryRun) {
$progress = $this->loadBatchProgress($batchId)
?? $this->initialiseBatchState($batchId, $spec['articles'] ?? [], $provider);
}
foreach ($spec['articles'] ?? [] as $article) {
$slug = $article['slug'] ?? null;
if (! $slug) {
@ -165,13 +152,10 @@ class ContentService
$draftPath = $this->getDraftPath($spec, $slug);
// Skip if draft file already exists on disk
// Skip if already drafted
if (File::exists($draftPath)) {
$results['articles'][$slug] = ['status' => 'skipped', 'reason' => 'already drafted'];
$results['skipped']++;
if ($progress !== null) {
$progress['articles'][$slug]['status'] = 'skipped';
}
continue;
}
@ -182,166 +166,18 @@ class ContentService
continue;
}
// Skip articles successfully generated in a prior run
if (($progress['articles'][$slug]['status'] ?? 'pending') === 'generated') {
$results['articles'][$slug] = ['status' => 'skipped', 'reason' => 'previously generated'];
$results['skipped']++;
continue;
}
$priorAttempts = $progress['articles'][$slug]['attempts'] ?? 0;
$articleResult = $this->attemptArticleGeneration($article, $spec, $promptTemplate, $provider, $maxRetries);
if ($articleResult['status'] === 'generated') {
$results['articles'][$slug] = ['status' => 'generated', 'path' => $articleResult['path']];
$results['generated']++;
$progress['articles'][$slug] = [
'status' => 'generated',
'attempts' => $priorAttempts + $articleResult['attempts'],
'last_error' => null,
'generated_at' => now()->toIso8601String(),
'last_attempt_at' => now()->toIso8601String(),
];
} else {
$results['articles'][$slug] = ['status' => 'failed', 'error' => $articleResult['error']];
$results['failed']++;
$progress['articles'][$slug] = [
'status' => 'failed',
'attempts' => $priorAttempts + $articleResult['attempts'],
'last_error' => $articleResult['error'],
'generated_at' => null,
'last_attempt_at' => now()->toIso8601String(),
];
}
// Persist after each article so a crash mid-batch is recoverable
$progress['last_updated'] = now()->toIso8601String();
$this->saveBatchProgress($batchId, $progress);
}
if ($progress !== null) {
$progress['last_updated'] = now()->toIso8601String();
$this->saveBatchProgress($batchId, $progress);
}
return $results;
}
/**
* Resume a batch from its last saved state.
*
* Articles that were successfully generated are skipped; failed and
* pending articles are retried. Returns an error if no progress state
* exists (i.e. generateBatch() has never been called for this batch).
*/
public function resumeBatch(string $batchId, ?string $provider = null, int $maxRetries = 1): array
{
$progress = $this->loadBatchProgress($batchId);
if ($progress === null) {
return ['error' => "No progress state found for batch: {$batchId}"];
}
$provider ??= $progress['provider'] ?? 'gemini';
$result = $this->generateBatch($batchId, $provider, false, $maxRetries);
$result['resumed_from'] = $progress['last_updated'];
return $result;
}
/**
* Load batch progress state from the state file.
*
* Returns null when no state file exists (batch has not been started).
*/
public function loadBatchProgress(string $batchId): ?array
{
$path = $this->getProgressPath($batchId);
if (! File::exists($path)) {
return null;
}
$data = json_decode(File::get($path), true);
return is_array($data) ? $data : null;
}
/**
* Attempt to generate a single article with retry logic.
*
* Returns ['status' => 'generated', 'path' => ..., 'attempts' => N]
* or ['status' => 'failed', 'error' => ..., 'attempts' => N].
*/
protected function attemptArticleGeneration(
array $article,
array $spec,
string $promptTemplate,
string $provider,
int $maxRetries,
): array {
$draftPath = $this->getDraftPath($spec, $article['slug']);
$lastError = null;
$totalAttempts = $maxRetries + 1;
for ($attempt = 1; $attempt <= $totalAttempts; $attempt++) {
try {
$content = $this->generateArticle($article, $spec, $promptTemplate, $provider);
$this->saveDraft($draftPath, $content, $article);
return ['status' => 'generated', 'path' => $draftPath, 'attempts' => $attempt];
$results['articles'][$slug] = ['status' => 'generated', 'path' => $draftPath];
$results['generated']++;
} catch (\Exception $e) {
$lastError = $e->getMessage();
$results['articles'][$slug] = ['status' => 'failed', 'error' => $e->getMessage()];
$results['failed']++;
}
}
return ['status' => 'failed', 'error' => $lastError, 'attempts' => $totalAttempts];
}
/**
* Initialise a fresh batch progress state.
*/
protected function initialiseBatchState(string $batchId, array $articles, string $provider): array
{
$articleStates = [];
foreach ($articles as $article) {
$slug = $article['slug'] ?? null;
if ($slug) {
$articleStates[$slug] = [
'status' => 'pending',
'attempts' => 0,
'last_error' => null,
'generated_at' => null,
'last_attempt_at' => null,
];
}
}
return [
'batch_id' => $batchId,
'provider' => $provider,
'started_at' => now()->toIso8601String(),
'last_updated' => now()->toIso8601String(),
'articles' => $articleStates,
];
}
/**
* Save batch progress state to the state file.
*/
protected function saveBatchProgress(string $batchId, array $state): void
{
File::put($this->getProgressPath($batchId), json_encode($state, JSON_PRETTY_PRINT));
}
/**
* Get the progress state file path for a batch.
*/
protected function getProgressPath(string $batchId): string
{
return base_path("{$this->batchPath}/{$batchId}.progress.json");
return $results;
}
/**

View file

@ -1,203 +0,0 @@
<?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\Services;
use Illuminate\Support\Facades\Http;
/**
* Forgejo REST API client for agent orchestration.
*
* Wraps the Forgejo v1 API for issue management, pull requests,
* commit statuses, and branch operations.
*/
class ForgejoService
{
public function __construct(
private string $baseUrl,
private string $token,
) {}
/**
* List issues for a repository.
*
* @return array<int, array<string, mixed>>
*/
public function listIssues(string $owner, string $repo, string $state = 'open', ?string $label = null): array
{
$query = ['state' => $state, 'type' => 'issues'];
if ($label !== null) {
$query['labels'] = $label;
}
return $this->get("/repos/{$owner}/{$repo}/issues", $query);
}
/**
* Get a single issue by number.
*
* @return array<string, mixed>
*/
public function getIssue(string $owner, string $repo, int $number): array
{
return $this->get("/repos/{$owner}/{$repo}/issues/{$number}");
}
/**
* Create a comment on an issue.
*
* @return array<string, mixed>
*/
public function createComment(string $owner, string $repo, int $issueNumber, string $body): array
{
return $this->post("/repos/{$owner}/{$repo}/issues/{$issueNumber}/comments", [
'body' => $body,
]);
}
/**
* Add labels to an issue.
*
* @param array<int> $labelIds
* @return array<int, array<string, mixed>>
*/
public function addLabels(string $owner, string $repo, int $issueNumber, array $labelIds): array
{
return $this->post("/repos/{$owner}/{$repo}/issues/{$issueNumber}/labels", [
'labels' => $labelIds,
]);
}
/**
* List pull requests for a repository.
*
* @return array<int, array<string, mixed>>
*/
public function listPullRequests(string $owner, string $repo, string $state = 'all'): array
{
return $this->get("/repos/{$owner}/{$repo}/pulls", ['state' => $state]);
}
/**
* Get a single pull request by number.
*
* @return array<string, mixed>
*/
public function getPullRequest(string $owner, string $repo, int $number): array
{
return $this->get("/repos/{$owner}/{$repo}/pulls/{$number}");
}
/**
* Get the combined commit status for a ref.
*
* @return array<string, mixed>
*/
public function getCombinedStatus(string $owner, string $repo, string $sha): array
{
return $this->get("/repos/{$owner}/{$repo}/commits/{$sha}/status");
}
/**
* Merge a pull request.
*
* @param string $method One of: merge, rebase, rebase-merge, squash, fast-forward-only
*
* @throws \RuntimeException
*/
public function mergePullRequest(string $owner, string $repo, int $number, string $method = 'merge'): void
{
$response = $this->request()
->post($this->url("/repos/{$owner}/{$repo}/pulls/{$number}/merge"), [
'Do' => $method,
]);
if (! $response->successful()) {
throw new \RuntimeException(
"Failed to merge PR #{$number}: {$response->status()} {$response->body()}"
);
}
}
/**
* Create a branch in a repository.
*
* @return array<string, mixed>
*/
public function createBranch(string $owner, string $repo, string $name, string $from = 'main'): array
{
return $this->post("/repos/{$owner}/{$repo}/branches", [
'new_branch_name' => $name,
'old_branch_name' => $from,
]);
}
/**
* Build an authenticated HTTP client.
*/
private function request(): \Illuminate\Http\Client\PendingRequest
{
return Http::withToken($this->token)
->acceptJson()
->timeout(15);
}
/**
* Build the full API URL for a path.
*/
private function url(string $path): string
{
return "{$this->baseUrl}/api/v1{$path}";
}
/**
* Perform a GET request and return decoded JSON.
*
* @param array<string, mixed> $query
* @return array<string, mixed>
*
* @throws \RuntimeException
*/
private function get(string $path, array $query = []): array
{
$response = $this->request()->get($this->url($path), $query);
if (! $response->successful()) {
throw new \RuntimeException(
"Forgejo API GET {$path} failed: {$response->status()}"
);
}
return $response->json();
}
/**
* Perform a POST request and return decoded JSON.
*
* @param array<string, mixed> $data
* @return array<string, mixed>
*
* @throws \RuntimeException
*/
private function post(string $path, array $data = []): array
{
$response = $this->request()->post($this->url($path), $data);
if (! $response->successful()) {
throw new \RuntimeException(
"Forgejo API POST {$path} failed: {$response->status()}"
);
}
return $response->json();
}
}

View file

@ -6,7 +6,6 @@ namespace Core\Mod\Agentic\Services;
use Core\Mod\Agentic\Models\AgentPhase;
use Core\Mod\Agentic\Models\AgentPlan;
use Core\Mod\Agentic\Models\PlanTemplateVersion;
use Core\Tenant\Models\Workspace;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\File;
@ -147,10 +146,6 @@ class PlanTemplateService
return null;
}
// Snapshot the raw template content before variable substitution so the
// version record captures the canonical template, not the instantiated copy.
$templateVersion = PlanTemplateVersion::findOrCreateFromTemplate($templateSlug, $template);
// Replace variables in template
$template = $this->substituteVariables($template, $variables);
@ -169,12 +164,10 @@ class PlanTemplateService
'description' => $template['description'] ?? null,
'context' => $context,
'status' => ($options['activate'] ?? false) ? AgentPlan::STATUS_ACTIVE : AgentPlan::STATUS_DRAFT,
'template_version_id' => $templateVersion->id,
'metadata' => array_merge($template['metadata'] ?? [], [
'source' => 'template',
'template_slug' => $templateSlug,
'template_name' => $template['name'],
'template_version' => $templateVersion->version,
'variables' => $variables,
'created_at' => now()->toIso8601String(),
]),
@ -336,18 +329,13 @@ class PlanTemplateService
/**
* Validate variables against template requirements.
*
* Returns a result array with:
* - valid: bool
* - errors: string[] actionable messages including description and examples
* - naming_convention: string reminder that variable names use snake_case
*/
public function validateVariables(string $templateSlug, array $variables): array
{
$template = $this->get($templateSlug);
if (! $template) {
return ['valid' => false, 'errors' => ['Template not found'], 'naming_convention' => self::NAMING_CONVENTION];
return ['valid' => false, 'errors' => ['Template not found']];
}
$errors = [];
@ -356,94 +344,16 @@ class PlanTemplateService
$required = $varDef['required'] ?? true;
if ($required && ! isset($variables[$name]) && ! isset($varDef['default'])) {
$errors[] = $this->buildVariableError($name, $varDef);
$errors[] = "Required variable '{$name}' is missing";
}
}
return [
'valid' => empty($errors),
'errors' => $errors,
'naming_convention' => self::NAMING_CONVENTION,
];
}
/**
<<<<<<< HEAD
* Naming convention reminder included in validation results.
*/
private const NAMING_CONVENTION = 'Variable names use snake_case (e.g. project_name, api_key)';
/**
* Build an actionable error message for a missing required variable.
*
* Incorporates the variable's description, example values, and expected
* format so the caller knows exactly what to provide.
*/
private function buildVariableError(string $name, array $varDef): string
{
$message = "Required variable '{$name}' is missing";
if (! empty($varDef['description'])) {
$message .= ": {$varDef['description']}";
}
$hints = [];
if (! empty($varDef['format'])) {
$hints[] = "expected format: {$varDef['format']}";
}
if (! empty($varDef['example'])) {
$hints[] = "example: '{$varDef['example']}'";
} elseif (! empty($varDef['examples'])) {
$exampleValues = is_array($varDef['examples'])
? array_slice($varDef['examples'], 0, 2)
: [$varDef['examples']];
$hints[] = "examples: '".implode("', '", $exampleValues)."'";
}
if (! empty($hints)) {
$message .= ' ('.implode('; ', $hints).')';
}
return $message;
}
/**
* Get the version history for a template slug, newest first.
*
* Returns an array of version summaries (without full content) for display.
*
* @return array<int, array{id: int, slug: string, version: int, name: string, content_hash: string, created_at: string}>
*/
public function getVersionHistory(string $slug): array
{
return PlanTemplateVersion::historyFor($slug)
->map(fn (PlanTemplateVersion $v) => [
'id' => $v->id,
'slug' => $v->slug,
'version' => $v->version,
'name' => $v->name,
'content_hash' => $v->content_hash,
'created_at' => $v->created_at?->toIso8601String(),
])
->toArray();
}
/**
* Get a specific stored version of a template by slug and version number.
*
* Returns the snapshotted content array, or null if not found.
*/
public function getVersion(string $slug, int $version): ?array
{
$record = PlanTemplateVersion::where('slug', $slug)
->where('version', $version)
->first();
return $record?->content;
}
/**
* Get templates by category.
*/

43
TODO.md
View file

@ -104,11 +104,10 @@ Production-quality task list for the AI agent orchestration package.
- Issue: No try/catch around streaming, could fail silently
- Fix: Wrap in exception handling, yield error events
- [x] **ERR-002: ContentService has no batch failure recovery** (FIXED 2026-02-23)
- [ ] **ERR-002: ContentService has no batch failure recovery**
- Location: `Services/ContentService.php::generateBatch()`
- Issue: Failed articles stop processing, no resume capability
- Fix: Added progress state file, per-article retry (maxRetries param), `resumeBatch()` method
- Tests: 6 new tests in `tests/Feature/ContentServiceTest.php` covering state persistence, resume, retries
- Fix: Add progress tracking, allow resuming from failed point
---
@ -116,29 +115,26 @@ Production-quality task list for the AI agent orchestration package.
### Developer Experience
- [x] **DX-001: Missing workspace context error messages unclear** (FIXED 2026-02-23)
- [ ] **DX-001: Missing workspace context error messages unclear**
- Location: Multiple MCP tools
- Issue: "workspace_id is required" didn't explain how to fix
- Fix: Updated error messages in PlanCreate, PlanGet, PlanList, StateSet, StateGet, StateList, SessionStart to include actionable guidance and link to documentation
- Issue: "workspace_id is required" doesn't explain how to fix
- Fix: Include context about authentication/session setup
- [x] **DX-002: AgenticManager doesn't validate API keys on init** (FIXED 2026-02-23)
- [ ] **DX-002: AgenticManager doesn't validate API keys on init**
- Location: `Services/AgenticManager.php::registerProviders()`
- Issue: Empty API key creates provider that fails on first use
- Fix: `Log::warning()` emitted for each provider registered without an API key; message names the env var to set
- Fix: Log warning or throw if provider configured without key
- [x] **DX-003: Plan template variable errors not actionable** (FIXED 2026-02-23)
- [ ] **DX-003: Plan template variable errors not actionable**
- Location: `Services/PlanTemplateService.php::validateVariables()`
- Fix: Error messages now include variable description, example/examples, and expected format
- Added `naming_convention` field to result; extracted `buildVariableError()` helper
- New tests: description in error, example value, multiple examples, format hint, naming_convention field
- Fix: Include expected format, examples in error messages
### Code Quality
- [x] **CQ-001: Duplicate state models (WorkspaceState vs AgentWorkspaceState)** (FIXED 2026-02-23)
- Deleted `Models/AgentWorkspaceState.php` (unused legacy port)
- Consolidated into `Models/WorkspaceState.php` backed by `agent_workspace_states` table
- Updated `AgentPlan`, `StateSet`, `SecurityTest` to use `WorkspaceState`
- Added `WorkspaceStateTest` covering model behaviour and static helpers
- [ ] **CQ-001: Duplicate state models (WorkspaceState vs AgentWorkspaceState)**
- Files: `Models/WorkspaceState.php`, `Models/AgentWorkspaceState.php`
- Issue: Two similar models for same purpose
- Fix: Consolidate into single model, or clarify distinct purposes
- [x] **CQ-002: ApiKeyManager uses Core\Api\ApiKey, not AgentApiKey** (FIXED 2026-02-23)
- Location: `View/Modal/Admin/ApiKeyManager.php`
@ -172,10 +168,10 @@ Production-quality task list for the AI agent orchestration package.
### Documentation Gaps
- [x] **DOC-001: Add PHPDoc to AgentDetection patterns** (FIXED 2026-02-23)
- [ ] **DOC-001: Add PHPDoc to AgentDetection patterns**
- Location: `Services/AgentDetection.php`
- Issue: User-Agent patterns undocumented
- Fix: Added PHPDoc with real UA examples to all pattern constants, class-level usage examples, and MCP_TOKEN_HEADER docs
- Fix: Document each pattern with agent examples
- [ ] **DOC-002: Document MCP tool dependency system**
- Location: `Mcp/Tools/Agent/` directory
@ -192,17 +188,16 @@ Production-quality task list for the AI agent orchestration package.
- Issue: Archived plans kept forever
- Fix: Add configurable retention period, cleanup job
- [x] **FEAT-003: Template version management**
- Location: `Services/PlanTemplateService.php`, `Models/PlanTemplateVersion.php`
- [ ] **FEAT-003: Template version management**
- Location: `Services/PlanTemplateService.php`
- Issue: Template changes affect existing plan references
- Fix: Add version tracking to templates — implemented in #35
- Fix: Add version tracking to templates
### Consistency
- [x] **CON-001: Mixed UK/US spelling in code comments** (FIXED 2026-02-23)
- [ ] **CON-001: Mixed UK/US spelling in code comments**
- Issue: Some comments use "organize" instead of "organise"
- Fix: Audit and fix to UK English per CLAUDE.md
- Changed: `Mcp/Servers/Marketing.php` "Organize" → "Organise" in docstring
- [ ] **CON-002: Inconsistent error response format**
- Issue: Some tools return `['error' => ...]`, others `['success' => false, ...]`

View file

@ -1,37 +0,0 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Plan Retention Policy
|--------------------------------------------------------------------------
|
| Archived plans are permanently deleted after this many days. This frees
| up storage and keeps the database lean over time.
|
| Set to 0 or null to disable automatic cleanup entirely.
|
| Default: 90 days
|
*/
'plan_retention_days' => env('AGENTIC_PLAN_RETENTION_DAYS', 90),
/*
|--------------------------------------------------------------------------
| Forgejo Integration
|--------------------------------------------------------------------------
|
| Configuration for the Forgejo-based scan/dispatch/PR pipeline.
| AGENTIC_SCAN_REPOS is a comma-separated list of owner/name repos.
|
*/
'scan_repos' => array_filter(explode(',', env('AGENTIC_SCAN_REPOS', ''))),
'forge_url' => env('FORGE_URL', 'https://forge.lthn.ai'),
'forge_token' => env('FORGE_TOKEN', ''),
];

View file

@ -1,5 +1,5 @@
{
"name": "lthn/php-agentic",
"name": "host-uk/core-agentic",
"description": "AI agent orchestration and MCP tools for Laravel",
"keywords": [
"ai",
@ -10,7 +10,7 @@
"license": "EUPL-1.2",
"require": {
"php": "^8.2",
"lthn/php": "*"
"host-uk/core": "dev-main"
},
"require-dev": {
"laravel/pint": "^1.18",
@ -49,8 +49,5 @@
}
},
"minimum-stability": "dev",
"prefer-stable": true,
"replace": {
"core/php-agentic": "self.version"
}
"prefer-stable": true
}

View file

@ -7,12 +7,15 @@ return [
| MCP Portal Domain
|--------------------------------------------------------------------------
|
| Default domain for the MCP Portal. The app-level Boot may override this
| with a wildcard (e.g. mcp.{tld}) for multi-domain support.
| The domain where the MCP Portal is served. This hosts the server registry,
| documentation, and discovery endpoints for AI agents.
|
| Production: mcp.host.uk.com
| Local dev: mcp.host.test (Valet)
|
*/
'domain' => env('MCP_DOMAIN', 'mcp.'.env('APP_DOMAIN', 'host.uk.com')),
'domain' => env('MCP_DOMAIN', 'mcp.host.uk.com'),
/*
|--------------------------------------------------------------------------
@ -68,37 +71,4 @@ return [
'for_agents_ttl' => 3600,
],
/*
|--------------------------------------------------------------------------
| OpenBrain (Shared Agent Knowledge Store)
|--------------------------------------------------------------------------
|
| Configuration for the vector-indexed knowledge store. Requires
| Ollama (for embeddings) and Qdrant (for vector search).
|
*/
'brain' => [
'ollama_url' => env('BRAIN_OLLAMA_URL', 'https://ollama.lthn.sh'),
'qdrant_url' => env('BRAIN_QDRANT_URL', 'https://qdrant.lthn.sh'),
'collection' => env('BRAIN_COLLECTION', 'openbrain'),
'embedding_model' => env('BRAIN_EMBEDDING_MODEL', 'embeddinggemma'),
// Dedicated database connection for brain_memories.
// Defaults to the app's main database when BRAIN_DB_* env vars are absent.
// Set BRAIN_DB_HOST to a remote MariaDB (e.g. the homelab) to co-locate
// DB rows with their Qdrant vectors.
'database' => [
'driver' => env('BRAIN_DB_DRIVER', env('DB_CONNECTION', 'mariadb')),
'host' => env('BRAIN_DB_HOST', env('DB_HOST', '127.0.0.1')),
'port' => env('BRAIN_DB_PORT', env('DB_PORT', '3306')),
'database' => env('BRAIN_DB_DATABASE', env('DB_DATABASE', 'forge')),
'username' => env('BRAIN_DB_USERNAME', env('DB_USERNAME', 'forge')),
'password' => env('BRAIN_DB_PASSWORD', env('DB_PASSWORD', '')),
'charset' => 'utf8mb4',
'collation' => 'utf8mb4_unicode_ci',
'prefix' => '',
],
],
];

View file

View file

@ -1,213 +0,0 @@
# OpenBrain Design
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Shared vector-indexed knowledge store that all agents (Virgil, Charon, Darbs, LEM) read/write through MCP, building singular state across sessions.
**Architecture:** MariaDB for relational metadata + Qdrant for vector embeddings. Four MCP tools in php-agentic. Go bridge in go-ai for CLI agents. Ollama for embedding generation.
**Repos:** `forge.lthn.ai/core/php-agentic` (primary), `forge.lthn.ai/core/go-ai` (bridge)
---
## Problem
Agent knowledge is scattered:
- Virgil's `MEMORY.md` files in `~/.claude/projects/*/memory/` — file-based, single-agent, no semantic search
- Plans in `docs/plans/` across repos — forgotten after completion
- Session handoff notes in `agent_sessions.handoff_notes` — JSON blobs, not searchable
- Research findings lost when context windows compress
When Charon discovers a scoring calibration bug, Virgil only knows about it if explicitly told. There's no shared knowledge graph.
## Concept
**OpenBrain** — "Open" means open protocol (MCP), not open source. All agents on the platform access the same knowledge graph via `brain_*` MCP tools. Data is stored *for agents* — structured for near-native context transfer between sessions and models.
## Data Model
### `brain_memories` table (MariaDB)
| Column | Type | Purpose |
|--------|------|---------|
| `id` | UUID | Primary key, also Qdrant point ID |
| `workspace_id` | FK | Multi-tenant isolation |
| `agent_id` | string | Who wrote it (virgil, charon, darbs, lem) |
| `type` | enum | `decision`, `observation`, `convention`, `research`, `plan`, `bug`, `architecture` |
| `content` | text | The knowledge (markdown) |
| `tags` | JSON | Topic tags for filtering |
| `project` | string nullable | Repo/project scope (null = cross-project) |
| `confidence` | float | 0.01.0, how certain the agent is |
| `supersedes_id` | UUID nullable | FK to older memory this replaces |
| `expires_at` | timestamp nullable | TTL for session-scoped context |
| `deleted_at` | timestamp nullable | Soft delete |
| `created_at` | timestamp | |
| `updated_at` | timestamp | |
### `openbrain` Qdrant collection
- **Vector dimension:** 768 (nomic-embed-text via Ollama)
- **Distance metric:** Cosine
- **Point ID:** MariaDB UUID
- **Payload:** `workspace_id`, `agent_id`, `type`, `tags`, `project`, `confidence`, `created_at` (for filtered search)
## MCP Tools
### `brain_remember` — Store a memory
```json
{
"content": "LEM emotional_register was blind to negative emotions. Fixed by adding 8 weighted pattern groups.",
"type": "bug",
"tags": ["scoring", "emotional-register", "lem"],
"project": "eaas",
"confidence": 0.95,
"supersedes": "uuid-of-outdated-memory"
}
```
Agent ID injected from MCP session context. Returns the new memory UUID.
**Pipeline:**
1. Validate input
2. Embed content via Ollama (`POST /api/embeddings`, model: `nomic-embed-text`)
3. Insert into MariaDB
4. Upsert into Qdrant with payload metadata
5. If `supersedes` set, soft-delete the old memory and remove from Qdrant
### `brain_recall` — Semantic search
```json
{
"query": "How does verdict classification work?",
"top_k": 5,
"filter": {
"project": "eaas",
"type": ["decision", "architecture"],
"min_confidence": 0.5
}
}
```
**Pipeline:**
1. Embed query via Ollama
2. Search Qdrant with vector + payload filters
3. Get top-K point IDs with similarity scores
4. Hydrate from MariaDB (content, tags, supersedes chain)
5. Return ranked results with scores
Only returns latest version of superseded memories (includes `supersedes_count` so agent knows history exists).
### `brain_forget` — Soft-delete or supersede
```json
{
"id": "uuid",
"reason": "Superseded by new calibration approach"
}
```
Sets `deleted_at` in MariaDB, removes point from Qdrant. Keeps audit trail.
### `brain_list` — Browse (no vectors)
```json
{
"project": "eaas",
"type": "decision",
"agent_id": "charon",
"limit": 20
}
```
Pure MariaDB query. For browsing, auditing, bulk export. No embedding needed.
## Architecture
### PHP side (`php-agentic`)
```
Mcp/Tools/Agent/Brain/
├── BrainRemember.php
├── BrainRecall.php
├── BrainForget.php
└── BrainList.php
Services/
└── BrainService.php # Ollama embeddings + Qdrant client + MariaDB CRUD
Models/
└── BrainMemory.php # Eloquent model
Migrations/
└── XXXX_create_brain_memories_table.php
```
`BrainService` handles:
- Ollama HTTP calls for embeddings
- Qdrant REST API (upsert, search, delete points)
- MariaDB CRUD via Eloquent
- Supersession chain management
### Go side (`go-ai`)
Thin bridge tools in the MCP server that proxy `brain_*` calls to Laravel via the existing WebSocket bridge. Same pattern as `ide_chat_send` / `ide_session_create`.
### Data flow
```
Agent (any Claude)
↓ MCP tool call
Go MCP server (local, macOS/Linux)
↓ WebSocket bridge
Laravel php-agentic (lthn.ai, de1)
↓ ↓
MariaDB Qdrant
(relational) (vectors)
Ollama (embeddings)
```
PHP-native agents skip the Go bridge — call `BrainService` directly.
### Infrastructure
- **Qdrant:** New container on de1. Shared between OpenBrain and EaaS scoring (different collections).
- **Ollama:** Existing instance. `nomic-embed-text` model for 768d embeddings. CPU is fine for the volume (~10K memories).
- **MariaDB:** Existing instance on de1. New table in the agentic database.
## Integration
### Plans → Brain
On plan completion, agents can extract key decisions/findings and `brain_remember` them. Optional — agents decide what's worth persisting. The plan itself stays in `agent_plans`; lessons learned go to the brain.
### Sessions → Brain
Handoff notes (summary, next_steps, blockers) can auto-persist as memories with `type: observation` and optional TTL. Agents can also manually remember during a session.
### MEMORY.md migration
Seed data: collect all `MEMORY.md` files from `~/.claude/projects/*/memory/` across worktrees. Parse into individual memories, embed, and load into OpenBrain. After migration, `brain_recall` replaces file-based memory.
### EaaS
Same Qdrant instance, different collection (`eaas_scoring` vs `openbrain`). Shared infrastructure, separate concerns.
### LEM
LEM models query the brain for project context during training data curation or benchmark analysis. Same MCP tools, different agent ID.
## What this replaces
- Virgil's `MEMORY.md` files (file-based, single-agent, no search)
- Scattered `docs/plans/` findings that get forgotten
- Manual "Charon found X" cross-agent handoffs
- Session-scoped knowledge that dies with context compression
## What this enables
- Any Claude picks up where another left off — semantically
- Decisions surface when related code is touched
- Knowledge graph grows with every session across all agents
- Near-native context transfer between models and sessions

File diff suppressed because it is too large Load diff

3
routes/api.php Normal file
View file

@ -0,0 +1,3 @@
<?php
// API routes are registered via Core modules

View file

@ -286,78 +286,6 @@ class AgentPhaseTest extends TestCase
$this->assertEquals($dep2->id, $blockers[0]['phase_id']);
}
public function test_check_dependencies_returns_empty_when_no_dependencies(): void
{
$phase = AgentPhase::factory()->pending()->create([
'agent_plan_id' => $this->plan->id,
'dependencies' => null,
]);
$this->assertSame([], $phase->checkDependencies());
}
public function test_check_dependencies_not_blocked_by_skipped_phase(): void
{
$dep = AgentPhase::factory()->skipped()->create([
'agent_plan_id' => $this->plan->id,
'order' => 1,
]);
$phase = AgentPhase::factory()->pending()->create([
'agent_plan_id' => $this->plan->id,
'order' => 2,
'dependencies' => [$dep->id],
]);
$this->assertSame([], $phase->checkDependencies());
$this->assertTrue($phase->canStart());
}
public function test_check_dependencies_uses_single_query_for_multiple_deps(): void
{
$deps = AgentPhase::factory()->pending()->count(5)->create([
'agent_plan_id' => $this->plan->id,
]);
$phase = AgentPhase::factory()->pending()->create([
'agent_plan_id' => $this->plan->id,
'dependencies' => $deps->pluck('id')->toArray(),
]);
$queryCount = 0;
\DB::listen(function () use (&$queryCount) {
$queryCount++;
});
$blockers = $phase->checkDependencies();
$this->assertCount(5, $blockers);
$this->assertSame(1, $queryCount, 'checkDependencies() should issue exactly one query');
}
public function test_check_dependencies_blocker_contains_expected_keys(): void
{
$dep = AgentPhase::factory()->inProgress()->create([
'agent_plan_id' => $this->plan->id,
'order' => 1,
'name' => 'Blocker Phase',
]);
$phase = AgentPhase::factory()->pending()->create([
'agent_plan_id' => $this->plan->id,
'order' => 2,
'dependencies' => [$dep->id],
]);
$blockers = $phase->checkDependencies();
$this->assertCount(1, $blockers);
$this->assertEquals($dep->id, $blockers[0]['phase_id']);
$this->assertEquals(1, $blockers[0]['phase_order']);
$this->assertEquals('Blocker Phase', $blockers[0]['phase_name']);
$this->assertEquals(AgentPhase::STATUS_IN_PROGRESS, $blockers[0]['status']);
}
public function test_can_start_checks_dependencies(): void
{
$dep = AgentPhase::factory()->pending()->create([

View file

@ -76,7 +76,7 @@ class AgentPlanTest extends TestCase
$fresh = $plan->fresh();
$this->assertEquals(AgentPlan::STATUS_ARCHIVED, $fresh->status);
$this->assertEquals('No longer needed', $fresh->metadata['archive_reason']);
$this->assertNotNull($fresh->archived_at);
$this->assertNotNull($fresh->metadata['archived_at']);
}
public function test_it_generates_unique_slugs(): void

Some files were not shown because too many files have changed in this diff Show more