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
169 changes: 169 additions & 0 deletions backend/scripts/probe_static_prefix_ttl.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
"""Does a 1h TTL on the tools + system cachePoints pay for itself on Claude/Bedrock?

The gate for AGENTCORE_PROMPT_CACHE_STATIC_PREFIX_TTL=1h
(docs/specs/compaction-model-relative-thresholds.md §3.6, PR-5). A caching
default must never be adopted on inspection alone — #954 shipped on a wrong
premise and measured 57% more expensive live before #956 reverted it — so this
script measures the two arms against the same static prefix:

arm 5m : tools + system points with no ttl (today's shape)
arm 1h : tools + system points with ttl "1h"; message point unchanged

Per arm: call 1 (the write), sleep --gap-seconds, call 2 (the read-or-rewrite).
It reports cacheRead / cacheWrite per call, whether Bedrock accepted the 1h
point at all, and prices the pair at the model's own rates — 1.25x base for a
5m write, 2x base for a 1h write, 0.1x for a read — so the break-even is read
off the output rather than argued.

What "pays" means: with a gap between 5 and 60 minutes, the 1h arm's second
call should READ the static prefix (cacheRead ≈ static tokens) where the 5m
arm re-WRITES it. Over a session the 1h arm costs +0.75x base on every static
write and saves 1.15x base on every cold-within-the-hour return; it pays when
the second event is more frequent than the first. Run this with a gap of
~420s (past 5m) and again with ~60s (inside 5m) to see both regimes.

Read-only apart from the model invocations. ⚠️ Real spend: four calls at
~8k input tokens each plus the sleep. Nothing is written anywhere.

Usage:
cd backend
AWS_PROFILE=dev-ai uv run python scripts/probe_static_prefix_ttl.py --gap-seconds 420
AWS_PROFILE=dev-ai uv run python scripts/probe_static_prefix_ttl.py --gap-seconds 60 --model-id us.anthropic.claude-haiku-4-5-20251001-v1:0

Baseline, dev-ai us-west-2, 2026-09-16, Haiku 4.5, gap 420s:
5m first read 0 write 6251 second read 0 write 6251
1h first read 0 write 6251 second read 5924 write 327 <- honored
pair $0.017197 (5m) vs $0.015130 (1h): 1h CHEAPER by 12% at this gap
Same day, gap 60s (both arms warm):
5m second read 6251 write 0 ; 1h second read 6251 write 0
pair $0.009289 (5m) vs $0.014446 (1h): 1h MORE EXPENSIVE by $0.005157
= the 0.75x-base premium on the first write, nothing to recover inside 5m
"""

from __future__ import annotations

import argparse
import sys
import time
from typing import Any, Dict, List

import boto3

# Clears every Claude family's cache minimum (4,096 on Haiku) with margin.
_SYSTEM_TEXT = ("You are a careful assistant. " * 40 + "Policy: answer briefly. ") * 20
_TOOL_SPECS: List[Dict[str, Any]] = [
{
"toolSpec": {
"name": f"tool_{i}",
"description": "A probe tool that does nothing useful. " * 12,
"inputSchema": {"json": {"type": "object", "properties": {"q": {"type": "string"}}}},
}
}
for i in range(6)
]

RATES = { # $/MTok base input, Global CRIS; adjust for the model under test
"us.anthropic.claude-haiku-4-5-20251001-v1:0": 1.10,
"global.anthropic.claude-sonnet-5": 2.00,
"us.anthropic.claude-sonnet-4-6": 3.30,
}


def _request(model_id: str, ttl: str | None, marker: str) -> Dict[str, Any]:
point: Dict[str, Any] = {"type": "default"}
if ttl:
point["ttl"] = ttl
return {
"modelId": model_id,
"system": [{"text": _SYSTEM_TEXT + f"\nProbe arm: {marker}."}, {"cachePoint": dict(point)}],
"toolConfig": {"tools": _TOOL_SPECS + [{"cachePoint": dict(point)}]},
"messages": [{"role": "user", "content": [{"text": "Reply with the single word OK."}, {"cachePoint": {"type": "default"}}]}],
"inferenceConfig": {"maxTokens": 5},
}


def _call(client: Any, req: Dict[str, Any]) -> Dict[str, int]:
resp = client.converse(**req)
u = resp.get("usage", {})
return {
"input": int(u.get("inputTokens", 0)),
"read": int(u.get("cacheReadInputTokens", 0)),
"write": int(u.get("cacheWriteInputTokens", 0)),
}


def _price(usage: Dict[str, int], base: float, write_mult: float) -> float:
return (usage["input"] * base + usage["read"] * base * 0.1 + usage["write"] * base * write_mult) / 1e6


