diff --git a/autogpt_platform/backend/backend/copilot/baseline/reasoning.py b/autogpt_platform/backend/backend/copilot/baseline/reasoning.py index e2511b34eb..f301b50147 100644 --- a/autogpt_platform/backend/backend/copilot/baseline/reasoning.py +++ b/autogpt_platform/backend/backend/copilot/baseline/reasoning.py @@ -265,11 +265,15 @@ class BaselineReasoningEmitter: # rather than waiting for the coalesce window to elapse. Subsequent # chunks buffer into ``_pending_delta`` and only flush when the # char/time thresholds trip. + # Sample the monotonic clock exactly once per chunk — at ~4,700 + # chunks per turn, folding the two calls into one cuts ~4,700 + # syscalls off the hot path without changing semantics. + now = time.monotonic() if not self._open: events.append(StreamReasoningStart(id=self._block_id)) events.append(StreamReasoningDelta(id=self._block_id, delta=text)) self._open = True - self._last_flush_monotonic = time.monotonic() + self._last_flush_monotonic = now if self._session_messages is not None: self._current_row = ChatMessage(role="reasoning", content=text) self._session_messages.append(self._current_row) @@ -282,21 +286,26 @@ class BaselineReasoningEmitter: self._current_row.content = (self._current_row.content or "") + text self._pending_delta += text - if self._should_flush_pending(): + if self._should_flush_pending(now): events.append( StreamReasoningDelta(id=self._block_id, delta=self._pending_delta) ) self._pending_delta = "" - self._last_flush_monotonic = time.monotonic() + self._last_flush_monotonic = now return events - def _should_flush_pending(self) -> bool: - """Return True when the accumulated delta should be emitted now.""" + def _should_flush_pending(self, now: float) -> bool: + """Return True when the accumulated delta should be emitted now. + + *now* is the monotonic timestamp sampled by the caller so the + clock is read at most once per chunk (the flush-timestamp update + reuses the same value). + """ if not self._pending_delta: return False if len(self._pending_delta) >= self._coalesce_min_chars: return True - elapsed_ms = (time.monotonic() - self._last_flush_monotonic) * 1000.0 + elapsed_ms = (now - self._last_flush_monotonic) * 1000.0 return elapsed_ms >= self._coalesce_max_interval_ms def close(self) -> list[StreamBaseResponse]: diff --git a/autogpt_platform/backend/backend/copilot/model.py b/autogpt_platform/backend/backend/copilot/model.py index 34a89c62a7..1adef8e7c8 100644 --- a/autogpt_platform/backend/backend/copilot/model.py +++ b/autogpt_platform/backend/backend/copilot/model.py @@ -254,6 +254,13 @@ class ChatSession(ChatSessionInfo): def announce_inflight_tool_call(self, tool_name: str) -> None: """Record that *tool_name* is being dispatched in the current turn. + Called by the baseline tool executor **before** the tool actually + runs (the announcement is about dispatch, not success). If the + tool raises, the name stays in the buffer for the rest of the + turn — that matches the guide-read gate's contract ("was the tool + called?") but means any future gate wanting *successful* + dispatches would need its own tracking. + Lets in-turn guards (see ``copilot/tools/helpers.py::require_guide_read``) see a tool call the moment it's issued, instead of waiting for the @@ -270,13 +277,18 @@ class ChatSession(ChatSessionInfo): """Reset the in-flight tool-call announcement buffer.""" self._inflight_tool_calls.clear() - def has_tool_been_called_this_turn(self, tool_name: str) -> bool: - """True when *tool_name* has been called in the current turn. + def has_tool_been_called(self, tool_name: str) -> bool: + """True when *tool_name* has been called in this session. - Checks the in-flight announcement buffer first (for calls - dispatched in *this* turn but not yet persisted) and then the - durable ``messages`` history (for past turns + prior rounds - within this turn whose writes already landed). + Checks the in-flight announcement buffer (for calls dispatched + in the *current* turn but not yet flushed into ``messages``) and + the durable ``messages`` history (for past turns + prior rounds + within this turn whose writes already landed). The durable + scan is session-wide, not turn-scoped: a matching tool call + anywhere in ``messages`` counts. This matches the guide-read + contract — once the guide has been read in the session, the + agent doesn't need to re-read it for later create/edit/fix + tools. """ if tool_name in self._inflight_tool_calls: return True diff --git a/autogpt_platform/backend/backend/copilot/tools/agent_guide_gate_test.py b/autogpt_platform/backend/backend/copilot/tools/agent_guide_gate_test.py index 4db63fcb9c..a5c03162e3 100644 --- a/autogpt_platform/backend/backend/copilot/tools/agent_guide_gate_test.py +++ b/autogpt_platform/backend/backend/copilot/tools/agent_guide_gate_test.py @@ -23,7 +23,7 @@ def _session_with_messages( Uses ``ChatSession.new`` + attribute reassignment rather than ``MagicMock(spec=...)`` because the gate now calls - ``session.has_tool_been_called_this_turn(...)`` and a ``spec`` mock + ``session.has_tool_been_called(...)`` and a ``spec`` mock returns a truthy ``MagicMock`` from that call, hiding real gate behaviour. A live ``ChatSession`` also correctly initialises the ``_inflight_tool_calls`` PrivateAttr scratch buffer used by the diff --git a/autogpt_platform/backend/backend/copilot/tools/helpers.py b/autogpt_platform/backend/backend/copilot/tools/helpers.py index ccd335868d..6c25e79188 100644 --- a/autogpt_platform/backend/backend/copilot/tools/helpers.py +++ b/autogpt_platform/backend/backend/copilot/tools/helpers.py @@ -791,14 +791,14 @@ def require_guide_read(session: ChatSession, tool_name: str): """Return an ErrorResponse if the guide hasn't been loaded this session. Import inline to keep ``helpers.py`` free of tool-response imports. - Uses :meth:`ChatSession.has_tool_been_called_this_turn` which checks - both the persisted ``messages`` list and the in-flight announcement - buffer — so a guide call dispatched earlier in the *current* turn - (before ``session.messages`` flushes at turn end) is recognised too. - Otherwise a second tool in the same turn would re-fire this guard - despite the guide having been called — seen on Kimi K2.6 in - particular because its aggressive tool-call chaining exercises this - path far more than Sonnet does. + Uses :meth:`ChatSession.has_tool_been_called` which checks both the + persisted ``messages`` list (session-wide) and the in-flight + announcement buffer — so a guide call dispatched earlier in the + *current* turn (before ``session.messages`` flushes at turn end) is + recognised too. Otherwise a second tool in the same turn would + re-fire this guard despite the guide having been called — seen on + Kimi K2.6 in particular because its aggressive tool-call chaining + exercises this path far more than Sonnet does. """ from .models import ErrorResponse # noqa: PLC0415 — avoid circular import @@ -808,7 +808,7 @@ def require_guide_read(session: ChatSession, tool_name: str): # requiring one would waste a round-trip every turn. if session.metadata.builder_graph_id: return None - if session.has_tool_been_called_this_turn(_AGENT_GUIDE_TOOL_NAME): + if session.has_tool_been_called(_AGENT_GUIDE_TOOL_NAME): return None return ErrorResponse( message=(