agent/php/Jobs/DispatchMantisTicketJob.php
Snider 82ffd420e0 feat(agent/php): DispatchMantisTicketJob + HermesClient + agent_dispatches table (#827)
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>
2026-04-26 00:52:37 +01:00

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;
}
}