def main() -> int:
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--model-id", default="us.anthropic.claude-haiku-4-5-20251001-v1:0")
ap.add_argument("--region", default="us-west-2")
ap.add_argument("--gap-seconds", type=int, default=420)
ap.add_argument("--base-rate", type=float, default=None, help="$/MTok base input; defaults from a small table")
args = ap.parse_args()

base = args.base_rate or RATES.get(args.model_id)
if base is None:
print(f"no base rate for {args.model_id}; pass --base-rate", file=sys.stderr)
return 2
client = boto3.client("bedrock-runtime", region_name=args.region)

arms = [("5m", None), ("1h", "1h")]
results: Dict[str, Any] = {}
# Distinct markers per arm so the two arms never share a cache entry.
for name, ttl in arms:
marker = f"{name}-{int(time.time())}"
req = _request(args.model_id, ttl, marker)
try:
first = _call(client, req)
except Exception as e: # noqa: BLE001
results[name] = {"error": f"{type(e).__name__}: {e}"}
continue
results[name] = {"first": first, "marker": marker}
if all("error" in r for r in results.values()):
print(results)
return 1

print(f"sleeping {args.gap_seconds}s so the 5m entry {'expires' if args.gap_seconds > 300 else 'stays warm'} ...")
time.sleep(args.gap_seconds)

for name, ttl in arms:
if "error" in results[name]:
continue
# Re-send the SAME request (same marker) — identical bytes.
try:
results[name]["second"] = _call(client, _request(args.model_id, ttl, results[name]["marker"]))
except Exception as e: # noqa: BLE001
results[name]["error"] = f"{type(e).__name__}: {e}"

print(f"\nmodel={args.model_id} base=${base}/MTok gap={args.gap_seconds}s\n")
print(f"{'arm':<4} {'call':<7} {'input':>7} {'read':>8} {'write':>8} {'$':>10}")
total = {}
for name, ttl in arms:
r = results[name]
if "error" in r:
print(f"{name:<4} ERROR {r['error']}")
continue
mult = 2.0 if ttl == "1h" else 1.25
cost = 0.0
for which in ("first", "second"):
u = r[which]
c = _price(u, base, mult)
cost += c
print(f"{name:<4} {which:<7} {u['input']:>7} {u['read']:>8} {u['write']:>8} {c:>10.6f}")
total[name] = cost
print(f"{name:<4} {'pair':<7} {'':>7} {'':>8} {'':>8} {cost:>10.6f}")
if "5m" in total and "1h" in total:
delta = total["1h"] - total["5m"]
verdict = "1h CHEAPER" if delta < 0 else "1h MORE EXPENSIVE"
print(f"\n{verdict} by ${abs(delta):.6f} for this pair at a {args.gap_seconds}s gap")
second = results["1h"].get("second", {})
if args.gap_seconds > 300 and second.get("read", 0) == 0:
print("⚠️ the 1h arm did NOT read after the gap — Bedrock may not honor ttl=1h for this model; do not enable the flag")
return 0


