{"repo":"NousResearch/hermes-agent","public_id":"28204","display_label":"Tool loop warnings with recovery hints","summary":"These items address adding recovery hints to tool loop warnings in the agent and related gateway tool-use hardening, including skill preload and loop-guard improvements. The work centers on improving guidance when tool loops are detected.","subsystem":null,"counts":{"members":3,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":28592,"merged_at":"2026-05-19T07:12:13+00:00"},"resolution":{"state":"resolved","by":"pr-28592"},"freshness":{"state":"verified","last_verified_at":"2026-05-19T07:12:13+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":28204,"pr_number":28592,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-05-19T07:12:13+00:00"}],"members":[{"kind":"issue","number":28204,"title":"Gateway tool-use hardening: skill preload, loop-guard recovery, path resolution docs","url":null,"state":"closed"},{"kind":"pr","number":28256,"title":"fix(agent): add recovery hints to tool loop warnings","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"d759a67c0f7320a55e14154e15f525d36aaf1f06","number":0},{"kind":"pr","value":"#28592","number":28592}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":28592,"title":"fix(agent): add recovery hints to tool loop warnings (#28256)","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/tool_guardrails.py","tests/agent/test_tool_guardrails.py","tests/run_agent/test_tool_call_guardrail_runtime.py"],"fix_hunks":[{"file":"agent/tool_guardrails.py","hunks":["@@ -336,10 +336,7 @@ def after_call(\n                 return ToolGuardrailDecision(\n                     action=\"warn\",\n                     code=\"same_tool_failure_warning\",\n-                    message=(\n-                        f\"{tool_name} has failed {same_count} times this turn. \"\n-                        \"This looks like a loop; change approach before retrying.\"\n-                    ),\n+                    message=_tool_failure_recovery_hint(tool_name, same_count),\n                     tool_name=tool_name,\n                     count=same_count,\n                     signature=signature,","@@ -406,6 +403,26 @@ def append_toolguard_guidance(result: str, decision: ToolGuardrailDecision) -> s\n     return (result or \"\") + suffix\n \n \n+def _tool_failure_recovery_hint(tool_name: str, count: int) -> str:\n+    \"\"\"Action-oriented guidance for recovering from repeated tool failures.\"\"\"\n+    common = (\n+        f\"{tool_name} has failed {count} times this turn. This looks like a loop. \"\n+        \"Do not switch to text-only replies; keep using tools, but diagnose before retrying. \"\n+        \"First inspect the latest error/output and verify your assumptions. \"\n+    )\n+    if tool_name == \"terminal\":\n+        return common + (\n+            \"For terminal failures, run a small diagnostic such as `pwd && ls -la` \"\n+            \"in the same tool, then try an absolute path, a simpler command, a different \"\n+            \"working directory, or a different tool such as read_file/write_file/patch.\"\n+        )\n+    return common + (\n+        \"Try different arguments, a narrower query/path, an absolute path when relevant, \"\n+        \"or a different tool that can make progress. If the blocker is external, report \"\n+        \"the blocker after one diagnostic attempt instead of repeating the same failing path.\"\n+    )\n+\n+\n def _coerce_args(args: Mapping[str, Any] | None) -> Mapping[str, Any]:\n     return args if isinstance(args, Mapping) else {}\n "]},{"file":"tests/agent/test_tool_guardrails.py","hunks":["@@ -160,6 +160,10 @@ def test_same_tool_varying_args_warns_by_default_without_halting():\n     assert first.action == \"allow\"\n     assert [second.action, third.action, fourth.action] == [\"warn\", \"warn\", \"warn\"]\n     assert {second.code, third.code, fourth.code} == {\"same_tool_failure_warning\"}\n+    assert \"Do not switch to text-only replies\" in second.message\n+    assert \"keep using tools\" in second.message\n+    assert \"diagnose before retrying\" in second.message\n+    assert \"different tool\" in second.message\n     assert controller.halt_decision is None\n \n "]},{"file":"tests/run_agent/test_tool_call_guardrail_runtime.py","hunks":["@@ -153,6 +153,37 @@ def test_sequential_after_call_appends_guidance_to_tool_result_without_extra_mes\n     assert \"repeated_exact_failure_warning\" in messages[0][\"content\"]\n \n \n+def test_same_tool_failure_warning_tells_model_to_recover_with_tools():\n+    agent = _make_agent(\"terminal\")\n+    guardrails = getattr(agent, \"_tool_guardrails\")\n+    guardrails.after_call(\n+        \"terminal\",\n+        {\"command\": \"bad-1\"},\n+        json.dumps({\"exit_code\": 1}),\n+        failed=True,\n+    )\n+    guardrails.after_call(\n+        \"terminal\",\n+        {\"command\": \"bad-2\"},\n+        json.dumps({\"exit_code\": 1}),\n+        failed=True,\n+    )\n+    tc = _mock_tool_call(\"terminal\", json.dumps({\"command\": \"bad-3\"}), \"c-recover\")\n+    msg = SimpleNamespace(content=\"\", tool_calls=[tc])\n+    messages = []\n+\n+    with patch(\"run_agent.handle_function_call\", return_value=json.dumps({\"exit_code\": 1})):\n+        agent._execute_tool_calls_sequential(msg, messages, \"task-1\")\n+\n+    content = messages[0][\"content\"]\n+    assert \"same_tool_failure_warning\" in content\n+    assert \"Do not switch to text-only replies\" in content\n+    assert \"keep using tools\" in content\n+    assert \"pwd && ls -la\" in content\n+    assert \"absolute path\" in content\n+    assert \"different tool\" in content\n+\n+\n def test_config_enabled_hard_stop_concurrent_path_does_not_submit_blocked_calls_and_preserves_result_order():\n     agent = _make_agent(\"web_search\", config=_hard_stop_config())\n     blocked_args = {\"query\": \"blocked\"}\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}