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
19 changes: 10 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ Version `0.1.x` contains the converged foundation layer:
- loop configuration, lifecycle contexts, observer protocol, intervention
merging, and observer dispatch helpers.
- streamed tool-call recovery checks for missing required arguments.
- LLM binding, response normalization, streaming assembly/watchdogs, retry
classification, runaway recovery, and physical-call orchestration.

The initial extraction is based on the already-merged integration branches:

Expand All @@ -30,10 +32,10 @@ The initial extraction is based on the already-merged integration branches:
Those revisions are provenance, not runtime dependencies. AgentCore tests and
builds without either product checkout.

The LLM call runtime and agent loop remain in the products until tool
execution, model profiles, retry classification, execution-context storage,
and runtime hooks have product-neutral boundaries. Moving those files before
that boundary exists would only hide product coupling inside this package.
The agent loop remains in the products until tool execution, model profiles,
tool-call parsing, and execution-context storage have converged boundaries.
The LLM runtime accepts the three remaining product decisions through explicit
hooks: wall-deadline lookup, provider-chain state, and sticky-session policy.

## Repository boundary

Expand Down Expand Up @@ -109,11 +111,10 @@ edit both products' core copies, that is evidence it belongs here.

1. **Foundation** (this version): messages, token estimation, compaction,
context budget, trimming.
2. **Runtime contracts** (in progress): LLM protocols and loop types are now
shared; errors, execution-context storage, retry classification, and
explicit product hooks remain.
3. **LLM runtime:** binding, calls, streaming, response normalization, runaway
recovery, and the public `llm_client` facade.
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:** model/tool parsing, tool execution, and `agent_loop`.
5. Remove product compatibility facades after downstream imports have moved to
`agent_core`.
Expand Down
2 changes: 2 additions & 0 deletions agent_core/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Product-neutral building blocks for Apodex agent runtimes."""

from agent_core.errors import AgentCoreError
from agent_core.llm import LLMClient, LLMResponse, StreamDelta
from agent_core.messages import (
Message,
Expand All @@ -11,6 +12,7 @@
)

__all__ = [
"AgentCoreError",
"LLMClient",
"LLMResponse",
"Message",
Expand Down
164 changes: 164 additions & 0 deletions agent_core/errors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
"""Exception hierarchy for AgentCore."""

from __future__ import annotations

from typing import Any


class AgentCoreError(Exception):
"""Base exception for all AgentCore errors."""


# ── Kernel errors ───────────────────────────────────────────────────────────


class KernelError(AgentCoreError):
"""Errors originating from the OS kernel layer."""


class TaskNotFoundError(KernelError):
def __init__(self, task_id: str) -> None:
super().__init__(f"Task not found: {task_id}")
self.task_id = task_id


class InvalidStateTransition(KernelError):
def __init__(self, task_id: str, current: str, target: str) -> None:
super().__init__(f"Invalid transition for {task_id}: {current} → {target}")


class ServiceNotRegistered(KernelError):
def __init__(self, service_type: type) -> None:
super().__init__(f"Service not registered: {service_type.__name__}")


class PermissionDenied(KernelError):
def __init__(self, role: str, tool: str) -> None:
super().__init__(f"Role '{role}' has no permission for tool '{tool}'")

# LLM request errors

class LLMError(AgentCoreError):
"""Errors from the LLM/provider layer."""


class LLMReasoningRunaway(LLMError):
"""A live stream spent its semantic budget on reasoning-only output.

Unlike :class:`LLMStreamStalled`, the provider is healthy and actively
emitting chunks. The failure is semantic: no non-whitespace visible text
or tool-call delta appeared before the configured time/token guard fired.

``partial_response`` is intentionally carried separately from provider
usage. Early stream cancellation often happens before the terminal usage
chunk arrives, so its estimated reasoning tokens must never be presented
as authoritative billing data.
"""

def __init__(
self,
*,
elapsed_s: float,
estimated_tokens: int,
trigger: str,
partial_response: Any,
) -> None:
self.elapsed_s = float(elapsed_s)
self.estimated_tokens = int(estimated_tokens)
self.trigger = trigger
self.partial_response = partial_response
super().__init__(
"reasoning-only stream exceeded "
f"{trigger} guard (elapsed={self.elapsed_s:.1f}s, "
f"estimated_tokens={self.estimated_tokens})",
)


class LLMStreamStalled(LLMError, TimeoutError):
"""A streaming LLM call went silent mid-flight.

