agent/php/Services/ProfileSelector.php
Snider 060f5abb66 feat(agent/php): ProfileSelector + ShapeClassifier services per Phase 3 (#826)
ShapeClassifier::classify($ticket) returns 'A'|'B'|'C' per policy v1:
- (severity=critical OR priority=urgent) → A
- has tag in ['security','crypto','core'] → A
- (severity=major OR priority=high) → B
- everything else → C

ProfileSelector::pickFor($ticket) walks AgentProfile::active(), matches
capability tags case-insensitively against ticket.tags:
- Class A: cheapest matching profile (cost_class alphabetic order)
- Class B: any active profile with quota_headroom_pct >= 25
- Class C: deterministic round-robin via last_dispatched_at

Pest Unit tests cover Good (matching profile picked), Bad (no match → null),
Ugly (all profiles disabled → null), plus class A/B headroom gating + class C
round-robin determinism.

Codex note: php -l clean; pest skipped — no vendor/ at this repo root
(downstream lab/lthn.ai owns composer install).

Closes tasks.lthn.sh/view.php?id=826

Co-authored-by: Codex <noreply@openai.com>
2026-04-26 00:48:37 +01:00

208 lines
5.7 KiB
PHP

<?php
// SPDX-License-Identifier: EUPL-1.2
declare(strict_types=1);
namespace Core\Mod\Agentic\Services;
use ArrayAccess;
use Core\Mod\Agentic\Models\AgentProfile;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
class ProfileSelector
{
private ShapeClassifier $shapeClassifier;
public function __construct(?ShapeClassifier $shapeClassifier = null)
{
$this->shapeClassifier = $shapeClassifier ?? new ShapeClassifier;
}
/**
* @param array<string, mixed>|object $ticket
*/
public function pickFor(mixed $ticket): ?AgentProfile
{
return match ($this->shapeClassifier->classify($ticket)) {
ShapeClassifier::CLASS_A => $this->pickClassA($ticket),
ShapeClassifier::CLASS_B => $this->pickClassB($ticket),
default => $this->pickClassC(),
};
}
/**
* @param array<string, mixed>|object $ticket
*/
private function pickClassA(mixed $ticket): ?AgentProfile
{
$requiredCapabilities = $this->requiredCapabilities($ticket);
if ($requiredCapabilities === []) {
return null;
}
$profiles = AgentProfile::query()
->active()
->orderBy('cost_class')
->orderBy('id')
->get();
foreach ($profiles as $profile) {
if ($this->matchesRequiredCapability($profile, $requiredCapabilities)) {
$this->recordDispatch($profile);
return $profile->fresh();
}
}
return null;
}
/**
* @param array<string, mixed>|object $ticket
*/
private function pickClassB(mixed $ticket): ?AgentProfile
{
$requiredCapabilities = $this->requiredCapabilities($ticket);
if ($requiredCapabilities === []) {
return null;
}
$profiles = AgentProfile::query()
->active()
->where('quota_headroom_pct', '>=', 25)
->orderByRaw('CASE WHEN last_dispatched_at IS NULL THEN 0 ELSE 1 END')
->orderBy('last_dispatched_at')
->orderBy('id')
->get();
foreach ($profiles as $profile) {
if ($this->matchesRequiredCapability($profile, $requiredCapabilities)) {
$this->recordDispatch($profile);
return $profile->fresh();
}
}
return null;
}
private function pickClassC(): ?AgentProfile
{
return DB::transaction(function (): ?AgentProfile {
$profiles = AgentProfile::query()
->active()
->orderByRaw('CASE WHEN last_dispatched_at IS NULL THEN 0 ELSE 1 END')
->orderBy('last_dispatched_at')
->orderBy('id')
->lockForUpdate()
->get();
$profile = $profiles->first();
if (! $profile instanceof AgentProfile) {
return null;
}
$this->recordDispatch($profile, $profiles);
return $profile->fresh();
});
}
/**
* @param list<string> $requiredCapabilities
*/
private function matchesRequiredCapability(AgentProfile $profile, array $requiredCapabilities): bool
{
$profileCapabilities = $this->normaliseTags($profile->capability_tags);
return array_intersect($requiredCapabilities, $profileCapabilities) !== [];
}
/**
* @param array<string, mixed>|object $ticket
* @return list<string>
*/
private function requiredCapabilities(mixed $ticket): array
{
return $this->normaliseTags($this->value($ticket, 'tags'));
}
/**
* @return list<string>
*/
private function normaliseTags(mixed $tags): array
{
if (! is_array($tags)) {
return [];
}
$normalised = [];
foreach ($tags as $key => $value) {
if (is_string($key) && $value) {
$tag = strtolower(trim($key));
if ($tag !== '') {
$normalised[] = $tag;
}
}
if (is_string($value)) {
$tag = strtolower(trim($value));
if ($tag !== '') {
$normalised[] = $tag;
}
}
}
return array_values(array_unique($normalised));
}
/**
* @param array<string, mixed>|object $ticket
*/
private function value(mixed $ticket, string $key): mixed
{
if (is_array($ticket)) {
return $ticket[$key] ?? null;
}
if ($ticket instanceof ArrayAccess && $ticket->offsetExists($key)) {
return $ticket[$key];
}
if (is_object($ticket) && property_exists($ticket, $key)) {
return $ticket->{$key};
}
return null;
}
private function recordDispatch(AgentProfile $profile, ?Collection $activeProfiles = null): void
{
$profile->forceFill([
'last_dispatched_at' => $this->dispatchTimestamp($activeProfiles),
])->save();
}
private function dispatchTimestamp(?Collection $activeProfiles = null): Carbon
{
$dispatchAt = now();
$latestDispatch = $activeProfiles
? $activeProfiles
->pluck('last_dispatched_at')
->filter(static fn (mixed $value): bool => $value instanceof Carbon)
->sortBy(static fn (Carbon $value): int => $value->getTimestamp())
->last()
: null;
if ($latestDispatch instanceof Carbon && $latestDispatch->greaterThanOrEqualTo($dispatchAt)) {
return $latestDispatch->copy()->addSecond();
}
return $dispatchAt;
}
}