Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/contributor-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ jobs:
while IFS= read -r email; do
# Skip teknium and bot emails
case "$email" in
*teknium*|*noreply@github.com*|*dependabot*|*github-actions*|*anthropic.com*|*cursor.com*)
*teknium*|*noreply@github.com*|*@users.noreply.github.com|*dependabot*|*github-actions*|*anthropic.com*|*cursor.com*)
continue ;;
esac

Expand Down
36 changes: 20 additions & 16 deletions run_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -9985,25 +9985,29 @@ def _stop_spinner():

assistant_msg = self._build_assistant_message(assistant_message, finish_reason)

# If this turn has both content AND tool_calls, capture the content
# as a fallback final response. Common pattern: model delivers its
# answer and calls memory/skill tools as a side-effect in the same
# turn. If the follow-up turn after tools is empty, we use this.
# Only carry forward earlier turn content as a fallback
# when EVERY tool call is post-response housekeeping
# (memory, todo, skill_manage, etc.). Using substantive
# tool turns as fallback content silently abandons
# unfinished multi-step tasks on the next empty response.
_HOUSEKEEPING_TOOLS = frozenset({
"memory", "todo", "skill_manage", "session_search",
})
_all_housekeeping = all(
tc.function.name in _HOUSEKEEPING_TOOLS
for tc in assistant_message.tool_calls
)
turn_content = assistant_message.content or ""
if turn_content and self._has_content_after_think_block(turn_content):
self._last_content_with_tools = turn_content
if _all_housekeeping:
self._last_content_with_tools = turn_content
else:
self._last_content_with_tools = None
# Only mute subsequent output when EVERY tool call in
# this turn is post-response housekeeping (memory, todo,
# skill_manage, etc.). If any substantive tool is present
# (search_files, read_file, write_file, terminal, ...),
# keep output visible so the user sees progress.
_HOUSEKEEPING_TOOLS = frozenset({
"memory", "todo", "skill_manage", "session_search",
})
_all_housekeeping = all(
tc.function.name in _HOUSEKEEPING_TOOLS
for tc in assistant_message.tool_calls
)
# this turn is post-response housekeeping. If any
# substantive tool is present (search_files, read_file,
# write_file, terminal, ...), keep output visible so
# the user sees progress.
if _all_housekeeping and self._has_stream_consumers():
self._mute_post_response = True
elif self.quiet_mode:
Expand Down
66 changes: 66 additions & 0 deletions tests/run_agent/test_run_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -2079,6 +2079,72 @@ def _fake_api_call(api_kwargs):
assert result["final_response"] == "Fresh partial content from this turn"
assert result["api_calls"] == 1

def test_empty_after_substantive_tool_turn_retries_instead_of_using_stale_content(self, agent):
"""Substantive tool turns must not become the final answer fallback."""
self._setup_agent(agent)
tc = _mock_tool_call(name="web_search", arguments='{"q":"test"}', call_id="c1")
resp1 = _mock_response(
content="I found a lead and I'm checking one more source.",
finish_reason="tool_calls",
tool_calls=[tc],
)
resp2 = _mock_response(content=None, finish_reason="stop")
resp3 = _mock_response(
content="Here is the completed answer after the follow-up retry.",
finish_reason="stop",
)
agent.client.chat.completions.create.side_effect = [resp1, resp2, resp3]

status_messages = []

with (
patch("run_agent.handle_function_call", return_value="search result"),
patch.object(agent, "_persist_session"),
patch.object(agent, "_save_trajectory"),
patch.object(agent, "_cleanup_task_resources"),
patch.object(agent, "_emit_status", side_effect=status_messages.append),
):
result = agent.run_conversation("search something")

assert result["completed"] is True
assert result["final_response"] == "Here is the completed answer after the follow-up retry."
assert result["api_calls"] == 3
assert not any(
"using earlier content as final answer" in msg.lower()
for msg in status_messages
)

def test_empty_after_housekeeping_tool_turn_uses_prior_content_fallback(self, agent_with_memory_tool):
"""Housekeeping-only tool turns may still reuse prior visible content."""
self._setup_agent(agent_with_memory_tool)
tc = _mock_tool_call(name="memory", arguments='{"content":"saved note"}', call_id="c1")
resp1 = _mock_response(
content="Saved that to memory.",
finish_reason="tool_calls",
tool_calls=[tc],
)
resp2 = _mock_response(content=None, finish_reason="stop")
agent_with_memory_tool.client.chat.completions.create.side_effect = [resp1, resp2]

status_messages = []

with (
patch("run_agent.handle_function_call", return_value="saved"),
patch.object(agent_with_memory_tool, "_persist_session"),
patch.object(agent_with_memory_tool, "_save_trajectory"),
patch.object(agent_with_memory_tool, "_cleanup_task_resources"),
patch.object(agent_with_memory_tool, "_emit_status", side_effect=status_messages.append),
):
result = agent_with_memory_tool.run_conversation("remember this")

assert result["completed"] is True
assert result["final_response"] == "Saved that to memory."
assert result["api_calls"] == 2
assert any(
"using earlier content as final answer" in msg.lower()
for msg in status_messages
)

def test_nous_401_refreshes_after_remint_and_retries(self, agent):
self._setup_agent(agent)
agent.provider = "nous"
Expand Down
Loading