style: revert unrelated Black-style formatting churn (#3220)

The earlier commits picked up a large amount of Black-style reformatting
(multi-line frozenset / keyword-arg wrapping / docstring blanks / removed
parens) on top of the actual guard fix. @chengyongru flagged it; the
first pass reverted some but not all.

This restores nanobot/providers/base.py, runner.py, heartbeat/service.py,
and utils/evaluator.py to origin/main and reapplies only the guard logic:

  - base.py: add should_execute_tools property
  - runner.py / heartbeat/service.py / utils/evaluator.py: route through it
    + log a warning when has_tool_calls but finish_reason is anomalous

Net diff vs main is now +87/-4 (was +211/-102) — roughly 30 lines of real
logic, which is what the PR is actually about.

Behavior unchanged from previous HEAD; full suite still 2014 passed.

Made-with: Cursor
This commit is contained in:
Xubin Ren 2026-04-17 12:35:30 +00:00 committed by Xubin Ren
parent 9a569fdc6a
commit 14ee7cb121
4 changed files with 104 additions and 178 deletions

View File

@ -47,6 +47,7 @@ _COMPACTABLE_TOOLS = frozenset({
_BACKFILL_CONTENT = "[Tool result unavailable — call was interrupted or lost]" _BACKFILL_CONTENT = "[Tool result unavailable — call was interrupted or lost]"
@dataclass(slots=True) @dataclass(slots=True)
class AgentRunSpec: class AgentRunSpec:
"""Configuration for a single agent execution.""" """Configuration for a single agent execution."""
@ -119,7 +120,11 @@ class AgentRunner:
) -> None: ) -> None:
"""Append injected user messages while preserving role alternation.""" """Append injected user messages while preserving role alternation."""
for injection in injections: for injection in injections:
if messages and injection.get("role") == "user" and messages[-1].get("role") == "user": if (
messages
and injection.get("role") == "user"
and messages[-1].get("role") == "user"
):
merged = dict(messages[-1]) merged = dict(messages[-1])
merged["content"] = cls._merge_message_content( merged["content"] = cls._merge_message_content(
merged.get("content"), merged.get("content"),
@ -169,10 +174,7 @@ class AgentRunner:
self._append_injected_messages(messages, injections) self._append_injected_messages(messages, injections)
logger.info( logger.info(
"Injected {} follow-up message(s) {} ({}/{})", "Injected {} follow-up message(s) {} ({}/{})",
len(injections), len(injections), phase, injection_cycles, _MAX_INJECTION_CYCLES,
phase,
injection_cycles,
_MAX_INJECTION_CYCLES,
) )
return True, injection_cycles return True, injection_cycles
@ -188,9 +190,12 @@ class AgentRunner:
return [] return []
try: try:
signature = inspect.signature(spec.injection_callback) signature = inspect.signature(spec.injection_callback)
accepts_limit = "limit" in signature.parameters or any( accepts_limit = (
parameter.kind is inspect.Parameter.VAR_KEYWORD "limit" in signature.parameters
for parameter in signature.parameters.values() or any(
parameter.kind is inspect.Parameter.VAR_KEYWORD
for parameter in signature.parameters.values()
)
) )
if accepts_limit: if accepts_limit:
items = await spec.injection_callback(limit=_MAX_INJECTIONS_PER_TURN) items = await spec.injection_callback(limit=_MAX_INJECTIONS_PER_TURN)
@ -213,9 +218,7 @@ class AgentRunner:
dropped = len(injected_messages) - _MAX_INJECTIONS_PER_TURN dropped = len(injected_messages) - _MAX_INJECTIONS_PER_TURN
logger.warning( logger.warning(
"Injection callback returned {} messages, capping to {} ({} dropped)", "Injection callback returned {} messages, capping to {} ({} dropped)",
len(injected_messages), len(injected_messages), _MAX_INJECTIONS_PER_TURN, dropped,
_MAX_INJECTIONS_PER_TURN,
dropped,
) )
injected_messages = injected_messages[:_MAX_INJECTIONS_PER_TURN] injected_messages = injected_messages[:_MAX_INJECTIONS_PER_TURN]
return injected_messages return injected_messages
@ -290,9 +293,7 @@ class AgentRunner:
"model": spec.model, "model": spec.model,
"assistant_message": assistant_message, "assistant_message": assistant_message,
"completed_tool_results": [], "completed_tool_results": [],
"pending_tool_calls": [ "pending_tool_calls": [tc.to_openai_tool_call() for tc in response.tool_calls],
tc.to_openai_tool_call() for tc in response.tool_calls
],
}, },
) )
@ -331,10 +332,7 @@ class AgentRunner:
context.stop_reason = stop_reason context.stop_reason = stop_reason
await hook.after_iteration(context) await hook.after_iteration(context)
should_continue, injection_cycles = await self._try_drain_injections( should_continue, injection_cycles = await self._try_drain_injections(
spec, spec, messages, None, injection_cycles,
messages,
None,
injection_cycles,
phase="after tool error", phase="after tool error",
) )
if should_continue: if should_continue:
@ -356,10 +354,7 @@ class AgentRunner:
length_recovery_count = 0 length_recovery_count = 0
# Checkpoint 1: drain injections after tools, before next LLM call # Checkpoint 1: drain injections after tools, before next LLM call
_drained, injection_cycles = await self._try_drain_injections( _drained, injection_cycles = await self._try_drain_injections(
spec, spec, messages, None, injection_cycles,
messages,
None,
injection_cycles,
phase="after tool execution", phase="after tool execution",
) )
if _drained: if _drained:
@ -367,9 +362,9 @@ class AgentRunner:
await hook.after_iteration(context) await hook.after_iteration(context)
continue continue
elif response.has_tool_calls: if response.has_tool_calls:
logger.warning( logger.warning(
"Ignoring tool calls under finish_reason='%s' for %s", "Ignoring tool calls under finish_reason='{}' for {}",
response.finish_reason, response.finish_reason,
spec.session_key or "default", spec.session_key or "default",
) )
@ -418,13 +413,11 @@ class AgentRunner:
) )
if hook.wants_streaming(): if hook.wants_streaming():
await hook.on_stream_end(context, resuming=True) await hook.on_stream_end(context, resuming=True)
messages.append( messages.append(build_assistant_message(
build_assistant_message( clean,
clean, reasoning_content=response.reasoning_content,
reasoning_content=response.reasoning_content, thinking_blocks=response.thinking_blocks,
thinking_blocks=response.thinking_blocks, ))
)
)
messages.append(build_length_recovery_message()) messages.append(build_length_recovery_message())
await hook.after_iteration(context) await hook.after_iteration(context)
continue continue
@ -441,10 +434,7 @@ class AgentRunner:
# If injections are found we keep the stream alive (resuming=True) # If injections are found we keep the stream alive (resuming=True)
# so streaming channels don't prematurely finalize the card. # so streaming channels don't prematurely finalize the card.
should_continue, injection_cycles = await self._try_drain_injections( should_continue, injection_cycles = await self._try_drain_injections(
spec, spec, messages, assistant_message, injection_cycles,
messages,
assistant_message,
injection_cycles,
phase="after final response", phase="after final response",
iteration=iteration, iteration=iteration,
) )
@ -468,10 +458,7 @@ class AgentRunner:
context.stop_reason = stop_reason context.stop_reason = stop_reason
await hook.after_iteration(context) await hook.after_iteration(context)
should_continue, injection_cycles = await self._try_drain_injections( should_continue, injection_cycles = await self._try_drain_injections(
spec, spec, messages, None, injection_cycles,
messages,
None,
injection_cycles,
phase="after LLM error", phase="after LLM error",
) )
if should_continue: if should_continue:
@ -488,10 +475,7 @@ class AgentRunner:
context.stop_reason = stop_reason context.stop_reason = stop_reason
await hook.after_iteration(context) await hook.after_iteration(context)
should_continue, injection_cycles = await self._try_drain_injections( should_continue, injection_cycles = await self._try_drain_injections(
spec, spec, messages, None, injection_cycles,
messages,
None,
injection_cycles,
phase="after empty response", phase="after empty response",
) )
if should_continue: if should_continue:
@ -499,14 +483,11 @@ class AgentRunner:
continue continue
break break
messages.append( messages.append(assistant_message or build_assistant_message(
assistant_message clean,
or build_assistant_message( reasoning_content=response.reasoning_content,
clean, thinking_blocks=response.thinking_blocks,
reasoning_content=response.reasoning_content, ))
thinking_blocks=response.thinking_blocks,
)
)
await self._emit_checkpoint( await self._emit_checkpoint(
spec, spec,
{ {
@ -542,10 +523,7 @@ class AgentRunner:
# We ignore should_continue here because the for-loop has already # We ignore should_continue here because the for-loop has already
# exhausted all iterations. # exhausted all iterations.
drained_after_max_iterations, injection_cycles = await self._try_drain_injections( drained_after_max_iterations, injection_cycles = await self._try_drain_injections(
spec, spec, messages, None, injection_cycles,
messages,
None,
injection_cycles,
phase="after max_iterations", phase="after max_iterations",
) )
if drained_after_max_iterations: if drained_after_max_iterations:
@ -597,7 +575,6 @@ class AgentRunner:
tools=spec.tools.get_definitions(), tools=spec.tools.get_definitions(),
) )
if hook.wants_streaming(): if hook.wants_streaming():
async def _stream(delta: str) -> None: async def _stream(delta: str) -> None:
await hook.on_stream(context, delta) await hook.on_stream(context, delta)
@ -651,19 +628,13 @@ class AgentRunner:
tool_results: list[tuple[Any, dict[str, str], BaseException | None]] = [] tool_results: list[tuple[Any, dict[str, str], BaseException | None]] = []
for batch in batches: for batch in batches:
if spec.concurrent_tools and len(batch) > 1: if spec.concurrent_tools and len(batch) > 1:
tool_results.extend( tool_results.extend(await asyncio.gather(*(
await asyncio.gather( self._run_tool(spec, tool_call, external_lookup_counts)
*( for tool_call in batch
self._run_tool(spec, tool_call, external_lookup_counts) )))
for tool_call in batch
)
)
)
else: else:
for tool_call in batch: for tool_call in batch:
tool_results.append( tool_results.append(await self._run_tool(spec, tool_call, external_lookup_counts))
await self._run_tool(spec, tool_call, external_lookup_counts)
)
results: list[Any] = [] results: list[Any] = []
events: list[dict[str, str]] = [] events: list[dict[str, str]] = []
@ -711,11 +682,7 @@ class AgentRunner:
"status": "error", "status": "error",
"detail": prep_error.split(": ", 1)[-1][:120], "detail": prep_error.split(": ", 1)[-1][:120],
} }
return ( return prep_error + _HINT, event, RuntimeError(prep_error) if spec.fail_on_tool_error else None
prep_error + _HINT,
event,
RuntimeError(prep_error) if spec.fail_on_tool_error else None,
)
try: try:
if tool is not None: if tool is not None:
result = await tool.execute(**params) result = await tool.execute(**params)
@ -777,11 +744,7 @@ class AgentRunner:
@staticmethod @staticmethod
def _append_model_error_placeholder(messages: list[dict[str, Any]]) -> None: def _append_model_error_placeholder(messages: list[dict[str, Any]]) -> None:
if ( if messages and messages[-1].get("role") == "assistant" and not messages[-1].get("tool_calls"):
messages
and messages[-1].get("role") == "assistant"
and not messages[-1].get("tool_calls")
):
return return
messages.append(build_assistant_message(_PERSISTED_MODEL_ERROR_PLACEHOLDER)) messages.append(build_assistant_message(_PERSISTED_MODEL_ERROR_PLACEHOLDER))
@ -871,15 +834,12 @@ class AgentRunner:
insert_at = assistant_idx + 1 + offset insert_at = assistant_idx + 1 + offset
while insert_at < len(updated) and updated[insert_at].get("role") == "tool": while insert_at < len(updated) and updated[insert_at].get("role") == "tool":
insert_at += 1 insert_at += 1
updated.insert( updated.insert(insert_at, {
insert_at, "role": "tool",
{ "tool_call_id": call_id,
"role": "tool", "name": name,
"tool_call_id": call_id, "content": _BACKFILL_CONTENT,
"name": name, })
"content": _BACKFILL_CONTENT,
},
)
offset += 1 offset += 1
return updated return updated
@ -938,13 +898,9 @@ class AgentRunner:
if not messages or not spec.context_window_tokens: if not messages or not spec.context_window_tokens:
return messages return messages
provider_max_tokens = getattr( provider_max_tokens = getattr(getattr(self.provider, "generation", None), "max_tokens", 4096)
getattr(self.provider, "generation", None), "max_tokens", 4096 max_output = spec.max_tokens if isinstance(spec.max_tokens, int) else (
) provider_max_tokens if isinstance(provider_max_tokens, int) else 4096
max_output = (
spec.max_tokens
if isinstance(spec.max_tokens, int)
else (provider_max_tokens if isinstance(provider_max_tokens, int) else 4096)
) )
budget = spec.context_block_limit or ( budget = spec.context_block_limit or (
spec.context_window_tokens - max_output - _SNIP_SAFETY_BUFFER spec.context_window_tokens - max_output - _SNIP_SAFETY_BUFFER
@ -1027,3 +983,4 @@ class AgentRunner:
if current: if current:
batches.append(current) batches.append(current)
return batches return batches

View File

@ -93,18 +93,12 @@ class HeartbeatService:
response = await self.provider.chat_with_retry( response = await self.provider.chat_with_retry(
messages=[ messages=[
{ {"role": "system", "content": "You are a heartbeat agent. Call the heartbeat tool to report your decision."},
"role": "system", {"role": "user", "content": (
"content": "You are a heartbeat agent. Call the heartbeat tool to report your decision.", f"Current Time: {current_time_str(self.timezone)}\n\n"
}, "Review the following HEARTBEAT.md and decide whether there are active tasks.\n\n"
{ f"{content}"
"role": "user", )},
"content": (
f"Current Time: {current_time_str(self.timezone)}\n\n"
"Review the following HEARTBEAT.md and decide whether there are active tasks.\n\n"
f"{content}"
),
},
], ],
tools=_HEARTBEAT_TOOL, tools=_HEARTBEAT_TOOL,
model=self.model, model=self.model,
@ -113,7 +107,7 @@ class HeartbeatService:
if not response.should_execute_tools: if not response.should_execute_tools:
if response.has_tool_calls: if response.has_tool_calls:
logger.warning( logger.warning(
"Ignoring tool calls under finish_reason='%s' in heartbeat", "Ignoring heartbeat tool calls under finish_reason='{}'",
response.finish_reason, response.finish_reason,
) )
return "skip", "" return "skip", ""
@ -177,10 +171,7 @@ class HeartbeatService:
if response: if response:
should_notify = await evaluate_response( should_notify = await evaluate_response(
response, response, tasks, self.provider, self.model,
tasks,
self.provider,
self.model,
) )
if should_notify and self.on_notify: if should_notify and self.on_notify:
logger.info("Heartbeat: completed, delivering response") logger.info("Heartbeat: completed, delivering response")

View File

@ -18,7 +18,6 @@ from nanobot.utils.helpers import image_placeholder_text
@dataclass @dataclass
class ToolCallRequest: class ToolCallRequest:
"""A tool call request from the LLM.""" """A tool call request from the LLM."""
id: str id: str
name: str name: str
arguments: dict[str, Any] arguments: dict[str, Any]
@ -41,16 +40,13 @@ class ToolCallRequest:
if self.provider_specific_fields: if self.provider_specific_fields:
tool_call["provider_specific_fields"] = self.provider_specific_fields tool_call["provider_specific_fields"] = self.provider_specific_fields
if self.function_provider_specific_fields: if self.function_provider_specific_fields:
tool_call["function"]["provider_specific_fields"] = ( tool_call["function"]["provider_specific_fields"] = self.function_provider_specific_fields
self.function_provider_specific_fields
)
return tool_call return tool_call
@dataclass @dataclass
class LLMResponse: class LLMResponse:
"""Response from an LLM provider.""" """Response from an LLM provider."""
content: str | None content: str | None
tool_calls: list[ToolCallRequest] = field(default_factory=list) tool_calls: list[ToolCallRequest] = field(default_factory=list)
finish_reason: str = "stop" finish_reason: str = "stop"
@ -73,7 +69,8 @@ class LLMResponse:
@property @property
def should_execute_tools(self) -> bool: def should_execute_tools(self) -> bool:
"""True only if tool_calls present AND finish_reason is a known-good signal (``tool_calls`` or ``stop``); blocks gateway-injected calls under ``refusal`` / ``content_filter`` / ``error``.""" """Tools execute only when has_tool_calls AND finish_reason is ``tool_calls`` / ``stop``.
Blocks gateway-injected calls under ``refusal`` / ``content_filter`` / ``error`` (#3220)."""
if not self.has_tool_calls: if not self.has_tool_calls:
return False return False
return self.finish_reason in ("tool_calls", "stop") return self.finish_reason in ("tool_calls", "stop")
@ -114,28 +111,24 @@ class LLMProvider(ABC):
) )
_RETRYABLE_STATUS_CODES = frozenset({408, 409, 429}) _RETRYABLE_STATUS_CODES = frozenset({408, 409, 429})
_TRANSIENT_ERROR_KINDS = frozenset({"timeout", "connection"}) _TRANSIENT_ERROR_KINDS = frozenset({"timeout", "connection"})
_NON_RETRYABLE_429_ERROR_TOKENS = frozenset( _NON_RETRYABLE_429_ERROR_TOKENS = frozenset({
{ "insufficient_quota",
"insufficient_quota", "quota_exceeded",
"quota_exceeded", "quota_exhausted",
"quota_exhausted", "billing_hard_limit_reached",
"billing_hard_limit_reached", "insufficient_balance",
"insufficient_balance", "credit_balance_too_low",
"credit_balance_too_low", "billing_not_active",
"billing_not_active", "payment_required",
"payment_required", })
} _RETRYABLE_429_ERROR_TOKENS = frozenset({
) "rate_limit_exceeded",
_RETRYABLE_429_ERROR_TOKENS = frozenset( "rate_limit_error",
{ "too_many_requests",
"rate_limit_exceeded", "request_limit_exceeded",
"rate_limit_error", "requests_limit_exceeded",
"too_many_requests", "overloaded_error",
"request_limit_exceeded", })
"requests_limit_exceeded",
"overloaded_error",
}
)
_NON_RETRYABLE_429_TEXT_MARKERS = ( _NON_RETRYABLE_429_TEXT_MARKERS = (
"insufficient_quota", "insufficient_quota",
"insufficient quota", "insufficient quota",
@ -179,11 +172,7 @@ class LLMProvider(ABC):
if isinstance(content, str) and not content: if isinstance(content, str) and not content:
clean = dict(msg) clean = dict(msg)
clean["content"] = ( clean["content"] = None if (msg.get("role") == "assistant" and msg.get("tool_calls")) else "(empty)"
None
if (msg.get("role") == "assistant" and msg.get("tool_calls"))
else "(empty)"
)
result.append(clean) result.append(clean)
continue continue
@ -357,7 +346,10 @@ class LLMProvider(ABC):
def _is_retryable_429_response(cls, response: LLMResponse) -> bool: def _is_retryable_429_response(cls, response: LLMResponse) -> bool:
type_token = cls._normalize_error_token(response.error_type) type_token = cls._normalize_error_token(response.error_type)
code_token = cls._normalize_error_token(response.error_code) code_token = cls._normalize_error_token(response.error_code)
semantic_tokens = {token for token in (type_token, code_token) if token is not None} semantic_tokens = {
token for token in (type_token, code_token)
if token is not None
}
if any(token in cls._NON_RETRYABLE_429_ERROR_TOKENS for token in semantic_tokens): if any(token in cls._NON_RETRYABLE_429_ERROR_TOKENS for token in semantic_tokens):
return False return False
@ -511,13 +503,9 @@ class LLMProvider(ABC):
streaming should override this method. streaming should override this method.
""" """
response = await self.chat( response = await self.chat(
messages=messages, messages=messages, tools=tools, model=model,
tools=tools, max_tokens=max_tokens, temperature=temperature,
model=model, reasoning_effort=reasoning_effort, tool_choice=tool_choice,
max_tokens=max_tokens,
temperature=temperature,
reasoning_effort=reasoning_effort,
tool_choice=tool_choice,
) )
if on_content_delta and response.content: if on_content_delta and response.content:
await on_content_delta(response.content) await on_content_delta(response.content)
@ -554,13 +542,9 @@ class LLMProvider(ABC):
reasoning_effort = self.generation.reasoning_effort reasoning_effort = self.generation.reasoning_effort
kw: dict[str, Any] = dict( kw: dict[str, Any] = dict(
messages=messages, messages=messages, tools=tools, model=model,
tools=tools, max_tokens=max_tokens, temperature=temperature,
model=model, reasoning_effort=reasoning_effort, tool_choice=tool_choice,
max_tokens=max_tokens,
temperature=temperature,
reasoning_effort=reasoning_effort,
tool_choice=tool_choice,
on_content_delta=on_content_delta, on_content_delta=on_content_delta,
) )
return await self._run_with_retry( return await self._run_with_retry(
@ -600,13 +584,9 @@ class LLMProvider(ABC):
reasoning_effort = self.generation.reasoning_effort reasoning_effort = self.generation.reasoning_effort
kw: dict[str, Any] = dict( kw: dict[str, Any] = dict(
messages=messages, messages=messages, tools=tools, model=model,
tools=tools, max_tokens=max_tokens, temperature=temperature,
model=model, reasoning_effort=reasoning_effort, tool_choice=tool_choice,
max_tokens=max_tokens,
temperature=temperature,
reasoning_effort=reasoning_effort,
tool_choice=tool_choice,
) )
return await self._run_with_retry( return await self._run_with_retry(
self._safe_chat, self._safe_chat,
@ -734,7 +714,7 @@ class LLMProvider(ABC):
if response.finish_reason != "error": if response.finish_reason != "error":
return response return response
last_response = response last_response = response
error_key = (response.content or "").strip().lower() or None error_key = ((response.content or "").strip().lower() or None)
if error_key and error_key == last_error_key: if error_key and error_key == last_error_key:
identical_error_count += 1 identical_error_count += 1
else: else:
@ -776,7 +756,9 @@ class LLMProvider(ABC):
(response.content or "")[:120].lower(), (response.content or "")[:120].lower(),
) )
if on_retry_wait: if on_retry_wait:
await on_retry_wait(f"Model request failed after {attempt} retries, giving up.") await on_retry_wait(
f"Model request failed after {attempt} retries, giving up."
)
break break
base_delay = delays[min(attempt - 1, len(delays) - 1)] base_delay = delays[min(attempt - 1, len(delays) - 1)]

View File

@ -39,7 +39,6 @@ _EVALUATE_TOOL = [
} }
] ]
async def evaluate_response( async def evaluate_response(
response: str, response: str,
task_context: str, task_context: str,
@ -56,15 +55,12 @@ async def evaluate_response(
llm_response = await provider.chat_with_retry( llm_response = await provider.chat_with_retry(
messages=[ messages=[
{"role": "system", "content": render_template("agent/evaluator.md", part="system")}, {"role": "system", "content": render_template("agent/evaluator.md", part="system")},
{ {"role": "user", "content": render_template(
"role": "user", "agent/evaluator.md",
"content": render_template( part="user",
"agent/evaluator.md", task_context=task_context,
part="user", response=response,
task_context=task_context, )},
response=response,
),
},
], ],
tools=_EVALUATE_TOOL, tools=_EVALUATE_TOOL,
model=model, model=model,
@ -75,7 +71,7 @@ async def evaluate_response(
if not llm_response.should_execute_tools: if not llm_response.should_execute_tools:
if llm_response.has_tool_calls: if llm_response.has_tool_calls:
logger.warning( logger.warning(
"evaluate_response: ignoring tool calls under finish_reason='%s', defaulting to notify", "evaluate_response: ignoring tool calls under finish_reason='{}', defaulting to notify",
llm_response.finish_reason, llm_response.finish_reason,
) )
else: else: