Skip to content
Merged
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
32 changes: 23 additions & 9 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -58,17 +58,31 @@ TARGET_REPO=owner/repository
# PR_MERGE_METHOD selects the merge method for both paths.
# PR_MERGE_METHOD=merge

# Provider-side spending caps are the source of truth. MAX_BUDGET_USD=5 is an
# SDK circuit breaker, not authoritative Meta billing. MAX_TURNS bounds turns
# per attempt. Before either hard limit, the agent asks the model to hand off
# its progress cooperatively: SOFT_THRESHOLD_PERCENTAGE (0-1, exclusive) sets
# how far below MAX_BUDGET_USD that cost soft threshold sits
# (soft = max_budget_usd - max_budget_usd * percentage). Turns/MODEL_TIMEOUT
# have no soft threshold; reaching either gets one best-effort handoff
# follow-up instead. See docs/agents/operator-onboarding.md.
# Token budget and handoff reserve. Each attempt counts every processed and
# generated token (unweighted, subagents included) against MAX_BUDGET_TOKENS
# (default 4000000); reaching it stops the attempt unconditionally (hard
# ceiling). Before that, the agent asks the model to hand off cooperatively
# once the soft threshold is crossed; the crossing latches for the attempt
# and is evaluated on every counted response and in the PreToolUse hook.
# The effective soft threshold is the earlier of two rules, computed from
# the main thread's latest context size:
# soft = min(MAX_BUDGET_TOKENS * (1 - SOFT_THRESHOLD_PERCENTAGE),
# MAX_BUDGET_TOKENS - (HANDOFF_RESERVE_TURNS + 1) * main_context_tokens)
# SOFT_THRESHOLD_PERCENTAGE (0-1, exclusive; default 0.2) sets the fixed
# share of the budget, and HANDOFF_RESERVE_TURNS (integer 1-20; default 6)
# guarantees that many turns after the crossing turn at the current context
# size. A huge context whose reserve exceeds the remaining budget trips the
# threshold on the next check. MAX_TURNS bounds turns per attempt.
# MAX_BUDGET_USD (default 20) is only a looser SDK circuit breaker, not
# authoritative Meta billing, with a startup floor check against the token
# budget at the $5/MTok reference rate. Turns/MODEL_TIMEOUT have no soft
# threshold; reaching either gets one best-effort handoff follow-up instead.
# See docs/agents/operator-onboarding.md.
# MAX_TURNS=60
# MAX_BUDGET_USD=5
# MAX_BUDGET_TOKENS=4000000
# SOFT_THRESHOLD_PERCENTAGE=0.2
# HANDOFF_RESERVE_TURNS=6
# MAX_BUDGET_USD=20
#
# MODEL_NAME pins the Meta model with no fallback. CLAUDE_AGENT_SDK_VERSION
# pins the runtime that provenance verification checks against at startup
Expand Down
4 changes: 4 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,10 @@ _Avoid_: Cost budget, spend limit
The point in the token budget past which the model must end the attempt with a handoff, leaving the rest of the budget for writing the Handoff note.
_Avoid_: Cost threshold, warning limit

**Handoff reserve**:
The number of turns guaranteed after the turn that crosses the soft threshold, sized at the main thread's latest context size (`HANDOFF_RESERVE_TURNS`, default 6). It pulls the effective soft threshold earlier when the context is large.
_Avoid_: Turn cap, handoff note

**Hard ceiling**:
The point at which the attempt is stopped regardless of the model, whether or not it has handed off.
_Avoid_: Budget cap, kill limit
Expand Down
22 changes: 18 additions & 4 deletions docs/agents/operator-onboarding.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,10 +166,24 @@ default-model rates. They are still estimates for observability, not a
measured bill: rely on Meta's billing dashboard for accurate cost data.

Before the hard ceiling, the agent tries a cooperative `handoff`: once the
token budget crosses `soft = MAX_BUDGET_TOKENS * (1 -
SOFT_THRESHOLD_PERCENTAGE)` tokens (default `MAX_BUDGET_TOKENS=4000000` and
`SOFT_THRESHOLD_PERCENTAGE=0.2`, i.e. 3,200,000 tokens), it asks the model to
commit its progress and stop. The token budget comes from a per-attempt
token budget crosses the effective soft threshold, it asks the model to
commit its progress and stop. The threshold is the earlier of two rules,
computed from the main thread's latest context size (defaults
`MAX_BUDGET_TOKENS=4000000`, `SOFT_THRESHOLD_PERCENTAGE=0.2`,
`HANDOFF_RESERVE_TURNS=6`):

