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
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ Version `0.1.x` contains the converged foundation layer:
- message trimming;
- bounded LLM summaries, projected-token triggers, and tiered compaction;
- session-isolated, content-addressed spill storage for recoverable tool output;
- product-neutral identity/status types, kernel events, and task-local execution
context propagation;
- failure-isolated async event dispatch and composable fail-closed tool
permission policy;
- provider-neutral LLM response, stream, and client contracts;
- loop configuration, lifecycle contexts, observer protocol, intervention
merging, and observer dispatch helpers.
Expand Down Expand Up @@ -126,7 +130,9 @@ edit both products' core copies, that is evidence it belongs here.
execution, and `agent_loop` orchestration.
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
6. **Runtime foundation** (complete in AgentCore): task identity/status,
execution ContextVars, kernel events, event dispatch, and tool permissions.
7. 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.
7 changes: 7 additions & 0 deletions agent_core/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
"""Product-neutral building blocks for Apodex agent runtimes."""

from agent_core.errors import AgentCoreError
from agent_core.events import EventType
from agent_core.execution_context import ExecutionScope, build_execution_scope
from agent_core.llm import LLMClient, LLMResponse, StreamDelta
from agent_core.messages import (
Message,
Expand All @@ -10,15 +12,20 @@
tool_msg,
user_msg,
)
from agent_core.types import TaskStatus

__all__ = [
"AgentCoreError",
"EventType",
"ExecutionScope",
"LLMClient",
"LLMResponse",
"Message",
"StreamDelta",
"TaskStatus",
"ToolCall",
"assistant_msg",
"build_execution_scope",
"system_msg",
"tool_msg",
"user_msg",
Expand Down
24 changes: 24 additions & 0 deletions agent_core/events.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
"""Product-neutral kernel event identifiers."""

from __future__ import annotations

from enum import StrEnum


class EventType(StrEnum):
"""Framework-mechanical events shared by all products."""

TASK_CREATED = "task_created"
TASK_STATUS_CHANGED = "task_status_changed"
PHASE_TRANSITION = "phase_transition"
AGENT_ACTION = "agent_action"
AGENT_MESSAGE = "agent_message"
AGENT_TOOL_CALL = "agent_tool_call"
TOOL_CALLED = "tool_called"
TOOL_RESULT = "tool_result"
ERROR = "error"
REPORT_GENERATED = "report_generated"
WORKING_MEMORY_SNAPSHOT = "working_memory_snapshot"


__all__ = ["EventType"]
171 changes: 171 additions & 0 deletions agent_core/execution_context.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
"""Task-local execution identity shared across LLM and tool calls."""

from __future__ import annotations

import copy
import logging
from collections.abc import Generator
from contextlib import contextmanager
from contextvars import ContextVar, Token
from dataclasses import dataclass, field
from typing import Any, cast

from agent_core.types import new_prompt_id, new_session_id, new_step_id

logger = logging.getLogger(__name__)


@dataclass
class ExecutionScope:
"""Runtime execution scope for the current phase."""

task_id: str = ""
phase_id: str = ""
role_id: str = ""
metadata: dict[str, Any] = field(default_factory=dict[str, Any])


_CURRENT_SCOPE: ContextVar[ExecutionScope | None] = ContextVar(
"agent_core_execution_scope", default=None
)
_CURRENT_TOOL_CALL_ID: ContextVar[str] = ContextVar(
"agent_core_current_tool_call_id", default=""
)
_CURRENT_TOOL_BUDGET: ContextVar[float | None] = ContextVar(
"agent_core_current_tool_budget", default=None
)
_CHAIN_FALLBACK_ACTIVE: ContextVar[bool] = ContextVar(
"agent_core_chain_fallback_active", default=False
)


def normalize_execution_context(value: Any) -> dict[str, Any]:
"""Return a fully independent mutable execution-context mapping.

The copy is deep: callers mutate scope metadata freely (including nested
dicts and lists), and persisted checkpoint state it was loaded from must
not change underneath them.
"""
if not isinstance(value, dict):
return {}
mapping = cast("dict[str, Any]", value)
try:
return copy.deepcopy(mapping)
except Exception:
# Persisted state is expected to be plain data, but an in-memory state
# dict may carry something uncopyable. Degrading to a shallow copy is
# better than failing to build the scope at all.
logger.warning(
"Execution context could not be deep-copied; falling back to a "
"shallow copy, so nested values stay shared with the source state.",
exc_info=True,
)
return dict(mapping)


def build_execution_scope(
*,
task_id: str,
phase_id: str,
role_id: str,
state: dict[str, Any] | None = None,
) -> ExecutionScope:
"""Build a scope from task identity and optional persisted state."""
persisted = state.get("execution_context") if state is not None else None
metadata = normalize_execution_context(persisted)
# ``role_id`` is authoritative for this scope: a resumed task may carry an
# ``agent_id`` written during a different phase, and keeping it would
# attribute this role's work to the previous agent.
if role_id or "agent_id" not in metadata:
metadata["agent_id"] = role_id
return ExecutionScope(
task_id=task_id,
phase_id=phase_id,
role_id=role_id,
metadata=metadata,
)


def set_current_execution_scope(scope: ExecutionScope) -> Token[ExecutionScope | None]:
return _CURRENT_SCOPE.set(scope)


def get_current_execution_scope() -> ExecutionScope | None:
return _CURRENT_SCOPE.get()


def reset_current_execution_scope(token: Token[ExecutionScope | None]) -> None:
_CURRENT_SCOPE.reset(token)


def set_current_tool_call_id(tool_call_id: str) -> Token[str]:
return _CURRENT_TOOL_CALL_ID.set(tool_call_id)


def get_current_tool_call_id() -> str:
return _CURRENT_TOOL_CALL_ID.get()


def reset_current_tool_call_id(token: Token[str]) -> None:
_CURRENT_TOOL_CALL_ID.reset(token)


def set_current_tool_budget(seconds: float | None) -> Token[float | None]:
"""Publish the outer loop budget for the active tool task."""
return _CURRENT_TOOL_BUDGET.set(seconds)


def get_current_tool_budget() -> float | None:
return _CURRENT_TOOL_BUDGET.get()


def reset_current_tool_budget(token: Token[float | None]) -> None:
_CURRENT_TOOL_BUDGET.reset(token)


def chain_fallback_active() -> bool:
"""Whether an outer provider-chain runner can rotate this attempt."""
return _CHAIN_FALLBACK_ACTIVE.get()


@contextmanager
def chain_fallback_scope() -> Generator[None, None, None]:
"""Mark this context as running beneath an outer provider chain."""
token = _CHAIN_FALLBACK_ACTIVE.set(True)
try:
yield
finally:
_CHAIN_FALLBACK_ACTIVE.reset(token)


def ensure_trace_metadata(
metadata: dict[str, Any],
*,
default_step_id: str | None = None,
refresh_prompt_id: bool = False,
) -> dict[str, Any]:
"""Ensure stable session, step, and prompt identifiers exist."""
metadata.setdefault("session_id", str(new_session_id()))
metadata.setdefault("step_id", default_step_id or str(new_step_id()))
if refresh_prompt_id or not metadata.get("prompt_id"):
metadata["prompt_id"] = str(new_prompt_id())
return metadata


__all__ = [
"ExecutionScope",
"build_execution_scope",
"chain_fallback_active",
"chain_fallback_scope",
"ensure_trace_metadata",
"get_current_execution_scope",
"get_current_tool_budget",
"get_current_tool_call_id",
"normalize_execution_context",
"reset_current_execution_scope",
"reset_current_tool_budget",
"reset_current_tool_call_id",
"set_current_execution_scope",
"set_current_tool_budget",
"set_current_tool_call_id",
]
15 changes: 14 additions & 1 deletion agent_core/runtime/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,18 @@
"""Shared runtime implementations."""

from agent_core.runtime.events import EventBus, Handler
from agent_core.runtime.spill import SpillStore
from agent_core.runtime.tool_permission import (
ToolPermissionContext,
from_config_map,
from_execution_policy,
)

__all__ = ["SpillStore"]
__all__ = [
"EventBus",
"Handler",
"SpillStore",
"ToolPermissionContext",
"from_config_map",
"from_execution_policy",
]
98 changes: 98 additions & 0 deletions agent_core/runtime/events.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
"""Async pub/sub for portable runtime events."""

from __future__ import annotations

import asyncio
import logging
from collections import defaultdict
from collections.abc import Awaitable, Callable
from enum import Enum
from typing import Any

logger = logging.getLogger(__name__)

Handler = Callable[[dict[str, Any]], Awaitable[None]]


def _coerce_key(event_type: str | Enum) -> str:
if isinstance(event_type, Enum):
value = getattr(event_type, "value", None)
if isinstance(value, str):
return value
return str(value)
return str(event_type)


class EventBus:
"""Small failure-isolated async event bus.

Subscriptions are held strongly and never expire on their own. A bus shared
across tasks must be paired with :meth:`unsubscribe` (or :meth:`clear`) when
a subscriber goes away, or every finished task leaves its handler — and the
state that handler closes over — reachable forever.
"""

def __init__(self) -> None:
self._handlers: dict[str, list[Handler]] = defaultdict(list)
self._global_handlers: list[Handler] = []

def subscribe(self, event_type: str | Enum, handler: Handler) -> None:
self._handlers[_coerce_key(event_type)].append(handler)

def subscribe_all(self, handler: Handler) -> None:
self._global_handlers.append(handler)

def unsubscribe(self, event_type: str | Enum, handler: Handler) -> bool:
"""Remove one typed subscription. Returns whether it was registered."""
key = _coerce_key(event_type)
registered = self._handlers.get(key)
if not registered:
return False
try:
registered.remove(handler)
except ValueError:
return False
if not registered:
del self._handlers[key]
return True

def unsubscribe_all(self, handler: Handler) -> bool:
"""Remove one catch-all subscription. Returns whether it was registered."""
try:
self._global_handlers.remove(handler)
except ValueError:
return False
return True

def clear(self) -> None:
"""Drop every subscription."""
self._handlers.clear()
self._global_handlers.clear()

async def publish(self, event_type: str | Enum, payload: dict[str, Any]) -> None:
key = _coerce_key(event_type)
handlers = [*self._handlers.get(key, ()), *self._global_handlers]
if not handlers:
return
if "event_type" in payload and payload["event_type"] != key:
logger.warning(
"Event payload carried event_type=%r for a %r publish; the published type wins.",
payload["event_type"],
key,
)
# The published type is authoritative, so it is applied last.
event_data = {**payload, "event_type": key}
# Each handler gets its own top-level copy: handlers run concurrently
# and interleave at every await, so a handler that mutates the payload
# must not change what its peers observe.
await asyncio.gather(*(self._safe_call(handler, dict(event_data)) for handler in handlers))

@staticmethod
async def _safe_call(handler: Handler, data: dict[str, Any]) -> None:
try:
await handler(data)
except Exception:
logger.exception("Event handler failed: %s", getattr(handler, "__name__", repr(handler)))


__all__ = ["EventBus", "Handler"]
Loading