Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 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
76 changes: 64 additions & 12 deletions autogpt_platform/backend/backend/copilot/baseline/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,8 +121,35 @@
# Set to hold background tasks to prevent garbage collection
_background_tasks: set[asyncio.Task[Any]] = set()

# Maximum number of tool-call rounds before forcing a text response.
_MAX_TOOL_ROUNDS = 30
# Hint appended on the last tool round so the model wraps up with a summary
# instead of issuing another tool call that gets cut off cold. The shared
# ``tool_call_loop`` drops ``tools`` on the last iteration (see util/tool_call_loop.py),
# so the model is forced to produce text and always finishes naturally.
_LAST_ITERATION_HINT = (
"You have reached the tool-call budget for this turn. Do not call any "
"more tools — produce a final text response summarizing what you did, "
"what remains, and how the user can continue the work in the next turn."
)

# Fallback surfaced when the tool-round budget is exhausted *and* the forced-
# text last round left the user with zero visible response.
_BUDGET_EXHAUSTED_FALLBACK_TEXT = (
"Reached the tool-call budget for this turn. "
"Send a follow-up message to continue from here."
)


def _budget_exhausted_notice_text(assistant_text: str) -> str | None:
"""Return the fallback notice when a budget-exhausted turn produced no
visible text, or ``None`` when the model already summarised itself.

Factored out so callers can unit-test the decision without the
surrounding async streaming machinery.
"""
if assistant_text.strip():
return None
return _BUDGET_EXHAUSTED_FALLBACK_TEXT


