Skip to content

Commit 7080234

Browse files
authored
Fix transformers /tasks failures: bigger budget, empty-answer salvage, and chat-message run logs (#48)
Every recent huggingface/transformers /tasks job was failing with "LLM returned unparseable output (finish_reason=None)" — 20/20 in a row. Investigation showed two compounding causes, and fixing them surfaced a logging gap and some UI rough edges. This PR bundles the whole fix. Root cause Budget too small. Tasks run in strict mode (every tool call counts). 30 tool calls wasn't enough for a repo the size of transformers — the agent spent its whole budget exploring and never emitted a patch. No salvage for empty final answers. When the budget was exhausted, serge forced one tool-free final turn and handed its output straight to the parser. That turn came back empty with finish_reason=None (the provider truncating a huge-context stream), so it surfaced as "unparseable output". The existing salvage only handled finish_reason="length". Changes Salvage empty / None-finish final answers (reviewer.py): both final-answer paths now re-ask once for the JSON only when the answer is empty or hit the output-token limit, bounded by _MAX_TRUNCATION_RETRIES. Raise the task tool budget 30 → 80 (prod.yaml) so large-repo fixes can converge. Log chat messages in the run transcript: _run_agentic_loop emits a chat event per turn (assistant content, reasoning length, finish_reason, tool-call names/args) plus truncated tool results — so unparseable-output-class failures are debuggable after the fact. chat, not message (the SSE default type). Persisted via PERSIST_EVENT_KINDS (token/reasoning still dropped). Web UI: assistant patch/review answers render human-readably (📝 title + body + "patch: N lines", or summary + inline-comment count) instead of raw JSON; tool results collapse to a single line with a size hint (full content stays in stored history); fixed doubled console lines on SSE reconnect via seq + Last-Event-ID resumption; widened the shared page container 960 → 1200px.
1 parent e2273c2 commit 7080234

12 files changed

Lines changed: 450 additions & 31 deletions

File tree

deploy/helm/env/prod.yaml

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99

1010
image:
1111
# Bump to a newer sha-<commit> (pushed by CI on merge to main) or use latest.
12-
tag: sha-09bcf3a
12+
tag: sha-bc6c73d
1313

1414
replicas: 1
1515

@@ -61,7 +61,14 @@ envVars:
6161
LLM_MAX_TOKENS: "49152"
6262
TOOL_MAX_ITERATIONS: "30"
6363
TASK_LLM_MAX_TOKENS: "49152"
64-
TASK_TOOL_MAX_ITERATIONS: "30"
64+
# Tasks run in STRICT budget mode (every tool call counts, not just blind
65+
# turns — see reviewer._run_tool_loop), to preserve output budget for the
66+
# final patch JSON. 30 was too tight for a repo the size of transformers:
67+
# the agent spent the whole budget exploring and never emitted a patch, so
68+
# the forced tool-free final turn returned empty/unparseable output. Give
69+
# large-repo fixes room to converge. Higher = more exploration but larger
70+
# per-turn context (cost + latency); tune against the target repo.
71+
TASK_TOOL_MAX_ITERATIONS: "80"
6572
# Normalize gate for /tasks. In the pod-per-task model the runner runs this
6673
# command in-process (formerly the separate normalize Job). Unset = gate is
6774
# skipped entirely, so it MUST be set. Argv is shlex-split; bash -lc runs the
@@ -105,7 +112,7 @@ taskExecution:
105112
# draft on the terminal callback. false = reviews run in-process in serge.
106113
reviewPods: true
107114
# transformers toolchain + serge (docker/Dockerfile.task-runner).
108-
image: ghcr.io/huggingface/serge-task-runner:sha-09bcf3a
115+
image: ghcr.io/huggingface/serge-task-runner:sha-bc6c73d
109116
# Wall-clock cap per task (matches the old in-process worker budget).
110117
timeout: 3600
111118
# The in-pod normalizer (make style / checkers over transformers) is
@@ -115,4 +122,4 @@ taskExecution:
115122
nodeSelector: "scheduling.cast.ai/node-template=default-by-castai"
116123
egress:
117124
# Self-built tinyproxy (docker/Dockerfile.egress-proxy).
118-
image: ghcr.io/huggingface/serge-egress:sha-09bcf3a
125+
image: ghcr.io/huggingface/serge-egress:sha-bc6c73d

reviewbot/config.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -502,8 +502,7 @@ def from_env(
502502
os.environ.get("TASK_K8S_NODE_SELECTOR") or ""
503503
).strip()
504504
or None,
505-
task_egress_name=(os.environ.get("TASK_EGRESS_NAME") or "").strip()
506-
or None,
505+
task_egress_name=(os.environ.get("TASK_EGRESS_NAME") or "").strip() or None,
507506
slack_bot_token=(
508507
os.environ.get("SLACK_CIFEEDBACK_BOT_TOKEN")
509508
or os.environ.get("CI_SLACK_BOT_TOKEN")

reviewbot/review_runner.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,7 @@ def build_review_request(spec: RunnerSpec) -> ReviewRequest:
3535
only the fields the dataclass declares. ``inline`` (webhook follow-up
3636
context) is left at its default — UI reviews never carry it."""
3737
fields = {f.name for f in dataclasses.fields(ReviewRequest)}
38-
kwargs = {
39-
k: v for k, v in spec.request.items() if k in fields and k != "inline"
40-
}
38+
kwargs = {k: v for k, v in spec.request.items() if k in fields and k != "inline"}
4139
return ReviewRequest(**kwargs)
4240

4341

reviewbot/reviewer.py

Lines changed: 132 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -656,6 +656,41 @@ def _merge_chunk_event(events: list[str], comments_count: int) -> str:
656656
"analysis, no explanation, no <think> block, no markdown fences. Keep it "
657657
"minimal so the whole answer fits within the output limit."
658658
)
659+
# An empty final answer (blank content, usually finish_reason=None) means the
660+
# model returned nothing parseable — often the provider truncated the stream on
661+
# a very large context. Re-ask once, tool-free, for just the JSON — same
662+
# recovery path as the length-truncation case, bounded by the same retry cap.
663+
_EMPTY_ANSWER_RECOVERY_MESSAGE = (
664+
"Your previous reply was empty — no JSON object came through. Reply now "
665+
"with ONLY the final JSON object the task requires: no analysis, no "
666+
"explanation, no <think> block, no markdown fences."
667+
)
668+
669+
670+
def _needs_final_salvage(chat: ChatResult) -> bool:
671+
"""True when a tool-free final answer should be re-asked rather than
672+
parsed: it either hit the output-token limit (``finish_reason="length"``)
673+
or came back with blank content (commonly ``finish_reason=None`` when the
674+
provider truncates the stream on a very large context)."""
675+
return chat.finish_reason == "length" or not (chat.content or "").strip()
676+
677+
678+
def _final_recovery_message(chat: ChatResult) -> str:
679+
blank = not (chat.content or "").strip()
680+
return _EMPTY_ANSWER_RECOVERY_MESSAGE if blank else _TRUNCATION_RECOVERY_MESSAGE
681+
682+
683+
def _emit_final_salvage(
684+
emit: Optional[Callable[[str, str], None]], chat: ChatResult, attempt: int
685+
) -> None:
686+
if emit is None:
687+
return
688+
what = "empty" if not (chat.content or "").strip() else "truncated"
689+
emit(
690+
"log",
691+
f"Final answer was {what} (finish_reason={chat.finish_reason}); re-asking "
692+
f"for the JSON only (recovery {attempt}/{_MAX_TRUNCATION_RETRIES})",
693+
)
659694

660695

661696
def _run_agentic_loop(
@@ -806,27 +841,35 @@ def _run_agentic_loop(
806841
if chat.completion_tokens is not None:
807842
metrics.completion_tokens += chat.completion_tokens
808843
_emit_metrics(emit, metrics)
844+
# Log what the model emitted this turn (content + finish_reason +
845+
# tool-call names). Captures the empty final turn behind
846+
# "unparseable output" failures.
847+
_emit_chat_message(
848+
emit,
849+
"assistant",
850+
content=chat.content,
851+
reasoning_chars=chat.reasoning_chars,
852+
finish_reason=chat.finish_reason,
853+
tool_calls=chat.tool_calls,
854+
)
809855

810856
if not chat.tool_calls:
811-
# Salvage a truncated final answer before anything else: the model
812-
# ran out of output budget mid-JSON (reasoning ate it). Re-ask for
813-
# the JSON only, tool-less and low-reasoning, instead of returning
814-
# unparseable content that fails the whole task.
857+
# Salvage a truncated OR empty final answer before anything else:
858+
# the model either ran out of output budget mid-JSON
859+
# (finish_reason="length", reasoning ate it) or returned nothing
860+
# parseable at all (blank content — commonly finish_reason=None when
861+
# the provider truncates a huge-context stream). Re-ask for the JSON
862+
# only, tool-less and low-reasoning, instead of returning content
863+
# that just fails the parse.
815864
if (
816-
chat.finish_reason == "length"
865+
_needs_final_salvage(chat)
817866
and truncation_retries < _MAX_TRUNCATION_RETRIES
818867
):
819868
truncation_retries += 1
820-
if emit is not None:
821-
emit(
822-
"log",
823-
"Final answer hit the output-token limit "
824-
f"(recovery {truncation_retries}/{_MAX_TRUNCATION_RETRIES}); "
825-
"re-asking for the JSON only",
826-
)
869+
_emit_final_salvage(emit, chat, truncation_retries)
827870
messages.append({"role": "assistant", "content": chat.content or None})
828871
messages.append(
829-
{"role": "user", "content": _TRUNCATION_RECOVERY_MESSAGE}
872+
{"role": "user", "content": _final_recovery_message(chat)}
830873
)
831874
force_json_only = True
832875
continue
@@ -901,6 +944,7 @@ def _run_agentic_loop(
901944
"content": result,
902945
}
903946
)
947+
_emit_chat_message(emit, "tool", content=result, tool_name=tc.name)
904948

905949
# Iteration budget hit — force a final answer with tools disabled.
906950
# Only reachable when ``tool_max_iterations > 0`` and the model
@@ -942,6 +986,25 @@ def _run_agentic_loop(
942986
if chat.completion_tokens is not None:
943987
metrics.completion_tokens += chat.completion_tokens
944988
_emit_metrics(emit, metrics)
989+
_emit_chat_message(
990+
emit,
991+
"assistant",
992+
content=chat.content,
993+
reasoning_chars=chat.reasoning_chars,
994+
finish_reason=chat.finish_reason,
995+
)
996+
997+
# Salvage an empty/truncated forced-final answer before validating or
998+
# returning it. This is the exact failure the budget-exhausted path used
999+
# to die on: an empty completion (finish_reason=None) went straight to
1000+
# the parser and surfaced as "LLM returned unparseable output". Re-ask
1001+
# for the JSON only instead (bounded by _MAX_TRUNCATION_RETRIES).
1002+
if _needs_final_salvage(chat) and truncation_retries < _MAX_TRUNCATION_RETRIES:
1003+
truncation_retries += 1
1004+
_emit_final_salvage(emit, chat, truncation_retries)
1005+
messages.append({"role": "assistant", "content": chat.content or None})
1006+
messages.append({"role": "user", "content": _final_recovery_message(chat)})
1007+
continue
9451008

9461009
# The verification gate must run on the forced final answer too —
9471010
# exhausting the tool budget must not silently bypass validation (for
@@ -1033,6 +1096,62 @@ def _emit_metrics(
10331096
emit("metrics", payload)
10341097

10351098

1099+
# Per-message log cap. Assistant turns are small (the model's own output),
1100+
# but tool results can be large (a grepped file); truncate so the run log
1101+
# stays debuggable without bloating the SQLite job store with the full
1102+
# 500k-token context that already lives in the prompt.
1103+
_LOG_MSG_MAX_CHARS = 2000
1104+
1105+
1106+
def _emit_chat_message(
1107+
emit: Optional[Callable[[str, str], None]],
1108+
role: str,
1109+
*,
1110+
content: Optional[str] = None,
1111+
reasoning_chars: int = 0,
1112+
finish_reason: Optional[str] = None,
1113+
tool_calls: Optional[list[ToolCall]] = None,
1114+
tool_name: Optional[str] = None,
1115+
) -> None:
1116+
"""Record one chat turn in the run log as a ``message`` event.
1117+
1118+
Captures what the model actually emitted each turn — content,
1119+
reasoning length, finish_reason, tool-call names/args — plus truncated
1120+
tool results. Deliberately does NOT re-store the giant diff/user
1121+
context (it is the same every turn and already accounted for in the
1122+
metrics). This is what makes "LLM returned unparseable output"
1123+
failures diagnosable: the empty final turn (content="",
1124+
finish_reason=None) shows up here. Best-effort — never raises into the
1125+
agent loop."""
1126+
if emit is None:
1127+
return
1128+
payload: dict[str, Any] = {"role": role}
1129+
if content:
1130+
payload["content"] = (
1131+
content
1132+
if len(content) <= _LOG_MSG_MAX_CHARS
1133+
else content[:_LOG_MSG_MAX_CHARS]
1134+
+ f"… [+{len(content) - _LOG_MSG_MAX_CHARS} chars truncated]"
1135+
)
1136+
if reasoning_chars:
1137+
payload["reasoning_chars"] = reasoning_chars
1138+
if finish_reason is not None:
1139+
payload["finish_reason"] = finish_reason
1140+
if tool_calls:
1141+
payload["tool_calls"] = [
1142+
{"name": tc.name, "arguments": _summarize_args_str(tc.arguments)}
1143+
for tc in tool_calls
1144+
]
1145+
if tool_name:
1146+
payload["name"] = tool_name
1147+
try:
1148+
# kind "chat" (not "message"): "message" is the SSE default event type,
1149+
# which would double-fire the browser's onmessage handler.
1150+
emit("chat", json.dumps(payload))
1151+
except Exception: # never let logging break a run
1152+
log.debug("failed to emit chat message", exc_info=True)
1153+
1154+
10361155
def _assistant_tool_call_dict(tc: ToolCall) -> dict[str, Any]:
10371156
"""Serialize a ToolCall back into the OpenAI-compat assistant message.
10381157
Re-attaches Gemini 3's thought_signature at the exact path the API

reviewbot/static/review.js

Lines changed: 77 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,73 @@
168168
const consolePending = [];
169169
let consoleFlushScheduled = false;
170170

171+
// A `message` event carries a JSON payload of what the model emitted that
172+
// turn (see reviewer._emit_chat_message). Render it as a readable line.
173+
function formatAssistantContent(content) {
174+
if (!content) return "";
175+
let o;
176+
try {
177+
o = JSON.parse(content);
178+
} catch (_) {
179+
return content;
180+
}
181+
if (!o || typeof o !== "object") return content;
182+
// Task patch answer: {title, body, patch}.
183+
if ("patch" in o || "title" in o) {
184+
const out = [];
185+
if (o.title) out.push(`📝 ${o.title}`);
186+
if (o.body) out.push(String(o.body));
187+
const patch = String(o.patch || "").trim();
188+
out.push(
189+
patch
190+
? `⟶ patch: ${patch.split("\n").length} lines`
191+
: "⟶ no patch (no change proposed)",
192+
);
193+
return "\n " + out.join("\n ");
194+
}
195+
// Review answer: {summary, event, comments}.
196+
if ("summary" in o || "comments" in o) {
197+
const n = Array.isArray(o.comments) ? o.comments.length : 0;
198+
const ev = o.event ? ` [${o.event}]` : "";
199+
return `\n ${o.summary || ""}${ev}\n ⟶ ${n} inline comment${n === 1 ? "" : "s"}`;
200+
}
201+
return content;
202+
}
203+
204+
function formatMessageEvent(text) {
205+
let m;
206+
try {
207+
m = JSON.parse(text);
208+
} catch (_) {
209+
return text;
210+
}
211+
if (!m || typeof m !== "object") return text;
212+
if (m.role === "tool") {
213+
// Don't dump the (often large) tool output into the console; just note
214+
// the tool and a size hint. Full result is in stored history.
215+
const c = String(m.content || "");
216+
const lines = c ? c.split("\n").length : 0;
217+
const size = lines ? ` (${lines} line${lines === 1 ? "" : "s"})` : "";
218+
return `tool ⟵ ${m.name || "?"}${size}`;
219+
}
220+
const meta = [];
221+
if (m.finish_reason != null && m.finish_reason !== "stop") {
222+
meta.push(`finish=${m.finish_reason}`);
223+
}
224+
if (m.reasoning_chars) meta.push(`reasoning=${m.reasoning_chars}c`);
225+
const metaStr = meta.length ? ` [${meta.join(", ")}]` : "";
226+
const calls =
227+
Array.isArray(m.tool_calls) && m.tool_calls.length
228+
? " → " +
229+
m.tool_calls
230+
.map((t) => `${t.name}(${String(t.arguments || "").slice(0, 80)})`)
231+
.join(", ")
232+
: "";
233+
const body = formatAssistantContent(m.content);
234+
if (!body && !calls) return `assistant: (thinking…)${metaStr}`;
235+
return `assistant:${body ? " " + body : ""}${calls}${metaStr}`;
236+
}
237+
171238
function flushConsole() {
172239
consoleFlushScheduled = false;
173240
if (consolePending.length === 0) return;
@@ -179,8 +246,10 @@
179246
let prefix = streamed || consoleAtLineStart ? "" : "\n";
180247
if (kind === "log") prefix += "› ";
181248
else if (kind === "tool") prefix += "⚙ ";
249+
else if (kind === "chat") prefix += "💬 ";
182250
else if (kind === "error") prefix += "✗ ";
183-
const body = prefix + text + (streamed ? "" : "\n");
251+
const shown = kind === "chat" ? formatMessageEvent(text) : text;
252+
const body = prefix + shown + (streamed ? "" : "\n");
184253
span.textContent = body;
185254
frag.appendChild(span);
186255
consoleAtLineStart = body.endsWith("\n");
@@ -671,6 +740,13 @@
671740
appendConsole("tool", ev.data);
672741
}
673742
});
743+
es.addEventListener("chat", (ev) => {
744+
try {
745+
appendConsole("chat", JSON.parse(ev.data));
746+
} catch {
747+
appendConsole("chat", ev.data);
748+
}
749+
});
674750
es.addEventListener("reasoning", (ev) => {
675751
try {
676752
appendConsole("reasoning", JSON.parse(ev.data));

reviewbot/static/styles.css

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ body {
3737
}
3838

3939
main {
40-
max-width: 960px;
40+
max-width: 1200px;
4141
margin: 0 auto;
4242
padding: 24px 16px 80px;
4343
}

0 commit comments

Comments
 (0)