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
21 changes: 13 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ Version `0.1.x` contains the converged foundation layer:
- compaction policy and deterministic compactor;
- context-budget estimation and non-blocking tokenizer access;
- message trimming;
- bounded LLM summaries, projected-token triggers, and tiered compaction;
- session-isolated, content-addressed spill storage for recoverable tool output;
- provider-neutral LLM response, stream, and client contracts;
- loop configuration, lifecycle contexts, observer protocol, intervention
merging, and observer dispatch helpers.
Expand All @@ -37,9 +39,9 @@ Those revisions are provenance, not runtime dependencies. AgentCore tests and
builds without either product checkout.

The shared loop accepts product decisions through explicit hooks. Products own
execution-context storage, endpoint registries, metering, deadline policy,
result spilling and aggregate budgets; they no longer need private copies of
the orchestration or parsing engines.
execution-context storage, endpoint registries, metering, deadline policy, and
sandbox mount policy; they no longer need private copies of the orchestration,
parsing, context-management, or spill-storage engines.

## Repository boundary

Expand All @@ -51,8 +53,9 @@ Code belongs in AgentCore when it:
- accepts product behavior through typed inputs or explicit hooks;
- has tests that run without either product repository installed.

Provider clients, environment/config loading, session affinity, persistence,
workflow assembly, and product-specific observers remain in their product.
Provider clients, environment/config loading, session affinity, checkpoint
persistence, workflow assembly, sandbox mounting, and product-specific
observers remain in their product.

## Development

Expand Down Expand Up @@ -113,15 +116,17 @@ edit both products' core copies, that is evidence it belongs here.

## Migration plan

1. **Foundation** (this version): messages, token estimation, compaction,
context budget, trimming.
1. **Foundation** (complete): messages, token estimation, deterministic
compaction, context budget, trimming.
2. **Runtime contracts** (complete): LLM protocols, loop types, errors, retry
classification, and explicit product hooks are shared.
3. **LLM runtime** (complete): binding, calls, streaming, response
normalization, runaway recovery, and the public `llm_client` facade.
4. **Agent loop** (complete in AgentCore): model/tool parsing, hook-driven tool
execution, and `agent_loop` orchestration.
5. Remove product compatibility facades after downstream imports have moved to
5. **Context management Phase 2** (complete in AgentCore): calibrated trigger,
bounded LLM summary, tier selection, observability, and spill storage.
6. Remove product compatibility facades after downstream imports have moved to
`agent_core`.

Each slice must leave product CI green and must not depend on a floating branch.
4 changes: 4 additions & 0 deletions agent_core/runtime/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1,5 @@
"""Shared runtime implementations."""

from agent_core.runtime.spill import SpillStore

__all__ = ["SpillStore"]
20 changes: 20 additions & 0 deletions agent_core/runtime/loop/__init__.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
"""Shared loop foundation primitives."""

from agent_core.runtime.loop.agent_loop import AgentLoopHooks, run_agent_loop
from agent_core.runtime.loop.budget_consistency import (
COMPACTION_TRIGGER_RATIO,
check_context_budget,
)
from agent_core.runtime.loop.compact import (
DefaultCompactionPolicy,
DefaultMessageCompactor,
)
from agent_core.runtime.loop.compact_llm import LLMSummaryCompactor
from agent_core.runtime.loop.llm_client import (
RUNAWAY_STATE_KEY,
TRUNCATION_CONTINUATION_GUIDANCE,
Expand Down Expand Up @@ -36,6 +41,13 @@
NativeMessageNormalizer,
configure_model_registry,
)
from agent_core.runtime.loop.tiered_compact import (
DEFAULT_TRIGGER_RATIO,
InputTokenGauge,
InputTokenThresholdPolicy,
TieredCompactor,
compaction_trigger_tokens,
)
from agent_core.runtime.loop.tool_call_parser import (
DefaultToolCallParser,
MultiFormatToolCallParser,
Expand All @@ -47,6 +59,8 @@
)

