$messages * @return array> */ public static function prepare(array $messages, bool $summarised = false): array { $clean = []; foreach ($messages as $message) { $one = self::message($message); if ($one !== null) { $clean[] = $one; } } if (count($clean) <= self::MAX_MESSAGES) { return $clean; } if ($summarised) { $rest = array_slice($clean, count($clean) - self::MAX_MESSAGES); while ($rest !== [] && $rest[0]['role'] !== 'user') { array_shift($rest); } return $rest; } $first = $clean[0]; $rest = array_slice($clean, 1); $rest = array_slice($rest, count($rest) - (self::MAX_MESSAGES - 1)); while ($rest !== [] && $rest[0]['role'] !== 'user') { array_shift($rest); } return array_merge([$first], $rest); } /** * Where to cut when the conversation outgrows MAX_MESSAGES: everything up * to the start of the newest KEEP_MESSAGES (moved back to a person's * message, so a reply never travels without what it answered) is the part * to summarise. Counted on the messages as the browser holds them, so the * browser can drop the same ones. * * @param array $messages * @return array{0:array,1:array} the old part and the part to keep */ public static function split(array $messages): array { $messages = array_values($messages); $cut = max(0, count($messages) - self::KEEP_MESSAGES); while ($cut > 0 && $cut < count($messages) && !(is_array($messages[$cut]) && ($messages[$cut]['role'] ?? '') === 'user')) { $cut -= 1; } return [array_slice($messages, 0, $cut), array_slice($messages, $cut)]; } /** @return array */ public static function capOutput(array $output): array { $encoded = (string) wp_json_encode($output); if (strlen($encoded) <= self::MAX_OUTPUT_CHARS) { return $output; } $budget = self::MAX_OUTPUT_CHARS; $capped = self::shrink($output, $budget); $capped['truncated'] = true; return $capped; } /** @return array|null */ private static function message(mixed $message): ?array { if (!is_array($message) || !is_string($message['role'] ?? null)) { return null; } switch ($message['role']) { case 'user': $text = is_string($message['text'] ?? null) ? mb_substr($message['text'], 0, self::MAX_TEXT_CHARS) : ''; return $text === '' ? null : ['role' => 'user', 'text' => $text]; case 'assistant': $calls = []; foreach ((array) ($message['calls'] ?? []) as $call) { if (is_array($call) && is_string($call['id'] ?? null) && is_string($call['ability'] ?? null)) { $calls[] = [ 'ability' => $call['ability'], 'args' => is_array($call['args'] ?? null) && $call['args'] !== [] ? $call['args'] : new \stdClass(), 'id' => mb_substr($call['id'], 0, 40), 'why' => is_string($call['why'] ?? null) ? mb_substr($call['why'], 0, 500) : '', ]; } } return [ 'calls' => $calls, 'role' => 'assistant', 'text' => is_string($message['text'] ?? null) ? mb_substr($message['text'], 0, self::MAX_TEXT_CHARS) : '', ]; case 'result': $results = []; foreach ((array) ($message['results'] ?? []) as $result) { if (!is_array($result) || !is_string($result['id'] ?? null)) { continue; } $one = ['id' => mb_substr($result['id'], 0, 40), 'ok' => (bool) ($result['ok'] ?? false)]; if (!empty($result['skipped'])) { $one['skipped'] = true; } elseif ($one['ok']) { $output = self::capOutput(is_array($result['output'] ?? null) ? $result['output'] : []); $one['output'] = $output === [] ? new \stdClass() : $output; } else { $one['error'] = is_string($result['error'] ?? null) ? mb_substr($result['error'], 0, 1000) : 'Failed'; } $results[] = $one; } return ['results' => $results, 'role' => 'result']; default: return null; } } /** @param array $value */ private static function shrink(array $value, int &$budget): array { $out = []; foreach ($value as $key => $item) { if ($budget <= 0) { break; } if (is_array($item)) { $item = self::shrink($item, $budget); } elseif (is_string($item)) { $keep = max(0, min(mb_strlen($item), (int) ($budget / 2))); $item = $keep < mb_strlen($item) ? mb_substr($item, 0, $keep) . '…' : $item; } $out[$key] = $item; $budget -= strlen((string) wp_json_encode($item)) + strlen((string) $key) + 4; } return $out; } }