if __name__ == "__main__":
sys.exit(main())
26 changes: 25 additions & 1 deletion backend/src/agents/main_agent/chat_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"""

import logging
import os
from typing import Any, AsyncGenerator, Dict, List, Optional

from agents.main_agent.base_agent import BaseAgent
Expand Down Expand Up @@ -58,14 +59,37 @@ def _create_agent(self) -> None:
# files (added after tool filtering — it is infrastructure, not an
# RBAC-gated tool, and is implicitly scoped to the turn's skills).
plugin, read_skill_file = build_skills_runtime(self._accessible_skill_ids)
plugins = [plugin] if plugin else None
plugins = [plugin] if plugin else []
if plugin:
tools = list(tools) + [read_skill_file]
logger.info(
"ChatAgent: skills disclosure enabled (%d accessible skill ids)",
len(self._accessible_skill_ids or []),
)

# Tool-result offload at intake (compaction PR-4): oversized tool
# results become a bounded preview + retrieval references before
# they enter the cacheable prefix. Fail-open: None when off or
# unconfigured. The plugin registers retrieve_offloaded_content
# itself — one stable spec in toolConfig, not an RBAC-gated tool,
# like read_skill_file above.
from agents.main_agent.core.tool_result_offload import build_tool_result_offloader

offload_session = getattr(self, "session_id", None)
offload_user = getattr(self, "user_id", None)
offloader = (
build_tool_result_offloader(
session_id=offload_session,
user_id=offload_user,
region=os.environ.get("AWS_REGION"),
)
if offload_session and offload_user
else None
)
if offloader is not None:
plugins.append(offloader)
plugins = plugins or None

self.agent = AgentFactory.create_agent(
model_config=self.model_config,
system_prompt=self._system_prompt_for(tools),
Expand Down
47 changes: 47 additions & 0 deletions backend/src/agents/main_agent/config/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,30 @@ class EnvVars:
# (a prefix re-write per turn, and it moves the coordinates the compaction
# checkpoint is expressed in). Setting this to 40 restores the SDK default.
CONVERSATION_WINDOW_MESSAGES = "AGENTCORE_CONVERSATION_WINDOW_MESSAGES"
# Bounded compaction summary (spiral spec PR-2 /
# compaction-model-relative-thresholds.md §3.6). Budget in tokens; the
# re-summarize call is a Nova Micro side-channel with its own kill switch.
COMPACTION_SUMMARY_TOKEN_BUDGET = "AGENTCORE_MEMORY_COMPACTION_SUMMARY_TOKEN_BUDGET"
COMPACTION_SUMMARY_MODEL_ENABLED = "AGENTCORE_MEMORY_COMPACTION_SUMMARY_MODEL_ENABLED"
COMPACTION_SUMMARY_MODEL_ID = "AGENTCORE_MEMORY_COMPACTION_SUMMARY_MODEL_ID"
# Paid-when-free scheduling (thresholds spec §3.5): a cut is computed and
# persisted as PENDING post-turn and applied to the live list pre-call only
# when the prefix re-write is free (cache expired, model/agent switched)
# or unavoidable (hard ceiling). "false" applies cuts immediately (PR-1/2).
COMPACTION_DEFERRED_APPLY_ENABLED = "AGENTCORE_MEMORY_COMPACTION_DEFERRED_APPLY_ENABLED"
# Tool-result offload at intake (thresholds spec §3.6 / PR-4): oversized
# tool results are stored in S3 (user-files bucket, per-session prefix) and
# replaced in context by a bounded preview + retrieval references before
# they ever enter the cacheable prefix. Strands' vended ContextOffloader.
TOOL_RESULT_OFFLOAD_ENABLED = "AGENTCORE_TOOL_RESULT_OFFLOAD_ENABLED"
TOOL_RESULT_OFFLOAD_MAX_TOKENS = "AGENTCORE_TOOL_RESULT_OFFLOAD_MAX_TOKENS"
TOOL_RESULT_OFFLOAD_PREVIEW_TOKENS = "AGENTCORE_TOOL_RESULT_OFFLOAD_PREVIEW_TOKENS"
# Selective long cache TTL on the STATIC prefix (thresholds spec §3.6,
# PR-5). "1h" puts a 1-hour TTL on the tools and system cachePoints only;
# the message-level point stays at Bedrock's 5-minute default. Unset/empty
# = today's shape. An experiment arm: default OFF until the live probe
# (scripts/probe_static_prefix_ttl.py) and the cost rows say it pays.
PROMPT_CACHE_STATIC_PREFIX_TTL = "AGENTCORE_PROMPT_CACHE_STATIC_PREFIX_TTL"

# --- Restored-history repair ---
# Kill switch for the restore-time tool-pairing/alternation repair
Expand Down Expand Up @@ -148,6 +172,29 @@ class Defaults:
# prompt. Overflow recovery (reduce_context on ContextWindowOverflow) still
# works at any window size.
CONVERSATION_WINDOW_MESSAGES = 2000
# 8k tokens ≈ 32k chars: a third of the 25k floor, so a bounded summary
# can never by itself hold a session above the ceiling (the incident's
# summary was 40k tokens against a 100k threshold). Same figure the admin
# SUMMARY_OVER_BUDGET diagnosis reads.
COMPACTION_SUMMARY_TOKEN_BUDGET = 8_000
COMPACTION_SUMMARY_MODEL_ENABLED = True
# Same cheap model as the title and tool-batch side-channels.
COMPACTION_SUMMARY_MODEL_ID = "us.amazon.nova-micro-v1:0"
COMPACTION_DEFERRED_APPLY_ENABLED = True
# Tool-result offload gate. 4k is well under the 25k compaction floor, so a
# protected tail of a few big results can no longer hold a session above
# the ceiling on its own; the 1k preview keeps the part of a result models
# actually quote (headers, first rows, the first error).
TOOL_RESULT_OFFLOAD_ENABLED = True
TOOL_RESULT_OFFLOAD_MAX_TOKENS = 4_000
TOOL_RESULT_OFFLOAD_PREVIEW_TOKENS = 1_000
TOOL_RESULT_OFFLOAD_S3_PREFIX = "compaction-offload"
# Default OFF, deliberately against the flags-default-on house style: a
# caching default adopted on inspection alone has already shipped wrong
# once (#954 measured 57% more expensive live before #956 reverted it).
# The 1h write premium is 2x base vs 1.25x at 5m, so this is a bet on the
# gap distribution that has to be measured, not read off the source.
PROMPT_CACHE_STATIC_PREFIX_TTL = ""

# --- DynamoDB Tables ---
DYNAMODB_QUOTA_TABLE = "UserQuotas"
Expand Down
7 changes: 7 additions & 0 deletions backend/src/agents/main_agent/core/agent_factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,13 @@ def create_agent(
# on a NON-Anthropic model this block is passed through untouched and
# Bedrock rejects the call with AccessDeniedException.
#
# PR-5 (thresholds spec §3.6): the point is placed TTL-less on purpose.
# With AGENTCORE_PROMPT_CACHE_STATIC_PREFIX_TTL=1h, ModelConfig sets
# CacheConfig(system_prompt_ttl="1h", tools_ttl="1h") and upstream's
# _apply_system_cache_ttl rewrites THIS point's ttl ("an explicit
# system_prompt_ttl string is honored as written"); the tools point
# gets its own. Flag unset → no ttl key anywhere → today's bytes.
#
# RE-VERIFY BEFORE ANY BUMP PAST 1.55.0. This is a statement about
# upstream internals and it has already rotted once. Re-check
# _should_cache_system's guard, CacheConfig.system_prompt_ttl's
Expand Down
44 changes: 42 additions & 2 deletions backend/src/agents/main_agent/core/model_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,25 @@ def from_env(cls) -> "RetryConfig":
)


# Bedrock's long cache TTL. Only "1h" is a change from the default; anything
# else (unset, "5m", garbage) means "today's shape" — no ttl key on any point,
# which is what keeps the static prefix bytes identical across the flip.
LONG_CACHE_TTL = "1h"


def static_prefix_cache_ttl() -> Optional[str]:
"""The long TTL to put on the tools + system cachePoints, or ``None``.