__all__ = [
"COMPACTION_TRIGGER_RATIO",
"DEFAULT_TRIGGER_RATIO",
"RUNAWAY_STATE_KEY",
"TRUNCATION_CONTINUATION_GUIDANCE",
"AgentLoopHooks",
Expand All @@ -56,23 +70,29 @@
"DefaultToolCallParser",
"DefaultToolResultPostProcessor",
"HistoryPolicy",
"InputTokenGauge",
"InputTokenThresholdPolicy",
"LLMCallExhausted",
"LLMDeadlineExceeded",
"LLMReasoningRunaway",
"LLMStreamStalled",
"LLMSummaryCompactor",
"MessageTrimmer",
"ModelProfile",
"MultiFormatToolCallParser",
"NativeMessageNormalizer",
"NullTrimmer",
"TaskBoundaryTrimmer",
"ThinkTagSplitter",
"TieredCompactor",
"ToolExecutionHooks",
"bind_max_tokens",
"bind_session_id",
"bind_temperature",
"bind_tools",
"call_llm",
"check_context_budget",
"compaction_trigger_tokens",
"configure_model_registry",
"execute_tools",
"extract_final_content",
Expand Down
128 changes: 128 additions & 0 deletions agent_core/runtime/loop/budget_consistency.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
"""Consistency of the three token budgets a loop is configured with.

``config/sglang/README.md`` already describes the context/output relationship,
and ``docker/sglang-doctor.sh`` checks it — but only for the sglang compose path,
expressed in ``SGLANG_*`` variables, by a script an operator has to remember to
run. The values that actually reach the loop are the profile's
``max_len`` / ``max_input_tokens`` and the LLM's ``max_tokens``, and those can be
set straight through ``OPENAI_CONTEXT_WINDOW`` / ``OPENAI_MAX_INPUT_TOKENS`` /
``OPENAI_MAX_TOKENS`` against any endpoint, with nothing checking them at all.

A violation is not visibly a misconfiguration. It surfaces as a provider
rejection mid-run, or as a reasoning watchdog that cannot reliably pre-empt the
provider's output cap. Both read as a runtime fault.

Warnings only. A deploy that is running today with an unusual combination must
keep running; the point is to say so once, at startup, in terms of the knob to
turn.
"""

from __future__ import annotations

import logging

from agent_core.runtime.loop.tiered_compact import (
DEFAULT_TRIGGER_RATIO as COMPACTION_TRIGGER_RATIO,
)
from agent_core.runtime.loop.tiered_compact import compaction_trigger_tokens

logger = logging.getLogger(__name__)

__all__ = ["COMPACTION_TRIGGER_RATIO", "check_context_budget"]

# The ratio of ``max_len`` at which tiered compaction triggers is re-exported,
# not restated: a second literal here could silently drift from the trigger, and
# a check that disagreed with the mechanism it describes would be worse than no
# check. A host that passes its own ``ratio`` to ``compaction_trigger_tokens``
# must pass the same one here.


def check_context_budget(
*,
max_len: int,
max_input_tokens: int | None,
max_tokens: int | None,
reasoning_only_max_tokens: int | None = None,
ratio: float = COMPACTION_TRIGGER_RATIO,
label: str,
) -> list[str]:
"""Warn about token budgets that cannot all hold at once.

Returns the warning strings, so a caller (or a test) can assert on them
rather than scraping the log. Anything unset or non-positive is skipped: a
missing bound is a deliberate configuration, not an inconsistency.

``ratio`` must be the one the loop's compaction trigger was built with; the
margin reported below is meaningless against any other.
"""
problems: list[str] = []

# This relationship does not depend on knowing the context window. Keep it
# outside the max_len-gated checks so profiles without tiered compaction
# still get the warning.
if (
max_tokens is not None
and max_tokens > 0
and reasoning_only_max_tokens is not None
and reasoning_only_max_tokens > 0
and reasoning_only_max_tokens >= max_tokens
):
# The reasoning-only watchdog cancels a stream that is still ONLY
# reasoning. At or above max_tokens it cannot reliably pre-empt the
# provider's completion cap, so the runaway path handles the reply
# instead — a different mechanism with a different recovery.
problems.append(
f"{label}: reasoning_only_max_tokens "
f"({reasoning_only_max_tokens:,}) is not below max_tokens "
f"({max_tokens:,}); the reasoning watchdog cannot reliably fire "
f"before the provider truncates the reply"
)

if max_len <= 0:
for problem in problems:
logger.warning("%s", problem)
return problems

trigger = compaction_trigger_tokens(max_len, ratio)

if (
max_input_tokens is not None
and max_input_tokens > 0
and max_tokens is not None
and max_tokens > 0
and max_input_tokens + max_tokens > max_len
):
# A prompt sitting at the guard limit plus a full-length completion does
# not fit what the endpoint serves. Nothing catches this before the
# provider does, and by then the turn is lost.
problems.append(
f"{label}: max_input_tokens ({max_input_tokens:,}) + max_tokens "
f"({max_tokens:,}) = {max_input_tokens + max_tokens:,} exceeds "
f"max_len ({max_len:,}); a full-length reply to a prompt at the "
f"input guard cannot fit the served context"
)

if max_tokens is not None and max_tokens > 0 and max_len - trigger > 0:
# The margin the compaction trigger has before the hard wall, and what
# one full-length reply costs against it. INFORMATIONAL, deliberately not
# a threshold: whether a turn actually crosses this is dynamic (reply +
# its tool result + whatever the reasoning guard really allowed), and no
# static rule separates a safe configuration from an unsafe one.
#
# It is logged because the diagnosis that needed it (PR #66) had to be
# done by hand: 51 of 51 dead sub-agents had a last-successful prompt
# BELOW the trigger and a next request past the wall — a whole turn's
# growth fitting inside the margin. `should_compact` reads the request
# already sent, so it cannot see that coming; the standing fix is to
# project the next request, not to bound this ratio.
margin = max_len - trigger
logger.info(
"%s: compaction trigger %s leaves %s tokens before max_len %s; "
"one full-length reply (max_tokens %s) is %.0f%% of that margin",
label, f"{trigger:,}", f"{margin:,}", f"{max_len:,}",
f"{max_tokens:,}", 100.0 * max_tokens / margin,
)

for problem in problems:
logger.warning("%s", problem)
return problems
11 changes: 8 additions & 3 deletions agent_core/runtime/loop/compact.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
"compact_messages",
"compress_tool_results",
"estimate_tokens",
"tool_names_by_call_id",
]

# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -89,7 +90,7 @@
)


def _tool_names_by_call_id(messages: list[Message]) -> dict[str, str]:
def tool_names_by_call_id(messages: list[Message]) -> dict[str, str]:
"""Map ``tool_call_id`` → tool name from AIMessage ``tool_calls``.

A ``ToolMessage`` carries no tool name, so any name-keyed policy has to
Expand All @@ -112,6 +113,10 @@ def _tool_names_by_call_id(messages: list[Message]) -> dict[str, str]:
return out


# Compatibility for product facades that predate the public spelling.
_tool_names_by_call_id = tool_names_by_call_id


def _condense(content: str, max_chars: int) -> str:
"""Head + tail + URLs of *content*, never longer than the original."""
prefix = f"[Compressed tool result: {len(content):,} characters]\n"
Expand Down Expand Up @@ -169,7 +174,7 @@ def compress_tool_results(
if protect_max_chars is not None and protect_max_chars < 200:
raise ValueError("protect_max_chars must be at least 200")

id_to_name = _tool_names_by_call_id(messages) if protect_tool_names else {}
id_to_name = tool_names_by_call_id(messages) if protect_tool_names else {}
compacted: list[Message] = []
for message in messages:
clone = message.copy()
Expand Down Expand Up @@ -464,7 +469,7 @@ def compact(
return messages

id_to_name = (
_tool_names_by_call_id(messages) if self._protect or self._spill is not None else {}
tool_names_by_call_id(messages) if self._protect or self._spill is not None else {}
)

out: list[Message] = []
Expand Down
Loading