Skip to content
15 changes: 11 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ Version `0.1.x` contains the converged foundation layer:
- failure-isolated async event dispatch and composable fail-closed tool
permission policy;
- provider-neutral LLM response, stream, and client contracts;
- profile-driven auxiliary-client construction, summary execution, legacy
cooldown fallback, and task-local external-API metering;
- loop configuration, lifecycle contexts, observer protocol, intervention
merging, and observer dispatch helpers.
- streamed tool-call recovery checks for missing required arguments.
Expand Down Expand Up @@ -72,10 +74,12 @@ 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, session affinity, durable process/event-store implementations,
Provider catalogs, credentials, endpoint selection, host session-affinity
context, billing policy, durable process/event-store implementations,
user/session retention policy, checkpoint association, workflow node
implementations, sandbox mounting/authorization, UI history, and
product-specific observers remain in their product.
product-specific observers remain in their product. AgentCore owns the
provider transports and product-neutral affinity lifecycle safeguards.

## Development

Expand Down Expand Up @@ -160,7 +164,10 @@ edit both products' core copies, that is evidence it belongs here.
9. **Provider substrate** (complete in AgentCore): shared provider transports,
fallback engine, prompt cache, usage metadata normalization, and non-blocking streaming
behind product configuration and session-affinity adapters.
10. Remove product compatibility facades after downstream imports have moved to
`agent_core`.
10. **Shared runtime closeout**: portable usage metering, configurable aux-LLM
construction, summary execution, tool-call repair/guardrails, and safe
workflow-default merging.
11. Remove product compatibility facades after downstream imports have moved to
`agent_core` and the compatibility window has elapsed.

Each slice must leave product CI green and must not depend on a floating branch.
24 changes: 24 additions & 0 deletions agent_core/protocols.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,15 @@ def __post_init__(self) -> None:
self.start_time = time.time()


# Reserved ``ToolCallContext.metadata`` keys. A ``before_tool_call``
# middleware sets ``BLOCKED_KEY`` to veto a call; the host dispatcher MUST
# check it after running the middleware chain and, when set, skip the tool and
# return ``BLOCK_REASON_KEY`` to the model as the tool result. AgentCore does
# not dispatch tools itself, so an unchecked flag means silent non-enforcement.
BLOCKED_KEY = "blocked"
BLOCK_REASON_KEY = "block_reason"


@dataclass
class ToolCallContext:
task_id: str
Expand All @@ -107,6 +116,19 @@ class ToolCallContext:
tool_args: dict[str, Any] = field(default_factory=dict[str, Any])
metadata: dict[str, Any] = field(default_factory=dict[str, Any])

def block(self, reason: str) -> None:
"""Veto this tool call. See ``BLOCKED_KEY`` for the host contract."""
self.metadata[BLOCKED_KEY] = True
self.metadata[BLOCK_REASON_KEY] = reason

@property
def is_blocked(self) -> bool:
return bool(self.metadata.get(BLOCKED_KEY))

@property
def block_reason(self) -> str:
return str(self.metadata.get(BLOCK_REASON_KEY) or "")


class ExecutionMiddleware:
async def before_phase(self, ctx: PhaseContext) -> PhaseContext:
Expand Down Expand Up @@ -191,6 +213,8 @@ def reload(self) -> None: ...


__all__ = [
"BLOCKED_KEY",
"BLOCK_REASON_KEY",
"EventReader",
"EventSink",
"ExecutionMiddleware",
Expand Down
39 changes: 38 additions & 1 deletion agent_core/providers/__init__.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,19 @@
"""Provider transports and provider-neutral client wrappers."""

from agent_core.providers.anthropic import AnthropicClient
from agent_core.providers.fallback import FallbackEntry, LLMFallbackChain
from agent_core.providers.aux_builder import AuxLLMFactory
from agent_core.providers.fallback import (
CooldownFallbackLLM,
FallbackEntry,
LLMFallbackChain,
legacy_retryable,
)
from agent_core.providers.finish_reason import (
FINISH_REASON_LENGTH,
TRUNCATION_MARKERS,
normalize_finish_reason,
responses_finish_reason,
)
from agent_core.providers.nonblocking_stream import NonBlockingStream, nonblocking_stderr
from agent_core.providers.openai_chat import (
OpenAIClient,
Expand All @@ -21,23 +33,48 @@
provider_label,
thinking_format_for_protocol,
)
from agent_core.providers.summary import (
EXTRACT_INFO_PROMPT,
SummaryCandidate,
SummaryLLMEngine,
build_summary_payload,
default_summary_retryable,
describe_summary_candidates,
normalize_summary_endpoint,
truncate_summary_fallback,
)

__all__ = [
"EXTRACT_INFO_PROMPT",
"FINISH_REASON_LENGTH",
"TRUNCATION_MARKERS",
"AnthropicClient",
"AnthropicPromptCacheAdapter",
"AuxLLMFactory",
"CooldownFallbackLLM",
"FallbackEntry",
"LLMFallbackChain",
"NonBlockingStream",
"OpenAIClient",
"OpenAIResponsesClient",
"SessionQueryResolver",
"SessionScopeResolver",
"SummaryCandidate",
"SummaryLLMEngine",
"build_protocol_client",
"build_summary_payload",
"configure_session_query_resolver",
"configure_session_scope_resolver",
"default_summary_retryable",
"describe_summary_candidates",
"legacy_retryable",
"maybe_wrap_for_prompt_cache",
"nonblocking_stderr",
"normalize_finish_reason",
"normalize_summary_endpoint",
"protocol_of",
"provider_label",
"responses_finish_reason",
"thinking_format_for_protocol",
"truncate_summary_fallback",
]
7 changes: 5 additions & 2 deletions agent_core/providers/anthropic.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@

from agent_core.llm import LLMClient, LLMResponse, StreamDelta
from agent_core.messages import Message, ToolCall, text_of
from agent_core.providers.finish_reason import normalize_finish_reason

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -297,7 +298,9 @@ async def stream(
cache_write,
reasoning_tokens,
),
finish_reason=stop_reason,
# ``max_tokens`` must reach the runaway/truncation checks as
# ``length``; other stop reasons pass through untouched.
finish_reason=normalize_finish_reason(stop_reason),
model=model,
reasoning_blocks=ordered if _has_thinking(ordered) else [],
)
Expand Down Expand Up @@ -711,7 +714,7 @@ def _to_llm_response(raw: Any) -> LLMResponse:
content=content,
tool_calls=tool_calls,
reasoning_content="\n".join(thinking_parts),
finish_reason=getattr(raw, "stop_reason", "") or "",
finish_reason=normalize_finish_reason(getattr(raw, "stop_reason", "")),
model=getattr(raw, "model", "") or "",
usage=usage_dict,
response_metadata={"id": getattr(raw, "id", "")},
Expand Down
202 changes: 202 additions & 0 deletions agent_core/providers/aux_builder.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
"""Configurable factory for profile-defined auxiliary LLM clients."""

from __future__ import annotations

# pyright: basic
import logging
from collections.abc import Callable, Mapping
from typing import Any

logger = logging.getLogger(__name__)

type ClientFactory = Callable[..., Any]
type ProviderTypeResolver = Callable[[str], str]
type SessionHeadersResolver = Callable[[str, Mapping[str, Any]], Mapping[str, str]]
type ClientDecorator = Callable[[Any, str, str], Any]
type APIKeyResolver = Callable[[Mapping[str, Any], str], str]

_DUMMY_KEY_WARNED: set[tuple[str, str]] = set()


def _resolve_api_key(section: Mapping[str, Any], provider: str) -> str:
key = section.get("api_key") or ""
if key and str(key).strip():
return str(key)
model = str(section.get("model") or "")
cache_key = (provider, model)
if cache_key not in _DUMMY_KEY_WARNED:
_DUMMY_KEY_WARNED.add(cache_key)
logger.warning(
"Aux LLM provider=%r model=%r has no api_key; using the legacy "
"'dummy' token",
provider or "<unknown>",
model or "<unknown>",
)
return "dummy"


def _as_int(value: object) -> int:
"""Coerce a profile-supplied budget, tolerating numeric strings."""
if isinstance(value, bool):
return int(value)
if isinstance(value, int):
return value
return int(float(str(value).strip().replace("_", "")))


def _thinking_budget(section: Mapping[str, Any]) -> object | None:
for key in ("thinking_budget", "thinking_budget_tokens"):
value = section.get(key)
if value is not None:
return value
thinking = section.get("thinking")
if isinstance(thinking, Mapping):
for key in ("budget", "budget_tokens", "max_tokens"):
value = thinking.get(key)
if value is not None:
return value
return None


def _anthropic_thinking(section: Mapping[str, Any]) -> dict[str, Any] | None:
raw = section.get("thinking")
if not isinstance(raw, Mapping):
return None
thinking = {str(key): value for key, value in raw.items()}
kind = str(thinking.get("type") or "").strip().lower()
if kind in {"", "disabled", "off", "none", "false"}:
return None
return thinking


def _headers(value: object) -> dict[str, str]:
if not isinstance(value, Mapping):
return {}
return {
str(key): str(item)
for key, item in value.items()
if item is not None
}


class AuxLLMFactory:
"""Build OpenAI-compatible, Anthropic, or Bedrock auxiliary clients.

Host products provide catalog resolution, concrete constructors, session
headers, and post-build decoration. All request-shape normalization stays
here so profile-driven DAG/report/summary clients cannot drift.
"""

def __init__(
self,
*,
openai_factory: ClientFactory,
anthropic_factory: ClientFactory,
provider_type: ProviderTypeResolver,
session_headers: SessionHeadersResolver | None = None,
decorate: ClientDecorator | None = None,
api_key_resolver: APIKeyResolver = _resolve_api_key,
) -> None:
self._openai_factory = openai_factory
self._anthropic_factory = anthropic_factory
self._provider_type = provider_type
self._session_headers = session_headers
self._decorate = decorate
self._api_key_resolver = api_key_resolver

def build(self, section: Mapping[str, Any]) -> Any:
provider = str(
section.get("_provider_label") or section.get("provider") or "",
)
provider_type = self._provider_type(provider).lower()
model = str(section.get("model") or "")
if provider_type in {"anthropic", "bedrock"}:
if "claude" not in model.strip().lower():
raise ValueError(
f"provider {provider!r} uses {provider_type!r} transport, "
f"which requires a Claude model; got {model!r}",
)
client = self._build_anthropic(
section,
provider,
bedrock=provider_type == "bedrock",
)
else:
client = self._build_openai(section, provider)
if self._decorate is not None:
client = self._decorate(client, provider, model)
return client

def _build_anthropic(
self,
section: Mapping[str, Any],
provider: str,
*,
bedrock: bool,
) -> Any:
kwargs: dict[str, Any] = {
"model": section["model"],
"api_key": self._api_key_resolver(section, provider),
"temperature": section.get("temperature", 0.0),
"timeout": float(section.get("llm_timeout_s", 120)),
"thinking": _anthropic_thinking(section),
"effort": str(section.get("effort") or ""),
"bedrock": bedrock,
}
if section.get("base_url"):
kwargs["base_url"] = section["base_url"]
default_headers = _headers(section.get("extra_headers"))
if default_headers:
kwargs["default_headers"] = default_headers
maximum = section.get("max_completion_tokens") or section.get("max_tokens")
if maximum is not None:
kwargs["max_tokens"] = int(maximum)
return self._anthropic_factory(**kwargs)

def _build_openai(
self,
section: Mapping[str, Any],
provider: str,
) -> Any:
kwargs: dict[str, Any] = {
"model": section["model"],
"api_key": self._api_key_resolver(section, provider),
"base_url": section.get("base_url") or None,
"temperature": section.get("temperature", 0.0),
"timeout": float(section.get("llm_timeout_s", 120)),
}
maximum = section.get("max_completion_tokens") or section.get("max_tokens")
if maximum is not None:
kwargs["max_completion_tokens"] = int(maximum)

extra_body_value = section.get("extra_body")
extra_body = dict(extra_body_value) if isinstance(extra_body_value, Mapping) else {}
template_value = extra_body.get("chat_template_kwargs")
template = dict(template_value) if isinstance(template_value, Mapping) else {}
# Distinguish "absent" from an explicit false: models such as Qwen3 and
# SGLang default thinking on, so a profile disabling it must emit the
# key rather than fall through silently.
enable_thinking = section.get("enable_thinking")
if enable_thinking is not None:
template["enable_thinking"] = bool(enable_thinking)
if enable_thinking:
template.setdefault("preserve_thinking", False)
if enable_thinking is None or enable_thinking:
budget = _thinking_budget(section)
if budget is not None:
template["thinking_budget"] = _as_int(budget)
if template:
extra_body["chat_template_kwargs"] = template
if extra_body:
kwargs["extra_body"] = extra_body

default_headers: dict[str, str] = {}
if self._session_headers is not None:
default_headers.update(self._session_headers(provider, section))
default_headers.update(_headers(section.get("extra_headers")))
if default_headers:
kwargs["default_headers"] = default_headers
return self._openai_factory(**kwargs)


__all__ = ["AuxLLMFactory"]
Loading