Read from ``AGENTCORE_PROMPT_CACHE_STATIC_PREFIX_TTL`` at agent
construction (a cached agent keeps the arm it was built under). See
docs/specs/compaction-model-relative-thresholds.md §3.6 PR-5 for the
economics: 2x write premium on the static segments in exchange for
reading them, rather than re-writing them, on every 5–60 minute pause.
"""
raw = os.environ.get(EnvVars.PROMPT_CACHE_STATIC_PREFIX_TTL, "").strip().lower()
return LONG_CACHE_TTL if raw == LONG_CACHE_TTL else None


@dataclass
class ModelConfig:
"""Configuration for multi-provider LLM models.
Expand Down Expand Up @@ -342,6 +361,15 @@ def get_provider(self) -> ModelProvider:
# Default to configured provider
return self.provider

def long_ttl_static_prefix(self) -> bool:
"""True when this model's tools + system cachePoints carry the 1h TTL.

The cost path uses it to bill the static segment's cache writes at
Bedrock's 1h premium (2x base) instead of the 5m one (1.25x) — the
correction that keeps the experiment arm's own cost rows honest.
"""
return bool(self.caching_enabled and self.bedrock_cache_points_supported() and static_prefix_cache_ttl())

def bedrock_cache_points_supported(self) -> bool:
"""Whether a hand-placed Bedrock system cachePoint may be sent.

Expand Down Expand Up @@ -471,10 +499,22 @@ def to_bedrock_config(self) -> Dict[str, Any]:
# that reaches Bedrock without going through that factory.
if self.caching_enabled:
from strands.models import CacheConfig

# PR-5 (thresholds spec §3.6): an explicit "1h" on the two STATIC
# points only. system_prompt_ttl as a string is "honored as
# written" by _apply_system_cache_ttl, which rewrites the TTL on
# the hand-placed, TTL-less system point AgentFactory places;
# tools_ttl as a string sets the tools point's own TTL. The
# message-level auto point carries no ttl (cache_config.ttl stays
# unset) and so stays at 5m — tools(1h) → system(1h) → messages(5m)
# is the non-increasing order Bedrock requires. Off (the default)
# emits exactly today's bytes.
supported = self.bedrock_cache_points_supported()
long_ttl = static_prefix_cache_ttl() if supported else None
config["cache_config"] = CacheConfig(
strategy="auto",
system_prompt_ttl=True,
tools_ttl=self.bedrock_cache_points_supported(),
system_prompt_ttl=long_ttl or True,
tools_ttl=(long_ttl or True) if supported else False,
)

if self.retry_config:
Expand Down
Loading