feat(agent/php): AgentProfile migration + Eloquent model + scopes (#825)
Adds Phase 3 foundation: agent_profiles table (Postgres-compatible, FK-free),
AgentProfile Eloquent model with encrypted api_key_cipher cast, array
capability_tags cast, datetime last_dispatched_at cast, active() and
forClass(string) local scopes.
AX-10 Pest Feature test covers:
- active() returns only enabled+headroom>0 rows
- active()->forClass('A') chains correctly
- api_key_cipher round-trips via encrypted cast
- capability_tags round-trips as array
Codex note: php -l clean on all 3 files; pest+artisan unavailable at
repo root for runtime verification (composer + bootstrap live downstream
in lab/lthn.ai).
Closes tasks.lthn.sh/view.php?id=825
Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
parent
084e69357f
commit
38dcb17083
3 changed files with 218 additions and 0 deletions
|
|
@ -0,0 +1,37 @@
|
|||
<?php
|
||||
|
||||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
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
|
||||
{
|
||||
if (Schema::hasTable('agent_profiles')) {
|
||||
return;
|
||||
}
|
||||
|
||||
Schema::create('agent_profiles', function (Blueprint $table): void {
|
||||
$table->bigIncrements('id');
|
||||
$table->string('name')->unique();
|
||||
$table->string('gateway_url');
|
||||
$table->text('api_key_cipher');
|
||||
$table->string('cost_class', 1);
|
||||
$table->json('capability_tags');
|
||||
$table->integer('quota_headroom_pct')->default(100);
|
||||
$table->boolean('enabled')->default(true);
|
||||
$table->timestamp('last_dispatched_at')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('agent_profiles');
|
||||
}
|
||||
};
|
||||
43
php/Models/AgentProfile.php
Normal file
43
php/Models/AgentProfile.php
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
<?php
|
||||
|
||||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Agentic\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class AgentProfile extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'gateway_url',
|
||||
'api_key_cipher',
|
||||
'cost_class',
|
||||
'capability_tags',
|
||||
'quota_headroom_pct',
|
||||
'enabled',
|
||||
'last_dispatched_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'api_key_cipher' => 'encrypted',
|
||||
'capability_tags' => 'array',
|
||||
'quota_headroom_pct' => 'integer',
|
||||
'enabled' => 'boolean',
|
||||
'last_dispatched_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function scopeActive(Builder $query): Builder
|
||||
{
|
||||
return $query->where('enabled', true)
|
||||
->where('quota_headroom_pct', '>', 0);
|
||||
}
|
||||
|
||||
public function scopeForClass(Builder $query, string $class): Builder
|
||||
{
|
||||
return $query->where('cost_class', $class);
|
||||
}
|
||||
}
|
||||
138
php/tests/Feature/AgentProfileTest.php
Normal file
138
php/tests/Feature/AgentProfileTest.php
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
<?php
|
||||
|
||||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Core\Mod\Agentic\Models\AgentProfile;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function (): void {
|
||||
if (! is_string(config('app.key')) || config('app.key') === '') {
|
||||
config(['app.key' => 'base64:'.base64_encode(random_bytes(32))]);
|
||||
}
|
||||
});
|
||||
|
||||
it('returns only enabled profiles with remaining headroom from the active scope', function () {
|
||||
$activeProfile = AgentProfile::create([
|
||||
'name' => 'active-profile',
|
||||
'gateway_url' => 'https://gateway-a.example.com',
|
||||
'api_key_cipher' => 'secret-active',
|
||||
'cost_class' => 'A',
|
||||
'capability_tags' => ['dispatch'],
|
||||
'quota_headroom_pct' => 100,
|
||||
'enabled' => true,
|
||||
]);
|
||||
|
||||
AgentProfile::create([
|
||||
'name' => 'disabled-profile',
|
||||
'gateway_url' => 'https://gateway-b.example.com',
|
||||
'api_key_cipher' => 'secret-disabled',
|
||||
'cost_class' => 'A',
|
||||
'capability_tags' => ['dispatch'],
|
||||
'quota_headroom_pct' => 100,
|
||||
'enabled' => false,
|
||||
]);
|
||||
|
||||
AgentProfile::create([
|
||||
'name' => 'exhausted-profile',
|
||||
'gateway_url' => 'https://gateway-c.example.com',
|
||||
'api_key_cipher' => 'secret-exhausted',
|
||||
'cost_class' => 'A',
|
||||
'capability_tags' => ['dispatch'],
|
||||
'quota_headroom_pct' => 0,
|
||||
'enabled' => true,
|
||||
]);
|
||||
|
||||
$profiles = AgentProfile::active()->get();
|
||||
|
||||
expect($profiles)->toHaveCount(1)
|
||||
->and($profiles->first()->id)->toBe($activeProfile->id);
|
||||
});
|
||||
|
||||
it('chains the active and forClass scopes correctly', function () {
|
||||
$matchingProfile = AgentProfile::create([
|
||||
'name' => 'class-a-active',
|
||||
'gateway_url' => 'https://gateway-d.example.com',
|
||||
'api_key_cipher' => 'secret-class-a',
|
||||
'cost_class' => 'A',
|
||||
'capability_tags' => ['dispatch'],
|
||||
'quota_headroom_pct' => 75,
|
||||
'enabled' => true,
|
||||
]);
|
||||
|
||||
AgentProfile::create([
|
||||
'name' => 'class-b-active',
|
||||
'gateway_url' => 'https://gateway-e.example.com',
|
||||
'api_key_cipher' => 'secret-class-b',
|
||||
'cost_class' => 'B',
|
||||
'capability_tags' => ['dispatch'],
|
||||
'quota_headroom_pct' => 75,
|
||||
'enabled' => true,
|
||||
]);
|
||||
|
||||
AgentProfile::create([
|
||||
'name' => 'class-a-disabled',
|
||||
'gateway_url' => 'https://gateway-f.example.com',
|
||||
'api_key_cipher' => 'secret-class-a-disabled',
|
||||
'cost_class' => 'A',
|
||||
'capability_tags' => ['dispatch'],
|
||||
'quota_headroom_pct' => 75,
|
||||
'enabled' => false,
|
||||
]);
|
||||
|
||||
AgentProfile::create([
|
||||
'name' => 'class-a-exhausted',
|
||||
'gateway_url' => 'https://gateway-g.example.com',
|
||||
'api_key_cipher' => 'secret-class-a-exhausted',
|
||||
'cost_class' => 'A',
|
||||
'capability_tags' => ['dispatch'],
|
||||
'quota_headroom_pct' => 0,
|
||||
'enabled' => true,
|
||||
]);
|
||||
|
||||
$profiles = AgentProfile::active()->forClass('A')->get();
|
||||
|
||||
expect($profiles)->toHaveCount(1)
|
||||
->and($profiles->first()->id)->toBe($matchingProfile->id);
|
||||
});
|
||||
|
||||
it('round-trips the api_key_cipher attribute through the encrypted cast', function () {
|
||||
$profile = AgentProfile::create([
|
||||
'name' => 'encrypted-profile',
|
||||
'gateway_url' => 'https://gateway-h.example.com',
|
||||
'api_key_cipher' => 'plain-secret-value',
|
||||
'cost_class' => 'A',
|
||||
'capability_tags' => ['dispatch', 'review'],
|
||||
'quota_headroom_pct' => 100,
|
||||
'enabled' => true,
|
||||
]);
|
||||
|
||||
$rawCiphertext = DB::table('agent_profiles')
|
||||
->where('id', $profile->id)
|
||||
->value('api_key_cipher');
|
||||
|
||||
expect($rawCiphertext)->not->toBe('plain-secret-value')
|
||||
->and($profile->fresh()->api_key_cipher)->toBe('plain-secret-value');
|
||||
});
|
||||
|
||||
it('round-trips capability tags as an array', function () {
|
||||
$profile = AgentProfile::create([
|
||||
'name' => 'tagged-profile',
|
||||
'gateway_url' => 'https://gateway-i.example.com',
|
||||
'api_key_cipher' => 'plain-secret-tags',
|
||||
'cost_class' => 'C',
|
||||
'capability_tags' => ['dispatch', 'analysis', 'handoff'],
|
||||
'quota_headroom_pct' => 100,
|
||||
'enabled' => true,
|
||||
]);
|
||||
|
||||
expect($profile->fresh()->capability_tags)->toBe([
|
||||
'dispatch',
|
||||
'analysis',
|
||||
'handoff',
|
||||
]);
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue