Phase 3 lane: queueable Job that resolves a profile via ProfileSelector,
posts POST {gateway}/v1/responses to the chosen Hermes gateway, persists
ticket_id/profile_id/response_id/run_id/status in agent_dispatches, and
chains CaptureDispatchResultJob.
Migration 2026_04_25_000003 creates agent_dispatches table (FK-free,
Postgres-compatible).
HermesClient: thin Laravel HTTP wrapper around the Hermes /v1/responses
endpoint with Authorization header + JSON body.
DispatchMantisTicketJob behaviour:
- Resolves profile via ProfileSelector::pickFor()
- Null-profile → log warn + ->release(60) requeue
- Otherwise POSTs to gateway, persists AgentDispatch row, queues
CaptureDispatchResultJob
AgentDispatch Eloquent model with minimal $fillable.
Pest Feature test (Http::fake): verifies request shape, persisted row,
downstream capture-job queueing, and the no-profile requeue path.
Test file conditionally aliases minimal stubs for sibling-lane services
so this file remains runnable before #826/#828 fully land in dev.
Codex note: php -l clean; pest skipped (no vendor/).
Closes tasks.lthn.sh/view.php?id=827
Co-authored-by: Codex <noreply@openai.com>
163 lines
4.5 KiB
PHP
163 lines
4.5 KiB
PHP
<?php
|
|
|
|
// SPDX-License-Identifier: EUPL-1.2
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Core\Mod\Agentic\Jobs;
|
|
|
|
use Core\Mod\Agentic\Models\AgentDispatch;
|
|
use Core\Mod\Agentic\Models\AgentProfile;
|
|
use Core\Mod\Agentic\Services\HermesClient;
|
|
use Core\Mod\Agentic\Services\MantisClient;
|
|
use Core\Mod\Agentic\Services\ProfileSelector;
|
|
use Illuminate\Bus\Queueable;
|
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
|
use Illuminate\Foundation\Bus\Dispatchable;
|
|
use Illuminate\Queue\InteractsWithQueue;
|
|
use Illuminate\Queue\SerializesModels;
|
|
use Illuminate\Support\Facades\Log;
|
|
use RuntimeException;
|
|
|
|
class DispatchMantisTicketJob implements ShouldQueue
|
|
{
|
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
|
|
|
public int $tries = 5;
|
|
|
|
public int $backoff = 60;
|
|
|
|
public int $timeout = 120;
|
|
|
|
public function __construct(
|
|
public int $ticketId,
|
|
) {
|
|
$this->onQueue('ai');
|
|
}
|
|
|
|
public function handle(
|
|
MantisClient $mantisClient,
|
|
HermesClient $hermesClient,
|
|
?ProfileSelector $profileSelector = null,
|
|
): void {
|
|
$profileSelector ??= $this->resolveProfileSelector();
|
|
$ticket = $mantisClient->get($this->ticketId);
|
|
|
|
$profile = $this->pickProfile($profileSelector, $ticket);
|
|
|
|
if ($profile === null) {
|
|
Log::warning('DispatchMantisTicketJob: no agent profile available for Mantis ticket', [
|
|
'ticket_id' => $this->ticketId,
|
|
]);
|
|
|
|
$this->release(60);
|
|
|
|
return;
|
|
}
|
|
|
|
$response = $hermesClient->createResponse(
|
|
$profile,
|
|
$this->buildPrompt($ticket),
|
|
[
|
|
'conversation' => "mantis-{$this->ticketId}",
|
|
'ticket_id' => $this->ticketId,
|
|
],
|
|
);
|
|
|
|
$dispatch = $this->createDispatch($profile, $response);
|
|
|
|
CaptureDispatchResultJob::dispatch($this->ticketId, $dispatch->response_id);
|
|
}
|
|
|
|
/**
|
|
* @return array<int, string>
|
|
*/
|
|
public function tags(): array
|
|
{
|
|
return [
|
|
'mantis-dispatch',
|
|
"ticket:{$this->ticketId}",
|
|
];
|
|
}
|
|
|
|
private function resolveProfileSelector(): ?ProfileSelector
|
|
{
|
|
if (! class_exists(ProfileSelector::class)) {
|
|
return null;
|
|
}
|
|
|
|
return app(ProfileSelector::class);
|
|
}
|
|
|
|
private function pickProfile(?ProfileSelector $profileSelector, mixed $ticket): ?AgentProfile
|
|
{
|
|
if ($profileSelector === null) {
|
|
return null;
|
|
}
|
|
|
|
$profile = $profileSelector->pickFor($ticket);
|
|
|
|
return $profile instanceof AgentProfile ? $profile : null;
|
|
}
|
|
|
|
private function createDispatch(AgentProfile $profile, array $response): AgentDispatch
|
|
{
|
|
return AgentDispatch::query()->create([
|
|
'ticket_id' => $this->ticketId,
|
|
'profile_id' => $profile->getKey() === null ? null : (int) $profile->getKey(),
|
|
'response_id' => $this->extractResponseId($response),
|
|
'run_id' => $this->extractRunId($response),
|
|
'status' => $this->extractStatus($response),
|
|
]);
|
|
}
|
|
|
|
private function buildPrompt(mixed $ticket): string
|
|
{
|
|
foreach (['body', 'description', 'text', 'content', 'summary', 'title'] as $key) {
|
|
$value = data_get($ticket, $key);
|
|
|
|
if (is_string($value) && trim($value) !== '') {
|
|
return trim($value);
|
|
}
|
|
}
|
|
|
|
if (is_string($ticket) && trim($ticket) !== '') {
|
|
return trim($ticket);
|
|
}
|
|
|
|
$fallback = json_encode($ticket, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
|
|
|
if (is_string($fallback) && $fallback !== '') {
|
|
return $fallback;
|
|
}
|
|
|
|
return "Mantis ticket {$this->ticketId}";
|
|
}
|
|
|
|
private function extractResponseId(array $response): string
|
|
{
|
|
$responseId = data_get($response, 'id');
|
|
|
|
if (is_scalar($responseId) && $responseId !== '') {
|
|
return (string) $responseId;
|
|
}
|
|
|
|
throw new RuntimeException('Hermes response did not include an id');
|
|
}
|
|
|
|
private function extractRunId(array $response): ?string
|
|
{
|
|
$runId = data_get($response, 'run_id') ?? data_get($response, 'run.id');
|
|
|
|
return is_scalar($runId) && $runId !== '' ? (string) $runId : null;
|
|
}
|
|
|
|
private function extractStatus(array $response): string
|
|
{
|
|
$status = data_get($response, 'status');
|
|
|
|
return is_string($status) && $status !== ''
|
|
? $status
|
|
: AgentDispatch::STATUS_QUEUED;
|
|
}
|
|
}
|