`soft = min(MAX_BUDGET_TOKENS * (1 - SOFT_THRESHOLD_PERCENTAGE), MAX_BUDGET_TOKENS - (HANDOFF_RESERVE_TURNS + 1) * main_context_tokens)`

`SOFT_THRESHOLD_PERCENTAGE` (0-1, exclusive) sets the fixed share of the
budget (3,200,000 tokens by default); `HANDOFF_RESERVE_TURNS` (integer 1-20)
guarantees that many turns after the crossing turn at the current context
size (with a 150K context the reserve rule sets the threshold to 2,950,000
tokens, leaving at least six turns before the hard ceiling). The threshold
is evaluated on every counted response and in the `PreToolUse` hook, and
once crossed it stays crossed for the attempt, even if a compaction later
shrinks the context. A huge context whose reserve exceeds the remaining
budget trips the threshold on the next check. The token budget comes from
a per-attempt
ledger: each streamed response is first counted as a local estimate and is
replaced by the usage the backend reports (`message_delta` events) when that
arrives, so the threshold still works when the backend reports no usage; the
Expand Down
70 changes: 69 additions & 1 deletion simple_coding_agent/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,38 @@ class ProfileError(ValueError):
_MERGE_METHODS = frozenset({"merge", "squash", "rebase"})
_DEFAULT_MODEL_STREAM_IDLE_TIMEOUT_MS = 60000
_DEFAULT_CLAUDE_AGENT_SDK_VERSION = "0.2.163"
_DEFAULT_HANDOFF_RESERVE_TURNS = 6
_MAX_HANDOFF_RESERVE_TURNS = 20


def effective_soft_threshold_tokens(
max_budget_tokens: int,
soft_threshold_percentage: float,
handoff_reserve_turns: int,
main_context_tokens: float,
) -> tuple[int, str, int]:
"""Effective soft threshold, the rule that set it, and the reserve size.

The threshold is the earlier of two rules, computed from the main
thread's latest context size (not its peak): a fixed share of the
budget (``percentage`` rule) and a handoff reserve of
``handoff_reserve_turns`` turns after the crossing turn at the current
context size (``reserve`` rule)::

soft = min(max_budget_tokens * (1 - pct),
max_budget_tokens - (reserve_turns + 1) * main_context_tokens)

When the reserve exceeds the remaining budget (a huge context) the
reserve rule yields a threshold at or below zero, so the next limit
check fires immediately. That is correct behaviour, not an error.
"""

percentage_threshold = int(max_budget_tokens * (1 - soft_threshold_percentage))
reserve_tokens = int((handoff_reserve_turns + 1) * main_context_tokens)
reserve_threshold = max_budget_tokens - reserve_tokens
if reserve_threshold <= percentage_threshold:
return reserve_threshold, "reserve", reserve_tokens
return percentage_threshold, "percentage", reserve_tokens


@dataclass(frozen=True)
Expand Down Expand Up @@ -61,6 +93,7 @@ class RuntimeConfig:
max_budget_usd: int = 20
max_budget_tokens: int = 4_000_000
soft_threshold_percentage: float = 0.2
handoff_reserve_turns: int = _DEFAULT_HANDOFF_RESERVE_TURNS
model_base_url: str = _DEFAULT_MODEL_BASE_URL
model_api_key: str = ""
model_auth_mode: str = _DEFAULT_MODEL_AUTH_MODE
Expand All @@ -70,10 +103,27 @@ class RuntimeConfig:

@property
def soft_threshold_tokens(self) -> int:
"""Token budget reading past which the attempt must hand off."""
"""Percentage-rule token reading past which the attempt must hand off.

This is the fixed-share rule only
(``max_budget_tokens * (1 - soft_threshold_percentage)``). The
effective threshold is the earlier of this rule and the handoff
reserve; use :meth:`effective_soft_threshold` with the main thread's
latest context size for that.
"""

return int(self.max_budget_tokens * (1 - self.soft_threshold_percentage))

def effective_soft_threshold(self, main_context_tokens: float) -> tuple[int, str, int]:
"""Effective threshold, rule, and reserve size for a context size."""

return effective_soft_threshold_tokens(
self.max_budget_tokens,
self.soft_threshold_percentage,
self.handoff_reserve_turns,
main_context_tokens,
)

