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.
*