validate([ 'message' => ['required', 'string', 'max:2000'], ]); // Persist the conversation in the agent's last_decision for audit // and stream the pending state to any connected SSE listener. $history = $agent->last_decision ?? []; $history[] = ['role' => 'user', 'content' => $data['message'], 'at' => now()->toIso8601String()]; $agent->update([ 'last_decision' => array_slice($history, -20), 'status' => 'thinking', ]); self::publish($agent, ['type' => 'thinking', 'agent_id' => $agent->id, 'status' => 'thinking']); // Fire the OpenRouter call without blocking the HTTP response. dispatch(function () use ($agent, $data): void { self::think($agent, $data['message']); }); return response()->json([ 'status' => 'accepted', 'agent_id' => $agent->id, 'message' => __('city.chat_accepted'), ], 202); } /** * Stream agent status and replies as Server-Sent Events. */ public function stream(Request $request, AiAgent $agent): StreamedResponse { $request->session()->save(); return response()->stream(function () use ($agent): void { echo "retry: 3000\n\n"; flush(); $last = null; while (true) { if (connection_aborted()) { break; } // Poll the cache for a message published by think(). $payload = cache()->pull("chat.{$agent->id}"); if ($payload !== null) { $json = json_encode($payload, JSON_UNESCAPED_UNICODE); echo "event: message\n"; echo "data: {$json}\n\n"; flush(); $last = $payload; } // Heartbeat keeps proxies from closing the stream. echo ": ping\n\n"; flush(); sleep(2); } }, 200, [ 'Content-Type' => 'text/event-stream', 'Cache-Control' => 'no-cache', 'X-Accel-Buffering' => 'no', ]); } /** * Call OpenRouter with the agent persona and simulated city context. * * Never includes personal data — only city state (RULES.md 2.2). */ private static function think(AiAgent $agent, string $userMessage): void { try { $apiKey = config('ai.openrouter.api_key'); if (! filled($apiKey)) { self::fail($agent, 'OPENROUTER_API_KEY not configured'); return; } // Simulated city context only — no PII (RULES.md 2.2). $cityContext = [ 'stores' => Store::query() ->get(['name', 'type', 'is_open']) ->map(fn ($s) => ['name' => $s->name, 'type' => $s->type, 'open' => $s->is_open]) ->all(), 'agent_name' => $agent->name, 'agent_type' => $agent->type, 'current_goal' => $agent->current_goal, ]; $system = ($agent->system_prompt ?: "Você é {$agent->name}, um habitante da cidade virtual City10.") ."\n\nContexto simulado da cidade: ".json_encode($cityContext, JSON_UNESCAPED_UNICODE) ."\nResponda de forma curta e natural, em português. Não invente dados pessoais de usuários."; // Preferred agent model first, then the configured fallback // chain (AI_FALLBACK_MODEL) before failing the turn. $models = [$agent->model]; $fallback = config('ai.fallback_model'); if (is_string($fallback) && $fallback !== '' && $fallback !== $agent->model && in_array($fallback, config('ai.allowed_models', []), true)) { $models[] = $fallback; } $response = null; foreach ($models as $candidate) { try { $response = Http::withHeaders([ 'Authorization' => 'Bearer '.$apiKey, 'HTTP-Referer' => config('ai.openrouter.app_url'), 'X-Title' => config('ai.openrouter.app_name'), ])->timeout((int) config('ai.openrouter.timeout', 30)) ->post(config('ai.openrouter.base_url').'/chat/completions', [ 'model' => $candidate, 'messages' => [ ['role' => 'system', 'content' => $system], ['role' => 'user', 'content' => $userMessage], ], 'max_tokens' => 300, ]); } catch (\Throwable $e) { Log::warning('NPC think model exception', ['model' => $candidate, 'error' => $e->getMessage()]); $response = null; continue; } if ($response->failed()) { Log::warning('NPC think model failed', ['model' => $candidate, 'status' => $response->status()]); $response = null; continue; } break; } if ($response === null) { self::fail($agent, 'all models failed'); return; } $reply = $response->json('choices.0.message.content') ?? __('city.chat_error'); $history = $agent->last_decision ?? []; $history[] = ['role' => 'assistant', 'content' => $reply, 'at' => now()->toIso8601String()]; $agent->update([ 'last_decision' => array_slice($history, -20), 'status' => 'active', 'error_count' => 0, 'last_think_at' => now(), ]); self::publish($agent, [ 'type' => 'reply', 'agent_id' => $agent->id, 'name' => $agent->name, 'reply' => $reply, 'status' => 'active', ]); } catch (\Throwable $e) { Log::error('NPC think failed', ['agent' => $agent->id, 'error' => $e->getMessage()]); self::fail($agent, $e->getMessage()); } } /** * Record a failure with exponential backoff (RULES.md 2.4). */ private static function fail(AiAgent $agent, string $reason): void { $errors = (int) $agent->error_count + 1; $backoff = min(300, 2 ** min($errors, 8)); $agent->update([ 'status' => $errors >= 5 ? 'error' : 'active', 'error_count' => $errors, ]); self::publish($agent, [ 'type' => 'error', 'agent_id' => $agent->id, 'reply' => __('city.chat_error'), 'status' => $agent->status, 'retry_after' => $backoff, ]); } /** * Publish a message for the SSE stream to pick up. */ private static function publish(AiAgent $agent, array $payload): void { cache()->put("chat.{$agent->id}", $payload, now()->addMinutes(5)); } }