Malformed tool-call arguments sanitization and repair
NousResearch/hermes-agent · cluster 4662 · agent
Data as of
2 open items. Fix merged in #12259. Check it covers your case.
The short answer
- Known?
- Yes. 36 related items grouped here, 9 issues already closed.
- Fix event
- A fix was merged to main in PR #12259. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
partially resolved, deciding PRs #12259, #15348. That is separate from the fix event. - Still open
- 2 issues and 0 PRs.
Next step: read #12259, then check whether your report is one of the 2 still open.
Read #12259Pick up a Lab taskThese issues and fixes center on detecting, repairing, and preventing malformed tool-call argument JSON—including truncated strings from session compression, wrong JSON types (arrays instead of objects), and unescaped control characters—before they are persisted or sent to model providers.
Open issues2
Open PRs0
Fixes on record
-
#12259 fix(context_compressor): keep tool-call arguments JSON valid when shrinking
files:
agent/context_compressor.py,scripts/release.py,tests/agent/test_context_compressor.pyShow the diff
-
agent/context_compressor.py@@ -63,6 +63,52 @@ _SUMMARY_FAILURE_COOLDOWN_SECONDS = 600 +def _truncate_tool_call_args_json(args: str, head_chars: int = 200) -> str: + """Shrink long string values inside a tool-call arguments JSON blob while + preserving JSON validity. + + The ``function.arguments`` field on a tool call is a JSON-encoded string + passed through to the LLM provider; downstream providers strictly + validate it and return a non-retryable 400 when it is not well-formed. + An earlier implementation sliced the raw JSON at a fixed byte offset and + appended ``...[truncated]`` — which routinely produced strings like:: + + {"path": "/foo/bar", "content": "# long markdown + ...[truncated] + + i.e. an unterminated string and a missing closing brace. MiniMax, for + example, rejects this with ``invalid function arguments json string`` + and the session gets stuck re-sending the same broken history on every + turn. See issue #11762 for the observed loop. + + This helper parses the arguments, shrinks long string leaves inside the + parsed structure, and re-serialises. Non-string values (paths, ints, + booleans) are preserved intact. If the arguments are not valid JSON + to begin with — some model backends use non-JSON tool arguments — the + original string is returned unchanged rather than replaced with + something neither we nor the backend can parse. + """ + try: + parsed = json.loads(args) + except (ValueError, TypeError): + return args + + def _shrink(obj: Any) -> Any: + if isinstance(obj, str): + if len(obj) > head_chars: + return obj[:head_chars] + "...[truncated]" + return obj + if isinstance(obj, dict): + return {k: _shrink(v) for k, v in obj.items()} + if isinstance(obj, list): + return [_shrink(v) for v in obj] + return obj + + shrunken = _shrink(parsed) + # ensure_ascii=False preserves CJK/emoji instead of bloating with \uXXXX + return json.dumps(shrunken, ensure_ascii=False) + + def _summarize_tool_result(tool_name: str, tool_args: str, tool_content: str) -> str: """Create an informative 1-line summary of a tool call + result. @@ -449,6 +495,11 @@ def _prune_old_tool_results( # Pass 3: Truncate large tool_call arguments in assistant messages # outside the protected tail. write_file with 50KB content, for # example, survives pruning entirely without this. + # + # The shrinking is done inside the parsed JSON structure so the + # result remains valid JSON — otherwise downstream providers 400 + # on every subsequent turn until the broken call falls out of + # the window. See ``_truncate_tool_call_args_json`` docstring. for i in range(prune_boundary): msg = result[i] if msg.get("role") != "assistant" or not msg.get("tool_calls"): @@ -459,8 +510,10 @@ def _prune_old_tool_results( if isinstance(tc, dict): args = tc.get("function", {}).get("arguments", "") if len(args) > 500: - tc = {**tc, "function": {**tc["function"], "arguments": args[:200] + "...[truncated]"}} - modified = True + new_args = _truncate_tool_call_args_json(args) + if new_args != args: + tc = {**tc, "function": {**tc["function"], "arguments": new_args}} + modified = True new_tcs.append(tc) if modified: result[i] = {**msg, "tool_calls": new_tcs} -
scripts/release.py@@ -74,6 +74,7 @@ "[redacted:email]": "davetist", "[redacted:email]": "yyq4193", "[redacted:email]": "Asunfly", + "[redacted:email]": "honghua", # contributors (manual mapping from git names) "[redacted:email]": "asheriif", "[redacted:email]": "liujinkun2025", -
tests/agent/test_context_compressor.py@@ -781,3 +781,127 @@ def test_prune_without_token_budget_uses_message_count(self, budget_compressor): # Tool at index 2 is outside the protected tail (last 3 = indices 2,3,4) # so it might or might not be pruned depending on boundary assert isinstance(pruned, int) + + +class TestTruncateToolCallArgsJson: + """Regression tests for #11762. + + The previous implementation produced invalid JSON by slicing + ``function.arguments`` mid-string, which caused non-retryable 400s from + strict providers (observed on MiniMax) and stuck long sessions in a + re-send loop. The helper here must always emit parseable JSON whose + shape matches the original — shrunken, not corrupted. + """ + + def _helper(self): + from agent.context_compressor import _truncate_tool_call_args_json + return _truncate_tool_call_args_json + + def test_shrunken_args_remain_valid_json(self): + import json as _json + shrink = self._helper() + original = _json.dumps({ + "path": "~/.hermes/skills/shopping/browser-setup-notes.md", + "content": "# Shopping Browser Setup Notes\n\n" + "abc " * 400, + }) + assert len(original) > 500 + shrunk = shrink(original) + parsed = _json.loads(shrunk) # must not raise + assert parsed["path"] == "~/.hermes/skills/shopping/browser-setup-notes.md" + assert parsed["content"].endswith("...[truncated]") + assert len(shrunk) < len(original) + + def test_non_json_arguments_pass_through(self): + shrink = self._helper() + not_json = "this is not json at all, " * 50 + assert shrink(not_json) == not_json + + def test_short_string_leaves_unchanged(self): + import json as _json + shrink = self._helper() + payload = _json.dumps({"command": "ls -la", "cwd": "/tmp"}) + assert _json.loads(shrink(payload)) == {"command": "ls -la", "cwd": "/tmp"} + + def test_nested_structures_are_walked(self): + import json as _json + shrink = self._helper() + payload = _json.dumps({ + "messages": [ + {"role": "user", "content": "x" * 500}, + {"role": "assistant", "content": "ok"}, + ], + "meta": {"note": "y" * 500}, + }) + parsed = _json.loads(shrink(payload)) + assert parsed["messages"][0]["content"].endswith("...[truncated]") + assert parsed["messages"][1]["content"] == "ok" + assert parsed["meta"]["note"].endswith("...[truncated]") + + def test_non_string_leaves_preserved(self): + import json as _json + shrink = self._helper() + payload = _json.dumps({ + "retries": 3, + "enabled": True, + "timeout": None, + "items": [1, 2, 3], + "note": "z" * 500, + }) + parsed = _json.loads(shrink(payload)) + assert parsed["retries"] == 3 + assert parsed["enabled"] is True + assert parsed["timeout"] is None + assert parsed["items"] == [1, 2, 3] + assert parsed["note"].endswith("...[truncated]") + + def test_scalar_json_string_gets_shrunk(self): + import json as _json + shrink = self._helper() + payload = _json.dumps("q" * 500) + parsed = _json.loads(shrink(payload)) + assert isinstance(parsed, str) + assert parsed.endswith("...[truncated]") + + def test_unicode_preserved(self): + import json as _json + shrink = self._helper() + payload = _json.dumps({"content": "非德满" + ("a" * 500)}) + out = shrink(payload) + # ensure_ascii=False keeps CJK intact rather than emitting \uXXXX + assert "非德满" in out + + def test_pass3_emits_valid_json_for_downstream_provider(self): + """End-to-end: Pass 3 must never produce the exact failure payload + that caused the 400 loop (unterminated string, missing brace).""" + import json as _json + with patch("agent.context_compressor.get_model_context_length", return_value=100000): + c = ContextCompressor( + model="test/model", + threshold_percent=0.85, + protect_first_n=1, + protect_last_n=1, + quiet_mode=True, + ) + huge_content = "# Shopping Browser Setup Notes\n\n## Overview\n" + "x " * 400 + args_payload = _json.dumps({ + "path": "~/.hermes/skills/shopping/browser-setup-notes.md", + "content": huge_content, + }) + assert len(args_payload) > 500 # triggers the Pass-3 shrink + messages = [ + {"role": "user", "content": "please write two files"}, + {"role": "assistant", "content": None, "tool_calls": [ + {"id": "call_1", "type": "function", + "function": {"name": "write_file", "arguments": args_payload}}, + ]}, + {"role": "tool", "tool_call_id": "call_1", + "content": '{"bytes_written": 727}'}, + {"role": "user", "content": "ok"}, + {"role": "assistant", "content": "done"}, + ] + result, _ = c._prune_old_tool_results(messages, protect_tail_count=2) + shrunk = result[1]["tool_calls"][0]["function"]["arguments"] + # Must parse — otherwise downstream provider returns 400 + parsed = _json.loads(shrunk) + assert parsed["path"] == "~/.hermes/skills/shopping/browser-setup-notes.md" + assert parsed["content"].endswith("...[truncated]")
-
-
#13005 fix(agent): repair malformed tool_call arguments before API send
files:
run_agent.py,scripts/release.py,tests/run_agent/test_repair_tool_call_arguments.pyShow the diff
-
run_agent.py@@ -555,6 +555,71 @@ def _sanitize_messages_surrogates(messages: list) -> bool: return found +def _repair_tool_call_arguments(raw_args: str, tool_name: str = "?") -> str: + """Attempt to repair malformed tool_call argument JSON. + + Models like GLM-5.1 via Ollama can produce truncated JSON, trailing + commas, Python ``None``, etc. The API proxy rejects these with HTTP 400 + "invalid tool call arguments". This function applies common repairs; + if all fail it returns ``"{}"`` so the request succeeds (better than + crashing the session). All repairs are logged at WARNING level. + """ + raw_stripped = raw_args.strip() if isinstance(raw_args, str) else "" + + # Fast-path: empty / whitespace-only -> empty object + if not raw_stripped: + logger.warning("Sanitized empty tool_call arguments for %s", tool_name) + return "{}" + + # Python-literal None -> normalise to {} + if raw_stripped == "None": + logger.warning("Sanitized Python-None tool_call arguments for %s", tool_name) + return "{}" + + # Attempt common JSON repairs + fixed = raw_stripped + # 1. Strip trailing commas before } or ] + fixed = re.sub(r',\s*([}\]])', r'\1', fixed) + # 2. Close unclosed structures + open_curly = fixed.count('{') - fixed.count('}') + open_bracket = fixed.count('[') - fixed.count(']') + if open_curly > 0: + fixed += '}' * open_curly + if open_bracket > 0: + fixed += ']' * open_bracket + # 3. Remove excess closing braces/brackets (bounded to 50 iterations) + for _ in range(50): + try: + json.loads(fixed) + break + except json.JSONDecodeError: + if fixed.endswith('}') and fixed.count('}') > fixed.count('{'): + fixed = fixed[:-1] + elif fixed.endswith(']') and fixed.count(']') > fixed.count('['): + fixed = fixed[:-1] + else: + break + + try: + json.loads(fixed) + logger.warning( + "Repaired malformed tool_call arguments for %s: %s → %s", + tool_name, raw_stripped[:80], fixed[:80], + ) + return fixed + except json.JSONDecodeError: + pass + + # Last resort: replace with empty object so the API request doesn't + # crash the entire session. + logger.warning( + "Unrepairable tool_call arguments for %s — " + "replaced with empty object (was: %s)", + tool_name, raw_stripped[:80], + ) + return "{}" + + def _strip_non_ascii(text: str) -> str: """Remove non-ASCII characters, replacing with closest ASCII equivalent or removing. @@ -9662,7 +9727,10 @@ def run_conversation( ), }} except Exception: - pass + tc["function"]["arguments"] = _repair_tool_call_arguments( + tc["function"]["arguments"], + tc["function"].get("name", "?"), + ) new_tcs.append(tc) am["tool_calls"] = new_tcs -
scripts/release.py@@ -66,6 +66,8 @@ "[redacted:email]": "Sertug17", "[redacted:email]": "caentzminger", "[redacted:email]": "voidborne-d", + "[redacted:email]": "sirEven", + "[redacted:email]": "sirEven", "[redacted:email]": "insecurejezza", "[redacted:email]": "dodo-reach", "[redacted:email]": "Bartok9", -
tests/run_agent/test_repair_tool_call_arguments.py@@ -0,0 +1,107 @@ +"""Tests for _repair_tool_call_arguments — malformed JSON repair pipeline.""" + +import json +import pytest + +from run_agent import _repair_tool_call_arguments + + +class TestRepairToolCallArguments: + """Verify each repair stage in the pipeline.""" + + # -- Stage 1: empty / whitespace-only -- + + def test_empty_string_returns_empty_object(self): + assert _repair_tool_call_arguments("", "t") == "{}" + + def test_whitespace_only_returns_empty_object(self): + assert _repair_tool_call_arguments(" \n\t ", "t") == "{}" + + def test_none_type_returns_empty_object(self): + """Non-string input (e.g. None from a broken model response).""" + assert _repair_tool_call_arguments(None, "t") == "{}" + + # -- Stage 2: Python None literal -- + + def test_python_none_literal(self): + assert _repair_tool_call_arguments("None", "t") == "{}" + + def test_python_none_with_whitespace(self): + assert _repair_tool_call_arguments(" None ", "t") == "{}" + + # -- Stage 3: trailing comma repair -- + + def test_trailing_comma_in_object(self): + result = _repair_tool_call_arguments('{"key": "value",}', "t") + assert json.loads(result) == {"key": "value"} + + def test_trailing_comma_in_array(self): + result = _repair_tool_call_arguments('{"a": [1, 2,]}', "t") + parsed = json.loads(result) + assert parsed == {"a": [1, 2]} + + def test_multiple_trailing_commas(self): + result = _repair_tool_call_arguments('{"a": 1, "b": 2,}', "t") + parsed = json.loads(result) + assert parsed["a"] == 1 + assert parsed["b"] == 2 + + # -- Stage 4: unclosed brackets -- + + def test_unclosed_brace(self): + result = _repair_tool_call_arguments('{"key": "value"', "t") + parsed = json.loads(result) + assert parsed == {"key": "value"} + + def test_unclosed_bracket_and_brace(self): + result = _repair_tool_call_arguments('{"a": [1, 2', "t") + # Bracket counting adds ']' then '}', producing {"a": [1, 2]} + # which is valid JSON. But the naive count can't always recover + # complex nesting — verify we at least get valid JSON. + json.loads(result) + + # -- Stage 5: excess closing delimiters -- + + def test_extra_closing_brace(self): + result = _repair_tool_call_arguments('{"key": "value"}}', "t") + parsed = json.loads(result) + assert parsed == {"key": "value"} + + def test_extra_closing_bracket(self): + result = _repair_tool_call_arguments('{"a": [1]]}', "t") + # Should produce valid JSON + json.loads(result) + + # -- Stage 6: last resort -- + + def test_unrepairable_garbage_returns_empty_object(self): + assert _repair_tool_call_arguments("totally not json", "t") == "{}" + + def test_unrepairable_partial_returns_empty_object(self): + # Truncated in the middle of a string key — bracket closing won't help + assert _repair_tool_call_arguments('{"truncated": "val', "t") == "{}" + + # -- Valid JSON passthrough (this path is via except, but still works) -- + + def test_already_valid_json_passes_through(self): + """When json.loads fails for a non-JSON reason (shouldn't normally + happen), but the repair pipeline still produces valid output.""" + raw = '{"path": "/tmp/foo", "content": "hello"}' + result = _repair_tool_call_arguments(raw, "t") + parsed = json.loads(result) + assert parsed["path"] == "/tmp/foo" + + # -- Combined repairs -- + + def test_trailing_comma_plus_unclosed_brace(self): + result = _repair_tool_call_arguments('{"a": 1, "b": 2,', "t") + # Trailing comma stripped first, then closing brace added. + # May or may not fully recover — verify valid JSON at minimum. + json.loads(result) + + def test_real_world_glm_truncation(self): + """Simulates GLM-5.1 truncating mid-argument.""" + raw = '{"command": "ls -la /tmp", "timeout": 30, "background":' + result = _repair_tool_call_arguments(raw, "terminal") + # Should at least be valid JSON, even if background is lost + json.loads(result)
-
-
#15348 fix(run_agent): persist tool_call argument repairs into session history
files:
run_agent.py,tests/run_agent/test_tool_call_args_sanitizer.py -
#15352 fix(run_agent): repair malformed tool_call args during streaming assembly
files:
run_agent.py,scripts/release.py,tests/run_agent/test_streaming_tool_call_repair.py -
#55922 fix(agent): repair empty-name tool_calls in sanitizer to prevent Responses 400 (salvage #12807/#52893)
files:
agent/agent_runtime_helpers.py,tests/run_agent/test_run_agent.pyShow the diff
-
agent/agent_runtime_helpers.py@@ -2257,6 +2257,54 @@ def sanitize_api_messages(messages: List[Dict[str, Any]]) -> List[Dict[str, Any] filtered.append(msg) messages = filtered + # --- Repair tool_calls whose function.name is empty/missing --- + # Some providers (and partially-streamed responses) emit a tool_call with + # id="call_xxx" but function.name="". Downstream Responses-API adapters + # silently DROP such function_call items while still emitting the matching + # function_call_output, producing the gateway's HTTP 400 + # "No tool call found for function call output with call_id ...". + # + # We do NOT drop the call: hermes' own dispatch loop intentionally keeps an + # empty-name call paired with a synthesized anti-priming tool result + # ("tool name was empty", see #47967) so weak models self-correct instead of + # being fed the full tool catalog. Dropping the call here would (a) orphan + # that result and strip the anti-priming signal, and (b) still leave any + # provider-side orphan. Instead, rename the blank name to a non-empty + # sentinel so the call and its result stay PAIRED — the adapter no longer + # drops the function_call, so there is no orphaned output and no 400, while + # the result content the model needs is preserved. + _EMPTY_NAME_SENTINEL = "invalid_tool_call" + for msg in messages: + if msg.get("role") != "assistant": + continue + tcs = msg.get("tool_calls") or [] + if not tcs: + continue + for tc in tcs: + if isinstance(tc, dict): + fn = tc.get("function") + name = fn.get("name") if isinstance(fn, dict) else getattr(fn, "name", None) + else: + fn = getattr(tc, "function", None) + name = getattr(fn, "name", None) if fn else None + if isinstance(name, str) and name.strip(): + continue + _ra().logger.warning( + "Pre-call sanitizer: repairing tool_call with empty " + "function.name -> %r (id=%s)", + _EMPTY_NAME_SENTINEL, + _ra().AIAgent._get_tool_call_id_static(tc), + ) + if isinstance(fn, dict): + fn["name"] = _EMPTY_NAME_SENTINEL + elif fn is not None and hasattr(fn, "name"): + try: + fn.name = _EMPTY_NAME_SENTINEL + except Exception: + pass + elif isinstance(tc, dict): + tc["function"] = {"name": _EMPTY_NAME_SENTINEL, "arguments": "{}"} + surviving_call_ids: set = set() for msg in messages: if msg.get("role") == "assistant": -
tests/run_agent/test_run_agent.py@@ -3659,6 +3659,48 @@ def test_api_sanitizer_matches_responses_call_id_when_id_differs(self, agent): "call_123" ] + def test_api_sanitizer_repairs_tool_call_with_empty_function_name(self, agent): + """A tool_call with id but empty function.name makes the Responses-API + adapter drop the function_call while keeping its function_call_output, + causing the gateway's HTTP 400 'No tool call found for function call + output ...'. The sanitizer renames the blank name to a non-empty + sentinel so the call and its result stay PAIRED (no orphaned output, + no 400) while the result content is preserved — it must NOT drop the + call, because hermes' dispatch loop keeps empty-name calls paired with + an anti-priming result for self-correction (#47967). (#12807)""" + messages = [ + { + "role": "assistant", + "content": "", + "tool_calls": [ + { + "id": "call_good", + "type": "function", + "function": {"name": "web_search", "arguments": "{}"}, + }, + { + "id": "call_bad", + "type": "function", + "function": {"name": "", "arguments": "{}"}, + }, + ], + }, + {"role": "tool", "tool_call_id": "call_good", "content": "ok"}, + {"role": "tool", "tool_call_id": "call_bad", "content": "orphan"}, + ] + + sanitized = agent._sanitize_api_messages(messages) + + # The good call is untouched; the malformed call is repaired in place + # (renamed to a non-empty sentinel) rather than dropped. + assistant = next(m for m in sanitized if m.get("role") == "assistant") + names = [tc["function"]["name"] for tc in assistant["tool_calls"]] + assert names == ["web_search", "invalid_tool_call"] + # Both calls now have non-empty names, so neither output is orphaned + # and both tool results survive — this is what prevents the 400. + tool_ids = [m.get("tool_call_id") for m in sanitized if m.get("role") == "tool"] + assert tool_ids == ["call_good", "call_bad"] + class TestRunConversation: """Tests for the main run_conversation method.
-
20 PRs closed without merging
From the close comments: done elsewhere 7 · duplicate 3 · superseded by another PR 3 · merged via another change 1 · stale or abandoned 1 · other 5
-
#4724 fix: validate tool calls before persisting to prevent session poisoning · superseded by another PR →
#14407 - #4838 fix(agent): sanitize malformed tool calls before persist and replay · other
-
#5071 fix(agent): filter malformed tool calls before persistence and replay · done elsewhere →
#12259,#15348,#4662 -
#12093 fix(memory): handle unescaped apostrophes in tool call JSON from llama.cpp · other →
2d444fc - #12437 fix(context_compressor): properly truncate tool_calls to preserve valid JSON · done elsewhere
Show 15 more
-
#13057 fix(session): validate and repair tool_call arguments before persisting · superseded by another PR →
#14383 -
#14383 fix(session): validate and repair tool_call arguments on both shapes (supersedes #13057) · done elsewhere →
#13057,#12731,#15352,#13005 -
#14455 fix: sanitize malformed tool_calls and orphan tool results in outbound messages · other →
#14444,#14443 -
#15241 fix(run_agent): repair corrupted tool_call arguments before sending to provider · merged via another change →
7a192b1,#15348 -
#18650 fix(compression): sanitize malformed tool messages + auto-recover on API 400 · done elsewhere →
812ce0b,#21385 -
#54856 fix(agent): heal empty-name tool calls — drop on persist, heal on session load (salvage #5071) · done elsewhere →
0cebf99,#55922,#47967,#12807 -
#58065 fix(agent): require object tool-call arguments · other →
0d8b5f4 -
#58076 fix(agent): sanitize tool_call arguments that are JSON arrays, not ob… · done elsewhere →
#58168,#58065 -
#58092 fix: validate JSON type in sanitize_tool_call_arguments — reject non-dict arguments · superseded by another PR →
#58065,#58057,#58076 - #58124 fix: sanitize_tool_call_arguments now validates JSON type and unwraps single-element arrays · stale or abandoned
-
#58164 fix(agent): validate JSON type in sanitize_tool_call_arguments (#58057) · duplicate →
#58065,#58057,#58076,#58092,#58124 -
#66885 fix(agent): validate tool_call.arguments is a JSON object (not array/scalar) — #58057 · duplicate →
#58057,#58065 -
#69100 fix(agent): validate JSON type in sanitize_tool_call_arguments (#58057) · done elsewhere →
#58057 -
#74477 fix(agent): validate JSON type in sanitize_tool_call_arguments (#58057) · duplicate →
#58065 - #80594 fix(agent): validate tool_call argument type and unwrap single-element arrays (#58057) · other
Closed issues · 9
- Closed#4662[Bug]: Malformed persisted tool calls can poison a session and cause repeated 400 errors on subsequent requests
- Closed#10398[Bug]: 飞书发送消息回复错误
- Closed#12068[Bug]: Memory save tool call fails JSON parse on llama.cpp — unescaped apostrophes in summary string
- Closed#12167[MiniMax] Tool call parameter error - HTTP 400 with code 2013
- Closed#12242[Bug] StepFun API: Unterminated string error caused by broken JSON in context_compressor
- Closed#12731Session compression corrupts tool_call arguments, truncating JSON to 214 chars
- Closed#13574[BUG] Context compaction truncates tool_call.function.arguments mid-string, produces invalid JSON, causes 400 errors with strict providers (e.g. MiniMax)
- Closed#14443Truncated tool_call.arguments in conversation history wedges the retry loop
- Closed#15236Compressed sessions with corrupted tool_calls.arguments JSON brick chats with HTTP 400 (invalid_tool_call_format)
How this cluster was grouped
-
Link evidence (
partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text. - Members outside that structural core may belong to a different underlying problem.
-
Pairwise link density
0.05: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the Lab