@property
def credential_redactions(self) -> tuple[str, ...]:
"""Secrets that must never appear in logs or profile-command output."""
Expand Down Expand Up @@ -158,6 +208,9 @@ def load_runtime_config(environ: Mapping[str, str] | None = None) -> RuntimeConf
max_budget_usd=_positive_integer(values, "MAX_BUDGET_USD", 20),
max_budget_tokens=_positive_integer(values, "MAX_BUDGET_TOKENS", 4_000_000),
soft_threshold_percentage=_fraction(values, "SOFT_THRESHOLD_PERCENTAGE", 0.2),
handoff_reserve_turns=_reserve_turns(
values, "HANDOFF_RESERVE_TURNS", _DEFAULT_HANDOFF_RESERVE_TURNS
),
pr_auto_merge=_auto_merge_mode(values),
pr_merge_method=_merge_method(values),
)
Expand Down Expand Up @@ -290,6 +343,21 @@ def _fraction(values: Mapping[str, str], name: str, default: float) -> float:
return number


def _reserve_turns(values: Mapping[str, str], name: str, default: int) -> int:
value = values.get(name, str(default))
try:
number = int(value)
except ValueError as error:
raise ConfigurationError(
f"{name} must be an integer between 1 and {_MAX_HANDOFF_RESERVE_TURNS}"
) from error
if not 1 <= number <= _MAX_HANDOFF_RESERVE_TURNS:
raise ConfigurationError(
f"{name} must be an integer between 1 and {_MAX_HANDOFF_RESERVE_TURNS}"
)
return number


