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
12 changes: 12 additions & 0 deletions CONTRACTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,18 @@ is added later (adding one is a breaking change).
`module:on_session_ready_failed` event with payload `{"module_id": str, "error": str}`, in
addition to the WARNING log. Log-only failures are invisible to observability hooks.

**Same mechanism for provider/tool/hook load failures:** A provider, tool, or hook that
raises during `mount()` is caught, logged as a WARNING with `exc_info=True`, and does
**not** abort the session — the remaining modules still load and the session still starts.
This is intentional non-interference (a single broken optional module must not take down
an otherwise-working session) but it is silent by default: nothing short of grepping logs
tells you a configured module never mounted. The kernel emits a `module:load_failed` event
with payload `{"module_type": "provider"|"tool"|"hook", "module_id": str, "error": str}` so
a hook module can observe the gap and decide policy (abort, notify the user, inject a
system message telling the model the tool is unavailable, etc.). Required modules
(orchestrator, context manager) are unaffected by this — their failure still raises
`RuntimeError` and aborts initialization, as documented above.

#### When to use `on_session_ready()`

- **Discovering contributions from other modules** — read the fully-composed coordinator
Expand Down
4 changes: 4 additions & 0 deletions bindings/python/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,10 @@ fn _engine(m: &Bound<'_, PyModule>) -> PyResult<()> {
"MODULE_ON_SESSION_READY_FAILED",
amplifier_core::events::MODULE_ON_SESSION_READY_FAILED,
)?;
m.add(
"MODULE_LOAD_FAILED",
amplifier_core::events::MODULE_LOAD_FAILED,
)?;

// Aggregate list of all events
m.add("ALL_EVENTS", amplifier_core::events::ALL_EVENTS.to_vec())?;
Expand Down
2 changes: 1 addition & 1 deletion bindings/python/tests/test_event_constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ def test_all_events_is_list(self):
def test_all_events_count(self):
from amplifier_core.events import ALL_EVENTS

assert len(ALL_EVENTS) == 42, f"Expected 42 events, got {len(ALL_EVENTS)}"
assert len(ALL_EVENTS) == 43, f"Expected 43 events, got {len(ALL_EVENTS)}"

def test_all_events_contains_all_constants(self):
import amplifier_core.events as events
Expand Down
2 changes: 1 addition & 1 deletion bindings/python/tests/test_python_stubs.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ def test_events_reexport_provider_throttle():
def test_events_reexport_all_events():
from amplifier_core.events import ALL_EVENTS

assert len(ALL_EVENTS) == 42
assert len(ALL_EVENTS) == 43


def test_capabilities_reexport_tools():
Expand Down
2 changes: 1 addition & 1 deletion bindings/python/tests/test_schema_sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ def test_event_constants_match():
assert TOOL_ERROR == "tool:error"
assert CANCEL_REQUESTED == "cancel:requested"
assert CANCEL_COMPLETED == "cancel:completed"
assert len(ALL_EVENTS) == 42
assert len(ALL_EVENTS) == 43


def test_hook_result_json_roundtrip():
Expand Down
24 changes: 23 additions & 1 deletion crates/amplifier-core/src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,12 @@ pub const CANCEL_COMPLETED: &str = "cancel:completed";
/// Payload: {module_id: str, error: str}
pub const MODULE_ON_SESSION_READY_FAILED: &str = "module:on_session_ready_failed";

/// Emitted when a provider, tool, or hook module raises during load/mount and
/// the session continues without it (the failure is caught and logged, not
/// re-raised, so the module is silently absent unless something observes this
/// event). Payload: {module_type: "provider"|"tool"|"hook", module_id: str, error: str}
pub const MODULE_LOAD_FAILED: &str = "module:load_failed";

// --- Aggregate ---

/// All canonical event names, for iteration and validation.
Expand Down Expand Up @@ -205,6 +211,7 @@ pub const ALL_EVENTS: &[&str] = &[
CANCEL_REQUESTED,
CANCEL_COMPLETED,
MODULE_ON_SESSION_READY_FAILED,
MODULE_LOAD_FAILED,
];

