This commit is contained in:
team 1
2026-05-06 10:05:56 +02:00
parent e18abf4135
commit 68bfbd7802
11 changed files with 388 additions and 8 deletions

View File

@@ -2682,6 +2682,7 @@ final readonly class AgentRunner
{
$fullOutput = '';
$thinkingNoticeShown = false;
$stoppedByFinalAnswerGuard = false;
$chunker = new StreamChunker();
$this->thinkSuppressor->reset();
@@ -2706,11 +2707,36 @@ final readonly class AgentRunner
continue;
}
$fullOutput .= $cleanToken;
$guardReason = null;
$cleanToken = $this->guardFinalAnswerToken($fullOutput, $cleanToken, $guardReason);
$chunk = $chunker->push($cleanToken);
if ($chunk !== null) {
yield $this->systemMsg($chunk, 'answer');
if ($cleanToken !== '') {
$fullOutput .= $cleanToken;
$chunk = $chunker->push($cleanToken);
if ($chunk !== null) {
yield $this->systemMsg($chunk, 'answer');
}
}
if ($guardReason !== null) {
$stoppedByFinalAnswerGuard = true;
$finalChunk = $chunker->flush();
if ($finalChunk !== null) {
yield $this->systemMsg($finalChunk, 'answer');
}
$guardMessage = $this->agentRunnerConfig->getFinalAnswerGuardTruncationMessage();
$fullOutput .= $guardMessage;
yield $this->systemMsg($guardMessage, 'answer');
$this->agentLogger->warning('Final answer guard stopped LLM output', [
'reason' => $guardReason,
'outputLength' => mb_strlen($fullOutput, 'UTF-8'),
]);
break;
}
}
} catch (Throwable $e) {
@@ -2730,6 +2756,10 @@ final readonly class AgentRunner
return $fullOutput;
}
if ($stoppedByFinalAnswerGuard) {
return $fullOutput;
}
$finalChunk = $chunker->flush();
if ($finalChunk !== null) {
yield $this->systemMsg($finalChunk, 'answer');
@@ -2747,6 +2777,112 @@ final readonly class AgentRunner
return $fullOutput;
}
private function guardFinalAnswerToken(string $currentOutput, string $nextToken, ?string &$reason): string
{
$reason = null;
if (!$this->agentRunnerConfig->isFinalAnswerGuardEnabled()) {
return $nextToken;
}
$maxOutputChars = max(1000, $this->agentRunnerConfig->getFinalAnswerGuardMaxOutputChars());
$currentChars = mb_strlen($currentOutput, 'UTF-8');
$nextChars = mb_strlen($nextToken, 'UTF-8');
if (($currentChars + $nextChars) > $maxOutputChars) {
$reason = 'max_output_chars';
$remainingChars = max(0, $maxOutputChars - $currentChars);
return $remainingChars > 0 ? mb_substr($nextToken, 0, $remainingChars, 'UTF-8') : '';
}
$candidate = $currentOutput . $nextToken;
$cutoffBytes = $this->detectRepeatedFinalAnswerLineCutoff($candidate);
if ($cutoffBytes === null) {
return $nextToken;
}
$reason = 'repeated_line';
$currentBytes = strlen($currentOutput);
if ($cutoffBytes <= $currentBytes) {
return '';
}
return mb_strcut($nextToken, 0, $cutoffBytes - $currentBytes, 'UTF-8');
}
private function detectRepeatedFinalAnswerLineCutoff(string $text): ?int
{
if (!$this->agentRunnerConfig->isFinalAnswerRepeatedLineGuardEnabled()) {
return null;
}
if (mb_strlen($text, 'UTF-8') < max(0, $this->agentRunnerConfig->getFinalAnswerRepeatedLineMinOutputChars())) {
return null;
}
if (preg_match_all('/[^\r\n]+/u', $text, $matches, PREG_OFFSET_CAPTURE) === false) {
return null;
}
$lines = $matches[0] ?? [];
$window = max(10, $this->agentRunnerConfig->getFinalAnswerRepeatedLineTrailingWindowLines());
if (count($lines) > $window) {
$lines = array_slice($lines, -$window);
}
$counts = [];
$maxRepetitions = max(1, $this->agentRunnerConfig->getFinalAnswerRepeatedLineMaxRepetitions());
foreach ($lines as $lineMatch) {
$line = (string) ($lineMatch[0] ?? '');
$offset = (int) ($lineMatch[1] ?? 0);
$normalizedLine = $this->normalizeFinalAnswerLineForRepetitionGuard($line);
if ($normalizedLine === '') {
continue;
}
$counts[$normalizedLine] = ($counts[$normalizedLine] ?? 0) + 1;
if ($counts[$normalizedLine] > $maxRepetitions) {
return $offset;
}
}
return null;
}
private function normalizeFinalAnswerLineForRepetitionGuard(string $line): string
{
$line = html_entity_decode(strip_tags($line), ENT_QUOTES | ENT_HTML5, 'UTF-8');
$line = preg_replace('/^\s*(?:[-*•]+|\d+[.)])\s*/u', '', $line) ?? $line;
$line = preg_replace('/\s+/u', ' ', $line) ?? $line;
$line = trim($line, " \t\n\r\0\x0B:;.-");
if ($line === '') {
return '';
}
foreach ($this->agentRunnerConfig->getFinalAnswerRepeatedLineIgnorePatterns() as $pattern) {
try {
if (@preg_match($pattern, $line) === 1) {
return '';
}
} catch (Throwable) {
continue;
}
}
if (mb_strlen($line, 'UTF-8') < max(1, $this->agentRunnerConfig->getFinalAnswerRepeatedLineMinLineChars())) {
return '';
}
return mb_strtolower($line, 'UTF-8');
}
/**
* Build a deterministic safety answer for environments where the LLM returns no tokens.
*

View File

@@ -684,6 +684,54 @@ final class AgentRunnerConfig
return $this->getRequiredString('messages.no_llm_data_received');
}
public function isFinalAnswerGuardEnabled(): bool
{
return $this->getRequiredBool('final_answer_guard.enabled');
}
public function getFinalAnswerGuardMaxOutputChars(): int
{
return $this->getRequiredInt('final_answer_guard.max_output_chars');
}
public function getFinalAnswerGuardTruncationMessage(): string
{
return $this->getRequiredString('final_answer_guard.truncation_message');
}
public function isFinalAnswerRepeatedLineGuardEnabled(): bool
{
return $this->getRequiredBool('final_answer_guard.repeated_line.enabled');
}
public function getFinalAnswerRepeatedLineMinOutputChars(): int
{
return $this->getRequiredInt('final_answer_guard.repeated_line.min_output_chars');
}
public function getFinalAnswerRepeatedLineMinLineChars(): int
{
return $this->getRequiredInt('final_answer_guard.repeated_line.min_line_chars');
}
public function getFinalAnswerRepeatedLineMaxRepetitions(): int
{
return $this->getRequiredInt('final_answer_guard.repeated_line.max_line_repetitions');
}
public function getFinalAnswerRepeatedLineTrailingWindowLines(): int
{
return $this->getRequiredInt('final_answer_guard.repeated_line.trailing_window_lines');
}
/**
* @return string[]
*/
public function getFinalAnswerRepeatedLineIgnorePatterns(): array
{
return $this->getRequiredStringList('final_answer_guard.repeated_line.ignore_patterns');
}
public function getNoLlmFallbackMaxShopResults(): int
{
return $this->getRequiredInt('no_llm_fallback.max_shop_results');

View File

@@ -42,7 +42,10 @@ final readonly class RetriexEffectiveConfigProvider
'runtime' => $this->runtimeConfig(),
'index' => $this->indexConfig(),
'model_generation' => $this->modelConfig(),
'llm' => ['timeout_seconds' => $this->param('retriex.llm.timeout_seconds')],
'llm' => [
'timeout_seconds' => $this->param('retriex.llm.timeout_seconds'),
'num_predict' => $this->param('retriex.llm.num_predict'),
],
'retrieval' => $this->retrievalConfig(),
'prompt' => $this->promptConfig(),
'agent' => $this->agentConfig(),
@@ -639,6 +642,19 @@ final readonly class RetriexEffectiveConfigProvider
'generic_internal_error' => $this->agentRunnerConfig->getGenericInternalErrorMessage(),
'debug_internal_error_prefix' => $this->agentRunnerConfig->getDebugInternalErrorPrefix(),
],
'final_answer_guard' => [
'enabled' => $this->agentRunnerConfig->isFinalAnswerGuardEnabled(),
'max_output_chars' => $this->agentRunnerConfig->getFinalAnswerGuardMaxOutputChars(),
'truncation_message' => $this->agentRunnerConfig->getFinalAnswerGuardTruncationMessage(),
'repeated_line' => [
'enabled' => $this->agentRunnerConfig->isFinalAnswerRepeatedLineGuardEnabled(),
'min_output_chars' => $this->agentRunnerConfig->getFinalAnswerRepeatedLineMinOutputChars(),
'min_line_chars' => $this->agentRunnerConfig->getFinalAnswerRepeatedLineMinLineChars(),
'max_line_repetitions' => $this->agentRunnerConfig->getFinalAnswerRepeatedLineMaxRepetitions(),
'trailing_window_lines' => $this->agentRunnerConfig->getFinalAnswerRepeatedLineTrailingWindowLines(),
'ignore_patterns' => $this->agentRunnerConfig->getFinalAnswerRepeatedLineIgnorePatterns(),
],
],
'rag_evidence_guard' => [
'cleanup_profile' => $this->agentRunnerConfig->getRagEvidenceCleanupProfile(),
'stop_terms' => $this->agentRunnerConfig->getRagEvidenceStopTerms(),

View File

@@ -22,6 +22,7 @@ final class OllamaClient
public function __construct(
private string $apiUrl,
private string $timeoutSeconds,
private int|string $numPredict,
private ModelGenerationConfigProvider $configProvider
) {}
@@ -188,13 +189,20 @@ final class OllamaClient
private function buildOptions(): array
{
$this->config = $this->getConfig();
return [
$options = [
'temperature' => $this->config->getTemperature(),
'top_k' => $this->config->getTopK(),
'top_p' => $this->config->getTopP(),
'repeat_penalty' => $this->config->getRepeatPenalty(),
'num_ctx' => $this->config->getNumCtx(),
];
$numPredict = (int) $this->numPredict;
if ($numPredict > 0) {
$options['num_predict'] = $numPredict;
}
return $options;
}
private function requestTimeoutSeconds(): int