Compare commits
80 commits
fix/verify
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
841fc7f2ca | ||
|
|
da152fdd37 | ||
|
|
9a6aebd128 | ||
|
|
c47c23406f | ||
|
|
29625c462b | ||
|
|
f15093843b | ||
|
|
2ce8a02ce6 | ||
|
|
b0ed221cfa | ||
|
|
01826bc5e9 | ||
|
|
b32d339a53 | ||
|
|
6ac515d80e | ||
|
|
08d397fbf6 | ||
|
|
b3cf2a4b7d | ||
|
|
e82d35c13d | ||
|
|
440ea340df | ||
|
|
6f0618692a | ||
|
|
8b8a9c26e5 | ||
|
|
8bc6e62f13 | ||
|
|
bad718da8d | ||
|
|
b75fa0ba57 | ||
|
|
8efd939ce4 | ||
|
|
1ef8157822 | ||
|
|
331796c1da | ||
|
|
ad0ee04b83 | ||
|
|
02cc11d2cf | ||
|
|
9623e1e0b5 | ||
|
|
9d49fc601b | ||
|
|
b6823538d5 | ||
|
|
20a0b584ae | ||
|
|
31e2aae980 | ||
|
|
c697a6657f | ||
|
|
f17e1a0b6c | ||
|
|
43b470257b | ||
|
|
dfd3dde7b1 | ||
|
|
d82ad2b9b1 | ||
|
|
2c6a095a0e | ||
|
|
eeb6927d8f | ||
|
|
627813cc4d | ||
|
|
daa11bab39 | ||
|
|
a6e4f865e4 | ||
|
|
1ead364afe | ||
|
|
647635fc6d | ||
|
|
522433c019 | ||
|
|
29656e3b92 | ||
| 1bbc1336b7 | |||
|
|
7fadbcb96c | ||
| 80c778cb08 | |||
| 0e7b617551 | |||
| ffc441f22a | |||
| a9a6e258e1 | |||
| d26250fc12 | |||
| 6a1709fca9 | |||
|
|
7d6081bdd7 | ||
| cc1c4c1adc | |||
| b0e2be2633 | |||
| a5da40a202 | |||
|
|
be820fead8 | ||
|
|
5f016c6275 | ||
|
|
ae4188c063 | ||
|
|
eb6bc27a4e | ||
| b86714db6e | |||
|
|
6cd9ca09d7 | ||
|
|
e47998bc15 | ||
|
|
938081f2f5 | ||
|
|
91ee71b8a1 | ||
| 5fa46104f4 | |||
| 6b7a7ade15 | |||
| 968cbcdd63 | |||
| f528f94d68 | |||
| 8ade82587d | |||
|
|
c315fc43c6 | ||
| ff34ede167 | |||
|
|
6748e6cd84 | ||
|
|
78bdebcaaa | ||
|
|
77e4ae6bad | ||
|
|
a352f697a9 | ||
|
|
909c2da6df | ||
|
|
fcdeace290 | ||
|
|
d88095780e | ||
|
|
f2f27ec766 |
119 changed files with 11828 additions and 881 deletions
|
|
@ -10,6 +10,8 @@ jobs:
|
|||
test:
|
||||
name: PHP ${{ matrix.php }}
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: lthn/build:php-${{ matrix.php }}
|
||||
|
||||
strategy:
|
||||
fail-fast: true
|
||||
|
|
@ -19,13 +21,6 @@ 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 }}
|
||||
|
|
|
|||
38
.forgejo/workflows/release.yml
Normal file
38
.forgejo/workflows/release.yml
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
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}"
|
||||
62
Actions/Brain/ForgetKnowledge.php
Normal file
62
Actions/Brain/ForgetKnowledge.php
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
<?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,
|
||||
];
|
||||
}
|
||||
}
|
||||
56
Actions/Brain/ListKnowledge.php
Normal file
56
Actions/Brain/ListKnowledge.php
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
<?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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
85
Actions/Brain/RecallKnowledge.php
Normal file
85
Actions/Brain/RecallKnowledge.php
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
<?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']),
|
||||
];
|
||||
}
|
||||
}
|
||||
94
Actions/Brain/RememberKnowledge.php
Normal file
94
Actions/Brain/RememberKnowledge.php
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
<?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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
40
Actions/Forge/AssignAgent.php
Normal file
40
Actions/Forge/AssignAgent.php
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
<?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);
|
||||
}
|
||||
}
|
||||
102
Actions/Forge/CreatePlanFromIssue.php
Normal file
102
Actions/Forge/CreatePlanFromIssue.php
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
<?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;
|
||||
}
|
||||
}
|
||||
59
Actions/Forge/ManagePullRequest.php
Normal file
59
Actions/Forge/ManagePullRequest.php
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
* Core PHP Framework
|
||||
*
|
||||
* Licensed under the European Union Public Licence (EUPL) v1.2.
|
||||
* See LICENSE file for details.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Agentic\Actions\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];
|
||||
}
|
||||
}
|
||||
34
Actions/Forge/ReportToIssue.php
Normal file
34
Actions/Forge/ReportToIssue.php
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
<?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);
|
||||
}
|
||||
}
|
||||
145
Actions/Forge/ScanForWork.php
Normal file
145
Actions/Forge/ScanForWork.php
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
<?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);
|
||||
}
|
||||
}
|
||||
69
Actions/Phase/AddCheckpoint.php
Normal file
69
Actions/Phase/AddCheckpoint.php
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
* Core PHP Framework
|
||||
*
|
||||
* Licensed under the European Union Public Licence (EUPL) v1.2.
|
||||
* See LICENSE file for details.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Agentic\Actions\Phase;
|
||||
|
||||
use Core\Actions\Action;
|
||||
use Core\Mod\Agentic\Models\AgentPhase;
|
||||
use Core\Mod\Agentic\Models\AgentPlan;
|
||||
|
||||
/**
|
||||
* Add a checkpoint note to a phase.
|
||||
*
|
||||
* Checkpoints record milestones, decisions, and progress notes
|
||||
* within a phase's metadata for later review.
|
||||
*
|
||||
* Usage:
|
||||
* $phase = AddCheckpoint::run('deploy-v2', '1', 'Tests passing', 1);
|
||||
*/
|
||||
class AddCheckpoint
|
||||
{
|
||||
use Action;
|
||||
|
||||
/**
|
||||
* @throws \InvalidArgumentException
|
||||
*/
|
||||
public function handle(string $planSlug, string|int $phase, string $note, int $workspaceId, array $context = []): AgentPhase
|
||||
{
|
||||
if ($note === '') {
|
||||
throw new \InvalidArgumentException('note is required');
|
||||
}
|
||||
|
||||
$plan = AgentPlan::forWorkspace($workspaceId)
|
||||
->where('slug', $planSlug)
|
||||
->first();
|
||||
|
||||
if (! $plan) {
|
||||
throw new \InvalidArgumentException("Plan not found: {$planSlug}");
|
||||
}
|
||||
|
||||
$resolved = $this->resolvePhase($plan, $phase);
|
||||
|
||||
if (! $resolved) {
|
||||
throw new \InvalidArgumentException("Phase not found: {$phase}");
|
||||
}
|
||||
|
||||
$resolved->addCheckpoint($note, $context);
|
||||
|
||||
return $resolved->fresh();
|
||||
}
|
||||
|
||||
private function resolvePhase(AgentPlan $plan, string|int $identifier): ?AgentPhase
|
||||
{
|
||||
if (is_numeric($identifier)) {
|
||||
return $plan->agentPhases()->where('order', (int) $identifier)->first();
|
||||
}
|
||||
|
||||
return $plan->agentPhases()
|
||||
->where('name', $identifier)
|
||||
->first();
|
||||
}
|
||||
}
|
||||
66
Actions/Phase/GetPhase.php
Normal file
66
Actions/Phase/GetPhase.php
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
* Core PHP Framework
|
||||
*
|
||||
* Licensed under the European Union Public Licence (EUPL) v1.2.
|
||||
* See LICENSE file for details.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Agentic\Actions\Phase;
|
||||
|
||||
use Core\Actions\Action;
|
||||
use Core\Mod\Agentic\Models\AgentPhase;
|
||||
use Core\Mod\Agentic\Models\AgentPlan;
|
||||
|
||||
/**
|
||||
* Get details of a specific phase within a plan.
|
||||
*
|
||||
* Resolves the phase by order number or name.
|
||||
*
|
||||
* Usage:
|
||||
* $phase = GetPhase::run('deploy-v2', '1', 1);
|
||||
* $phase = GetPhase::run('deploy-v2', 'Build', 1);
|
||||
*/
|
||||
class GetPhase
|
||||
{
|
||||
use Action;
|
||||
|
||||
/**
|
||||
* @throws \InvalidArgumentException
|
||||
*/
|
||||
public function handle(string $planSlug, string|int $phase, int $workspaceId): AgentPhase
|
||||
{
|
||||
$plan = AgentPlan::forWorkspace($workspaceId)
|
||||
->where('slug', $planSlug)
|
||||
->first();
|
||||
|
||||
if (! $plan) {
|
||||
throw new \InvalidArgumentException("Plan not found: {$planSlug}");
|
||||
}
|
||||
|
||||
$resolved = $this->resolvePhase($plan, $phase);
|
||||
|
||||
if (! $resolved) {
|
||||
throw new \InvalidArgumentException("Phase not found: {$phase}");
|
||||
}
|
||||
|
||||
return $resolved;
|
||||
}
|
||||
|
||||
private function resolvePhase(AgentPlan $plan, string|int $identifier): ?AgentPhase
|
||||
{
|
||||
if (is_numeric($identifier)) {
|
||||
return $plan->agentPhases()->where('order', (int) $identifier)->first();
|
||||
}
|
||||
|
||||
return $plan->agentPhases()
|
||||
->where(function ($query) use ($identifier) {
|
||||
$query->where('name', $identifier)
|
||||
->orWhere('order', $identifier);
|
||||
})
|
||||
->first();
|
||||
}
|
||||
}
|
||||
79
Actions/Phase/UpdatePhaseStatus.php
Normal file
79
Actions/Phase/UpdatePhaseStatus.php
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
* Core PHP Framework
|
||||
*
|
||||
* Licensed under the European Union Public Licence (EUPL) v1.2.
|
||||
* See LICENSE file for details.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Agentic\Actions\Phase;
|
||||
|
||||
use Core\Actions\Action;
|
||||
use Core\Mod\Agentic\Models\AgentPhase;
|
||||
use Core\Mod\Agentic\Models\AgentPlan;
|
||||
|
||||
/**
|
||||
* Update the status of a phase within a plan.
|
||||
*
|
||||
* Optionally adds a checkpoint note when the status changes.
|
||||
*
|
||||
* Usage:
|
||||
* $phase = UpdatePhaseStatus::run('deploy-v2', '1', 'in_progress', 1);
|
||||
* $phase = UpdatePhaseStatus::run('deploy-v2', 'Build', 'completed', 1, 'All tests pass');
|
||||
*/
|
||||
class UpdatePhaseStatus
|
||||
{
|
||||
use Action;
|
||||
|
||||
/**
|
||||
* @throws \InvalidArgumentException
|
||||
*/
|
||||
public function handle(string $planSlug, string|int $phase, string $status, int $workspaceId, ?string $notes = null): AgentPhase
|
||||
{
|
||||
$valid = ['pending', 'in_progress', 'completed', 'blocked', 'skipped'];
|
||||
if (! in_array($status, $valid, true)) {
|
||||
throw new \InvalidArgumentException(
|
||||
sprintf('status must be one of: %s', implode(', ', $valid))
|
||||
);
|
||||
}
|
||||
|
||||
$plan = AgentPlan::forWorkspace($workspaceId)
|
||||
->where('slug', $planSlug)
|
||||
->first();
|
||||
|
||||
if (! $plan) {
|
||||
throw new \InvalidArgumentException("Plan not found: {$planSlug}");
|
||||
}
|
||||
|
||||
$resolved = $this->resolvePhase($plan, $phase);
|
||||
|
||||
if (! $resolved) {
|
||||
throw new \InvalidArgumentException("Phase not found: {$phase}");
|
||||
}
|
||||
|
||||
if ($notes !== null && $notes !== '') {
|
||||
$resolved->addCheckpoint($notes, ['status_change' => $status]);
|
||||
}
|
||||
|
||||
$resolved->update(['status' => $status]);
|
||||
|
||||
return $resolved->fresh();
|
||||
}
|
||||
|
||||
private function resolvePhase(AgentPlan $plan, string|int $identifier): ?AgentPhase
|
||||
{
|
||||
if (is_numeric($identifier)) {
|
||||
return $plan->agentPhases()->where('order', (int) $identifier)->first();
|
||||
}
|
||||
|
||||
return $plan->agentPhases()
|
||||
->where(function ($query) use ($identifier) {
|
||||
$query->where('name', $identifier)
|
||||
->orWhere('order', $identifier);
|
||||
})
|
||||
->first();
|
||||
}
|
||||
}
|
||||
51
Actions/Plan/ArchivePlan.php
Normal file
51
Actions/Plan/ArchivePlan.php
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
* Core PHP Framework
|
||||
*
|
||||
* Licensed under the European Union Public Licence (EUPL) v1.2.
|
||||
* See LICENSE file for details.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Agentic\Actions\Plan;
|
||||
|
||||
use Core\Actions\Action;
|
||||
use Core\Mod\Agentic\Models\AgentPlan;
|
||||
|
||||
/**
|
||||
* Archive a completed or abandoned plan.
|
||||
*
|
||||
* Sets the plan status to archived with an optional reason.
|
||||
* Scoped to workspace for tenant isolation.
|
||||
*
|
||||
* Usage:
|
||||
* $plan = ArchivePlan::run('deploy-v2', 1, 'Superseded by v3');
|
||||
*/
|
||||
class ArchivePlan
|
||||
{
|
||||
use Action;
|
||||
|
||||
/**
|
||||
* @throws \InvalidArgumentException
|
||||
*/
|
||||
public function handle(string $slug, int $workspaceId, ?string $reason = null): AgentPlan
|
||||
{
|
||||
if ($slug === '') {
|
||||
throw new \InvalidArgumentException('slug is required');
|
||||
}
|
||||
|
||||
$plan = AgentPlan::forWorkspace($workspaceId)
|
||||
->where('slug', $slug)
|
||||
->first();
|
||||
|
||||
if (! $plan) {
|
||||
throw new \InvalidArgumentException("Plan not found: {$slug}");
|
||||
}
|
||||
|
||||
$plan->archive($reason);
|
||||
|
||||
return $plan->fresh();
|
||||
}
|
||||
}
|
||||
89
Actions/Plan/CreatePlan.php
Normal file
89
Actions/Plan/CreatePlan.php
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
* Core PHP Framework
|
||||
*
|
||||
* Licensed under the European Union Public Licence (EUPL) v1.2.
|
||||
* See LICENSE file for details.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Agentic\Actions\Plan;
|
||||
|
||||
use Core\Actions\Action;
|
||||
use Core\Mod\Agentic\Models\AgentPhase;
|
||||
use Core\Mod\Agentic\Models\AgentPlan;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* Create a new work plan with phases and tasks.
|
||||
*
|
||||
* Validates input, generates a unique slug, creates the plan
|
||||
* and any associated phases with their tasks.
|
||||
*
|
||||
* Usage:
|
||||
* $plan = CreatePlan::run([
|
||||
* 'title' => 'Deploy v2',
|
||||
* 'phases' => [['name' => 'Build', 'tasks' => ['compile', 'test']]],
|
||||
* ], 1);
|
||||
*/
|
||||
class CreatePlan
|
||||
{
|
||||
use Action;
|
||||
|
||||
/**
|
||||
* @param array{title: string, slug?: string, description?: string, context?: array, phases?: array} $data
|
||||
*
|
||||
* @throws \InvalidArgumentException
|
||||
*/
|
||||
public function handle(array $data, int $workspaceId): AgentPlan
|
||||
{
|
||||
$title = $data['title'] ?? null;
|
||||
if (! is_string($title) || $title === '' || mb_strlen($title) > 255) {
|
||||
throw new \InvalidArgumentException('title is required and must be a non-empty string (max 255 characters)');
|
||||
}
|
||||
|
||||
$slug = $data['slug'] ?? null;
|
||||
if ($slug !== null) {
|
||||
if (! is_string($slug) || mb_strlen($slug) > 255) {
|
||||
throw new \InvalidArgumentException('slug must be a string (max 255 characters)');
|
||||
}
|
||||
} else {
|
||||
$slug = Str::slug($title).'-'.Str::random(6);
|
||||
}
|
||||
|
||||
if (AgentPlan::where('slug', $slug)->exists()) {
|
||||
throw new \InvalidArgumentException("Plan with slug '{$slug}' already exists");
|
||||
}
|
||||
|
||||
$plan = AgentPlan::create([
|
||||
'slug' => $slug,
|
||||
'title' => $title,
|
||||
'description' => $data['description'] ?? null,
|
||||
'status' => AgentPlan::STATUS_DRAFT,
|
||||
'context' => $data['context'] ?? [],
|
||||
'workspace_id' => $workspaceId,
|
||||
]);
|
||||
|
||||
if (! empty($data['phases'])) {
|
||||
foreach ($data['phases'] as $order => $phaseData) {
|
||||
$tasks = collect($phaseData['tasks'] ?? [])->map(fn ($task) => [
|
||||
'name' => $task,
|
||||
'status' => 'pending',
|
||||
])->all();
|
||||
|
||||
AgentPhase::create([
|
||||
'agent_plan_id' => $plan->id,
|
||||
'name' => $phaseData['name'] ?? 'Phase '.($order + 1),
|
||||
'description' => $phaseData['description'] ?? null,
|
||||
'order' => $order + 1,
|
||||
'status' => AgentPhase::STATUS_PENDING,
|
||||
'tasks' => $tasks,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return $plan->load('agentPhases');
|
||||
}
|
||||
}
|
||||
50
Actions/Plan/GetPlan.php
Normal file
50
Actions/Plan/GetPlan.php
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
* Core PHP Framework
|
||||
*
|
||||
* Licensed under the European Union Public Licence (EUPL) v1.2.
|
||||
* See LICENSE file for details.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Agentic\Actions\Plan;
|
||||
|
||||
use Core\Actions\Action;
|
||||
use Core\Mod\Agentic\Models\AgentPlan;
|
||||
|
||||
/**
|
||||
* Get detailed information about a specific plan.
|
||||
*
|
||||
* Returns the plan with all phases, progress, and context data.
|
||||
* Scoped to workspace for tenant isolation.
|
||||
*
|
||||
* Usage:
|
||||
* $plan = GetPlan::run('deploy-v2', 1);
|
||||
*/
|
||||
class GetPlan
|
||||
{
|
||||
use Action;
|
||||
|
||||
/**
|
||||
* @throws \InvalidArgumentException
|
||||
*/
|
||||
public function handle(string $slug, int $workspaceId): AgentPlan
|
||||
{
|
||||
if ($slug === '') {
|
||||
throw new \InvalidArgumentException('slug is required');
|
||||
}
|
||||
|
||||
$plan = AgentPlan::with('agentPhases')
|
||||
->forWorkspace($workspaceId)
|
||||
->where('slug', $slug)
|
||||
->first();
|
||||
|
||||
if (! $plan) {
|
||||
throw new \InvalidArgumentException("Plan not found: {$slug}");
|
||||
}
|
||||
|
||||
return $plan;
|
||||
}
|
||||
}
|
||||
59
Actions/Plan/ListPlans.php
Normal file
59
Actions/Plan/ListPlans.php
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
* Core PHP Framework
|
||||
*
|
||||
* Licensed under the European Union Public Licence (EUPL) v1.2.
|
||||
* See LICENSE file for details.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Agentic\Actions\Plan;
|
||||
|
||||
use Core\Actions\Action;
|
||||
use Core\Mod\Agentic\Models\AgentPlan;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
/**
|
||||
* List work plans for a workspace with optional filtering.
|
||||
*
|
||||
* Returns plans ordered by most recently updated, with progress data.
|
||||
*
|
||||
* Usage:
|
||||
* $plans = ListPlans::run(1);
|
||||
* $plans = ListPlans::run(1, 'active');
|
||||
*/
|
||||
class ListPlans
|
||||
{
|
||||
use Action;
|
||||
|
||||
/**
|
||||
* @return Collection<int, AgentPlan>
|
||||
*/
|
||||
public function handle(int $workspaceId, ?string $status = null, bool $includeArchived = false): Collection
|
||||
{
|
||||
if ($status !== null) {
|
||||
$valid = ['draft', 'active', 'paused', 'completed', 'archived'];
|
||||
if (! in_array($status, $valid, true)) {
|
||||
throw new \InvalidArgumentException(
|
||||
sprintf('status must be one of: %s', implode(', ', $valid))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
$query = AgentPlan::with('agentPhases')
|
||||
->forWorkspace($workspaceId)
|
||||
->orderBy('updated_at', 'desc');
|
||||
|
||||
if (! $includeArchived && $status !== 'archived') {
|
||||
$query->notArchived();
|
||||
}
|
||||
|
||||
if ($status !== null) {
|
||||
$query->where('status', $status);
|
||||
}
|
||||
|
||||
return $query->get();
|
||||
}
|
||||
}
|
||||
54
Actions/Plan/UpdatePlanStatus.php
Normal file
54
Actions/Plan/UpdatePlanStatus.php
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
* Core PHP Framework
|
||||
*
|
||||
* Licensed under the European Union Public Licence (EUPL) v1.2.
|
||||
* See LICENSE file for details.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Agentic\Actions\Plan;
|
||||
|
||||
use Core\Actions\Action;
|
||||
use Core\Mod\Agentic\Models\AgentPlan;
|
||||
|
||||
/**
|
||||
* Update the status of a plan.
|
||||
*
|
||||
* Validates the transition and updates the plan status.
|
||||
* Scoped to workspace for tenant isolation.
|
||||
*
|
||||
* Usage:
|
||||
* $plan = UpdatePlanStatus::run('deploy-v2', 'active', 1);
|
||||
*/
|
||||
class UpdatePlanStatus
|
||||
{
|
||||
use Action;
|
||||
|
||||
/**
|
||||
* @throws \InvalidArgumentException
|
||||
*/
|
||||
public function handle(string $slug, string $status, int $workspaceId): AgentPlan
|
||||
{
|
||||
$valid = ['draft', 'active', 'paused', 'completed'];
|
||||
if (! in_array($status, $valid, true)) {
|
||||
throw new \InvalidArgumentException(
|
||||
sprintf('status must be one of: %s', implode(', ', $valid))
|
||||
);
|
||||
}
|
||||
|
||||
$plan = AgentPlan::forWorkspace($workspaceId)
|
||||
->where('slug', $slug)
|
||||
->first();
|
||||
|
||||
if (! $plan) {
|
||||
throw new \InvalidArgumentException("Plan not found: {$slug}");
|
||||
}
|
||||
|
||||
$plan->update(['status' => $status]);
|
||||
|
||||
return $plan->fresh();
|
||||
}
|
||||
}
|
||||
56
Actions/Session/ContinueSession.php
Normal file
56
Actions/Session/ContinueSession.php
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
* Core PHP Framework
|
||||
*
|
||||
* Licensed under the European Union Public Licence (EUPL) v1.2.
|
||||
* See LICENSE file for details.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Agentic\Actions\Session;
|
||||
|
||||
use Core\Actions\Action;
|
||||
use Core\Mod\Agentic\Models\AgentSession;
|
||||
use Core\Mod\Agentic\Services\AgentSessionService;
|
||||
|
||||
/**
|
||||
* Continue from a previous session (multi-agent handoff).
|
||||
*
|
||||
* Creates a new session with context inherited from the previous one
|
||||
* and marks the previous session as handed off.
|
||||
*
|
||||
* Usage:
|
||||
* $session = ContinueSession::run('ses_abc123', 'opus');
|
||||
*/
|
||||
class ContinueSession
|
||||
{
|
||||
use Action;
|
||||
|
||||
public function __construct(
|
||||
private AgentSessionService $sessionService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @throws \InvalidArgumentException
|
||||
*/
|
||||
public function handle(string $previousSessionId, string $agentType): AgentSession
|
||||
{
|
||||
if ($previousSessionId === '') {
|
||||
throw new \InvalidArgumentException('previous_session_id is required');
|
||||
}
|
||||
|
||||
if ($agentType === '') {
|
||||
throw new \InvalidArgumentException('agent_type is required');
|
||||
}
|
||||
|
||||
$session = $this->sessionService->continueFrom($previousSessionId, $agentType);
|
||||
|
||||
if (! $session) {
|
||||
throw new \InvalidArgumentException("Previous session not found: {$previousSessionId}");
|
||||
}
|
||||
|
||||
return $session;
|
||||
}
|
||||
}
|
||||
56
Actions/Session/EndSession.php
Normal file
56
Actions/Session/EndSession.php
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
* Core PHP Framework
|
||||
*
|
||||
* Licensed under the European Union Public Licence (EUPL) v1.2.
|
||||
* See LICENSE file for details.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Agentic\Actions\Session;
|
||||
|
||||
use Core\Actions\Action;
|
||||
use Core\Mod\Agentic\Models\AgentSession;
|
||||
use Core\Mod\Agentic\Services\AgentSessionService;
|
||||
|
||||
/**
|
||||
* End an agent session with a final status and optional summary.
|
||||
*
|
||||
* Usage:
|
||||
* $session = EndSession::run('ses_abc123', 'completed', 'All phases done');
|
||||
*/
|
||||
class EndSession
|
||||
{
|
||||
use Action;
|
||||
|
||||
public function __construct(
|
||||
private AgentSessionService $sessionService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @throws \InvalidArgumentException
|
||||
*/
|
||||
public function handle(string $sessionId, string $status, ?string $summary = null): AgentSession
|
||||
{
|
||||
if ($sessionId === '') {
|
||||
throw new \InvalidArgumentException('session_id is required');
|
||||
}
|
||||
|
||||
$valid = ['completed', 'handed_off', 'paused', 'failed'];
|
||||
if (! in_array($status, $valid, true)) {
|
||||
throw new \InvalidArgumentException(
|
||||
sprintf('status must be one of: %s', implode(', ', $valid))
|
||||
);
|
||||
}
|
||||
|
||||
$session = $this->sessionService->end($sessionId, $status, $summary);
|
||||
|
||||
if (! $session) {
|
||||
throw new \InvalidArgumentException("Session not found: {$sessionId}");
|
||||
}
|
||||
|
||||
return $session;
|
||||
}
|
||||
}
|
||||
49
Actions/Session/GetSession.php
Normal file
49
Actions/Session/GetSession.php
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
* Core PHP Framework
|
||||
*
|
||||
* Licensed under the European Union Public Licence (EUPL) v1.2.
|
||||
* See LICENSE file for details.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Agentic\Actions\Session;
|
||||
|
||||
use Core\Actions\Action;
|
||||
use Core\Mod\Agentic\Models\AgentSession;
|
||||
|
||||
/**
|
||||
* Get detailed information about a specific session.
|
||||
*
|
||||
* Returns the session with plan context, scoped to workspace.
|
||||
*
|
||||
* Usage:
|
||||
* $session = GetSession::run('ses_abc123', 1);
|
||||
*/
|
||||
class GetSession
|
||||
{
|
||||
use Action;
|
||||
|
||||
/**
|
||||
* @throws \InvalidArgumentException
|
||||
*/
|
||||
public function handle(string $sessionId, int $workspaceId): AgentSession
|
||||
{
|
||||
if ($sessionId === '') {
|
||||
throw new \InvalidArgumentException('session_id is required');
|
||||
}
|
||||
|
||||
$session = AgentSession::with('plan')
|
||||
->where('session_id', $sessionId)
|
||||
->where('workspace_id', $workspaceId)
|
||||
->first();
|
||||
|
||||
if (! $session) {
|
||||
throw new \InvalidArgumentException("Session not found: {$sessionId}");
|
||||
}
|
||||
|
||||
return $session;
|
||||
}
|
||||
}
|
||||
68
Actions/Session/ListSessions.php
Normal file
68
Actions/Session/ListSessions.php
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
* Core PHP Framework
|
||||
*
|
||||
* Licensed under the European Union Public Licence (EUPL) v1.2.
|
||||
* See LICENSE file for details.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Agentic\Actions\Session;
|
||||
|
||||
use Core\Actions\Action;
|
||||
use Core\Mod\Agentic\Models\AgentSession;
|
||||
use Core\Mod\Agentic\Services\AgentSessionService;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
/**
|
||||
* List sessions for a workspace, with optional filtering.
|
||||
*
|
||||
* Usage:
|
||||
* $sessions = ListSessions::run(1);
|
||||
* $sessions = ListSessions::run(1, 'active', 'deploy-v2', 20);
|
||||
*/
|
||||
class ListSessions
|
||||
{
|
||||
use Action;
|
||||
|
||||
public function __construct(
|
||||
private AgentSessionService $sessionService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return Collection<int, AgentSession>
|
||||
*/
|
||||
public function handle(int $workspaceId, ?string $status = null, ?string $planSlug = null, ?int $limit = null): Collection
|
||||
{
|
||||
if ($status !== null) {
|
||||
$valid = ['active', 'paused', 'completed', 'failed'];
|
||||
if (! in_array($status, $valid, true)) {
|
||||
throw new \InvalidArgumentException(
|
||||
sprintf('status must be one of: %s', implode(', ', $valid))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Active sessions use the optimised service method
|
||||
if ($status === 'active' || $status === null) {
|
||||
return $this->sessionService->getActiveSessions($workspaceId);
|
||||
}
|
||||
|
||||
$query = AgentSession::query()
|
||||
->where('workspace_id', $workspaceId)
|
||||
->where('status', $status)
|
||||
->orderBy('last_active_at', 'desc');
|
||||
|
||||
if ($planSlug !== null) {
|
||||
$query->whereHas('plan', fn ($q) => $q->where('slug', $planSlug));
|
||||
}
|
||||
|
||||
if ($limit !== null && $limit > 0) {
|
||||
$query->limit(min($limit, 1000));
|
||||
}
|
||||
|
||||
return $query->get();
|
||||
}
|
||||
}
|
||||
56
Actions/Session/StartSession.php
Normal file
56
Actions/Session/StartSession.php
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
* Core PHP Framework
|
||||
*
|
||||
* Licensed under the European Union Public Licence (EUPL) v1.2.
|
||||
* See LICENSE file for details.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Agentic\Actions\Session;
|
||||
|
||||
use Core\Actions\Action;
|
||||
use Core\Mod\Agentic\Models\AgentPlan;
|
||||
use Core\Mod\Agentic\Models\AgentSession;
|
||||
use Core\Mod\Agentic\Services\AgentSessionService;
|
||||
|
||||
/**
|
||||
* Start a new agent session, optionally linked to a plan.
|
||||
*
|
||||
* Creates an active session and caches it for fast lookup.
|
||||
* Workspace can be provided directly or inferred from the plan.
|
||||
*
|
||||
* Usage:
|
||||
* $session = StartSession::run('opus', null, 1);
|
||||
* $session = StartSession::run('sonnet', 'deploy-v2', 1, ['goal' => 'testing']);
|
||||
*/
|
||||
class StartSession
|
||||
{
|
||||
use Action;
|
||||
|
||||
public function __construct(
|
||||
private AgentSessionService $sessionService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @throws \InvalidArgumentException
|
||||
*/
|
||||
public function handle(string $agentType, ?string $planSlug, int $workspaceId, array $context = []): AgentSession
|
||||
{
|
||||
if ($agentType === '') {
|
||||
throw new \InvalidArgumentException('agent_type is required');
|
||||
}
|
||||
|
||||
$plan = null;
|
||||
if ($planSlug !== null && $planSlug !== '') {
|
||||
$plan = AgentPlan::where('slug', $planSlug)->first();
|
||||
if (! $plan) {
|
||||
throw new \InvalidArgumentException("Plan not found: {$planSlug}");
|
||||
}
|
||||
}
|
||||
|
||||
return $this->sessionService->start($agentType, $plan, $workspaceId, $context);
|
||||
}
|
||||
}
|
||||
90
Actions/Task/ToggleTask.php
Normal file
90
Actions/Task/ToggleTask.php
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
* Core PHP Framework
|
||||
*
|
||||
* Licensed under the European Union Public Licence (EUPL) v1.2.
|
||||
* See LICENSE file for details.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Agentic\Actions\Task;
|
||||
|
||||
use Core\Actions\Action;
|
||||
use Core\Mod\Agentic\Models\AgentPhase;
|
||||
use Core\Mod\Agentic\Models\AgentPlan;
|
||||
|
||||
/**
|
||||
* Toggle a task's completion status (pending <-> completed).
|
||||
*
|
||||
* Quick convenience method for marking tasks done or undone.
|
||||
*
|
||||
* Usage:
|
||||
* $result = ToggleTask::run('deploy-v2', '1', 0, 1);
|
||||
*/
|
||||
class ToggleTask
|
||||
{
|
||||
use Action;
|
||||
|
||||
/**
|
||||
* @return array{task: array, plan_progress: array}
|
||||
*
|
||||
* @throws \InvalidArgumentException
|
||||
*/
|
||||
public function handle(string $planSlug, string|int $phase, int $taskIndex, int $workspaceId): array
|
||||
{
|
||||
$plan = AgentPlan::forWorkspace($workspaceId)
|
||||
->where('slug', $planSlug)
|
||||
->first();
|
||||
|
||||
if (! $plan) {
|
||||
throw new \InvalidArgumentException("Plan not found: {$planSlug}");
|
||||
}
|
||||
|
||||
$resolved = $this->resolvePhase($plan, $phase);
|
||||
|
||||
if (! $resolved) {
|
||||
throw new \InvalidArgumentException("Phase not found: {$phase}");
|
||||
}
|
||||
|
||||
$tasks = $resolved->tasks ?? [];
|
||||
|
||||
if (! isset($tasks[$taskIndex])) {
|
||||
throw new \InvalidArgumentException("Task not found at index: {$taskIndex}");
|
||||
}
|
||||
|
||||
$currentStatus = is_string($tasks[$taskIndex])
|
||||
? 'pending'
|
||||
: ($tasks[$taskIndex]['status'] ?? 'pending');
|
||||
|
||||
$newStatus = $currentStatus === 'completed' ? 'pending' : 'completed';
|
||||
|
||||
if (is_string($tasks[$taskIndex])) {
|
||||
$tasks[$taskIndex] = [
|
||||
'name' => $tasks[$taskIndex],
|
||||
'status' => $newStatus,
|
||||
];
|
||||
} else {
|
||||
$tasks[$taskIndex]['status'] = $newStatus;
|
||||
}
|
||||
|
||||
$resolved->update(['tasks' => $tasks]);
|
||||
|
||||
return [
|
||||
'task' => $tasks[$taskIndex],
|
||||
'plan_progress' => $plan->fresh()->getProgress(),
|
||||
];
|
||||
}
|
||||
|
||||
private function resolvePhase(AgentPlan $plan, string|int $identifier): ?AgentPhase
|
||||
{
|
||||
if (is_numeric($identifier)) {
|
||||
return $plan->agentPhases()->where('order', (int) $identifier)->first();
|
||||
}
|
||||
|
||||
return $plan->agentPhases()
|
||||
->where('name', $identifier)
|
||||
->first();
|
||||
}
|
||||
}
|
||||
101
Actions/Task/UpdateTask.php
Normal file
101
Actions/Task/UpdateTask.php
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
* Core PHP Framework
|
||||
*
|
||||
* Licensed under the European Union Public Licence (EUPL) v1.2.
|
||||
* See LICENSE file for details.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Agentic\Actions\Task;
|
||||
|
||||
use Core\Actions\Action;
|
||||
use Core\Mod\Agentic\Models\AgentPhase;
|
||||
use Core\Mod\Agentic\Models\AgentPlan;
|
||||
|
||||
/**
|
||||
* Update a task's status or notes within a phase.
|
||||
*
|
||||
* Tasks are stored as a JSON array on the phase model.
|
||||
* Handles legacy string-format tasks by normalising to {name, status}.
|
||||
*
|
||||
* Usage:
|
||||
* $task = UpdateTask::run('deploy-v2', '1', 0, 1, 'in_progress', 'Started build');
|
||||
*/
|
||||
class UpdateTask
|
||||
{
|
||||
use Action;
|
||||
|
||||
/**
|
||||
* @return array{task: array, plan_progress: array}
|
||||
*
|
||||
* @throws \InvalidArgumentException
|
||||
*/
|
||||
public function handle(string $planSlug, string|int $phase, int $taskIndex, int $workspaceId, ?string $status = null, ?string $notes = null): array
|
||||
{
|
||||
if ($status !== null) {
|
||||
$valid = ['pending', 'in_progress', 'completed', 'blocked', 'skipped'];
|
||||
if (! in_array($status, $valid, true)) {
|
||||
throw new \InvalidArgumentException(
|
||||
sprintf('status must be one of: %s', implode(', ', $valid))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
$plan = AgentPlan::forWorkspace($workspaceId)
|
||||
->where('slug', $planSlug)
|
||||
->first();
|
||||
|
||||
if (! $plan) {
|
||||
throw new \InvalidArgumentException("Plan not found: {$planSlug}");
|
||||
}
|
||||
|
||||
$resolved = $this->resolvePhase($plan, $phase);
|
||||
|
||||
if (! $resolved) {
|
||||
throw new \InvalidArgumentException("Phase not found: {$phase}");
|
||||
}
|
||||
|
||||
$tasks = $resolved->tasks ?? [];
|
||||
|
||||
if (! isset($tasks[$taskIndex])) {
|
||||
throw new \InvalidArgumentException("Task not found at index: {$taskIndex}");
|
||||
}
|
||||
|
||||
// Normalise legacy string-format tasks
|
||||
if (is_string($tasks[$taskIndex])) {
|
||||
$tasks[$taskIndex] = ['name' => $tasks[$taskIndex], 'status' => 'pending'];
|
||||
}
|
||||
|
||||
if ($status !== null) {
|
||||
$tasks[$taskIndex]['status'] = $status;
|
||||
}
|
||||
|
||||
if ($notes !== null) {
|
||||
$tasks[$taskIndex]['notes'] = $notes;
|
||||
}
|
||||
|
||||
$resolved->update(['tasks' => $tasks]);
|
||||
|
||||
return [
|
||||
'task' => $tasks[$taskIndex],
|
||||
'plan_progress' => $plan->fresh()->getProgress(),
|
||||
];
|
||||
}
|
||||
|
||||
private function resolvePhase(AgentPlan $plan, string|int $identifier): ?AgentPhase
|
||||
{
|
||||
if (is_numeric($identifier)) {
|
||||
return $plan->agentPhases()->where('order', (int) $identifier)->first();
|
||||
}
|
||||
|
||||
return $plan->agentPhases()
|
||||
->where(function ($query) use ($identifier) {
|
||||
$query->where('name', $identifier)
|
||||
->orWhere('order', $identifier);
|
||||
})
|
||||
->first();
|
||||
}
|
||||
}
|
||||
110
Boot.php
110
Boot.php
|
|
@ -5,9 +5,11 @@ 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;
|
||||
|
|
@ -23,6 +25,7 @@ class Boot extends ServiceProvider
|
|||
*/
|
||||
public static array $listens = [
|
||||
AdminPanelBooting::class => 'onAdminPanel',
|
||||
ApiRoutesRegistering::class => 'onApiRoutes',
|
||||
ConsoleBooting::class => 'onConsole',
|
||||
McpToolsRegistering::class => 'onMcpTools',
|
||||
];
|
||||
|
|
@ -32,6 +35,25 @@ 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();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -53,13 +75,71 @@ 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.
|
||||
*/
|
||||
|
|
@ -87,14 +167,32 @@ 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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -102,11 +200,17 @@ 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.
|
||||
* This method is available for Agentic-specific MCP tools if needed in future.
|
||||
* Brain tools are registered here as they belong to the Agentic module.
|
||||
*/
|
||||
public function onMcpTools(McpToolsRegistering $event): void
|
||||
{
|
||||
// Agent tools are registered in Mcp module via AgentToolRegistry
|
||||
// No additional MCP tools needed from Agentic module at this time
|
||||
$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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
699
Console/Commands/BrainIngestCommand.php
Normal file
699
Console/Commands/BrainIngestCommand.php
Normal file
|
|
@ -0,0 +1,699 @@
|
|||
<?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;
|
||||
}
|
||||
}
|
||||
230
Console/Commands/BrainSeedMemoryCommand.php
Normal file
230
Console/Commands/BrainSeedMemoryCommand.php
Normal file
|
|
@ -0,0 +1,230 @@
|
|||
<?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;
|
||||
}
|
||||
}
|
||||
80
Console/Commands/DispatchCommand.php
Normal file
80
Console/Commands/DispatchCommand.php
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
<?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;
|
||||
}
|
||||
}
|
||||
61
Console/Commands/PlanRetentionCommand.php
Normal file
61
Console/Commands/PlanRetentionCommand.php
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
<?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;
|
||||
}
|
||||
}
|
||||
94
Console/Commands/PrManageCommand.php
Normal file
94
Console/Commands/PrManageCommand.php
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
<?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;
|
||||
}
|
||||
}
|
||||
493
Console/Commands/PrepWorkspaceCommand.php
Normal file
493
Console/Commands/PrepWorkspaceCommand.php
Normal file
|
|
@ -0,0 +1,493 @@
|
|||
<?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;
|
||||
}
|
||||
}
|
||||
98
Console/Commands/ScanCommand.php
Normal file
98
Console/Commands/ScanCommand.php
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
<?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;
|
||||
}
|
||||
}
|
||||
649
Controllers/AgentApiController.php
Normal file
649
Controllers/AgentApiController.php
Normal file
|
|
@ -0,0 +1,649 @@
|
|||
<?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();
|
||||
}
|
||||
}
|
||||
164
Controllers/Api/BrainController.php
Normal file
164
Controllers/Api/BrainController.php
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
<?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);
|
||||
}
|
||||
}
|
||||
}
|
||||
115
Controllers/Api/PhaseController.php
Normal file
115
Controllers/Api/PhaseController.php
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Agentic\Controllers\Api;
|
||||
|
||||
use Core\Front\Controller;
|
||||
use Core\Mod\Agentic\Actions\Phase\AddCheckpoint;
|
||||
use Core\Mod\Agentic\Actions\Phase\GetPhase;
|
||||
use Core\Mod\Agentic\Actions\Phase\UpdatePhaseStatus;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class PhaseController extends Controller
|
||||
{
|
||||
/**
|
||||
* GET /api/plans/{slug}/phases/{phase}
|
||||
*/
|
||||
public function show(Request $request, string $slug, string $phase): JsonResponse
|
||||
{
|
||||
$workspace = $request->attributes->get('workspace');
|
||||
|
||||
try {
|
||||
$resolved = GetPhase::run($slug, $phase, $workspace->id);
|
||||
|
||||
return response()->json([
|
||||
'data' => [
|
||||
'order' => $resolved->order,
|
||||
'name' => $resolved->name,
|
||||
'description' => $resolved->description,
|
||||
'status' => $resolved->status,
|
||||
'tasks' => $resolved->tasks,
|
||||
'checkpoints' => $resolved->getCheckpoints(),
|
||||
'dependencies' => $resolved->dependencies,
|
||||
'task_progress' => $resolved->getTaskProgress(),
|
||||
],
|
||||
]);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return response()->json([
|
||||
'error' => 'not_found',
|
||||
'message' => $e->getMessage(),
|
||||
], 404);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/plans/{slug}/phases/{phase}
|
||||
*/
|
||||
public function update(Request $request, string $slug, string $phase): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'status' => 'required|string|in:pending,in_progress,completed,blocked,skipped',
|
||||
'notes' => 'nullable|string|max:5000',
|
||||
]);
|
||||
|
||||
$workspace = $request->attributes->get('workspace');
|
||||
|
||||
try {
|
||||
$resolved = UpdatePhaseStatus::run(
|
||||
$slug,
|
||||
$phase,
|
||||
$validated['status'],
|
||||
$workspace->id,
|
||||
$validated['notes'] ?? null,
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'data' => [
|
||||
'order' => $resolved->order,
|
||||
'name' => $resolved->name,
|
||||
'status' => $resolved->status,
|
||||
],
|
||||
]);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return response()->json([
|
||||
'error' => 'not_found',
|
||||
'message' => $e->getMessage(),
|
||||
], 404);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/plans/{slug}/phases/{phase}/checkpoint
|
||||
*/
|
||||
public function checkpoint(Request $request, string $slug, string $phase): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'note' => 'required|string|max:5000',
|
||||
'context' => 'nullable|array',
|
||||
]);
|
||||
|
||||
$workspace = $request->attributes->get('workspace');
|
||||
|
||||
try {
|
||||
$resolved = AddCheckpoint::run(
|
||||
$slug,
|
||||
$phase,
|
||||
$validated['note'],
|
||||
$workspace->id,
|
||||
$validated['context'] ?? [],
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'data' => [
|
||||
'checkpoints' => $resolved->getCheckpoints(),
|
||||
],
|
||||
]);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return response()->json([
|
||||
'error' => 'not_found',
|
||||
'message' => $e->getMessage(),
|
||||
], 404);
|
||||
}
|
||||
}
|
||||
}
|
||||
170
Controllers/Api/PlanController.php
Normal file
170
Controllers/Api/PlanController.php
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Agentic\Controllers\Api;
|
||||
|
||||
use Core\Front\Controller;
|
||||
use Core\Mod\Agentic\Actions\Plan\ArchivePlan;
|
||||
use Core\Mod\Agentic\Actions\Plan\CreatePlan;
|
||||
use Core\Mod\Agentic\Actions\Plan\GetPlan;
|
||||
use Core\Mod\Agentic\Actions\Plan\ListPlans;
|
||||
use Core\Mod\Agentic\Actions\Plan\UpdatePlanStatus;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class PlanController extends Controller
|
||||
{
|
||||
/**
|
||||
* GET /api/plans
|
||||
*/
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'status' => 'nullable|string|in:draft,active,paused,completed,archived',
|
||||
'include_archived' => 'nullable|boolean',
|
||||
]);
|
||||
|
||||
$workspace = $request->attributes->get('workspace');
|
||||
|
||||
try {
|
||||
$plans = ListPlans::run(
|
||||
$workspace->id,
|
||||
$validated['status'] ?? null,
|
||||
(bool) ($validated['include_archived'] ?? false),
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'data' => $plans->map(fn ($plan) => [
|
||||
'slug' => $plan->slug,
|
||||
'title' => $plan->title,
|
||||
'status' => $plan->status,
|
||||
'progress' => $plan->getProgress(),
|
||||
'updated_at' => $plan->updated_at->toIso8601String(),
|
||||
])->values()->all(),
|
||||
'total' => $plans->count(),
|
||||
]);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return response()->json([
|
||||
'error' => 'validation_error',
|
||||
'message' => $e->getMessage(),
|
||||
], 422);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/plans/{slug}
|
||||
*/
|
||||
public function show(Request $request, string $slug): JsonResponse
|
||||
{
|
||||
$workspace = $request->attributes->get('workspace');
|
||||
|
||||
try {
|
||||
$plan = GetPlan::run($slug, $workspace->id);
|
||||
|
||||
return response()->json([
|
||||
'data' => $plan->toMcpContext(),
|
||||
]);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return response()->json([
|
||||
'error' => 'not_found',
|
||||
'message' => $e->getMessage(),
|
||||
], 404);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/plans
|
||||
*/
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'title' => 'required|string|max:255',
|
||||
'slug' => 'nullable|string|max:255',
|
||||
'description' => 'nullable|string|max:10000',
|
||||
'context' => 'nullable|array',
|
||||
'phases' => 'nullable|array',
|
||||
'phases.*.name' => 'required_with:phases|string',
|
||||
'phases.*.description' => 'nullable|string',
|
||||
'phases.*.tasks' => 'nullable|array',
|
||||
'phases.*.tasks.*' => 'string',
|
||||
]);
|
||||
|
||||
$workspace = $request->attributes->get('workspace');
|
||||
|
||||
try {
|
||||
$plan = CreatePlan::run($validated, $workspace->id);
|
||||
|
||||
return response()->json([
|
||||
'data' => [
|
||||
'slug' => $plan->slug,
|
||||
'title' => $plan->title,
|
||||
'status' => $plan->status,
|
||||
'phases' => $plan->agentPhases->count(),
|
||||
],
|
||||
], 201);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return response()->json([
|
||||
'error' => 'validation_error',
|
||||
'message' => $e->getMessage(),
|
||||
], 422);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/plans/{slug}
|
||||
*/
|
||||
public function update(Request $request, string $slug): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'status' => 'required|string|in:draft,active,paused,completed',
|
||||
]);
|
||||
|
||||
$workspace = $request->attributes->get('workspace');
|
||||
|
||||
try {
|
||||
$plan = UpdatePlanStatus::run($slug, $validated['status'], $workspace->id);
|
||||
|
||||
return response()->json([
|
||||
'data' => [
|
||||
'slug' => $plan->slug,
|
||||
'status' => $plan->status,
|
||||
],
|
||||
]);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return response()->json([
|
||||
'error' => 'not_found',
|
||||
'message' => $e->getMessage(),
|
||||
], 404);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/plans/{slug}
|
||||
*/
|
||||
public function destroy(Request $request, string $slug): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'reason' => 'nullable|string|max:500',
|
||||
]);
|
||||
|
||||
$workspace = $request->attributes->get('workspace');
|
||||
|
||||
try {
|
||||
$plan = ArchivePlan::run($slug, $workspace->id, $request->input('reason'));
|
||||
|
||||
return response()->json([
|
||||
'data' => [
|
||||
'slug' => $plan->slug,
|
||||
'status' => 'archived',
|
||||
'archived_at' => $plan->archived_at?->toIso8601String(),
|
||||
],
|
||||
]);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return response()->json([
|
||||
'error' => 'not_found',
|
||||
'message' => $e->getMessage(),
|
||||
], 404);
|
||||
}
|
||||
}
|
||||
}
|
||||
173
Controllers/Api/SessionController.php
Normal file
173
Controllers/Api/SessionController.php
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Agentic\Controllers\Api;
|
||||
|
||||
use Core\Front\Controller;
|
||||
use Core\Mod\Agentic\Actions\Session\ContinueSession;
|
||||
use Core\Mod\Agentic\Actions\Session\EndSession;
|
||||
use Core\Mod\Agentic\Actions\Session\GetSession;
|
||||
use Core\Mod\Agentic\Actions\Session\ListSessions;
|
||||
use Core\Mod\Agentic\Actions\Session\StartSession;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class SessionController extends Controller
|
||||
{
|
||||
/**
|
||||
* GET /api/sessions
|
||||
*/
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'status' => 'nullable|string|in:active,paused,completed,failed',
|
||||
'plan_slug' => 'nullable|string|max:255',
|
||||
'limit' => 'nullable|integer|min:1|max:1000',
|
||||
]);
|
||||
|
||||
$workspace = $request->attributes->get('workspace');
|
||||
|
||||
try {
|
||||
$sessions = ListSessions::run(
|
||||
$workspace->id,
|
||||
$validated['status'] ?? null,
|
||||
$validated['plan_slug'] ?? null,
|
||||
$validated['limit'] ?? null,
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'data' => $sessions->map(fn ($session) => [
|
||||
'session_id' => $session->session_id,
|
||||
'agent_type' => $session->agent_type,
|
||||
'status' => $session->status,
|
||||
'plan' => $session->plan?->slug,
|
||||
'duration' => $session->getDurationFormatted(),
|
||||
'started_at' => $session->started_at->toIso8601String(),
|
||||
'last_active_at' => $session->last_active_at->toIso8601String(),
|
||||
])->values()->all(),
|
||||
'total' => $sessions->count(),
|
||||
]);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return response()->json([
|
||||
'error' => 'validation_error',
|
||||
'message' => $e->getMessage(),
|
||||
], 422);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/sessions/{id}
|
||||
*/
|
||||
public function show(Request $request, string $id): JsonResponse
|
||||
{
|
||||
$workspace = $request->attributes->get('workspace');
|
||||
|
||||
try {
|
||||
$session = GetSession::run($id, $workspace->id);
|
||||
|
||||
return response()->json([
|
||||
'data' => $session->toMcpContext(),
|
||||
]);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return response()->json([
|
||||
'error' => 'not_found',
|
||||
'message' => $e->getMessage(),
|
||||
], 404);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/sessions
|
||||
*/
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'agent_type' => 'required|string|max:50',
|
||||
'plan_slug' => 'nullable|string|max:255',
|
||||
'context' => 'nullable|array',
|
||||
]);
|
||||
|
||||
$workspace = $request->attributes->get('workspace');
|
||||
|
||||
try {
|
||||
$session = StartSession::run(
|
||||
$validated['agent_type'],
|
||||
$validated['plan_slug'] ?? null,
|
||||
$workspace->id,
|
||||
$validated['context'] ?? [],
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'data' => [
|
||||
'session_id' => $session->session_id,
|
||||
'agent_type' => $session->agent_type,
|
||||
'plan' => $session->plan?->slug,
|
||||
'status' => $session->status,
|
||||
],
|
||||
], 201);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return response()->json([
|
||||
'error' => 'validation_error',
|
||||
'message' => $e->getMessage(),
|
||||
], 422);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/sessions/{id}/end
|
||||
*/
|
||||
public function end(Request $request, string $id): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'status' => 'required|string|in:completed,handed_off,paused,failed',
|
||||
'summary' => 'nullable|string|max:10000',
|
||||
]);
|
||||
|
||||
try {
|
||||
$session = EndSession::run($id, $validated['status'], $validated['summary'] ?? null);
|
||||
|
||||
return response()->json([
|
||||
'data' => [
|
||||
'session_id' => $session->session_id,
|
||||
'status' => $session->status,
|
||||
'duration' => $session->getDurationFormatted(),
|
||||
],
|
||||
]);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return response()->json([
|
||||
'error' => 'not_found',
|
||||
'message' => $e->getMessage(),
|
||||
], 404);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/sessions/{id}/continue
|
||||
*/
|
||||
public function continue(Request $request, string $id): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'agent_type' => 'required|string|max:50',
|
||||
]);
|
||||
|
||||
try {
|
||||
$session = ContinueSession::run($id, $validated['agent_type']);
|
||||
|
||||
return response()->json([
|
||||
'data' => [
|
||||
'session_id' => $session->session_id,
|
||||
'agent_type' => $session->agent_type,
|
||||
'plan' => $session->plan?->slug,
|
||||
'status' => $session->status,
|
||||
'continued_from' => $session->context_summary['continued_from'] ?? null,
|
||||
],
|
||||
], 201);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return response()->json([
|
||||
'error' => 'not_found',
|
||||
'message' => $e->getMessage(),
|
||||
], 404);
|
||||
}
|
||||
}
|
||||
}
|
||||
68
Controllers/Api/TaskController.php
Normal file
68
Controllers/Api/TaskController.php
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Agentic\Controllers\Api;
|
||||
|
||||
use Core\Front\Controller;
|
||||
use Core\Mod\Agentic\Actions\Task\ToggleTask;
|
||||
use Core\Mod\Agentic\Actions\Task\UpdateTask;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class TaskController extends Controller
|
||||
{
|
||||
/**
|
||||
* PATCH /api/plans/{slug}/phases/{phase}/tasks/{index}
|
||||
*/
|
||||
public function update(Request $request, string $slug, string $phase, int $index): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'status' => 'nullable|string|in:pending,in_progress,completed,blocked,skipped',
|
||||
'notes' => 'nullable|string|max:5000',
|
||||
]);
|
||||
|
||||
$workspace = $request->attributes->get('workspace');
|
||||
|
||||
try {
|
||||
$result = UpdateTask::run(
|
||||
$slug,
|
||||
$phase,
|
||||
$index,
|
||||
$workspace->id,
|
||||
$validated['status'] ?? null,
|
||||
$validated['notes'] ?? null,
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'data' => $result,
|
||||
]);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return response()->json([
|
||||
'error' => 'not_found',
|
||||
'message' => $e->getMessage(),
|
||||
], 404);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/plans/{slug}/phases/{phase}/tasks/{index}/toggle
|
||||
*/
|
||||
public function toggle(Request $request, string $slug, string $phase, int $index): JsonResponse
|
||||
{
|
||||
$workspace = $request->attributes->get('workspace');
|
||||
|
||||
try {
|
||||
$result = ToggleTask::run($slug, $phase, $index, $workspace->id);
|
||||
|
||||
return response()->json([
|
||||
'data' => $result,
|
||||
]);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return response()->json([
|
||||
'error' => 'not_found',
|
||||
'message' => $e->getMessage(),
|
||||
], 404);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -111,10 +111,12 @@ 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("{{{$key}}}", $value, $template);
|
||||
$template = str_replace($placeholder, $value, $template);
|
||||
} elseif (is_array($value)) {
|
||||
$template = str_replace("{{{$key}}}", json_encode($value), $template);
|
||||
$template = str_replace($placeholder, json_encode($value), $template);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ class Marketing extends Server
|
|||
#### Other Bio Tools
|
||||
- `qr_tools` - Generate QR codes
|
||||
- `pixel_tools` - Manage tracking pixels
|
||||
- `project_tools` - Organize into projects
|
||||
- `project_tools` - Organise into projects
|
||||
- `notification_tools` - Manage notification handlers
|
||||
- `submission_tools` - Manage form submissions
|
||||
- `pwa_tools` - Configure PWA
|
||||
|
|
|
|||
78
Mcp/Tools/Agent/Brain/BrainForget.php
Normal file
78
Mcp/Tools/Agent/Brain/BrainForget.php
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
<?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'));
|
||||
}
|
||||
}
|
||||
81
Mcp/Tools/Agent/Brain/BrainList.php
Normal file
81
Mcp/Tools/Agent/Brain/BrainList.php
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
<?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);
|
||||
}
|
||||
}
|
||||
119
Mcp/Tools/Agent/Brain/BrainRecall.php
Normal file
119
Mcp/Tools/Agent/Brain/BrainRecall.php
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
<?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'));
|
||||
}
|
||||
}
|
||||
103
Mcp/Tools/Agent/Brain/BrainRemember.php
Normal file
103
Mcp/Tools/Agent/Brain/BrainRemember.php
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
<?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'));
|
||||
}
|
||||
}
|
||||
|
|
@ -4,9 +4,8 @@ declare(strict_types=1);
|
|||
|
||||
namespace Core\Mod\Agentic\Mcp\Tools\Agent\Phase;
|
||||
|
||||
use Core\Mod\Agentic\Actions\Phase\AddCheckpoint;
|
||||
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
|
||||
use Core\Mod\Agentic\Models\AgentPhase;
|
||||
use Core\Mod\Agentic\Models\AgentPlan;
|
||||
|
||||
/**
|
||||
* Add a checkpoint note to a phase.
|
||||
|
|
@ -55,44 +54,25 @@ class PhaseAddCheckpoint extends AgentTool
|
|||
|
||||
public function handle(array $args, array $context = []): array
|
||||
{
|
||||
$workspaceId = $context['workspace_id'] ?? null;
|
||||
if ($workspaceId === null) {
|
||||
return $this->error('workspace_id is required');
|
||||
}
|
||||
|
||||
try {
|
||||
$planSlug = $this->require($args, 'plan_slug');
|
||||
$phaseIdentifier = $this->require($args, 'phase');
|
||||
$note = $this->require($args, 'note');
|
||||
$phase = AddCheckpoint::run(
|
||||
$args['plan_slug'] ?? '',
|
||||
$args['phase'] ?? '',
|
||||
$args['note'] ?? '',
|
||||
(int) $workspaceId,
|
||||
$args['context'] ?? [],
|
||||
);
|
||||
|
||||
return $this->success([
|
||||
'checkpoints' => $phase->getCheckpoints(),
|
||||
]);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return $this->error($e->getMessage());
|
||||
}
|
||||
|
||||
$plan = AgentPlan::where('slug', $planSlug)->first();
|
||||
|
||||
if (! $plan) {
|
||||
return $this->error("Plan not found: {$planSlug}");
|
||||
}
|
||||
|
||||
$phase = $this->findPhase($plan, $phaseIdentifier);
|
||||
|
||||
if (! $phase) {
|
||||
return $this->error("Phase not found: {$phaseIdentifier}");
|
||||
}
|
||||
|
||||
$phase->addCheckpoint($note, $args['context'] ?? []);
|
||||
|
||||
return $this->success([
|
||||
'checkpoints' => $phase->fresh()->checkpoints,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a phase by order number or name.
|
||||
*/
|
||||
protected function findPhase(AgentPlan $plan, string|int $identifier): ?AgentPhase
|
||||
{
|
||||
if (is_numeric($identifier)) {
|
||||
return $plan->agentPhases()->where('order', (int) $identifier)->first();
|
||||
}
|
||||
|
||||
return $plan->agentPhases()
|
||||
->where('name', $identifier)
|
||||
->first();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,9 +4,8 @@ declare(strict_types=1);
|
|||
|
||||
namespace Core\Mod\Agentic\Mcp\Tools\Agent\Phase;
|
||||
|
||||
use Core\Mod\Agentic\Actions\Phase\GetPhase;
|
||||
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
|
||||
use Core\Mod\Agentic\Models\AgentPhase;
|
||||
use Core\Mod\Agentic\Models\AgentPlan;
|
||||
|
||||
/**
|
||||
* Get details of a specific phase within a plan.
|
||||
|
|
@ -47,52 +46,31 @@ class PhaseGet extends AgentTool
|
|||
|
||||
public function handle(array $args, array $context = []): array
|
||||
{
|
||||
$workspaceId = $context['workspace_id'] ?? null;
|
||||
if ($workspaceId === null) {
|
||||
return $this->error('workspace_id is required');
|
||||
}
|
||||
|
||||
try {
|
||||
$planSlug = $this->require($args, 'plan_slug');
|
||||
$phaseIdentifier = $this->require($args, 'phase');
|
||||
$phase = GetPhase::run(
|
||||
$args['plan_slug'] ?? '',
|
||||
$args['phase'] ?? '',
|
||||
(int) $workspaceId,
|
||||
);
|
||||
|
||||
return $this->success([
|
||||
'phase' => [
|
||||
'order' => $phase->order,
|
||||
'name' => $phase->name,
|
||||
'description' => $phase->description,
|
||||
'status' => $phase->status,
|
||||
'tasks' => $phase->tasks,
|
||||
'checkpoints' => $phase->getCheckpoints(),
|
||||
'dependencies' => $phase->dependencies,
|
||||
],
|
||||
]);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return $this->error($e->getMessage());
|
||||
}
|
||||
|
||||
$plan = AgentPlan::where('slug', $planSlug)->first();
|
||||
|
||||
if (! $plan) {
|
||||
return $this->error("Plan not found: {$planSlug}");
|
||||
}
|
||||
|
||||
$phase = $this->findPhase($plan, $phaseIdentifier);
|
||||
|
||||
if (! $phase) {
|
||||
return $this->error("Phase not found: {$phaseIdentifier}");
|
||||
}
|
||||
|
||||
return [
|
||||
'phase' => [
|
||||
'order' => $phase->order,
|
||||
'name' => $phase->name,
|
||||
'description' => $phase->description,
|
||||
'status' => $phase->status,
|
||||
'tasks' => $phase->tasks,
|
||||
'checkpoints' => $phase->checkpoints,
|
||||
'dependencies' => $phase->dependencies,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a phase by order number or name.
|
||||
*/
|
||||
protected function findPhase(AgentPlan $plan, string|int $identifier): ?AgentPhase
|
||||
{
|
||||
if (is_numeric($identifier)) {
|
||||
return $plan->agentPhases()->where('order', (int) $identifier)->first();
|
||||
}
|
||||
|
||||
return $plan->agentPhases()
|
||||
->where(function ($query) use ($identifier) {
|
||||
$query->where('name', $identifier)
|
||||
->orWhere('order', $identifier);
|
||||
})
|
||||
->first();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,9 +5,8 @@ declare(strict_types=1);
|
|||
namespace Core\Mod\Agentic\Mcp\Tools\Agent\Phase;
|
||||
|
||||
use Core\Mcp\Dependencies\ToolDependency;
|
||||
use Core\Mod\Agentic\Actions\Phase\UpdatePhaseStatus;
|
||||
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
|
||||
use Core\Mod\Agentic\Models\AgentPhase;
|
||||
use Core\Mod\Agentic\Models\AgentPlan;
|
||||
|
||||
/**
|
||||
* Update the status of a phase.
|
||||
|
|
@ -69,55 +68,29 @@ class PhaseUpdateStatus extends AgentTool
|
|||
|
||||
public function handle(array $args, array $context = []): array
|
||||
{
|
||||
$workspaceId = $context['workspace_id'] ?? null;
|
||||
if ($workspaceId === null) {
|
||||
return $this->error('workspace_id is required');
|
||||
}
|
||||
|
||||
try {
|
||||
$planSlug = $this->require($args, 'plan_slug');
|
||||
$phaseIdentifier = $this->require($args, 'phase');
|
||||
$status = $this->require($args, 'status');
|
||||
$phase = UpdatePhaseStatus::run(
|
||||
$args['plan_slug'] ?? '',
|
||||
$args['phase'] ?? '',
|
||||
$args['status'] ?? '',
|
||||
(int) $workspaceId,
|
||||
$args['notes'] ?? null,
|
||||
);
|
||||
|
||||
return $this->success([
|
||||
'phase' => [
|
||||
'order' => $phase->order,
|
||||
'name' => $phase->name,
|
||||
'status' => $phase->status,
|
||||
],
|
||||
]);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return $this->error($e->getMessage());
|
||||
}
|
||||
|
||||
$plan = AgentPlan::where('slug', $planSlug)->first();
|
||||
|
||||
if (! $plan) {
|
||||
return $this->error("Plan not found: {$planSlug}");
|
||||
}
|
||||
|
||||
$phase = $this->findPhase($plan, $phaseIdentifier);
|
||||
|
||||
if (! $phase) {
|
||||
return $this->error("Phase not found: {$phaseIdentifier}");
|
||||
}
|
||||
|
||||
if (! empty($args['notes'])) {
|
||||
$phase->addCheckpoint($args['notes'], ['status_change' => $status]);
|
||||
}
|
||||
|
||||
$phase->update(['status' => $status]);
|
||||
|
||||
return $this->success([
|
||||
'phase' => [
|
||||
'order' => $phase->order,
|
||||
'name' => $phase->name,
|
||||
'status' => $phase->fresh()->status,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a phase by order number or name.
|
||||
*/
|
||||
protected function findPhase(AgentPlan $plan, string|int $identifier): ?AgentPhase
|
||||
{
|
||||
if (is_numeric($identifier)) {
|
||||
return $plan->agentPhases()->where('order', (int) $identifier)->first();
|
||||
}
|
||||
|
||||
return $plan->agentPhases()
|
||||
->where(function ($query) use ($identifier) {
|
||||
$query->where('name', $identifier)
|
||||
->orWhere('order', $identifier);
|
||||
})
|
||||
->first();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,8 +4,8 @@ declare(strict_types=1);
|
|||
|
||||
namespace Core\Mod\Agentic\Mcp\Tools\Agent\Plan;
|
||||
|
||||
use Core\Mod\Agentic\Actions\Plan\ArchivePlan;
|
||||
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
|
||||
use Core\Mod\Agentic\Models\AgentPlan;
|
||||
|
||||
/**
|
||||
* Archive a completed or abandoned plan.
|
||||
|
|
@ -46,26 +46,27 @@ class PlanArchive extends AgentTool
|
|||
|
||||
public function handle(array $args, array $context = []): array
|
||||
{
|
||||
$workspaceId = $context['workspace_id'] ?? null;
|
||||
if ($workspaceId === null) {
|
||||
return $this->error('workspace_id is required');
|
||||
}
|
||||
|
||||
try {
|
||||
$slug = $this->require($args, 'slug');
|
||||
$plan = ArchivePlan::run(
|
||||
$args['slug'] ?? '',
|
||||
(int) $workspaceId,
|
||||
$args['reason'] ?? null,
|
||||
);
|
||||
|
||||
return $this->success([
|
||||
'plan' => [
|
||||
'slug' => $plan->slug,
|
||||
'status' => 'archived',
|
||||
'archived_at' => $plan->archived_at?->toIso8601String(),
|
||||
],
|
||||
]);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return $this->error($e->getMessage());
|
||||
}
|
||||
|
||||
$plan = AgentPlan::where('slug', $slug)->first();
|
||||
|
||||
if (! $plan) {
|
||||
return $this->error("Plan not found: {$slug}");
|
||||
}
|
||||
|
||||
$plan->archive($args['reason'] ?? null);
|
||||
|
||||
return $this->success([
|
||||
'plan' => [
|
||||
'slug' => $plan->slug,
|
||||
'status' => 'archived',
|
||||
'archived_at' => $plan->archived_at?->toIso8601String(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,10 +5,8 @@ declare(strict_types=1);
|
|||
namespace Core\Mod\Agentic\Mcp\Tools\Agent\Plan;
|
||||
|
||||
use Core\Mcp\Dependencies\ToolDependency;
|
||||
use Core\Mod\Agentic\Actions\Plan\CreatePlan;
|
||||
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
|
||||
use Core\Mod\Agentic\Models\AgentPhase;
|
||||
use Core\Mod\Agentic\Models\AgentPlan;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* Create a new work plan with phases and tasks.
|
||||
|
|
@ -84,61 +82,24 @@ class PlanCreate extends AgentTool
|
|||
|
||||
public function handle(array $args, array $context = []): array
|
||||
{
|
||||
$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 {
|
||||
$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);
|
||||
$plan = CreatePlan::run($args, (int) $workspaceId);
|
||||
|
||||
return $this->success([
|
||||
'plan' => [
|
||||
'slug' => $plan->slug,
|
||||
'title' => $plan->title,
|
||||
'status' => $plan->status,
|
||||
'phases' => $plan->agentPhases->count(),
|
||||
],
|
||||
]);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return $this->error($e->getMessage());
|
||||
}
|
||||
|
||||
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(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,8 +5,8 @@ declare(strict_types=1);
|
|||
namespace Core\Mod\Agentic\Mcp\Tools\Agent\Plan;
|
||||
|
||||
use Core\Mcp\Dependencies\ToolDependency;
|
||||
use Core\Mod\Agentic\Actions\Plan\GetPlan;
|
||||
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
|
||||
use Core\Mod\Agentic\Models\AgentPlan;
|
||||
|
||||
/**
|
||||
* Get detailed information about a specific plan.
|
||||
|
|
@ -62,56 +62,23 @@ class PlanGet extends AgentTool
|
|||
|
||||
public function handle(array $args, array $context = []): array
|
||||
{
|
||||
$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 {
|
||||
$slug = $this->require($args, 'slug');
|
||||
$plan = GetPlan::run($args['slug'] ?? '', (int) $workspaceId);
|
||||
} 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');
|
||||
$format = $args['format'] ?? 'json';
|
||||
|
||||
if ($format === 'markdown') {
|
||||
return $this->success(['markdown' => $plan->toMarkdown()]);
|
||||
}
|
||||
|
||||
$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'));
|
||||
return $this->success(['plan' => $plan->toMcpContext()]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,8 +5,8 @@ declare(strict_types=1);
|
|||
namespace Core\Mod\Agentic\Mcp\Tools\Agent\Plan;
|
||||
|
||||
use Core\Mcp\Dependencies\ToolDependency;
|
||||
use Core\Mod\Agentic\Actions\Plan\ListPlans;
|
||||
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
|
||||
use Core\Mod\Agentic\Models\AgentPlan;
|
||||
|
||||
/**
|
||||
* List all work plans with their current status and progress.
|
||||
|
|
@ -61,43 +61,30 @@ class PlanList extends AgentTool
|
|||
|
||||
public function handle(array $args, array $context = []): array
|
||||
{
|
||||
$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 {
|
||||
$status = $this->optionalEnum($args, 'status', ['draft', 'active', 'paused', 'completed', 'archived']);
|
||||
$includeArchived = (bool) ($args['include_archived'] ?? false);
|
||||
$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(),
|
||||
]);
|
||||
} 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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,8 +4,8 @@ declare(strict_types=1);
|
|||
|
||||
namespace Core\Mod\Agentic\Mcp\Tools\Agent\Plan;
|
||||
|
||||
use Core\Mod\Agentic\Actions\Plan\UpdatePlanStatus;
|
||||
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
|
||||
use Core\Mod\Agentic\Models\AgentPlan;
|
||||
|
||||
/**
|
||||
* Update the status of a plan.
|
||||
|
|
@ -47,26 +47,26 @@ class PlanUpdateStatus extends AgentTool
|
|||
|
||||
public function handle(array $args, array $context = []): array
|
||||
{
|
||||
$workspaceId = $context['workspace_id'] ?? null;
|
||||
if ($workspaceId === null) {
|
||||
return $this->error('workspace_id is required');
|
||||
}
|
||||
|
||||
try {
|
||||
$slug = $this->require($args, 'slug');
|
||||
$status = $this->require($args, 'status');
|
||||
$plan = UpdatePlanStatus::run(
|
||||
$args['slug'] ?? '',
|
||||
$args['status'] ?? '',
|
||||
(int) $workspaceId,
|
||||
);
|
||||
|
||||
return $this->success([
|
||||
'plan' => [
|
||||
'slug' => $plan->slug,
|
||||
'status' => $plan->status,
|
||||
],
|
||||
]);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return $this->error($e->getMessage());
|
||||
}
|
||||
|
||||
$plan = AgentPlan::where('slug', $slug)->first();
|
||||
|
||||
if (! $plan) {
|
||||
return $this->error("Plan not found: {$slug}");
|
||||
}
|
||||
|
||||
$plan->update(['status' => $status]);
|
||||
|
||||
return $this->success([
|
||||
'plan' => [
|
||||
'slug' => $plan->slug,
|
||||
'status' => $plan->fresh()->status,
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
279
Mcp/Tools/Agent/README.md
Normal file
279
Mcp/Tools/Agent/README.md
Normal file
|
|
@ -0,0 +1,279 @@
|
|||
# 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.
|
||||
|
|
@ -4,8 +4,8 @@ declare(strict_types=1);
|
|||
|
||||
namespace Core\Mod\Agentic\Mcp\Tools\Agent\Session;
|
||||
|
||||
use Core\Mod\Agentic\Actions\Session\ContinueSession;
|
||||
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
|
||||
use Core\Mod\Agentic\Services\AgentSessionService;
|
||||
|
||||
/**
|
||||
* Continue from a previous session (multi-agent handoff).
|
||||
|
|
@ -47,32 +47,27 @@ class SessionContinue extends AgentTool
|
|||
public function handle(array $args, array $context = []): array
|
||||
{
|
||||
try {
|
||||
$previousSessionId = $this->require($args, 'previous_session_id');
|
||||
$agentType = $this->require($args, 'agent_type');
|
||||
$session = ContinueSession::run(
|
||||
$args['previous_session_id'] ?? '',
|
||||
$args['agent_type'] ?? '',
|
||||
);
|
||||
|
||||
$inheritedContext = $session->context_summary ?? [];
|
||||
|
||||
return $this->success([
|
||||
'session' => [
|
||||
'session_id' => $session->session_id,
|
||||
'agent_type' => $session->agent_type,
|
||||
'status' => $session->status,
|
||||
'plan' => $session->plan?->slug,
|
||||
],
|
||||
'continued_from' => $inheritedContext['continued_from'] ?? null,
|
||||
'previous_agent' => $inheritedContext['previous_agent'] ?? null,
|
||||
'handoff_notes' => $inheritedContext['handoff_notes'] ?? null,
|
||||
'inherited_context' => $inheritedContext['inherited_context'] ?? null,
|
||||
]);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return $this->error($e->getMessage());
|
||||
}
|
||||
|
||||
$sessionService = app(AgentSessionService::class);
|
||||
$session = $sessionService->continueFrom($previousSessionId, $agentType);
|
||||
|
||||
if (! $session) {
|
||||
return $this->error("Previous session not found: {$previousSessionId}");
|
||||
}
|
||||
|
||||
$inheritedContext = $session->context_summary ?? [];
|
||||
|
||||
return $this->success([
|
||||
'session' => [
|
||||
'session_id' => $session->session_id,
|
||||
'agent_type' => $session->agent_type,
|
||||
'status' => $session->status,
|
||||
'plan' => $session->plan?->slug,
|
||||
],
|
||||
'continued_from' => $inheritedContext['continued_from'] ?? null,
|
||||
'previous_agent' => $inheritedContext['previous_agent'] ?? null,
|
||||
'handoff_notes' => $inheritedContext['handoff_notes'] ?? null,
|
||||
'inherited_context' => $inheritedContext['inherited_context'] ?? null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,8 +4,8 @@ declare(strict_types=1);
|
|||
|
||||
namespace Core\Mod\Agentic\Mcp\Tools\Agent\Session;
|
||||
|
||||
use Core\Mod\Agentic\Actions\Session\EndSession;
|
||||
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
|
||||
use Core\Mod\Agentic\Models\AgentSession;
|
||||
|
||||
/**
|
||||
* End the current session.
|
||||
|
|
@ -47,32 +47,27 @@ class SessionEnd extends AgentTool
|
|||
|
||||
public function handle(array $args, array $context = []): array
|
||||
{
|
||||
try {
|
||||
$status = $this->require($args, 'status');
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return $this->error($e->getMessage());
|
||||
}
|
||||
|
||||
$sessionId = $context['session_id'] ?? null;
|
||||
|
||||
if (! $sessionId) {
|
||||
return $this->error('No active session');
|
||||
}
|
||||
|
||||
$session = AgentSession::where('session_id', $sessionId)->first();
|
||||
try {
|
||||
$session = EndSession::run(
|
||||
$sessionId,
|
||||
$args['status'] ?? '',
|
||||
$args['summary'] ?? null,
|
||||
);
|
||||
|
||||
if (! $session) {
|
||||
return $this->error('Session not found');
|
||||
return $this->success([
|
||||
'session' => [
|
||||
'session_id' => $session->session_id,
|
||||
'status' => $session->status,
|
||||
'duration' => $session->getDurationFormatted(),
|
||||
],
|
||||
]);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return $this->error($e->getMessage());
|
||||
}
|
||||
|
||||
$session->end($status, $this->optional($args, 'summary'));
|
||||
|
||||
return $this->success([
|
||||
'session' => [
|
||||
'session_id' => $session->session_id,
|
||||
'status' => $session->status,
|
||||
'duration' => $session->getDurationFormatted(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,8 +4,8 @@ declare(strict_types=1);
|
|||
|
||||
namespace Core\Mod\Agentic\Mcp\Tools\Agent\Session;
|
||||
|
||||
use Core\Mod\Agentic\Actions\Session\ListSessions;
|
||||
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
|
||||
use Core\Mod\Agentic\Services\AgentSessionService;
|
||||
|
||||
/**
|
||||
* List sessions, optionally filtered by status.
|
||||
|
|
@ -50,54 +50,34 @@ class SessionList extends AgentTool
|
|||
|
||||
public function handle(array $args, array $context = []): array
|
||||
{
|
||||
$workspaceId = $context['workspace_id'] ?? null;
|
||||
if ($workspaceId === null) {
|
||||
return $this->error('workspace_id is required');
|
||||
}
|
||||
|
||||
try {
|
||||
$status = $this->optionalEnum($args, 'status', ['active', 'paused', 'completed', 'failed']);
|
||||
$planSlug = $this->optionalString($args, 'plan_slug', null, 255);
|
||||
$limit = $this->optionalInt($args, 'limit', null, min: 1, max: 1000);
|
||||
$sessions = ListSessions::run(
|
||||
(int) $workspaceId,
|
||||
$args['status'] ?? null,
|
||||
$args['plan_slug'] ?? null,
|
||||
isset($args['limit']) ? (int) $args['limit'] : null,
|
||||
);
|
||||
|
||||
return $this->success([
|
||||
'sessions' => $sessions->map(fn ($session) => [
|
||||
'session_id' => $session->session_id,
|
||||
'agent_type' => $session->agent_type,
|
||||
'status' => $session->status,
|
||||
'plan' => $session->plan?->slug,
|
||||
'duration' => $session->getDurationFormatted(),
|
||||
'started_at' => $session->started_at->toIso8601String(),
|
||||
'last_active_at' => $session->last_active_at->toIso8601String(),
|
||||
'has_handoff' => ! empty($session->handoff_notes),
|
||||
])->all(),
|
||||
'total' => $sessions->count(),
|
||||
]);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return $this->error($e->getMessage());
|
||||
}
|
||||
|
||||
$sessionService = app(AgentSessionService::class);
|
||||
|
||||
// Get active sessions (default)
|
||||
if ($status === 'active' || $status === null) {
|
||||
$sessions = $sessionService->getActiveSessions($context['workspace_id'] ?? null);
|
||||
} else {
|
||||
// Query with filters
|
||||
$query = \Core\Mod\Agentic\Models\AgentSession::query()
|
||||
->orderBy('last_active_at', 'desc');
|
||||
|
||||
// Apply workspace filter if provided
|
||||
if (! empty($context['workspace_id'])) {
|
||||
$query->where('workspace_id', $context['workspace_id']);
|
||||
}
|
||||
|
||||
$query->where('status', $status);
|
||||
|
||||
if ($planSlug !== null) {
|
||||
$query->whereHas('plan', fn ($q) => $q->where('slug', $planSlug));
|
||||
}
|
||||
|
||||
if ($limit !== null) {
|
||||
$query->limit($limit);
|
||||
}
|
||||
|
||||
$sessions = $query->get();
|
||||
}
|
||||
|
||||
return [
|
||||
'sessions' => $sessions->map(fn ($session) => [
|
||||
'session_id' => $session->session_id,
|
||||
'agent_type' => $session->agent_type,
|
||||
'status' => $session->status,
|
||||
'plan' => $session->plan?->slug,
|
||||
'duration' => $session->getDurationFormatted(),
|
||||
'started_at' => $session->started_at->toIso8601String(),
|
||||
'last_active_at' => $session->last_active_at->toIso8601String(),
|
||||
'has_handoff' => ! empty($session->handoff_notes),
|
||||
])->all(),
|
||||
'total' => $sessions->count(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,10 +5,8 @@ declare(strict_types=1);
|
|||
namespace Core\Mod\Agentic\Mcp\Tools\Agent\Session;
|
||||
|
||||
use Core\Mcp\Dependencies\ToolDependency;
|
||||
use Core\Mod\Agentic\Actions\Session\StartSession;
|
||||
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
|
||||
use Core\Mod\Agentic\Models\AgentPlan;
|
||||
use Core\Mod\Agentic\Models\AgentSession;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* Start a new agent session for a plan.
|
||||
|
|
@ -70,48 +68,29 @@ class SessionStart extends AgentTool
|
|||
|
||||
public function handle(array $args, array $context = []): array
|
||||
{
|
||||
try {
|
||||
$agentType = $this->require($args, 'agent_type');
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return $this->error($e->getMessage());
|
||||
$workspaceId = $context['workspace_id'] ?? null;
|
||||
if ($workspaceId === null) {
|
||||
return $this->error('workspace_id is required. Ensure you have authenticated with a valid API key and started a session, or provide a valid plan_slug to infer workspace context. See: https://host.uk.com/ai');
|
||||
}
|
||||
|
||||
// Use circuit breaker for Agentic module database calls
|
||||
return $this->withCircuitBreaker('agentic', function () use ($args, $context, $agentType) {
|
||||
$plan = null;
|
||||
if (! empty($args['plan_slug'])) {
|
||||
$plan = AgentPlan::where('slug', $args['plan_slug'])->first();
|
||||
}
|
||||
|
||||
$sessionId = 'ses_'.Str::random(12);
|
||||
|
||||
// Determine workspace_id - never fall back to hardcoded value in multi-tenant environment
|
||||
$workspaceId = $context['workspace_id'] ?? $plan?->workspace_id ?? null;
|
||||
if ($workspaceId === null) {
|
||||
return $this->error('workspace_id is required 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' => [],
|
||||
]);
|
||||
try {
|
||||
$session = StartSession::run(
|
||||
$args['agent_type'] ?? '',
|
||||
$args['plan_slug'] ?? null,
|
||||
(int) $workspaceId,
|
||||
$args['context'] ?? [],
|
||||
);
|
||||
|
||||
return $this->success([
|
||||
'session' => [
|
||||
'session_id' => $session->session_id,
|
||||
'agent_type' => $session->agent_type,
|
||||
'plan' => $plan?->slug,
|
||||
'plan' => $session->plan?->slug,
|
||||
'status' => $session->status,
|
||||
],
|
||||
]);
|
||||
}, fn () => $this->error('Agentic service temporarily unavailable. Session cannot be created.', 'service_unavailable'));
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return $this->error($e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 for state operations');
|
||||
return $this->error('workspace_id is required. Ensure you have authenticated with a valid API key and started a session. See: https://host.uk.com/ai');
|
||||
}
|
||||
|
||||
// Query plan with workspace scope to prevent cross-tenant access
|
||||
|
|
|
|||
|
|
@ -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 for state operations');
|
||||
return $this->error('workspace_id is required. Ensure you have authenticated with a valid API key and started a session. See: https://host.uk.com/ai');
|
||||
}
|
||||
|
||||
// Query plan with workspace scope to prevent cross-tenant access
|
||||
|
|
|
|||
|
|
@ -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\AgentWorkspaceState;
|
||||
use Core\Mod\Agentic\Models\WorkspaceState;
|
||||
|
||||
/**
|
||||
* 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 for state operations');
|
||||
return $this->error('workspace_id is required. Ensure you have authenticated with a valid API key and started a session. See: https://host.uk.com/ai');
|
||||
}
|
||||
|
||||
// Query 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 = AgentWorkspaceState::updateOrCreate(
|
||||
$state = WorkspaceState::updateOrCreate(
|
||||
[
|
||||
'agent_plan_id' => $plan->id,
|
||||
'key' => $key,
|
||||
|
|
|
|||
|
|
@ -5,9 +5,8 @@ declare(strict_types=1);
|
|||
namespace Core\Mod\Agentic\Mcp\Tools\Agent\Task;
|
||||
|
||||
use Core\Mcp\Dependencies\ToolDependency;
|
||||
use Core\Mod\Agentic\Actions\Task\ToggleTask;
|
||||
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
|
||||
use Core\Mod\Agentic\Models\AgentPhase;
|
||||
use Core\Mod\Agentic\Models\AgentPlan;
|
||||
|
||||
/**
|
||||
* Toggle a task completion status.
|
||||
|
|
@ -64,66 +63,22 @@ class TaskToggle extends AgentTool
|
|||
|
||||
public function handle(array $args, array $context = []): array
|
||||
{
|
||||
$workspaceId = $context['workspace_id'] ?? null;
|
||||
if ($workspaceId === null) {
|
||||
return $this->error('workspace_id is required');
|
||||
}
|
||||
|
||||
try {
|
||||
$planSlug = $this->requireString($args, 'plan_slug', 255);
|
||||
$phaseIdentifier = $this->requireString($args, 'phase', 255);
|
||||
$taskIndex = $this->requireInt($args, 'task_index', min: 0, max: 1000);
|
||||
$result = ToggleTask::run(
|
||||
$args['plan_slug'] ?? '',
|
||||
$args['phase'] ?? '',
|
||||
(int) ($args['task_index'] ?? 0),
|
||||
(int) $workspaceId,
|
||||
);
|
||||
|
||||
return $this->success($result);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return $this->error($e->getMessage());
|
||||
}
|
||||
|
||||
$plan = AgentPlan::where('slug', $planSlug)->first();
|
||||
|
||||
if (! $plan) {
|
||||
return $this->error("Plan not found: {$planSlug}");
|
||||
}
|
||||
|
||||
$phase = $this->findPhase($plan, $phaseIdentifier);
|
||||
|
||||
if (! $phase) {
|
||||
return $this->error("Phase not found: {$phaseIdentifier}");
|
||||
}
|
||||
|
||||
$tasks = $phase->tasks ?? [];
|
||||
|
||||
if (! isset($tasks[$taskIndex])) {
|
||||
return $this->error("Task not found at index: {$taskIndex}");
|
||||
}
|
||||
|
||||
$currentStatus = is_string($tasks[$taskIndex])
|
||||
? 'pending'
|
||||
: ($tasks[$taskIndex]['status'] ?? 'pending');
|
||||
|
||||
$newStatus = $currentStatus === 'completed' ? 'pending' : 'completed';
|
||||
|
||||
if (is_string($tasks[$taskIndex])) {
|
||||
$tasks[$taskIndex] = [
|
||||
'name' => $tasks[$taskIndex],
|
||||
'status' => $newStatus,
|
||||
];
|
||||
} else {
|
||||
$tasks[$taskIndex]['status'] = $newStatus;
|
||||
}
|
||||
|
||||
$phase->update(['tasks' => $tasks]);
|
||||
|
||||
return $this->success([
|
||||
'task' => $tasks[$taskIndex],
|
||||
'plan_progress' => $plan->fresh()->getProgress(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a phase by order number or name.
|
||||
*/
|
||||
protected function findPhase(AgentPlan $plan, string|int $identifier): ?AgentPhase
|
||||
{
|
||||
if (is_numeric($identifier)) {
|
||||
return $plan->agentPhases()->where('order', (int) $identifier)->first();
|
||||
}
|
||||
|
||||
return $plan->agentPhases()
|
||||
->where('name', $identifier)
|
||||
->first();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,9 +5,8 @@ declare(strict_types=1);
|
|||
namespace Core\Mod\Agentic\Mcp\Tools\Agent\Task;
|
||||
|
||||
use Core\Mcp\Dependencies\ToolDependency;
|
||||
use Core\Mod\Agentic\Actions\Task\UpdateTask;
|
||||
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
|
||||
use Core\Mod\Agentic\Models\AgentPhase;
|
||||
use Core\Mod\Agentic\Models\AgentPlan;
|
||||
|
||||
/**
|
||||
* Update task details (status, notes).
|
||||
|
|
@ -73,71 +72,24 @@ class TaskUpdate extends AgentTool
|
|||
|
||||
public function handle(array $args, array $context = []): array
|
||||
{
|
||||
try {
|
||||
$planSlug = $this->requireString($args, 'plan_slug', 255);
|
||||
$phaseIdentifier = $this->requireString($args, 'phase', 255);
|
||||
$taskIndex = $this->requireInt($args, 'task_index', min: 0, max: 1000);
|
||||
$workspaceId = $context['workspace_id'] ?? null;
|
||||
if ($workspaceId === null) {
|
||||
return $this->error('workspace_id is required');
|
||||
}
|
||||
|
||||
// Validate optional status enum
|
||||
$status = $this->optionalEnum($args, 'status', ['pending', 'in_progress', 'completed', 'blocked', 'skipped']);
|
||||
$notes = $this->optionalString($args, 'notes', null, 5000);
|
||||
try {
|
||||
$result = UpdateTask::run(
|
||||
$args['plan_slug'] ?? '',
|
||||
$args['phase'] ?? '',
|
||||
(int) ($args['task_index'] ?? 0),
|
||||
(int) $workspaceId,
|
||||
$args['status'] ?? null,
|
||||
$args['notes'] ?? null,
|
||||
);
|
||||
|
||||
return $this->success($result);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return $this->error($e->getMessage());
|
||||
}
|
||||
|
||||
$plan = AgentPlan::where('slug', $planSlug)->first();
|
||||
|
||||
if (! $plan) {
|
||||
return $this->error("Plan not found: {$planSlug}");
|
||||
}
|
||||
|
||||
$phase = $this->findPhase($plan, $phaseIdentifier);
|
||||
|
||||
if (! $phase) {
|
||||
return $this->error("Phase not found: {$phaseIdentifier}");
|
||||
}
|
||||
|
||||
$tasks = $phase->tasks ?? [];
|
||||
|
||||
if (! isset($tasks[$taskIndex])) {
|
||||
return $this->error("Task not found at index: {$taskIndex}");
|
||||
}
|
||||
|
||||
// Normalise task to array format
|
||||
if (is_string($tasks[$taskIndex])) {
|
||||
$tasks[$taskIndex] = ['name' => $tasks[$taskIndex], 'status' => 'pending'];
|
||||
}
|
||||
|
||||
// Update fields using pre-validated values
|
||||
if ($status !== null) {
|
||||
$tasks[$taskIndex]['status'] = $status;
|
||||
}
|
||||
|
||||
if ($notes !== null) {
|
||||
$tasks[$taskIndex]['notes'] = $notes;
|
||||
}
|
||||
|
||||
$phase->update(['tasks' => $tasks]);
|
||||
|
||||
return $this->success([
|
||||
'task' => $tasks[$taskIndex],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a phase by order number or name.
|
||||
*/
|
||||
protected function findPhase(AgentPlan $plan, string|int $identifier): ?AgentPhase
|
||||
{
|
||||
if (is_numeric($identifier)) {
|
||||
return $plan->agentPhases()->where('order', (int) $identifier)->first();
|
||||
}
|
||||
|
||||
return $plan->agentPhases()
|
||||
->where(function ($query) use ($identifier) {
|
||||
$query->where('name', $identifier)
|
||||
->orWhere('order', $identifier);
|
||||
})
|
||||
->first();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,33 @@
|
|||
<?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();
|
||||
});
|
||||
}
|
||||
};
|
||||
69
Migrations/0001_01_01_000007_add_template_versions.php
Normal file
69
Migrations/0001_01_01_000007_add_template_versions.php
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
<?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();
|
||||
}
|
||||
};
|
||||
59
Migrations/0001_01_01_000008_create_brain_memories_table.php
Normal file
59
Migrations/0001_01_01_000008_create_brain_memories_table.php
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
<?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');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
<?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.
|
||||
}
|
||||
};
|
||||
31
Migrations/0001_01_01_000010_rename_session_columns.php
Normal file
31
Migrations/0001_01_01_000010_rename_session_columns.php
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
<?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');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -317,11 +317,17 @@ class AgentPhase extends Model
|
|||
public function checkDependencies(): array
|
||||
{
|
||||
$dependencies = $this->dependencies ?? [];
|
||||
|
||||
if (empty($dependencies)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$blockers = [];
|
||||
|
||||
foreach ($dependencies as $depId) {
|
||||
$dep = AgentPhase::find($depId);
|
||||
if ($dep && ! $dep->isCompleted() && ! $dep->isSkipped()) {
|
||||
$deps = AgentPhase::whereIn('id', $dependencies)->get();
|
||||
|
||||
foreach ($deps as $dep) {
|
||||
if (! $dep->isCompleted() && ! $dep->isSkipped()) {
|
||||
$blockers[] = [
|
||||
'phase_id' => $dep->id,
|
||||
'phase_order' => $dep->order,
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ 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;
|
||||
|
|
@ -33,6 +34,8 @@ 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
|
||||
*/
|
||||
|
|
@ -44,6 +47,7 @@ class AgentPlan extends Model
|
|||
use HasFactory;
|
||||
|
||||
use LogsActivity;
|
||||
use SoftDeletes;
|
||||
|
||||
protected static function newFactory(): AgentPlanFactory
|
||||
{
|
||||
|
|
@ -61,12 +65,15 @@ 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
|
||||
|
|
@ -96,7 +103,12 @@ class AgentPlan extends Model
|
|||
|
||||
public function states(): HasMany
|
||||
{
|
||||
return $this->hasMany(AgentWorkspaceState::class);
|
||||
return $this->hasMany(WorkspaceState::class);
|
||||
}
|
||||
|
||||
public function templateVersion(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(PlanTemplateVersion::class, 'template_version_id');
|
||||
}
|
||||
|
||||
// Scopes
|
||||
|
|
@ -166,11 +178,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,
|
||||
]);
|
||||
|
||||
|
|
@ -228,7 +240,7 @@ class AgentPlan extends Model
|
|||
return $state?->value;
|
||||
}
|
||||
|
||||
public function setState(string $key, mixed $value, string $type = 'json', ?string $description = null): AgentWorkspaceState
|
||||
public function setState(string $key, mixed $value, string $type = 'json', ?string $description = null): WorkspaceState
|
||||
{
|
||||
return $this->states()->updateOrCreate(
|
||||
['key' => $key],
|
||||
|
|
|
|||
|
|
@ -1,116 +0,0 @@
|
|||
<?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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
190
Models/BrainMemory.php
Normal file
190
Models/BrainMemory.php
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
<?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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
92
Models/PlanTemplateVersion.php
Normal file
92
Models/PlanTemplateVersion.php
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
<?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();
|
||||
}
|
||||
}
|
||||
|
|
@ -12,12 +12,25 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|||
/**
|
||||
* Workspace State Model
|
||||
*
|
||||
* Key-value state storage for agent plans with typed content.
|
||||
* 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
|
||||
*/
|
||||
class WorkspaceState extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $table = 'agent_workspace_states';
|
||||
|
||||
public const TYPE_JSON = 'json';
|
||||
|
||||
public const TYPE_MARKDOWN = 'markdown';
|
||||
|
|
@ -31,34 +44,27 @@ class WorkspaceState extends Model
|
|||
'key',
|
||||
'value',
|
||||
'type',
|
||||
'metadata',
|
||||
'description',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'metadata' => 'array',
|
||||
'value' => 'array',
|
||||
];
|
||||
|
||||
protected $attributes = [
|
||||
'type' => self::TYPE_JSON,
|
||||
'metadata' => '{}',
|
||||
];
|
||||
// Relationships
|
||||
|
||||
public function plan(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(AgentPlan::class, 'agent_plan_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get typed value.
|
||||
*/
|
||||
public function getTypedValue(): mixed
|
||||
{
|
||||
return match ($this->type) {
|
||||
self::TYPE_JSON => json_decode($this->value, true),
|
||||
default => $this->value,
|
||||
};
|
||||
}
|
||||
// Scopes
|
||||
|
||||
public function scopeForPlan($query, AgentPlan|int $plan): mixed
|
||||
{
|
||||
$planId = $plan instanceof AgentPlan ? $plan->id : $plan;
|
||||
|
||||
<<<<<<< HEAD
|
||||
/**
|
||||
* Set typed value.
|
||||
*/
|
||||
|
|
@ -145,4 +151,71 @@ 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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
60
Routes/api.php
Normal file
60
Routes/api.php
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
<?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']);
|
||||
});
|
||||
|
|
@ -156,6 +156,9 @@ 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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -164,6 +167,9 @@ 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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -17,106 +17,221 @@ 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.
|
||||
*
|
||||
* @var array<string, array{pattern: string, model_pattern: ?string}>
|
||||
* 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>}>
|
||||
*/
|
||||
protected const PROVIDER_PATTERNS = [
|
||||
'anthropic' => [
|
||||
'patterns' => [
|
||||
'/claude[\s\-_]?code/i',
|
||||
'/\banthopic\b/i',
|
||||
'/\banthropic[\s\-_]?api\b/i',
|
||||
'/\bclaude\b.*\bai\b/i',
|
||||
'/\bclaude\b.*\bassistant\b/i',
|
||||
'/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"
|
||||
],
|
||||
'model_patterns' => [
|
||||
'claude-opus' => '/claude[\s\-_]?opus/i',
|
||||
'claude-sonnet' => '/claude[\s\-_]?sonnet/i',
|
||||
'claude-haiku' => '/claude[\s\-_]?haiku/i',
|
||||
'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"
|
||||
],
|
||||
],
|
||||
'openai' => [
|
||||
'patterns' => [
|
||||
'/\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',
|
||||
'/\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"
|
||||
],
|
||||
'model_patterns' => [
|
||||
'gpt-4' => '/\bGPT[\s\-_]?4/i',
|
||||
'gpt-3.5' => '/\bGPT[\s\-_]?3\.?5/i',
|
||||
'o1' => '/\bo1[\s\-_]?(preview|mini)?\b/i',
|
||||
'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"
|
||||
],
|
||||
],
|
||||
'google' => [
|
||||
'patterns' => [
|
||||
'/\bGoogle[\s\-_]?AI\b/i',
|
||||
'/\bGemini\b/i',
|
||||
'/\bBard\b/i',
|
||||
'/\bPaLM\b/i',
|
||||
'/\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)
|
||||
],
|
||||
'model_patterns' => [
|
||||
'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',
|
||||
'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"
|
||||
],
|
||||
],
|
||||
'meta' => [
|
||||
'patterns' => [
|
||||
'/\bMeta[\s\-_]?AI\b/i',
|
||||
'/\bLLaMA\b/i',
|
||||
'/\bLlama[\s\-_]?[23]\b/i',
|
||||
'/\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"
|
||||
],
|
||||
'model_patterns' => [
|
||||
'llama-3' => '/llama[\s\-_]?3/i',
|
||||
'llama-2' => '/llama[\s\-_]?2/i',
|
||||
'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"
|
||||
],
|
||||
],
|
||||
'mistral' => [
|
||||
'patterns' => [
|
||||
'/\bMistral\b/i',
|
||||
'/\bMixtral\b/i',
|
||||
'/\bMistral\b/i', // e.g. "Mistral/0.1.0 mistralai-python/0.1"
|
||||
'/\bMixtral\b/i', // e.g. "Mixtral-8x7B/1.0"
|
||||
],
|
||||
'model_patterns' => [
|
||||
'mistral-large' => '/mistral[\s\-_]?large/i',
|
||||
'mistral-medium' => '/mistral[\s\-_]?medium/i',
|
||||
'mixtral' => '/mixtral/i',
|
||||
'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"
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
/**
|
||||
* Patterns that indicate a typical web browser.
|
||||
* If none of these are present, it might be programmatic access.
|
||||
*
|
||||
* 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)
|
||||
*/
|
||||
protected const BROWSER_INDICATORS = [
|
||||
'/\bMozilla\b/i',
|
||||
'/\bChrome\b/i',
|
||||
'/\bSafari\b/i',
|
||||
'/\bFirefox\b/i',
|
||||
'/\bEdge\b/i',
|
||||
'/\bOpera\b/i',
|
||||
'/\bMSIE\b/i',
|
||||
'/\bTrident\b/i',
|
||||
'/\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
|
||||
];
|
||||
|
||||
/**
|
||||
* Known bot patterns that are NOT AI agents.
|
||||
* These should return notAnAgent, not unknown.
|
||||
*
|
||||
* 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"
|
||||
*/
|
||||
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',
|
||||
|
|
@ -124,17 +239,22 @@ class AgentDetection
|
|||
'/\bDiscordBot\b/i',
|
||||
'/\bTelegramBot\b/i',
|
||||
'/\bWhatsApp\//i',
|
||||
'/\bApplebot\b/i',
|
||||
|
||||
// SEO / analytics crawlers
|
||||
'/\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\b/i',
|
||||
'/\bPostman/i',
|
||||
'/\bInsomnia\b/i',
|
||||
'/\baxios\b/i',
|
||||
'/\bnode-fetch\b/i',
|
||||
|
||||
// Uptime / monitoring services
|
||||
'/\bUptimeRobot\b/i',
|
||||
'/\bPingdom\b/i',
|
||||
'/\bDatadog\b/i',
|
||||
|
|
@ -142,7 +262,19 @@ class AgentDetection
|
|||
];
|
||||
|
||||
/**
|
||||
* The MCP token header name.
|
||||
* 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`
|
||||
*/
|
||||
protected const MCP_TOKEN_HEADER = 'X-MCP-Token';
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ 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.
|
||||
|
|
@ -98,24 +99,57 @@ 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
|
||||
{
|
||||
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;
|
||||
}
|
||||
}
|
||||
$cacheKey = $this->apiKeyCacheKey($apiKey->getKey());
|
||||
|
||||
// Check if API key has tool-level permission
|
||||
return $this->apiKeyCanAccessTool($apiKey, $tool->name());
|
||||
$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;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if API key has tool-level permission
|
||||
return $this->apiKeyCanAccessTool($apiKey, $tool->name());
|
||||
})->keys()->all();
|
||||
});
|
||||
|
||||
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}";
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ declare(strict_types=1);
|
|||
|
||||
namespace Core\Mod\Agentic\Services;
|
||||
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use InvalidArgumentException;
|
||||
|
||||
class AgenticManager
|
||||
|
|
@ -91,22 +92,46 @@ 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: config('services.anthropic.api_key') ?? '',
|
||||
apiKey: $claudeKey,
|
||||
model: config('services.anthropic.model') ?? 'claude-sonnet-4-20250514',
|
||||
);
|
||||
|
||||
$this->providers['gemini'] = new GeminiService(
|
||||
apiKey: config('services.google.ai_api_key') ?? '',
|
||||
apiKey: $geminiKey,
|
||||
model: config('services.google.ai_model') ?? 'gemini-2.0-flash',
|
||||
);
|
||||
|
||||
$this->providers['openai'] = new OpenAIService(
|
||||
apiKey: config('services.openai.api_key') ?? '',
|
||||
apiKey: $openaiKey,
|
||||
model: config('services.openai.model') ?? 'gpt-4o-mini',
|
||||
);
|
||||
}
|
||||
|
|
|
|||
277
Services/BrainService.php
Normal file
277
Services/BrainService.php
Normal file
|
|
@ -0,0 +1,277 @@
|
|||
<?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()]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -9,6 +9,8 @@ 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
|
||||
{
|
||||
|
|
@ -58,28 +60,47 @@ 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 {
|
||||
$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],
|
||||
],
|
||||
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,
|
||||
]);
|
||||
|
||||
yield from $this->parseSSEStream(
|
||||
$response->getBody(),
|
||||
fn (array $data) => $data['delta']['text'] ?? null
|
||||
);
|
||||
yield ['type' => 'error', 'message' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
|
|
|
|||
|
|
@ -119,6 +119,7 @@ trait HasStreamParsing
|
|||
$inString = false;
|
||||
$escape = false;
|
||||
$objectStart = -1;
|
||||
$scanPos = 0;
|
||||
|
||||
while (! $stream->eof()) {
|
||||
$chunk = $stream->read(8192);
|
||||
|
|
@ -129,9 +130,10 @@ trait HasStreamParsing
|
|||
|
||||
$buffer .= $chunk;
|
||||
|
||||
// Parse JSON objects from the buffer
|
||||
// Parse JSON objects from the buffer, continuing from where
|
||||
// the previous iteration left off to preserve parser state.
|
||||
$length = strlen($buffer);
|
||||
$i = 0;
|
||||
$i = $scanPos;
|
||||
|
||||
while ($i < $length) {
|
||||
$char = $buffer[$i];
|
||||
|
|
@ -176,6 +178,7 @@ trait HasStreamParsing
|
|||
$buffer = substr($buffer, $i + 1);
|
||||
$length = strlen($buffer);
|
||||
$i = -1; // Will be incremented to 0
|
||||
$scanPos = 0;
|
||||
$objectStart = -1;
|
||||
}
|
||||
}
|
||||
|
|
@ -183,6 +186,9 @@ trait HasStreamParsing
|
|||
|
||||
$i++;
|
||||
}
|
||||
|
||||
// Save scan position so we resume from here on the next chunk
|
||||
$scanPos = $i;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -118,15 +118,21 @@ 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
|
||||
bool $dryRun = false,
|
||||
int $maxRetries = 1,
|
||||
): array {
|
||||
$spec = $this->loadBatch($batchId);
|
||||
if (! $spec) {
|
||||
|
|
@ -144,6 +150,13 @@ 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) {
|
||||
|
|
@ -152,10 +165,13 @@ class ContentService
|
|||
|
||||
$draftPath = $this->getDraftPath($spec, $slug);
|
||||
|
||||
// Skip if already drafted
|
||||
// Skip if draft file already exists on disk
|
||||
if (File::exists($draftPath)) {
|
||||
$results['articles'][$slug] = ['status' => 'skipped', 'reason' => 'already drafted'];
|
||||
$results['skipped']++;
|
||||
if ($progress !== null) {
|
||||
$progress['articles'][$slug]['status'] = 'skipped';
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
|
@ -166,20 +182,168 @@ class ContentService
|
|||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$content = $this->generateArticle($article, $spec, $promptTemplate, $provider);
|
||||
$this->saveDraft($draftPath, $content, $article);
|
||||
$results['articles'][$slug] = ['status' => 'generated', 'path' => $draftPath];
|
||||
$results['generated']++;
|
||||
} catch (\Exception $e) {
|
||||
$results['articles'][$slug] = ['status' => 'failed', 'error' => $e->getMessage()];
|
||||
$results['failed']++;
|
||||
// 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];
|
||||
} catch (\Exception $e) {
|
||||
$lastError = $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a single article.
|
||||
*/
|
||||
|
|
|
|||
203
Services/ForgejoService.php
Normal file
203
Services/ForgejoService.php
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
<?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();
|
||||
}
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ 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;
|
||||
|
|
@ -146,6 +147,10 @@ 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);
|
||||
|
||||
|
|
@ -164,10 +169,12 @@ 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(),
|
||||
]),
|
||||
|
|
@ -329,13 +336,18 @@ 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']];
|
||||
return ['valid' => false, 'errors' => ['Template not found'], 'naming_convention' => self::NAMING_CONVENTION];
|
||||
}
|
||||
|
||||
$errors = [];
|
||||
|
|
@ -344,16 +356,94 @@ class PlanTemplateService
|
|||
$required = $varDef['required'] ?? true;
|
||||
|
||||
if ($required && ! isset($variables[$name]) && ! isset($varDef['default'])) {
|
||||
$errors[] = "Required variable '{$name}' is missing";
|
||||
$errors[] = $this->buildVariableError($name, $varDef);
|
||||
}
|
||||
}
|
||||
|
||||
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
43
TODO.md
|
|
@ -104,10 +104,11 @@ 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
|
||||
|
||||
- [ ] **ERR-002: ContentService has no batch failure recovery**
|
||||
- [x] **ERR-002: ContentService has no batch failure recovery** (FIXED 2026-02-23)
|
||||
- Location: `Services/ContentService.php::generateBatch()`
|
||||
- Issue: Failed articles stop processing, no resume capability
|
||||
- Fix: Add progress tracking, allow resuming from failed point
|
||||
- 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
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -115,26 +116,29 @@ Production-quality task list for the AI agent orchestration package.
|
|||
|
||||
### Developer Experience
|
||||
|
||||
- [ ] **DX-001: Missing workspace context error messages unclear**
|
||||
- [x] **DX-001: Missing workspace context error messages unclear** (FIXED 2026-02-23)
|
||||
- Location: Multiple MCP tools
|
||||
- Issue: "workspace_id is required" doesn't explain how to fix
|
||||
- Fix: Include context about authentication/session setup
|
||||
- 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
|
||||
|
||||
- [ ] **DX-002: AgenticManager doesn't validate API keys on init**
|
||||
- [x] **DX-002: AgenticManager doesn't validate API keys on init** (FIXED 2026-02-23)
|
||||
- Location: `Services/AgenticManager.php::registerProviders()`
|
||||
- Issue: Empty API key creates provider that fails on first use
|
||||
- Fix: Log warning or throw if provider configured without key
|
||||
- Fix: `Log::warning()` emitted for each provider registered without an API key; message names the env var to set
|
||||
|
||||
- [ ] **DX-003: Plan template variable errors not actionable**
|
||||
- [x] **DX-003: Plan template variable errors not actionable** (FIXED 2026-02-23)
|
||||
- Location: `Services/PlanTemplateService.php::validateVariables()`
|
||||
- Fix: Include expected format, examples in error messages
|
||||
- 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
|
||||
|
||||
### Code Quality
|
||||
|
||||
- [ ] **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-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
|
||||
|
||||
- [x] **CQ-002: ApiKeyManager uses Core\Api\ApiKey, not AgentApiKey** (FIXED 2026-02-23)
|
||||
- Location: `View/Modal/Admin/ApiKeyManager.php`
|
||||
|
|
@ -168,10 +172,10 @@ Production-quality task list for the AI agent orchestration package.
|
|||
|
||||
### Documentation Gaps
|
||||
|
||||
- [ ] **DOC-001: Add PHPDoc to AgentDetection patterns**
|
||||
- [x] **DOC-001: Add PHPDoc to AgentDetection patterns** (FIXED 2026-02-23)
|
||||
- Location: `Services/AgentDetection.php`
|
||||
- Issue: User-Agent patterns undocumented
|
||||
- Fix: Document each pattern with agent examples
|
||||
- Fix: Added PHPDoc with real UA examples to all pattern constants, class-level usage examples, and MCP_TOKEN_HEADER docs
|
||||
|
||||
- [ ] **DOC-002: Document MCP tool dependency system**
|
||||
- Location: `Mcp/Tools/Agent/` directory
|
||||
|
|
@ -188,16 +192,17 @@ Production-quality task list for the AI agent orchestration package.
|
|||
- Issue: Archived plans kept forever
|
||||
- Fix: Add configurable retention period, cleanup job
|
||||
|
||||
- [ ] **FEAT-003: Template version management**
|
||||
- Location: `Services/PlanTemplateService.php`
|
||||
- [x] **FEAT-003: Template version management**
|
||||
- Location: `Services/PlanTemplateService.php`, `Models/PlanTemplateVersion.php`
|
||||
- Issue: Template changes affect existing plan references
|
||||
- Fix: Add version tracking to templates
|
||||
- Fix: Add version tracking to templates — implemented in #35
|
||||
|
||||
### Consistency
|
||||
|
||||
- [ ] **CON-001: Mixed UK/US spelling in code comments**
|
||||
- [x] **CON-001: Mixed UK/US spelling in code comments** (FIXED 2026-02-23)
|
||||
- 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, ...]`
|
||||
|
|
|
|||
37
agentic.php
Normal file
37
agentic.php
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
<?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', ''),
|
||||
|
||||
];
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"name": "host-uk/core-agentic",
|
||||
"name": "lthn/php-agentic",
|
||||
"description": "AI agent orchestration and MCP tools for Laravel",
|
||||
"keywords": [
|
||||
"ai",
|
||||
|
|
@ -10,7 +10,7 @@
|
|||
"license": "EUPL-1.2",
|
||||
"require": {
|
||||
"php": "^8.2",
|
||||
"host-uk/core": "dev-main"
|
||||
"lthn/php": "*"
|
||||
},
|
||||
"require-dev": {
|
||||
"laravel/pint": "^1.18",
|
||||
|
|
@ -49,5 +49,8 @@
|
|||
}
|
||||
},
|
||||
"minimum-stability": "dev",
|
||||
"prefer-stable": true
|
||||
"prefer-stable": true,
|
||||
"replace": {
|
||||
"core/php-agentic": "self.version"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
42
config.php
42
config.php
|
|
@ -7,15 +7,12 @@ return [
|
|||
| MCP Portal Domain
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| 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)
|
||||
| Default domain for the MCP Portal. The app-level Boot may override this
|
||||
| with a wildcard (e.g. mcp.{tld}) for multi-domain support.
|
||||
|
|
||||
*/
|
||||
|
||||
'domain' => env('MCP_DOMAIN', 'mcp.host.uk.com'),
|
||||
'domain' => env('MCP_DOMAIN', 'mcp.'.env('APP_DOMAIN', 'host.uk.com')),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
@ -71,4 +68,37 @@ 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' => '',
|
||||
],
|
||||
],
|
||||
|
||||
];
|
||||
|
|
|
|||
213
docs/plans/2026-03-03-openbrain-design.md
Normal file
213
docs/plans/2026-03-03-openbrain-design.md
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
# 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.0–1.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
|
||||
1722
docs/plans/2026-03-03-openbrain-impl.md
Normal file
1722
docs/plans/2026-03-03-openbrain-impl.md
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1,3 +0,0 @@
|
|||
<?php
|
||||
|
||||
// API routes are registered via Core modules
|
||||
|
|
@ -286,6 +286,78 @@ 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([
|
||||
|
|
|
|||
|
|
@ -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->metadata['archived_at']);
|
||||
$this->assertNotNull($fresh->archived_at);
|
||||
}
|
||||
|
||||
public function test_it_generates_unique_slugs(): void
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ declare(strict_types=1);
|
|||
* for all create, list, and revoke operations.
|
||||
*/
|
||||
|
||||
use Carbon\Carbon;
|
||||
use Core\Mod\Agentic\Models\AgentApiKey;
|
||||
use Core\Mod\Agentic\Services\AgentApiKeyService;
|
||||
use Core\Tenant\Models\Workspace;
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Reference in a new issue