Subclasses ``asyncio.TimeoutError`` so every existing transient-
timeout handler (retry/backoff in ``call_llm``, chain wrappers,
classification) treats it identically without changes; carried
fields make the distinct failure mode visible in logs and traces.
"""

def __init__(
self, stall_s: float, chunks_seen: int, elapsed_s: float,
) -> None:
self.stall_s = stall_s
self.chunks_seen = chunks_seen
self.elapsed_s = elapsed_s
super().__init__(
f"stream stalled: no chunks for {stall_s:.0f}s "
f"(chunks_seen={chunks_seen}, elapsed={elapsed_s:.0f}s)",
)


class LLMDeadlineExceeded(LLMError, TimeoutError):
"""An LLM attempt was stopped by an enclosing runtime deadline.

``reason`` is deliberately carried on the underlying exception as well as
on :class:`LLMCallExhausted`. Some callers unwrap ``last_exc`` before
handing it to a provider-chain policy; a dedicated type prevents that
policy from mistaking an exhausted run budget for an ordinary transient
provider timeout.
"""

def __init__(self, reason: str, detail: str) -> None:
self.reason = reason
super().__init__(f"{reason}: {detail}")


class LLMCallExhausted(LLMError, RuntimeError):
"""Raised by ``call_llm`` when retries are exhausted or the error is
structurally unrecoverable (4xx without proxy-wrap, or a chain-aware
fallback signal like ``model_not_found``).