#[cfg(test)]
Expand Down Expand Up @@ -327,6 +334,19 @@ mod tests {
);
}

#[test]
fn test_module_on_session_ready_failed_event_value() {
assert_eq!(
MODULE_ON_SESSION_READY_FAILED,
"module:on_session_ready_failed"
);
}

#[test]
fn test_module_load_failed_event_value() {
assert_eq!(MODULE_LOAD_FAILED, "module:load_failed");
}

#[test]
fn test_all_events_contains_new_constants() {
assert!(
Expand All @@ -347,7 +367,7 @@ mod tests {

#[test]
fn all_events_count() {
assert_eq!(ALL_EVENTS.len(), 42, "expected 42 canonical events");
assert_eq!(ALL_EVENTS.len(), 43, "expected 43 canonical events");
}

#[test]
Expand Down Expand Up @@ -391,6 +411,8 @@ mod tests {
APPROVAL_DENIED,
CANCEL_REQUESTED,
CANCEL_COMPLETED,
MODULE_ON_SESSION_READY_FAILED,
MODULE_LOAD_FAILED,
];
for event in expected {
assert!(ALL_EVENTS.contains(event), "ALL_EVENTS missing: {event}");
Expand Down
31 changes: 31 additions & 0 deletions python/amplifier_core/_session_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,33 @@ def _safe_exception_str(e: BaseException) -> str:
return repr(e)


async def _emit_module_load_failed(
coordinator: Any, module_type: str, module_id: str, error: BaseException
) -> None:
"""Emit the module:load_failed observability event for a provider, tool,
or hook that raised during load/mount.

This is a mechanism only: the kernel makes the failure observable via the
canonical event stream. It does not decide whether the session should
abort -- that policy choice belongs to a hook module subscribed to this
event. Mirrors the on_session_ready failure pattern below: event emission
failure must never suppress the original WARNING log.
"""
from .events import MODULE_LOAD_FAILED

try:
await coordinator.hooks.emit(
MODULE_LOAD_FAILED,
{
"module_type": module_type,
"module_id": module_id,
"error": _safe_exception_str(error),
},
)
except Exception:
pass # Event emission failure must not suppress the original warning


async def initialize_session(
config: dict[str, Any],
coordinator: Any,
Expand Down Expand Up @@ -190,6 +217,7 @@ async def initialize_session(
f"Failed to load provider '{module_id}': {_safe_exception_str(e)}",
exc_info=True,
)
await _emit_module_load_failed(coordinator, "provider", module_id, e)

# Load tools
for tool_config in config.get("tools", []):
Expand All @@ -215,6 +243,7 @@ async def initialize_session(
f"Failed to load tool '{module_id}': {_safe_exception_str(e)}",
exc_info=True,
)
await _emit_module_load_failed(coordinator, "tool", module_id, e)

# Load hooks
for hook_config in config.get("hooks", []):
Expand All @@ -240,6 +269,7 @@ async def initialize_session(
f"Failed to load hook '{module_id}': {_safe_exception_str(e)}",
exc_info=True,
)
await _emit_module_load_failed(coordinator, "hook", module_id, e)

# Phase 6 — on_session_ready callbacks
# Called after ALL modules have been mounted. Each callback receives the
Expand All @@ -260,6 +290,7 @@ async def initialize_session(
exc_info=True,
)
from .events import MODULE_ON_SESSION_READY_FAILED

try:
await coordinator.hooks.emit(
MODULE_ON_SESSION_READY_FAILED,
Expand Down
2 changes: 2 additions & 0 deletions python/amplifier_core/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@
CANCEL_COMPLETED,
# Module lifecycle events
MODULE_ON_SESSION_READY_FAILED,
MODULE_LOAD_FAILED,
ALL_EVENTS,
)

Expand Down Expand Up @@ -109,4 +110,5 @@
"CANCEL_COMPLETED",
"ALL_EVENTS",
"MODULE_ON_SESSION_READY_FAILED",
"MODULE_LOAD_FAILED",
]
Loading
Loading