# Max seconds to wait for transcript upload in the finally block before
# letting it continue as a background task (tracked in _background_tasks).
Expand Down Expand Up @@ -1736,6 +1763,12 @@ async def _bound_tool_executor(
# UI for the whole window before flushing the backlog in one burst.
loop_result_holder: list[Any] = [None]
loop_task: asyncio.Task[None] | None = None
# Length of ``state.assistant_text`` at the end of the last non-final
# yield — used as an anchor by the budget-exhausted fallback to check
# whether the *terminal* round produced any visible text, not the whole
# turn. Without this, earlier-round chatter would suppress a fallback
# that should fire.
text_len_before_final_round: list[int] = [0]

async def _run_tool_call_loop() -> None:
# Read/write the current session via ``_session_holder`` so this
Expand All @@ -1744,13 +1777,15 @@ async def _run_tool_call_loop() -> None:
# but the holder is typed non-optional after the preflight guard
# above.
try:
max_tool_rounds = config.agent_max_turns
async for loop_result in tool_call_loop(
messages=openai_messages,
tools=tools,
llm_call=_bound_llm_caller,
execute_tool=_bound_tool_executor,
update_conversation=_bound_conversation_updater,
max_iterations=_MAX_TOOL_ROUNDS,
max_iterations=max_tool_rounds,
last_iteration_message=_LAST_ITERATION_HINT,
):
loop_result_holder[0] = loop_result
# Inject any messages the user queued while the turn was
Expand All @@ -1771,10 +1806,15 @@ async def _run_tool_call_loop() -> None:
# get picked up at the start of the next turn.
is_final_yield = (
loop_result.finished_naturally
or loop_result.iterations >= _MAX_TOOL_ROUNDS
or loop_result.iterations >= max_tool_rounds
)
if is_final_yield:
continue
# Non-final yield: the next round may be the last one, so
# record where ``assistant_text`` ends now. If that next
# round hits the budget without adding any text, the outer
# fallback uses this anchor to detect a silent finish.
text_len_before_final_round[0] = len(state.assistant_text)
try:
pending = await drain_pending_messages(session_id)
except Exception:
Expand Down Expand Up @@ -1894,15 +1934,27 @@ def _trim_openai_on_rollback(_session_anchor: int) -> None:
await loop_task
loop_result = loop_result_holder[0]
if loop_result and not loop_result.finished_naturally:
limit_msg = (
f"Exceeded {_MAX_TOOL_ROUNDS} tool-call rounds "
"without a final response."
)
logger.error("[Baseline] %s", limit_msg)
yield StreamError(
errorText=limit_msg,
code="baseline_tool_round_limit",
# Budget reached without a natural finish. ``tool_call_loop``
# drops ``tools`` on the last iteration so the model is forced
# to produce text, but a non-compliant model (or one whose final
# text-only response still got truncated) can still land here.
# End the turn gracefully — the user already saw streamed output
# and a red error would just prompt them to retry the same work.
logger.warning(
"[Baseline] Hit %d-round tool budget without natural finish; "
"ending turn gracefully",
loop_result.iterations,
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
# Check only the *terminal* round's text — earlier rounds may
# have produced chatter that doesn't explain the budget hit.
terminal_round_text = state.assistant_text[text_len_before_final_round[0] :]
terminal_text = _budget_exhausted_notice_text(terminal_round_text)
if terminal_text is not None:
block_id = str(uuid.uuid4())
yield StreamTextStart(id=block_id)
yield StreamTextDelta(id=block_id, delta=terminal_text)
yield StreamTextEnd(id=block_id)
state.assistant_text += terminal_text
except Exception as e:
_stream_error = True
error_msg = str(e) or type(e).__name__
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,11 @@
from openai.types.chat import ChatCompletionToolParam

from backend.copilot.baseline.service import (
_BUDGET_EXHAUSTED_FALLBACK_TEXT,
_baseline_conversation_updater,
_baseline_llm_caller,
_BaselineStreamState,
_budget_exhausted_notice_text,
_build_cached_system_message,
_compress_session_messages,
_extract_cache_creation_tokens,
Expand Down Expand Up @@ -2078,3 +2080,28 @@ def test_other_providers_still_rejected(self, model):
"""Regression guard: OpenAI/Grok/Gemini still 400 on
``cache_control``, so the widened gate must keep them out."""
assert _supports_prompt_cache_markers(model) is False


class TestBudgetExhaustedNoticeText:
"""Tests for the fallback-notice decision used when the tool-round
budget is exhausted without a natural finish."""

def test_empty_text_returns_fallback(self):
assert _budget_exhausted_notice_text("") == _BUDGET_EXHAUSTED_FALLBACK_TEXT

def test_whitespace_only_returns_fallback(self):
"""A string of only whitespace is still "no visible response"."""
assert (
_budget_exhausted_notice_text(" \n\t ")
== _BUDGET_EXHAUSTED_FALLBACK_TEXT
)

def test_non_empty_text_returns_none(self):
"""When the model already summarised, stay quiet — no extra notice."""
assert _budget_exhausted_notice_text("Here is what I did...") is None

def test_fallback_text_is_user_facing(self):
"""Guard against accidentally shipping an empty / internal string."""
assert _BUDGET_EXHAUSTED_FALLBACK_TEXT.strip()
assert "tool-call budget" in _BUDGET_EXHAUSTED_FALLBACK_TEXT
assert "follow-up" in _BUDGET_EXHAUSTED_FALLBACK_TEXT
16 changes: 10 additions & 6 deletions autogpt_platform/backend/backend/copilot/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -207,14 +207,18 @@ class ChatConfig(BaseSettings):
"overloaded). The SDK automatically retries with this cheaper model. "
"Empty string disables the fallback (no --fallback-model flag passed to CLI).",
)
claude_agent_max_turns: int = Field(
default=50,
agent_max_turns: int = Field(
default=100,
ge=1,
le=10000,
description="Maximum number of agentic turns (tool-use loops) per query. "
"Prevents runaway tool loops from burning budget. "
"Changed from 1000 to 50 in SDK 0.1.58 upgrade — override via "
"CHAT_CLAUDE_AGENT_MAX_TURNS env var if your workflows need more.",
validation_alias=AliasChoices(
"CHAT_AGENT_MAX_TURNS",
"CHAT_CLAUDE_AGENT_MAX_TURNS",
),
description="Maximum number of tool-call rounds per turn — applies to "
"both the baseline and Claude Agent SDK paths. Prevents runaway tool "
"loops from burning budget. Override via CHAT_AGENT_MAX_TURNS env var "
"(legacy CHAT_CLAUDE_AGENT_MAX_TURNS still accepted).",
)
claude_agent_max_budget_usd: float = Field(
default=10.0,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,7 @@ def test_fallback_model_default(self):

def test_max_turns_default(self):
cfg = _make_config()
assert cfg.claude_agent_max_turns == 50
assert cfg.agent_max_turns == 100

def test_max_budget_usd_default(self):
cfg = _make_config()
Expand Down Expand Up @@ -506,21 +506,21 @@ class TestConfigValidators:

def test_max_turns_rejects_zero(self):
with pytest.raises(ValidationError):
_make_config(claude_agent_max_turns=0)
_make_config(agent_max_turns=0)

def test_max_turns_rejects_negative(self):
with pytest.raises(ValidationError):
_make_config(claude_agent_max_turns=-1)
_make_config(agent_max_turns=-1)

def test_max_turns_rejects_above_10000(self):
with pytest.raises(ValidationError):
_make_config(claude_agent_max_turns=10001)
_make_config(agent_max_turns=10001)

def test_max_turns_accepts_boundary_values(self):
cfg_low = _make_config(claude_agent_max_turns=1)
assert cfg_low.claude_agent_max_turns == 1
cfg_high = _make_config(claude_agent_max_turns=10000)
assert cfg_high.claude_agent_max_turns == 10000
cfg_low = _make_config(agent_max_turns=1)
assert cfg_low.agent_max_turns == 1
cfg_high = _make_config(agent_max_turns=10000)
assert cfg_high.agent_max_turns == 10000

def test_max_budget_rejects_zero(self):
with pytest.raises(ValidationError):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1034,7 +1034,7 @@ def _make_sdk_patches(
active_e2b_api_key=None,
use_e2b_sandbox=False,
claude_agent_max_transient_retries=1,
claude_agent_max_turns=1000,
agent_max_turns=1000,
claude_agent_max_budget_usd=100.0,
claude_agent_max_thinking_tokens=0,
claude_agent_thinking_effort=None,
Expand Down
2 changes: 1 addition & 1 deletion autogpt_platform/backend/backend/copilot/sdk/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -3321,7 +3321,7 @@ def _on_stderr(line: str) -> None:
"fallback_model": _resolve_fallback_model(),
# max_turns: hard cap on agentic tool-use loops per query to
# prevent runaway execution from burning budget.
"max_turns": config.claude_agent_max_turns,
"max_turns": config.agent_max_turns,
# max_budget_usd: per-query spend ceiling enforced by the CLI.
"max_budget_usd": config.claude_agent_max_budget_usd,
}
Expand Down
11 changes: 7 additions & 4 deletions autogpt_platform/backend/backend/util/tool_call_loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -203,9 +203,10 @@ async def tool_call_loop(
while max_iterations < 0 or iteration < max_iterations:
iteration += 1

# On last iteration, add a hint to finish. Only copy the list
# when the hint needs to be appended to avoid per-iteration overhead
# on long conversations.
# On last iteration, add a hint to finish and drop tools so the
# model is forced to produce a final text response instead of
# issuing another tool call that would get cut off cold. Only
# copy the message list when we actually need to mutate it.
is_last = (
last_iteration_message
and max_iterations > 0
Expand All @@ -216,11 +217,13 @@ async def tool_call_loop(
iteration_messages.append(
{"role": "system", "content": last_iteration_message}
)
iteration_tools: Sequence[Any] = []
else:
iteration_messages = messages
iteration_tools = tools

# Call LLM
response = await llm_call(iteration_messages, tools)
response = await llm_call(iteration_messages, iteration_tools)
total_prompt_tokens += response.prompt_tokens
total_completion_tokens += response.completion_tokens

Expand Down
12 changes: 9 additions & 3 deletions autogpt_platform/backend/backend/util/tool_call_loop_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -415,13 +415,16 @@ def update_conversation(

@pytest.mark.asyncio
async def test_last_iteration_message_appended():
"""On the final iteration, last_iteration_message should be appended."""
"""On the final iteration, last_iteration_message should be appended
and ``tools`` should be empty so the model is forced to produce text."""
captured_messages: list[list[dict[str, Any]]] = []
captured_tools: list[Sequence[Any]] = []

async def llm_call(
messages: list[dict[str, Any]], tools: Sequence[Any]
) -> LLMLoopResponse:
captured_messages.append(list(messages))
captured_tools.append(tools)
return _make_response(
tool_calls=[LLMToolCall(id="tc_1", name="get_weather", arguments="{}")]
)
Expand Down Expand Up @@ -452,14 +455,17 @@ def update_conversation(
):
pass

# First iteration: no extra message
# First iteration: no extra message, tools available
assert len(captured_messages[0]) == 1
# Second (last) iteration: should have the hint appended
assert list(captured_tools[0]) == list(TOOL_DEFS)
# Second (last) iteration: hint appended, tools dropped (forces the
# model to produce a text response instead of another tool call).
last_call_msgs = captured_messages[1]
assert any(
m.get("role") == "system" and "Please finish now." in m.get("content", "")
for m in last_call_msgs
)
assert list(captured_tools[1]) == []


@pytest.mark.asyncio
Expand Down
Loading