Wraps the last exception encountered so the caller (typically the
product's agent loop) can surface it to a provider-chain wrapper for
L1→L2→L3 rotation. Carries ``last_exc`` separately because
``raise from`` is too opaque for chain-aware classification — a chain
wrapper calls ``classify_error(last_exc)`` directly.

``last_exc`` must always agree with ``reason``: it is the exception that
*caused this raise*, not merely the most recent failure seen. A deadline
refusal therefore carries :class:`LLMDeadlineExceeded` even when earlier
attempts failed for unrelated reasons. The wrapper's ``reason`` remains
authoritative, while the underlying exception preserves the same reason
if a caller unwraps it before classification.

``prior_exc`` is where that earlier, superseded failure goes: diagnostic
context for logs and post-mortems, deliberately outside the field
classification reads.
"""

def __init__(
self,
last_exc: BaseException,
reason: str,
*,
prior_exc: BaseException | None = None,
) -> None:
self.last_exc = last_exc
self.reason = reason
self.prior_exc = prior_exc
detail = f"call_llm {reason}: {last_exc!r}"
if prior_exc is not None and prior_exc is not last_exc:
detail += f" (after {prior_exc!r})"
super().__init__(detail)


__all__ = [
"AgentCoreError",
"InvalidStateTransition",
"KernelError",
"LLMCallExhausted",
"LLMDeadlineExceeded",
"LLMError",
"LLMReasoningRunaway",
"LLMStreamStalled",
"PermissionDenied",
"ServiceNotRegistered",
"TaskNotFoundError",
]
6 changes: 3 additions & 3 deletions agent_core/llm.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,9 @@ class StreamDelta:
usage: dict[str, int] = field(default_factory=dict[str, int])
finish_reason: str = ""
model: str = ""
# Vendor label of the leg serving this stream, stamped by
# ``LLMFallbackChain.stream`` (constant once the chain commits to an
# entry — failover only fires before the first yield). The stream
# Vendor label of the leg serving this stream, stamped by a product's
# provider-chain wrapper (constant once the chain commits to an entry —
# failover only fires before the first yield). The stream
# assembler folds it into ``LLMResponse.response_metadata`` so per-call
# billing attribution works for streamed calls too — without this the
# streaming path had no channel for the provider and every billing
Expand Down
10 changes: 5 additions & 5 deletions agent_core/messages.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@
is preserved as the ``content`` value when the underlying client returns it;
callers that need flat text use :func:`text_of`.

This module replaces ``langchain_core.messages`` (BaseMessage / SystemMessage /
HumanMessage / AIMessage / ToolMessage). It is intentionally dependency-free.
This module replaces the former framework-specific message classes with a
small, dependency-free wire contract.
"""

from __future__ import annotations
Expand All @@ -34,8 +34,8 @@
class ToolCall(TypedDict):
"""OpenAI-style tool_call payload — ``function.arguments`` is JSON-encoded.

Wire key order is fixed ``{type, id, function}`` to match the LangChain
serializer the served checkpoints were aligned against; do not reorder.
Wire key order is fixed ``{type, id, function}`` to match the serializer
byte shape the served checkpoints were aligned against; do not reorder.
"""

id: str
Expand Down Expand Up @@ -141,7 +141,7 @@ def for_wire(messages: list[Message]) -> list[Message]:

# Key insertion order: ``content`` first, then ``role``. Some served
# checkpoints are sensitive to this byte shape (wire byte-equality with
# LangChain's ``_convert_message_to_dict`` — see migration gotcha #2). Do
# the legacy message serializer — see migration gotcha #2). Do
# not reorder these dict literals.


Expand Down
35 changes: 35 additions & 0 deletions agent_core/runtime/env.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
"""Shared env-variable prefix cascade.

Several modules independently re-walked the same
``AGENT_CORE_ / MIROHARNESS_ / FRONTIER_AGENT_`` prefix order looking for a
configured value — one copy per module, easy to drift if a prefix is ever
added or reordered in only one of them. This is the single implementation
they converge on: import :func:`first_configured` and let it own the order
rather than passing a locally-spelled tuple back in.
"""

from __future__ import annotations

import os

# The portable ``AGENT_CORE_`` spelling wins when multiple aliases are
# configured, followed by the MiroHarness and FrontierAgent compatibility
# names. Order matters:
# callers rely on the first configured prefix winning.
ENV_PREFIXES = ("AGENT_CORE_", "MIROHARNESS_", "FRONTIER_AGENT_")


def first_configured(suffix: str, prefixes: tuple[str, ...] = ENV_PREFIXES) -> tuple[str, str] | None:
"""Return the ``(name, value)`` of the first non-empty ``{prefix}{suffix}`` env var.

``None`` when none of the prefixed names are set (or all are blank).
"""
for prefix in prefixes:
name = f"{prefix}{suffix}"
raw = os.environ.get(name, "").strip()
if raw:
return name, raw
return None


__all__ = ["ENV_PREFIXES", "first_configured"]
68 changes: 68 additions & 0 deletions agent_core/runtime/llm_request_overrides.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
"""Task-local overrides for one physical LLM request.

The runtime occasionally needs to change generation behaviour for one retry
without mutating a cached/shared client. ``ContextVar`` keeps that override
isolated across concurrent tasks and automatically restores the client's
normal profile on exit.

Provider adapters opt in to the semantic override they understand. Today the
OpenAI-compatible adapter maps it onto SGLang/Qwen
``chat_template_kwargs`` and an explicitly configured ``reasoning_effort``.
Unsupported adapters simply keep their normal request shape; the runtime's
retry prompt and output cap remain the portable fallback.
"""

from __future__ import annotations

from collections.abc import Generator
from contextlib import contextmanager
from contextvars import ContextVar
from dataclasses import dataclass


@dataclass(frozen=True)
class ThinkingRetryOverride:
"""Semantic thinking controls for a single retry attempt."""

mode: str = "reduced"
thinking_budget: int | None = None
reasoning_effort: str | None = None

@property
def disabled(self) -> bool:
return self.mode == "disabled"


_THINKING_RETRY_OVERRIDE: ContextVar[ThinkingRetryOverride | None] = ContextVar(
"agent_core_thinking_retry_override",
default=None,
)


def current_thinking_retry_override() -> ThinkingRetryOverride | None:
"""Return the override active for the current async task, if any."""

return _THINKING_RETRY_OVERRIDE.get()


@contextmanager
def thinking_retry_override(
override: ThinkingRetryOverride | None,
) -> Generator[None, None, None]:
"""Apply ``override`` only inside this context and async task."""

if override is None:
yield
return
token = _THINKING_RETRY_OVERRIDE.set(override)
try:
yield
finally:
_THINKING_RETRY_OVERRIDE.reset(token)


__all__ = [
"ThinkingRetryOverride",
"current_thinking_retry_override",
"thinking_retry_override",
]
Loading