def _boolean(values: Mapping[str, str], name: str, default: bool) -> bool:
value = values.get(name, str(default).lower()).lower()
if value == "true":
Expand Down
69 changes: 63 additions & 6 deletions simple_coding_agent/model_execution.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,10 @@
# an estimate on first sight, ``StreamEvent`` ``message_delta`` usage and
# non-zero ``AssistantMessage.usage`` replace the estimate with the measured
# categories, and tool results / user messages grow the thread context. The
# soft threshold is evaluated against ``ledger.budget_tokens`` on every ledger
# update; only ``limits_checked`` logging waits for a response to settle.
# effective soft threshold (the percentage rule vs the handoff reserve at
# the main thread's latest context size) is evaluated against
# ``ledger.budget_tokens`` on every ledger update and in the PreToolUse
# hook; only ``limits_checked`` logging waits for a response to settle.
_HANDOFF_FOLLOWUP_TIMEOUT = 300
# One-time ``prompt_cache_ineffective`` diagnostic: evaluated when the main
# thread's K-th measured response settles. Observational only.
Expand Down Expand Up @@ -506,6 +508,8 @@ def __init__(
self._started_at = self._clock()
self._soft_threshold_crossed = False
self._soft_threshold_crossed_at_tokens: float | None = None
self._soft_threshold_rule: str | None = None
self._soft_threshold_effective_tokens: int | None = None
self._total_cost_usd: float | None = None
self._handoff_context_delivered = False
self._working_directory: Path | None = None
Expand Down Expand Up @@ -955,6 +959,8 @@ async def execute(
self._started_at = self._clock()
self._soft_threshold_crossed = False
self._soft_threshold_crossed_at_tokens = None
self._soft_threshold_rule = None
self._soft_threshold_effective_tokens = None
self._total_cost_usd = None
self._token_hard_ceiling_reached = False
self._handoff_context_delivered = False
Expand All @@ -981,12 +987,18 @@ async def execute(
self._tool_pending = {}
self._bash_history = []
self._bash_pending = {}
threshold, rule, reserve_tokens = self._effective_threshold()
self._log(
"model_execution_started",
f"issue_body_length={len(issue_body)}; limits: "
f"max_budget_usd={self._config.max_budget_usd:.4f}; "
f"max_budget_tokens={self._config.max_budget_tokens}; "
f"soft_threshold_tokens={self._config.soft_threshold_tokens}; "
f"soft_threshold_tokens={threshold}; "
f"soft_threshold_percentage={self._config.soft_threshold_percentage}; "
f"reserve_turns={self._config.handoff_reserve_turns}; "
f"rule={rule}; "
f"main_context_tokens={self._latest_main_context_tokens():.0f}; "
f"reserve_tokens={reserve_tokens}; "
f"max_turns={self._config.max_turns}; "
f"timeout_seconds={self._config.model_timeout}",
)
Expand Down Expand Up @@ -1700,12 +1712,16 @@ def _token_limits_detail(self, thread_id: str, response_id: str) -> str:
else:
main_context = 0.0
turns = 0
effective, rule, reserve_tokens = self._config.effective_soft_threshold(main_context)
detail = (
f"thread={thread_id}; "
f"budget_tokens={budget:.0f}; "
f"measured_tokens={measured:.0f}; "
f"estimated_tokens={estimated:.0f}; "
f"soft_threshold_tokens={self._config.soft_threshold_tokens}; "
f"soft_threshold_tokens={effective}; "
f"rule={rule}; "
f"reserve_turns={self._config.handoff_reserve_turns}; "
f"reserve_tokens={reserve_tokens}; "
f"max_budget_tokens={self._config.max_budget_tokens}; "
f"main_context_tokens={main_context:.0f}; "
f"turns={turns}; "
Expand Down Expand Up @@ -1750,17 +1766,49 @@ def _elapsed_seconds(self) -> int:
delta = (self._clock() - self._started_at).total_seconds()
return max(0, int(delta))

def _latest_main_context_tokens(self) -> float:
"""The main thread's latest context size (0 before it starts).

The effective soft threshold is computed from this latest reading,
not the peak: a compaction that shrinks the context moves the
reserve rule later again, but a latched crossing stays crossed.
"""

if self._ledger.has_thread("main"):
return self._ledger.context_tokens("main")
return 0.0

def _effective_threshold(self) -> tuple[int, str, int]:
"""Effective threshold, rule, and reserve size at the live context."""

return self._config.effective_soft_threshold(self._latest_main_context_tokens())

def _check_limits(self, thread_id: str = "main") -> None:
"""Latch one-time soft-threshold and hard-ceiling crossings from the ledger."""
"""Latch one-time soft-threshold and hard-ceiling crossings from the ledger.

The soft threshold is the effective one: the earlier of the
percentage rule and the handoff reserve at the main thread's
latest context size. It is evaluated on every counted response
(via ``_after_ledger_update``) and in the PreToolUse hook; once
crossed it stays crossed, even if a compaction later shrinks the
context.
"""

budget = self._ledger.budget_tokens
threshold = self._config.soft_threshold_tokens
main_context = self._latest_main_context_tokens()
threshold, rule, reserve_tokens = self._config.effective_soft_threshold(main_context)
if not self._soft_threshold_crossed and budget >= threshold:
self._soft_threshold_crossed = True
self._soft_threshold_crossed_at_tokens = budget
self._soft_threshold_rule = rule
self._soft_threshold_effective_tokens = threshold
self._log(
"token_soft_threshold_crossed",
f"budget_tokens={budget:.0f}; soft_threshold_tokens={threshold}; "
f"rule={rule}; "
f"reserve_turns={self._config.handoff_reserve_turns}; "
f"main_context_tokens={main_context:.0f}; "
f"reserve_tokens={reserve_tokens}; "
f"thread={thread_id}",
)
self._stop_running_subagents()
Expand Down Expand Up @@ -2007,6 +2055,10 @@ def _reconcile_token_budget(
"mode": reconciliation.mode,
"max_budget_tokens": self._config.max_budget_tokens,
"soft_threshold_tokens": self._config.soft_threshold_tokens,
"soft_threshold_percentage": self._config.soft_threshold_percentage,
"handoff_reserve_turns": self._config.handoff_reserve_turns,
"soft_threshold_rule": self._soft_threshold_rule,
"effective_soft_threshold_tokens": self._soft_threshold_effective_tokens,
"estimated_tokens": round(reconciliation.estimated_tokens),
"actual_tokens": reconciliation.actual_tokens,
"error_ratio": _round_ratio(reconciliation.error_ratio),
Expand Down Expand Up @@ -2052,6 +2104,11 @@ def _reconcile_token_budget(
async def _guard_and_record_pre_tool_use(
self, hook_input: Any, tool_use_id: str | None, context: Any
) -> dict[str, Any]:
# The soft threshold is evaluated here as well as on every counted
# response: the ledger reading is unchanged since the last update,
# but the effective threshold moves with the main context, and a
# crossing here latches exactly like a response-time crossing.
self._check_limits(_hook_agent_id(hook_input) or "main")
if self._token_hard_ceiling_reached:
return _deny(
"Token budget reached the hard ceiling "
Expand Down
Loading
Loading