diff --git a/run_agent.py b/run_agent.py
index 7c0157b2..31463b83 100644
--- a/run_agent.py
+++ b/run_agent.py
@@ -447,6 +447,13 @@ class AIAgent:
# Check if there's any non-whitespace content remaining
return bool(cleaned.strip())
+ def _strip_think_blocks(self, content: str) -> str:
+ """Remove ... blocks from content, returning only visible text."""
+ if not content:
+ return ""
+ return re.sub(r'.*?', '', content, flags=re.DOTALL)
+
+
def _extract_reasoning(self, assistant_message) -> Optional[str]:
"""
Extract reasoning/thinking content from an assistant message.
@@ -1387,6 +1394,12 @@ class AIAgent:
for i, tool_call in enumerate(assistant_message.tool_calls, 1):
function_name = tool_call.function.name
+ # Reset nudge counters when the relevant tool is actually used
+ if function_name == "memory":
+ self._turns_since_memory = 0
+ elif function_name == "skill_manage":
+ self._iters_since_skill = 0
+
try:
function_args = json.loads(tool_call.function.arguments)
except json.JSONDecodeError as e:
@@ -1631,6 +1644,9 @@ class AIAgent:
self._invalid_tool_retries = 0
self._invalid_json_retries = 0
self._empty_content_retries = 0
+ self._last_content_with_tools = None
+ self._turns_since_memory = 0
+ self._iters_since_skill = 0
# Initialize conversation
messages = conversation_history or []
@@ -1650,16 +1666,29 @@ class AIAgent:
# Track user turns for memory flush and periodic nudge logic
self._user_turn_count += 1
- # Periodic memory nudge: remind the model to consider saving memories
+ # Periodic memory nudge: remind the model to consider saving memories.
+ # Counter resets whenever the memory tool is actually used.
if (self._memory_nudge_interval > 0
- and self._user_turn_count % self._memory_nudge_interval == 0
- and self._user_turn_count > 0
and "memory" in self.valid_tool_names
and self._memory_store):
+ self._turns_since_memory += 1
+ if self._turns_since_memory >= self._memory_nudge_interval:
+ user_message += (
+ "\n\n[System: You've had several exchanges in this session. "
+ "Consider whether there's anything worth saving to your memories.]"
+ )
+ self._turns_since_memory = 0
+
+ # Skill creation nudge: fires on the first user message after a long tool loop.
+ # The counter increments per API iteration in the tool loop and is checked here.
+ if (self._skill_nudge_interval > 0
+ and self._iters_since_skill >= self._skill_nudge_interval
+ and "skill_manage" in self.valid_tool_names):
user_message += (
- "\n\n[System: You've had several exchanges in this session. "
- "Consider whether there's anything worth saving to your memories.]"
+ "\n\n[System: The previous task involved many steps. "
+ "If you discovered a reusable workflow, consider saving it as a skill.]"
)
+ self._iters_since_skill = 0
# Add user message
user_msg = {"role": "user", "content": user_message}
@@ -1702,18 +1731,11 @@ class AIAgent:
api_call_count += 1
- # Periodic skill creation nudge after many tool-calling iterations
+ # Track tool-calling iterations for skill nudge.
+ # Counter resets whenever skill_manage is actually used.
if (self._skill_nudge_interval > 0
- and api_call_count > 0
- and api_call_count % self._skill_nudge_interval == 0
and "skill_manage" in self.valid_tool_names):
- messages.append({
- "role": "user",
- "content": (
- "[System: This task has involved many steps. "
- "If you've discovered a reusable workflow, consider saving it as a skill.]"
- ),
- })
+ self._iters_since_skill += 1
# Prepare messages for API call
# If we have an ephemeral system prompt, prepend it to the messages
@@ -2212,6 +2234,20 @@ class AIAgent:
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.
+ 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
+ # Show intermediate commentary so the user can follow along
+ if self.quiet_mode:
+ clean = self._strip_think_blocks(turn_content).strip()
+ if clean:
+ preview = clean[:120] + "..." if len(clean) > 120 else clean
+ print(f" \033[2m┊ 💬 {preview}\033[0m")
+
messages.append(assistant_msg)
self._log_msg_to_db(assistant_msg)
@@ -2256,13 +2292,29 @@ class AIAgent:
print(f"{self.log_prefix}🔄 Retrying API call ({self._empty_content_retries}/3)...")
continue
else:
- # Max retries exceeded — keep the message history intact
- # and return what we have so the session is preserved.
print(f"{self.log_prefix}❌ Max retries (3) for empty content exceeded.")
self._empty_content_retries = 0
- # Append the empty assistant message so the session
- # log reflects what actually happened
+ # If a prior tool_calls turn had real content, salvage it:
+ # rewrite that turn's content to a brief tool description,
+ # and use the original content as the final response here.
+ fallback = getattr(self, '_last_content_with_tools', None)
+ if fallback:
+ self._last_content_with_tools = None
+ # Find the last assistant message with tool_calls and rewrite it
+ for i in range(len(messages) - 1, -1, -1):
+ msg = messages[i]
+ if msg.get("role") == "assistant" and msg.get("tool_calls"):
+ tool_names = []
+ for tc in msg["tool_calls"]:
+ fn = tc.get("function", {})
+ tool_names.append(fn.get("name", "unknown"))
+ msg["content"] = f"Calling the {', '.join(tool_names)} tool{'s' if len(tool_names) > 1 else ''}..."
+ break
+ final_response = fallback
+ break
+
+ # No fallback -- append the empty message as-is
empty_msg = {
"role": "assistant",
"content": final_response,